├── .gitignore ├── chordflow_audio ├── src │ ├── lib.rs │ └── audio.rs ├── assets │ └── TimGM6mb.sf2 └── Cargo.toml ├── chordflow_desktop ├── src │ ├── hooks.rs │ ├── components │ │ ├── header.rs │ │ ├── practice_state.rs │ │ ├── mode_selection.rs │ │ ├── buttons.rs │ │ ├── metronome.rs │ │ ├── metronome_settings.rs │ │ ├── play_controls.rs │ │ └── config_state.rs │ ├── components.rs │ ├── hooks │ │ └── use_metronome.rs │ └── main.rs ├── package.json ├── assets │ ├── favicon.ico │ └── tailwind.css ├── icons │ ├── AppIcon.icns │ ├── favicon.ico │ ├── icon-192.png │ ├── icon-512.png │ └── play_store_512.png ├── .gitignore ├── Dioxus.toml ├── input.css ├── Cargo.toml ├── README.md └── tailwind.config.js ├── icons ├── web │ ├── favicon.ico │ ├── icon-192.png │ ├── icon-512.png │ ├── apple-touch-icon.png │ ├── icon-192-maskable.png │ ├── icon-512-maskable.png │ └── README.txt ├── ios │ ├── AppIcon-29.png │ ├── AppIcon@2x.png │ ├── AppIcon@3x.png │ ├── AppIcon~ipad.png │ ├── AppIcon-20@2x.png │ ├── AppIcon-20@3x.png │ ├── AppIcon-29@2x.png │ ├── AppIcon-29@3x.png │ ├── AppIcon-40@2x.png │ ├── AppIcon-40@3x.png │ ├── AppIcon-20~ipad.png │ ├── AppIcon-29~ipad.png │ ├── AppIcon-40~ipad.png │ ├── AppIcon-60@2x~car.png │ ├── AppIcon-60@3x~car.png │ ├── AppIcon@2x~ipad.png │ ├── AppIcon-20@2x~ipad.png │ ├── AppIcon-29@2x~ipad.png │ ├── AppIcon-40@2x~ipad.png │ ├── AppIcon-83.5@2x~ipad.png │ ├── AppIcon~ios-marketing.png │ └── Contents.json ├── macos │ └── AppIcon.icns └── android │ ├── play_store_512.png │ └── res │ ├── mipmap-hdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ └── mipmap-anydpi-v26 │ └── ic_launcher.xml ├── docs └── chordflord_desktop.png ├── chordflow_tui ├── foo.log ├── Cargo.toml └── src │ ├── main.rs │ ├── keymap.rs │ └── ui.rs ├── chordflow_music_theory ├── src │ ├── lib.rs │ ├── util.rs │ ├── accidental.rs │ ├── chord.rs │ ├── scale.rs │ ├── interval.rs │ ├── quality.rs │ └── note.rs └── Cargo.toml ├── Cross.toml ├── Cargo.toml ├── chordflow_shared ├── Cargo.toml └── src │ ├── cli.rs │ ├── lib.rs │ ├── mode.rs │ ├── progression.rs │ ├── metronome.rs │ └── practice_state.rs ├── LICENSE ├── README.md └── .github └── workflows └── build.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | .DS_Store -------------------------------------------------------------------------------- /chordflow_audio/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod audio; 2 | -------------------------------------------------------------------------------- /chordflow_desktop/src/hooks.rs: -------------------------------------------------------------------------------- 1 | pub mod use_metronome; 2 | -------------------------------------------------------------------------------- /icons/web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/favicon.ico -------------------------------------------------------------------------------- /icons/web/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-192.png -------------------------------------------------------------------------------- /icons/web/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-512.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29.png -------------------------------------------------------------------------------- /icons/ios/AppIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon@2x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon@3x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon~ipad.png -------------------------------------------------------------------------------- /icons/macos/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/macos/AppIcon.icns -------------------------------------------------------------------------------- /chordflow_desktop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "tailwindcss": "^3.4.17" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/chordflord_desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/docs/chordflord_desktop.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20@2x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20@3x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29@2x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29@3x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40@2x.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40@3x.png -------------------------------------------------------------------------------- /chordflow_tui/foo.log: -------------------------------------------------------------------------------- 1 | DEBUG - "/var/folders/m2/63j5gnr57yjg8qxvyq_m1pr00000gn/T/guitar_practice_soundfont.sf2" 2 | -------------------------------------------------------------------------------- /icons/ios/AppIcon-20~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20~ipad.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-29~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29~ipad.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-40~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40~ipad.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-60@2x~car.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-60@2x~car.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-60@3x~car.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-60@3x~car.png -------------------------------------------------------------------------------- /icons/ios/AppIcon@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon@2x~ipad.png -------------------------------------------------------------------------------- /icons/web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/apple-touch-icon.png -------------------------------------------------------------------------------- /icons/web/icon-192-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-192-maskable.png -------------------------------------------------------------------------------- /icons/web/icon-512-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-512-maskable.png -------------------------------------------------------------------------------- /icons/android/play_store_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/play_store_512.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-20@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20@2x~ipad.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-29@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29@2x~ipad.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-40@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40@2x~ipad.png -------------------------------------------------------------------------------- /chordflow_audio/assets/TimGM6mb.sf2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_audio/assets/TimGM6mb.sf2 -------------------------------------------------------------------------------- /chordflow_desktop/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/assets/favicon.ico -------------------------------------------------------------------------------- /chordflow_desktop/icons/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/AppIcon.icns -------------------------------------------------------------------------------- /chordflow_desktop/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/favicon.ico -------------------------------------------------------------------------------- /chordflow_desktop/icons/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/icon-192.png -------------------------------------------------------------------------------- /chordflow_desktop/icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/icon-512.png -------------------------------------------------------------------------------- /icons/ios/AppIcon-83.5@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-83.5@2x~ipad.png -------------------------------------------------------------------------------- /icons/ios/AppIcon~ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon~ios-marketing.png -------------------------------------------------------------------------------- /chordflow_desktop/icons/play_store_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/play_store_512.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /icons/android/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /chordflow_music_theory/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod accidental; 2 | pub mod chord; 3 | pub mod interval; 4 | pub mod note; 5 | pub mod quality; 6 | pub mod scale; 7 | pub mod util; 8 | 9 | -------------------------------------------------------------------------------- /chordflow_desktop/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target 4 | .DS_Store 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | node_modules 9 | -------------------------------------------------------------------------------- /chordflow_music_theory/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chordflow_music_theory" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | color-eyre = "0.6.3" 8 | itertools = "0.14.0" 9 | rand = "0.9.0" 10 | strum = { version = "0.27.0", features = ["derive"] } 11 | strum_macros = "0.27.0" 12 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-unknown-linux-musl] 2 | pre-build = [ 3 | "dpkg --add-architecture $CROSS_DEB_ARCH", 4 | "apt-get update && apt-get --assume-yes install libasound2-dev" 5 | ] 6 | [target.x86_64-unknown-linux-musl.env] 7 | passthrough = [ 8 | "RUSTFLAGS=-Ctarget-feature=-crt-static" 9 | ] 10 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/header.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[component] 4 | pub fn Header() -> Element { 5 | rsx! { 6 | 7 | div { class: "w-screen bg-tokyoNight-bg_highlight/70", 8 | p { class: "font-mono font-bold text-3xl p-3 text-tokyoNight-blue", "ChordFlow" } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /icons/android/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "chordflow_audio", "chordflow_desktop", 4 | "chordflow_music_theory", 5 | "chordflow_shared", 6 | "chordflow_tui", "chordflow_desktop", 7 | ] 8 | 9 | 10 | [profile] 11 | 12 | [profile.wasm-dev] 13 | inherits = "dev" 14 | opt-level = 1 15 | 16 | [profile.server-dev] 17 | inherits = "dev" 18 | 19 | [profile.android-dev] 20 | inherits = "dev" 21 | 22 | 23 | [bundle] 24 | resources = ["assets/*"] 25 | 26 | -------------------------------------------------------------------------------- /chordflow_shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chordflow_shared" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.95" 8 | chordflow_music_theory = {path = "../chordflow_music_theory/"} 9 | clap = { version = "4.5.27", features = ["derive"] } 10 | futures = "0.3.31" 11 | rand = "0.9.0" 12 | regex = "1.11.1" 13 | strum = { version = "0.27.0", features = ["derive"] } 14 | strum_macros = "0.27.0" 15 | tokio = { version = "1.43.0", features = ["full"] } 16 | tokio-util = "0.7.13" 17 | -------------------------------------------------------------------------------- /chordflow_audio/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chordflow_audio" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | chordflow_shared = {path = "../chordflow_shared/"} 8 | chordflow_music_theory = {path = "../chordflow_music_theory/"} 9 | 10 | fluidlite = { version = "0.2.1", features = ["bindgen"] } 11 | midly = "0.5.3" 12 | rodio = "0.20.1" 13 | env_logger = "0.11.6" 14 | log = "0.4.25" 15 | log4rs = { version = "1.3.0", features = ["file_appender"] } 16 | tokio = "1.43.0" 17 | tokio-util = "0.7.13" 18 | futures = "0.3.31" 19 | -------------------------------------------------------------------------------- /chordflow_desktop/Dioxus.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | 3 | # App (Project) Name 4 | name = "chordflow_desktop" 5 | default_platform = "desktop" 6 | 7 | asset_dir = "assets" 8 | 9 | [web.app] 10 | 11 | # HTML title tag content 12 | title = "chordflow_desktop" 13 | 14 | # include `assets` in web platform 15 | [web.resource] 16 | 17 | # Additional CSS style files 18 | style = ["assets/tailwind.css"] 19 | 20 | # Additional JavaScript files 21 | script = [] 22 | 23 | [web.resource.dev] 24 | 25 | # Javascript code file 26 | # serve: [dev-server] only 27 | script = [] 28 | 29 | [bundle] 30 | resources = ["assets/*"] 31 | identifier = "io.timnology" 32 | publisher = "Timnology" 33 | icon = ["icons/icon-512.png", "icons/Appicon.icns"] 34 | -------------------------------------------------------------------------------- /chordflow_desktop/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .button { 6 | @apply border-[1px] border-tokyoNight-comment shadow-lg text-tokyoNight-blue p-2 rounded-md hover:border-tokyoNight-bg hover:bg-tokyoNight-bg hover:text-tokyoNight-fg transition-all active:shadow-none ; 7 | } 8 | 9 | .selected-button { 10 | @apply p-2 rounded-md transition-all shadow-none bg-tokyoNight-comment text-tokyoNight-bg_dark font-semibold 11 | } 12 | 13 | .select{ 14 | @apply bg-tokyoNight-bg border-[1px] border-tokyoNight-comment shadow-lg text-tokyoNight-blue p-2 rounded-md hover:border-tokyoNight-bg hover:bg-tokyoNight-bg hover:text-tokyoNight-fg transition-all active:shadow-none ; 15 | } 16 | -------------------------------------------------------------------------------- /icons/web/README.txt: -------------------------------------------------------------------------------- 1 | Add this to your HTML : 2 | 3 | 4 | 5 | 6 | Add this to your app's manifest.json: 7 | 8 | ... 9 | { 10 | "icons": [ 11 | { "src": "/favicon.ico", "type": "image/x-icon", "sizes": "16x16 32x32" }, 12 | { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" }, 13 | { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }, 14 | { "src": "/icon-192-maskable.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" }, 15 | { "src": "/icon-512-maskable.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" } 16 | ] 17 | } 18 | ... 19 | -------------------------------------------------------------------------------- /chordflow_desktop/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chordflow_desktop" 3 | version = "0.3.3" 4 | authors = ["Tim van Cann "] 5 | edition = "2021" 6 | 7 | 8 | [dependencies] 9 | dioxus = { version = "0.6.3", features = ["router", "fullstack"] } 10 | chordflow_shared = {path = "../chordflow_shared/"} 11 | chordflow_audio = {path = "../chordflow_audio/"} 12 | chordflow_music_theory = {path = "../chordflow_music_theory/"} 13 | strum = { version = "0.27.0", features = ["derive"] } 14 | strum_macros = "0.27.0" 15 | tokio = "1.43.0" 16 | dioxus-free-icons = { version = "0.9.0", features = ["hero-icons-solid", "font-awesome-solid", "ionicons"] } 17 | 18 | [features] 19 | default = ["desktop"] 20 | web = ["dioxus/web"] 21 | desktop = ["dioxus/desktop"] 22 | mobile = ["dioxus/mobile"] 23 | 24 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/util.rs: -------------------------------------------------------------------------------- 1 | use super::chord::Chord; 2 | use super::note::{generate_all_roots, Note}; 3 | use super::quality::Quality; 4 | use rand::{seq::IteratorRandom, Rng}; 5 | use strum::IntoEnumIterator; 6 | 7 | pub fn random_note() -> Note { 8 | let mut rng = rand::rng(); 9 | *generate_all_roots().iter().choose(&mut rng).unwrap() 10 | } 11 | 12 | pub fn random_quality(allowed: Option>) -> Quality { 13 | let qualities = allowed.unwrap_or(Quality::iter().collect()); 14 | 15 | let mut rng = rand::rng(); 16 | qualities[rng.random_range(0..qualities.len())] 17 | } 18 | 19 | pub fn random_chord(selected_qualities: Option>) -> Chord { 20 | Chord { 21 | root: random_note(), 22 | quality: random_quality(selected_qualities), 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/accidental.rs: -------------------------------------------------------------------------------- 1 | use strum::{AsRefStr, EnumCount, EnumIter, FromRepr}; 2 | 3 | #[derive(Default, Clone, Copy, Debug, EnumIter, AsRefStr, PartialEq, EnumCount, FromRepr, Eq)] 4 | pub enum Accidental { 5 | #[default] 6 | #[strum(to_string = "")] 7 | Natural, 8 | #[strum(to_string = "#")] 9 | Sharp, 10 | #[strum(to_string = "b")] 11 | Flat, 12 | } 13 | 14 | impl Accidental { 15 | pub fn from_string(accidental: &str) -> Accidental { 16 | match accidental { 17 | "#" => Accidental::Sharp, 18 | "b" => Accidental::Flat, 19 | _ => Accidental::Natural, 20 | } 21 | } 22 | 23 | pub fn to_semitones(self) -> i32 { 24 | match self { 25 | Accidental::Natural => 0, 26 | Accidental::Sharp => 1, 27 | Accidental::Flat => -1, 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /chordflow_shared/src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::*; 4 | 5 | #[derive(Parser, Debug)] 6 | pub struct Cli { 7 | #[arg( 8 | long, 9 | value_name = "INT", 10 | help = "BPM (Beats per minute)", 11 | default_value_t = 100 12 | )] 13 | pub bpm: usize, 14 | 15 | #[arg( 16 | short, 17 | long, 18 | value_name = "INT", 19 | help = "Number of bars per chord", 20 | default_value_t = 2 21 | )] 22 | pub bars_per_chord: usize, 23 | 24 | #[arg( 25 | short, 26 | long, 27 | value_name = "INT", 28 | help = "Number of beats per bar", 29 | default_value_t = 4 30 | )] 31 | pub ticks_per_bar: usize, 32 | 33 | #[arg(short, long, help = "Soundfont file path")] 34 | pub soundfont: Option, 35 | } 36 | 37 | pub fn parse_cli() -> Cli { 38 | Cli::parse() 39 | } 40 | -------------------------------------------------------------------------------- /chordflow_tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chordflow_tui" 3 | version = "0.3.2" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | 8 | chordflow_shared = {path = "../chordflow_shared/"} 9 | chordflow_audio = {path = "../chordflow_audio/"} 10 | chordflow_music_theory = {path = "../chordflow_music_theory/"} 11 | 12 | anyhow = "1.0.95" 13 | color-eyre = "0.6.3" 14 | crossterm = { version = "0.28.1", features = ["event-stream"] } 15 | figlet-rs = "0.1.5" 16 | futures = "0.3.31" 17 | log = "0.4.25" 18 | log4rs = { version = "1.3.0", features = ["file_appender"] } 19 | ratatui = "0.29.0" 20 | serde = { version = "1.0.217", features = ["derive"] } 21 | strum = { version = "0.27.0", features = ["derive"] } 22 | strum_macros = "0.27.0" 23 | tokio = { version = "1.43.0", features = ["full"] } 24 | tokio-util = "0.7.13" 25 | 26 | [profile.release] 27 | strip = true 28 | lto = true 29 | opt-level = "z" 30 | codegen-units = 1 31 | panic = "abort" 32 | -------------------------------------------------------------------------------- /chordflow_shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::default; 2 | 3 | use strum::{AsRefStr, Display, EnumCount, EnumIter, FromRepr}; 4 | 5 | pub mod cli; 6 | pub mod metronome; 7 | pub mod mode; 8 | pub mod practice_state; 9 | pub mod progression; 10 | 11 | #[derive( 12 | Clone, Copy, Debug, EnumIter, Display, AsRefStr, PartialEq, EnumCount, FromRepr, Default, 13 | )] 14 | pub enum ModeOption { 15 | #[default] 16 | #[strum(to_string = "Circle of Fourths")] 17 | Fourths, 18 | #[strum(to_string = "Diatonic Progression")] 19 | Diatonic, 20 | #[strum(to_string = "Random Chords")] 21 | Random, 22 | #[strum(to_string = "Custom Progression")] 23 | Custom, 24 | } 25 | 26 | #[derive( 27 | Clone, Copy, Debug, EnumIter, Display, AsRefStr, PartialEq, EnumCount, FromRepr, Default, 28 | )] 29 | pub enum DiatonicOption { 30 | #[default] 31 | Incemental, 32 | #[strum(to_string = "Random Chord")] 33 | Random, 34 | } 35 | -------------------------------------------------------------------------------- /chordflow_desktop/README.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Your new jumpstart project includes basic organization with an organized `assets` folder and a `components` folder. 4 | If you chose to develop with the router feature, you will also have a `views` folder. 5 | 6 | ### Tailwind 7 | 1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm 8 | 2. Install the Tailwind CSS CLI: https://tailwindcss.com/docs/installation 9 | 3. Run the following command in the root of the project to start the Tailwind CSS compiler: 10 | 11 | ```bash 12 | npx tailwindcss -i ./input.css -o ./assets/tailwind.css --watch 13 | ``` 14 | 15 | ### Serving Your App 16 | 17 | Run the following command in the root of your project to start developing with the default platform: 18 | 19 | ```bash 20 | dx serve --platform desktop 21 | ``` 22 | 23 | To run for a different platform, use the `--platform platform` flag. E.g. 24 | ```bash 25 | dx serve --platform desktop 26 | ``` 27 | 28 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/practice_state.rs: -------------------------------------------------------------------------------- 1 | use chordflow_shared::practice_state::{self, PracticState}; 2 | 3 | use dioxus::prelude::*; 4 | 5 | #[component] 6 | pub fn PracticeStateDisplay() -> Element { 7 | let practice_state: Signal = use_context(); 8 | let chord = practice_state.read().current_chord; 9 | let next_chord = practice_state.read().next_chord; 10 | rsx! { 11 | div { class: "flex flex-col items-center justify-center w-full space-y-2 mt-8", 12 | div { class: "flex text-8xl text-tokyoNight-magenta", 13 | div { class: "font-semibold", {chord.root.to_string()} } 14 | div { class: "text-5xl", {chord.quality.to_string()} } 15 | } 16 | div { class: "flex text-4xl text-tokyoNight-blue font-sans", 17 | div { class: "font-semibold", {next_chord.root.to_string()} } 18 | div { class: "text-2xl", {next_chord.quality.to_string()} } 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tim van Cann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/mode_selection.rs: -------------------------------------------------------------------------------- 1 | use chordflow_shared::{practice_state::ConfigState, ModeOption}; 2 | use dioxus::prelude::*; 3 | use strum::IntoEnumIterator; 4 | 5 | use crate::components::{apply_selected_changes, buttons::ToggleButton}; 6 | 7 | #[component] 8 | pub fn ModeSelectionDisplay() -> Element { 9 | let mut selected_mode: Signal = use_context(); 10 | let config_state: Signal = use_context(); 11 | rsx! { 12 | div { class: "space-y-4 w-60", 13 | p { class: "text-tokyoNight-blue font-bold text-xl", "Practice Mode" } 14 | div { class: "flex-col justify-center space-y-2", 15 | for mode in ModeOption::iter() { 16 | 17 | ToggleButton { 18 | text: mode.to_string(), 19 | is_selected: mode == selected_mode(), 20 | is_disabled: mode == ModeOption::Custom && config_state.read().progression.is_none(), 21 | onclick: move |_| { 22 | selected_mode.set(mode); 23 | apply_selected_changes(); 24 | }, 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/chord.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use super::{note::Note, quality::Quality}; 4 | 5 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 6 | pub struct Chord { 7 | pub root: Note, 8 | pub quality: Quality, 9 | } 10 | 11 | impl Display for Chord { 12 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 13 | write!(f, "{}{}", self.root, self.quality) 14 | } 15 | } 16 | 17 | impl Chord { 18 | pub fn new(root: Note, quality: Quality) -> Chord { 19 | Chord { root, quality } 20 | } 21 | 22 | pub fn to_c_based_semitones(self) -> Vec { 23 | let root_semitones = self.root.to_semitones(); 24 | let mut semitones = vec![]; 25 | 26 | for interval in self.quality.to_intervals().iter().map(|i| i.to_semitones()) { 27 | semitones.push(root_semitones + interval); 28 | } 29 | 30 | semitones 31 | .into_iter() 32 | .map(normalize_semitone_within_octave) 33 | .collect() 34 | } 35 | } 36 | 37 | fn normalize_semitone_within_octave(i: i32) -> i32 { 38 | if i < 0 { 39 | return normalize_semitone_within_octave(i + 12); 40 | } 41 | 42 | if i > 0 { 43 | return i % 12; 44 | } 45 | 46 | 0 47 | } 48 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/buttons.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[component] 4 | pub fn Button( 5 | text: Option, 6 | icon: Option, 7 | onclick: EventHandler, 8 | ) -> Element { 9 | rsx! { 10 | button { 11 | class: "button space-x-1 flex align-middle items-center", 12 | onclick, 13 | if let Some(ico) = icon { 14 | {ico} 15 | } 16 | span { 17 | if let Some(title) = text { 18 | {title} 19 | } 20 | } 21 | } 22 | } 23 | } 24 | 25 | #[component] 26 | pub fn ToggleButton( 27 | text: String, 28 | is_selected: bool, 29 | onclick: EventHandler, 30 | is_disabled: Option, 31 | ) -> Element { 32 | let dis: bool = is_disabled.unwrap_or(false); 33 | rsx! { 34 | button { 35 | class: format!( 36 | "flex items-center space-x-2 {} {}", 37 | if is_selected { "selected-button " } else { "button" }, 38 | if dis { "cursor-not-allowed" } else { "" }, 39 | ), 40 | disabled: dis, 41 | onclick, 42 | span { {text} } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /chordflow_desktop/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{rs,html,css,js}", "./dist/**/*.html"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | tokyoNight: { 8 | bg: "#1a1b26", 9 | bg_dark: "#16161e", 10 | bg_dark1: "#0C0E14", 11 | bg_highlight: "#292e42", 12 | blue: "#7aa2f7", 13 | blue0: "#3d59a1", 14 | blue1: "#2ac3de", 15 | blue2: "#0db9d7", 16 | blue5: "#89ddff", 17 | blue6: "#b4f9f8", 18 | blue7: "#394b70", 19 | comment: "#565f89", 20 | cyan: "#7dcfff", 21 | dark3: "#545c7e", 22 | dark5: "#737aa2", 23 | fg: "#c0caf5", 24 | fg_dark: "#a9b1d6", 25 | fg_gutter: "#3b4261", 26 | green: "#9ece6a", 27 | green1: "#73daca", 28 | green2: "#41a6b5", 29 | magenta: "#bb9af7", 30 | magenta2: "#ff007c", 31 | orange: "#ff9e64", 32 | purple: "#9d7cd8", 33 | red: "#f7768e", 34 | red1: "#db4b4b", 35 | teal: "#1abc9c", 36 | terminal_black: "#414868", 37 | yellow: "#e0af68", 38 | } 39 | } 40 | }, 41 | }, 42 | plugins: [], 43 | }; 44 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/metronome.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | use crate::MetronomeState; 4 | 5 | #[component] 6 | pub fn MetronomeDisplay() -> Element { 7 | let metronome_state: Signal = use_context(); 8 | 9 | let m = metronome_state.read(); 10 | rsx! { 11 | div { class: "space-y-4", 12 | div { class: "flex justify-center gap-2", 13 | for bar in 0..metronome_state.read().bars_per_chord { 14 | if bar > 0 { 15 | span { class: "text-tokyoNight-orange", " | " } 16 | } 17 | for tick in 0..m.ticks_per_bar { 18 | if bar < m.current_bar || (bar == m.current_bar && tick < m.current_tick) { 19 | div { class: "w-8 h-8 rounded-full transition-colors bg-tokyoNight-blue" } // completed tick 20 | } else if bar == m.current_bar && tick == m.current_tick { 21 | div { class: "w-8 h-8 rounded-full transition-colors bg-tokyoNight-orange" } // current tick 22 | } else { 23 | div { class: "w-8 h-8 rounded-full transition-colors bg-tokyoNight-fg_dark" } // upcoming tick 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/metronome_settings.rs: -------------------------------------------------------------------------------- 1 | use chordflow_shared::metronome::MetronomeCommand; 2 | use dioxus::prelude::*; 3 | 4 | use dioxus_free_icons::{ 5 | icons::hi_solid_icons::{HiMinusCircle, HiPlusCircle}, 6 | Icon, 7 | }; 8 | 9 | use crate::{components::buttons::Button, MetronomeSignal, MetronomeState}; 10 | 11 | #[component] 12 | pub fn MetronomSettingsDisplay() -> Element { 13 | let metronome: MetronomeSignal = use_context(); 14 | let mut metronome_state: Signal = use_context(); 15 | rsx! { 16 | div { class: "flex-col", 17 | div { class: "flex items-center justify-center align-middle space-x-4", 18 | Button { 19 | icon: rsx! { 20 | Icon { icon: HiMinusCircle } 21 | }, 22 | onclick: move |_| { 23 | metronome_state.write().bpm -= 2; 24 | let _ = metronome.read().0.send(MetronomeCommand::DecreaseBpm(2)); 25 | }, 26 | } 27 | div { class: "space-x-1 align-middle inline-block", 28 | 29 | span { class: "font-bold text-lg", {metronome_state.read().bpm.to_string()} } 30 | span { "bpm" } 31 | } 32 | div { 33 | class: "button space-x-1 flex align-middle items-center", 34 | onclick: move |_| { 35 | metronome_state.write().bpm += 2; 36 | let _ = metronome.read().0.send(MetronomeCommand::IncreaseBpm(2)); 37 | }, 38 | Icon { icon: HiPlusCircle } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /chordflow_shared/src/mode.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use chordflow_music_theory::{ 4 | quality::Quality, 5 | scale::{Scale, ScaleType}, 6 | }; 7 | 8 | use crate::{ 9 | practice_state::{ConfigState, PracticState}, 10 | progression::Progression, 11 | DiatonicOption, ModeOption, 12 | }; 13 | 14 | #[derive(Debug, PartialEq, Clone)] 15 | pub enum Mode { 16 | Fourths(Quality), 17 | Random(Vec), 18 | Custom(Option), 19 | Diatonic(Scale, DiatonicOption), 20 | } 21 | 22 | impl Display for Mode { 23 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 24 | match self { 25 | Mode::Fourths(q) => write!(f, "Fourths - {}", q.name()), 26 | Mode::Random(_) => write!(f, "Random"), 27 | Mode::Custom(p) => match p { 28 | Some(progression) => write!(f, "Custom: {}", progression), 29 | None => write!(f, "Custom"), 30 | }, 31 | Mode::Diatonic(chord, option) => write!(f, "Diatonic: {} - {}", chord, option), 32 | } 33 | } 34 | } 35 | 36 | impl Default for Mode { 37 | fn default() -> Self { 38 | Mode::Fourths(Quality::Major) 39 | } 40 | } 41 | 42 | pub fn update_mode_from_state( 43 | selected_mode: &ModeOption, 44 | practice_state: &mut PracticState, 45 | config_state: &ConfigState, 46 | ) -> bool { 47 | match selected_mode { 48 | ModeOption::Fourths => { 49 | practice_state.set_mode(Mode::Fourths(config_state.fourths_selected_quality)) 50 | } 51 | ModeOption::Random => { 52 | practice_state.set_mode(Mode::Random(config_state.random_selected_qualities.clone())) 53 | } 54 | ModeOption::Custom => { 55 | practice_state.set_mode(Mode::Custom(config_state.progression.clone())) 56 | } 57 | ModeOption::Diatonic => practice_state.set_mode(Mode::Diatonic( 58 | Scale::new(config_state.diatonic_root, ScaleType::Diatonic), 59 | config_state.diatonic_option, 60 | )), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::BorrowMut, sync::mpsc::Sender}; 2 | 3 | use chordflow_audio::audio::AudioCommand; 4 | use chordflow_shared::{ 5 | metronome::{calculate_duration_per_bar, MetronomeCommand}, 6 | mode::{update_mode_from_state, Mode}, 7 | practice_state::{ConfigState, PracticState}, 8 | ModeOption, 9 | }; 10 | use dioxus::prelude::*; 11 | 12 | use crate::{MetronomeSignal, MetronomeState}; 13 | 14 | pub mod buttons; 15 | pub mod config_state; 16 | pub mod header; 17 | pub mod metronome; 18 | pub mod metronome_settings; 19 | pub mod mode_selection; 20 | pub mod play_controls; 21 | pub mod practice_state; 22 | 23 | pub fn apply_selected_changes() { 24 | let mut metronome_state: Signal = use_context(); 25 | let selected_mode: Signal = use_context(); 26 | let mut practice_state: Signal = use_context(); 27 | let config_state: Signal = use_context(); 28 | let metronome: MetronomeSignal = use_context(); 29 | let tx_audio: Signal> = use_context(); 30 | let has_changed = update_mode_from_state( 31 | &selected_mode(), 32 | practice_state.write().borrow_mut(), 33 | &config_state(), 34 | ); 35 | if let Mode::Custom(Some(p)) = practice_state().mode { 36 | metronome_state.write().bars_per_chord = 37 | p.chords[practice_state().current_progression_chord_idx].bars; 38 | } 39 | let _ = metronome.read().0.send(MetronomeCommand::SetBars( 40 | metronome_state.read().bars_per_chord, 41 | )); 42 | if has_changed { 43 | let _ = metronome.read().0.send(MetronomeCommand::Reset); 44 | metronome_state.write().current_bar = 0; 45 | metronome_state.write().current_tick = 0; 46 | let _ = tx_audio.read().send(AudioCommand::PlayChord(( 47 | practice_state.read().current_chord, 48 | calculate_duration_per_bar( 49 | metronome_state.read().bpm, 50 | metronome_state.read().ticks_per_bar, 51 | ) 52 | .duration_per_bar, 53 | metronome_state.read().ticks_per_bar, 54 | ))); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/scale.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use strum::{AsRefStr, Display, EnumCount, EnumIter, FromRepr}; 4 | 5 | use super::{interval::Interval, note::Note}; 6 | 7 | #[derive( 8 | Default, Clone, Copy, Debug, EnumIter, AsRefStr, PartialEq, EnumCount, FromRepr, Eq, Display, 9 | )] 10 | pub enum ScaleType { 11 | #[default] 12 | Diatonic, 13 | } 14 | 15 | #[derive(Clone, Debug, Eq, PartialEq)] 16 | pub struct Scale { 17 | pub root: Note, 18 | pub scale_type: ScaleType, 19 | pub intervals: Vec, 20 | } 21 | 22 | impl Display for Scale { 23 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 24 | write!(f, "{} {}", self.root, self.scale_type) 25 | } 26 | } 27 | 28 | impl Scale { 29 | pub fn new(root: Note, scale_type: ScaleType) -> Scale { 30 | let intervals = match scale_type { 31 | ScaleType::Diatonic => vec![ 32 | Interval::Unison, 33 | Interval::MajorSecond, 34 | Interval::MajorThird, 35 | Interval::PerfectFourth, 36 | Interval::PerfectFifth, 37 | Interval::MajorSixth, 38 | Interval::MajorSeventh, 39 | ], 40 | }; 41 | 42 | Scale { 43 | root, 44 | scale_type, 45 | intervals, 46 | } 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use crate::note::{Note, NoteLetter}; 53 | 54 | use super::{Scale, ScaleType}; 55 | 56 | #[test] 57 | fn test_c_major_notes() { 58 | let scale = Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic); 59 | 60 | let actual_notes = vec![ 61 | Note::new(NoteLetter::C, 0), 62 | Note::new(NoteLetter::D, 0), 63 | Note::new(NoteLetter::E, 0), 64 | Note::new(NoteLetter::F, 0), 65 | Note::new(NoteLetter::G, 0), 66 | Note::new(NoteLetter::A, 0), 67 | Note::new(NoteLetter::B, 0), 68 | ]; 69 | 70 | for (interval, note) in scale.intervals.into_iter().zip(actual_notes) { 71 | assert_eq!(scale.root.add_interval(interval), note); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎸 ChordFlow 2 | 3 | ![Logo](icons/web/icon-192.png) 4 | 5 | ChordFlow is a GUI Desktop app and TUI (Terminal User Interface) tool designed to help guitarists/musicians 6 | practice improvisation and master the guitar neck by providing dynamic chord progressions with a built-in metronome. 7 | 8 | Grab the latest [release](https://github.com/timvancann/chordflow/releases) 9 | 10 | ## ✨ Features 11 | 12 | - 🎵 Metronome with Custom Sounds – Supports SoundFont-based metronome ticks. 13 | - 🔄 Random Chord Generation – Generate new chords every bar to improve improvisation skills. 14 | - 📊 Visual Progress Bar – Displays the current beat and bar progress. 15 | - 🎼 Real-Time Chord Display – Shows the current and upcoming chord. 16 | - ⚙️ Customizability – Users can supply their own SoundFont for metronome ticks and chord sounds. 17 | - 🎥 [TUI demo](https://www.youtube.com/watch?v=Oc7po6uNBfQ) 18 | - 🎥 [Desktop GUI demo](https://www.youtube.com/watch?v=X5V7tlbOBbY) 19 | 20 | ## 📦 Installation 21 | 22 | 1. Build from Source 23 | 24 | ```bash 25 | git clone https://github.com/timvancann/chordflow 26 | cd chordflow 27 | cargo build --release 28 | ``` 29 | 30 | 2. Grab the latest [release](https://github.com/timvancann/chordflow/releases) 31 | 32 | ## 🚀 Usage 33 | 34 | ### TUI 35 | 36 | ```bash 37 | ./chordflow_tui --help 38 | 39 | Usage: chordflow [OPTIONS] 40 | 41 | Options: 42 | --bpm BPM (Beats per minute) [default: 100] 43 | -b, --bars-per-chord Number of bars per chord [default: 2] 44 | -t, --ticks-per-bar Number of beats per bar [default: 4] 45 | -s, --soundfont Soundfont file path 46 | -h, --help Print help 47 | ``` 48 | 49 | ### GUI 50 | 51 | Install [Dioxus CLI](https://dioxuslabs.com/learn/0.6/getting_started/) 52 | 53 | ```dash 54 | cd chordflow_desktop 55 | dx serve 56 | ``` 57 | 58 | ## 🏗️ Roadmap 59 | 60 | - [ ] Fix Linux release 61 | - [ ] Add more scales (e.g. melodic minor) 62 | - [x] Better feedback and UI on custom progressions 63 | - [ ] Allow dynamically update the number of beats per bar 64 | - [x] Use [Dioxux](https://dioxuslabs.com/) to create a GUI native app 65 | 66 | ## 🤝 Contributing 67 | 68 | Contributions are welcome! Feel free to submit issues and pull requests. 69 | 70 | 1. Fork the repo 71 | 2. Create a new branch (git checkout -b feature-name) 72 | 3. Commit changes (git commit -m "Added cool feature") 73 | 4. Push to branch (git push origin feature-name) 74 | 5. Open a pull request 75 | -------------------------------------------------------------------------------- /chordflow_desktop/src/hooks/use_metronome.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::mpsc::Sender, time::Duration}; 2 | 3 | use chordflow_audio::audio::AudioCommand; 4 | use chordflow_shared::{ 5 | metronome::{calculate_duration_per_bar, MetronomeCommand, MetronomeEvent}, 6 | mode::Mode, 7 | practice_state::PracticState, 8 | }; 9 | use dioxus::prelude::*; 10 | 11 | use crate::{MetronomeSignal, MetronomeState}; 12 | 13 | pub fn use_metronome( 14 | metronome: MetronomeSignal, 15 | mut metronome_state: Signal, 16 | mut practice_state: Signal, 17 | audio_tx: Signal>, 18 | ) { 19 | use_future(move || async move { 20 | let m = metronome.read(); 21 | 22 | loop { 23 | while let Ok(event) = m.1.try_recv() { 24 | match event { 25 | MetronomeEvent::CycleComplete => { 26 | if let Mode::Custom(Some(p)) = &practice_state.read().mode { 27 | metronome_state.write().bars_per_chord = 28 | p.chords[practice_state.read().next_progression_chord_idx].bars; 29 | } 30 | let _ = m.0.send(MetronomeCommand::SetBars( 31 | metronome_state.read().bars_per_chord, 32 | )); 33 | let _ = m.0.send(MetronomeCommand::Reset); 34 | practice_state.write().next_chord(); 35 | metronome_state.write().current_bar = 0; 36 | metronome_state.write().current_tick = 0; 37 | } 38 | MetronomeEvent::BarComplete(b) => { 39 | let _ = audio_tx.read().send(AudioCommand::PlayChord(( 40 | practice_state.read().current_chord, 41 | calculate_duration_per_bar( 42 | metronome_state.read().bpm, 43 | metronome_state.read().ticks_per_bar, 44 | ) 45 | .duration_per_bar, 46 | metronome_state.read().ticks_per_bar, 47 | ))); 48 | metronome_state.write().current_bar = b; 49 | metronome_state.write().current_tick = 0; 50 | } 51 | MetronomeEvent::Tick(t) => metronome_state.write().current_tick = t, 52 | }; 53 | } 54 | 55 | tokio::time::sleep(Duration::from_millis(5)).await; 56 | } 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/interval.rs: -------------------------------------------------------------------------------- 1 | use strum::{AsRefStr, Display, EnumCount, EnumIter, FromRepr}; 2 | 3 | #[derive( 4 | Default, Clone, Copy, Debug, EnumIter, AsRefStr, PartialEq, EnumCount, FromRepr, Eq, Display, 5 | )] 6 | pub enum Interval { 7 | #[default] 8 | Unison, 9 | MinorSecond, 10 | MajorSecond, 11 | MinorThird, 12 | MajorThird, 13 | PerfectFourth, 14 | AugmentedFourth, 15 | Tritone, 16 | DiminishedFifth, 17 | PerfectFifth, 18 | MinorSixth, 19 | MajorSixth, 20 | MinorSeventh, 21 | MajorSeventh, 22 | Octave, 23 | } 24 | 25 | impl Interval { 26 | pub fn to_semitones(self) -> i32 { 27 | match self { 28 | Interval::Unison => 0, 29 | Interval::MinorSecond => 1, 30 | Interval::MajorSecond => 2, 31 | Interval::MinorThird => 3, 32 | Interval::MajorThird => 4, 33 | Interval::PerfectFourth => 5, 34 | Interval::AugmentedFourth => 6, 35 | Interval::Tritone => 6, 36 | Interval::DiminishedFifth => 6, 37 | Interval::PerfectFifth => 7, 38 | Interval::MinorSixth => 8, 39 | Interval::MajorSixth => 9, 40 | Interval::MinorSeventh => 10, 41 | Interval::MajorSeventh => 11, 42 | Interval::Octave => 12, 43 | } 44 | } 45 | pub fn from_semitone(semitone: i32) -> Self { 46 | match semitone { 47 | 0 => Interval::Unison, 48 | 1 => Interval::MinorSecond, 49 | 2 => Interval::MajorSecond, 50 | 3 => Interval::MinorThird, 51 | 4 => Interval::MajorThird, 52 | 5 => Interval::PerfectFourth, 53 | 6 => Interval::Tritone, 54 | 7 => Interval::PerfectFifth, 55 | 8 => Interval::MinorSixth, 56 | 9 => Interval::MajorSixth, 57 | 10 => Interval::MinorSeventh, 58 | 11 => Interval::MajorSeventh, 59 | _ => panic!("Invalid semitone"), 60 | } 61 | } 62 | 63 | pub fn to_index(self) -> i32 { 64 | match self { 65 | Interval::Unison => 0, 66 | Interval::MinorSecond => 1, 67 | Interval::MajorSecond => 1, 68 | Interval::MinorThird => 2, 69 | Interval::MajorThird => 2, 70 | Interval::PerfectFourth => 3, 71 | Interval::AugmentedFourth => 3, 72 | Interval::Tritone => 3, 73 | Interval::DiminishedFifth => 4, 74 | Interval::PerfectFifth => 4, 75 | Interval::MinorSixth => 5, 76 | Interval::MajorSixth => 5, 77 | Interval::MinorSeventh => 6, 78 | Interval::MajorSeventh => 6, 79 | _ => panic!("Invalid interval"), 80 | } 81 | } 82 | 83 | pub fn from_semitones(semitones: Vec) -> Vec { 84 | semitones 85 | .iter() 86 | .map(|&x| Interval::from_semitone(x)) 87 | .collect() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/play_controls.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc::Sender; 2 | 3 | use chordflow_audio::audio::AudioCommand; 4 | use chordflow_shared::{ 5 | metronome::{calculate_duration_per_bar, MetronomeCommand}, 6 | practice_state::PracticState, 7 | }; 8 | use dioxus::prelude::*; 9 | use dioxus_free_icons::{ 10 | icons::{ 11 | fa_solid_icons::{FaPause, FaPlay}, 12 | io_icons::{IoReloadCircle, IoSaveSharp}, 13 | }, 14 | Icon, 15 | }; 16 | 17 | use crate::{ 18 | components::{apply_selected_changes, buttons::Button}, 19 | MetronomeSignal, MetronomeState, 20 | }; 21 | 22 | pub fn restart() { 23 | let mut practice_state: Signal = use_context(); 24 | let mut metronome_state: Signal = use_context(); 25 | let metronome: MetronomeSignal = use_context(); 26 | let tx_audio: Signal> = use_context(); 27 | practice_state.write().reset(); 28 | metronome_state.write().current_bar = 0; 29 | metronome_state.write().current_tick = 0; 30 | let _ = metronome.read().0.send(MetronomeCommand::Reset); 31 | let _ = tx_audio.read().send(AudioCommand::PlayChord(( 32 | practice_state.read().current_chord, 33 | calculate_duration_per_bar( 34 | metronome_state.read().bpm, 35 | metronome_state.read().ticks_per_bar, 36 | ) 37 | .duration_per_bar, 38 | metronome_state.read().ticks_per_bar, 39 | ))); 40 | } 41 | 42 | #[component] 43 | pub fn PlayControls() -> Element { 44 | let metronome: MetronomeSignal = use_context(); 45 | let tx_audio: Signal> = use_context(); 46 | rsx! { 47 | div { class: "flex justify-center items-center space-x-4", 48 | Button { 49 | onclick: |_| restart(), 50 | icon: rsx! { 51 | Icon { icon: IoReloadCircle } 52 | }, 53 | text: "Restart", 54 | } 55 | Button { 56 | onclick: |_| { 57 | apply_selected_changes(); 58 | }, 59 | icon: rsx! { 60 | Icon { icon: IoSaveSharp } 61 | }, 62 | text: "Apply Changes", 63 | } 64 | Button { 65 | onclick: move |_| { 66 | let _ = tx_audio.read().send(AudioCommand::Play); 67 | let _ = metronome.read().0.send(MetronomeCommand::Play); 68 | }, 69 | icon: rsx! { 70 | Icon { icon: FaPlay } 71 | }, 72 | } 73 | Button { 74 | onclick: move |_| { 75 | let _ = metronome.read().0.send(MetronomeCommand::Pause); 76 | let _ = tx_audio.read().send(AudioCommand::Pause); 77 | }, 78 | icon: rsx! { 79 | Icon { icon: FaPause } 80 | }, 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/quality.rs: -------------------------------------------------------------------------------- 1 | use strum::{AsRefStr, Display, EnumCount, EnumIter, FromRepr}; 2 | 3 | use super::interval::Interval; 4 | 5 | #[derive( 6 | Default, Clone, Copy, Debug, EnumIter, AsRefStr, PartialEq, EnumCount, FromRepr, Eq, Display, 7 | )] 8 | pub enum Quality { 9 | #[default] 10 | #[strum(to_string = "")] 11 | Major, 12 | #[strum(to_string = "-")] 13 | Minor, 14 | #[strum(to_string = "o")] 15 | Diminished, 16 | #[strum(to_string = "+")] 17 | Augmented, 18 | #[strum(to_string = "7")] 19 | Dominant, 20 | #[strum(to_string = "Δ")] 21 | MajorSeventh, 22 | #[strum(to_string = "-7")] 23 | MinorSeventh, 24 | #[strum(to_string = "ø")] 25 | HalfDiminished, 26 | } 27 | 28 | impl Quality { 29 | pub fn from_string(quality: &str) -> Quality { 30 | match quality { 31 | "" => Quality::Major, 32 | "m" => Quality::Minor, 33 | "-" => Quality::Minor, 34 | "o" => Quality::Diminished, 35 | "dim" => Quality::Diminished, 36 | "+" => Quality::Augmented, 37 | "aug" => Quality::Augmented, 38 | "7" => Quality::Dominant, 39 | "maj7" => Quality::MajorSeventh, 40 | "m7" => Quality::MajorSeventh, 41 | "m7b5" => Quality::HalfDiminished, 42 | _ => Quality::Major, 43 | } 44 | } 45 | 46 | pub fn name(&self) -> String { 47 | match self { 48 | Quality::Major => "Major", 49 | Quality::Minor => "Minor", 50 | Quality::Diminished => "Diminished", 51 | Quality::Augmented => "Augmented", 52 | Quality::Dominant => "Dominant", 53 | Quality::MinorSeventh => "Minor Seventh", 54 | Quality::MajorSeventh => "Major Seventh", 55 | Quality::HalfDiminished => "Half Diminished", 56 | } 57 | .into() 58 | } 59 | 60 | pub fn to_intervals(self) -> Vec { 61 | match self { 62 | Quality::Major => Interval::from_semitones([0, 4, 7].to_vec()), 63 | Quality::Minor => Interval::from_semitones([0, 3, 7].to_vec()), 64 | Quality::Diminished => Interval::from_semitones([0, 3, 6].to_vec()), 65 | Quality::Augmented => Interval::from_semitones([0, 4, 8].to_vec()), 66 | Quality::Dominant => Interval::from_semitones([0, 4, 7, 10].to_vec()), 67 | Quality::MinorSeventh => Interval::from_semitones([0, 3, 7, 10].to_vec()), 68 | Quality::MajorSeventh => Interval::from_semitones([0, 4, 7, 11].to_vec()), 69 | Quality::HalfDiminished => Interval::from_semitones([0, 3, 6, 10].to_vec()), 70 | } 71 | } 72 | 73 | pub fn from_intervals(intervals: Vec) -> Quality { 74 | match intervals[..] { 75 | [0, 4, 7] => Quality::Major, 76 | [0, 3, 7] => Quality::Minor, 77 | [0, 3, 6] => Quality::Diminished, 78 | [0, 5, 7] => Quality::Augmented, 79 | [0, 4, 7, 10] => Quality::Dominant, 80 | [0, 3, 7, 10] => Quality::MinorSeventh, 81 | [0, 4, 7, 11] => Quality::MajorSeventh, 82 | [0, 3, 6, 10] => Quality::HalfDiminished, 83 | _ => panic!("Invalid intervals"), 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /icons/ios/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "filename": "AppIcon@2x.png", 5 | "idiom": "iphone", 6 | "scale": "2x", 7 | "size": "60x60" 8 | }, 9 | { 10 | "filename": "AppIcon@3x.png", 11 | "idiom": "iphone", 12 | "scale": "3x", 13 | "size": "60x60" 14 | }, 15 | { 16 | "filename": "AppIcon~ipad.png", 17 | "idiom": "ipad", 18 | "scale": "1x", 19 | "size": "76x76" 20 | }, 21 | { 22 | "filename": "AppIcon@2x~ipad.png", 23 | "idiom": "ipad", 24 | "scale": "2x", 25 | "size": "76x76" 26 | }, 27 | { 28 | "filename": "AppIcon-83.5@2x~ipad.png", 29 | "idiom": "ipad", 30 | "scale": "2x", 31 | "size": "83.5x83.5" 32 | }, 33 | { 34 | "filename": "AppIcon-40@2x.png", 35 | "idiom": "iphone", 36 | "scale": "2x", 37 | "size": "40x40" 38 | }, 39 | { 40 | "filename": "AppIcon-40@3x.png", 41 | "idiom": "iphone", 42 | "scale": "3x", 43 | "size": "40x40" 44 | }, 45 | { 46 | "filename": "AppIcon-40~ipad.png", 47 | "idiom": "ipad", 48 | "scale": "1x", 49 | "size": "40x40" 50 | }, 51 | { 52 | "filename": "AppIcon-40@2x~ipad.png", 53 | "idiom": "ipad", 54 | "scale": "2x", 55 | "size": "40x40" 56 | }, 57 | { 58 | "filename": "AppIcon-20@2x.png", 59 | "idiom": "iphone", 60 | "scale": "2x", 61 | "size": "20x20" 62 | }, 63 | { 64 | "filename": "AppIcon-20@3x.png", 65 | "idiom": "iphone", 66 | "scale": "3x", 67 | "size": "20x20" 68 | }, 69 | { 70 | "filename": "AppIcon-20~ipad.png", 71 | "idiom": "ipad", 72 | "scale": "1x", 73 | "size": "20x20" 74 | }, 75 | { 76 | "filename": "AppIcon-20@2x~ipad.png", 77 | "idiom": "ipad", 78 | "scale": "2x", 79 | "size": "20x20" 80 | }, 81 | { 82 | "filename": "AppIcon-29.png", 83 | "idiom": "iphone", 84 | "scale": "1x", 85 | "size": "29x29" 86 | }, 87 | { 88 | "filename": "AppIcon-29@2x.png", 89 | "idiom": "iphone", 90 | "scale": "2x", 91 | "size": "29x29" 92 | }, 93 | { 94 | "filename": "AppIcon-29@3x.png", 95 | "idiom": "iphone", 96 | "scale": "3x", 97 | "size": "29x29" 98 | }, 99 | { 100 | "filename": "AppIcon-29~ipad.png", 101 | "idiom": "ipad", 102 | "scale": "1x", 103 | "size": "29x29" 104 | }, 105 | { 106 | "filename": "AppIcon-29@2x~ipad.png", 107 | "idiom": "ipad", 108 | "scale": "2x", 109 | "size": "29x29" 110 | }, 111 | { 112 | "filename": "AppIcon-60@2x~car.png", 113 | "idiom": "car", 114 | "scale": "2x", 115 | "size": "60x60" 116 | }, 117 | { 118 | "filename": "AppIcon-60@3x~car.png", 119 | "idiom": "car", 120 | "scale": "3x", 121 | "size": "60x60" 122 | }, 123 | { 124 | "filename": "AppIcon~ios-marketing.png", 125 | "idiom": "ios-marketing", 126 | "scale": "1x", 127 | "size": "1024x1024" 128 | } 129 | ], 130 | "info": { 131 | "author": "iconkitchen", 132 | "version": 1 133 | } 134 | } -------------------------------------------------------------------------------- /chordflow_shared/src/progression.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::fmt::{self, Display}; 3 | 4 | use chordflow_music_theory::{ 5 | accidental::Accidental, 6 | chord::Chord, 7 | note::{Note, NoteLetter}, 8 | quality::Quality, 9 | }; 10 | use regex::Regex; 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub struct Progression { 14 | pub chords: Vec, 15 | } 16 | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 18 | pub struct ProgressionChord { 19 | pub chord: Chord, 20 | pub bars: usize, 21 | } 22 | 23 | impl Display for Progression { 24 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 25 | write!( 26 | f, 27 | "{}", 28 | self.chords 29 | .iter() 30 | .map(|c| format!("{}x{}", c.bars, c.chord)) 31 | .collect::>() 32 | .join(" ") 33 | ) 34 | } 35 | } 36 | 37 | impl ProgressionChord { 38 | pub fn new(chord: Chord, bars: usize) -> Self { 39 | Self { chord, bars } 40 | } 41 | 42 | /// Parse a string into a list of ProgressionChord, tne string should be in the repeated format of 43 | /// , where accidental and quality are optional 44 | /// Examples: 45 | /// 3C 2Bm 1F#aug 46 | pub fn from_string(str: String) -> Result> { 47 | let re = Regex::new( 48 | r"(?\d)(?[ABCDEFGabcdefgh])(?[#b])?(?M|m|\-|aug|\+|dim|o|maj7|7|m7b5)?", 49 | ) 50 | .unwrap(); 51 | 52 | let mut results = vec![]; 53 | for res in re.captures_iter(&str) { 54 | results.push(( 55 | res.name("n").unwrap().as_str().parse::().unwrap(), 56 | res.name("l") 57 | .map_or(NoteLetter::C, |l| NoteLetter::from_string(l.as_str())), 58 | res.name("a") 59 | .map_or(Accidental::Natural, |a| Accidental::from_string(a.as_str())), 60 | res.name("q") 61 | .map_or(Quality::Major, |q| Quality::from_string(q.as_str())), 62 | )); 63 | } 64 | 65 | if results.is_empty() { 66 | return Err(anyhow::anyhow!("Invalid progression")); 67 | } 68 | Ok(results 69 | .into_iter() 70 | .map(|(n, note, accidental, quality)| { 71 | let note = Note::new(note, accidental.to_semitones()); 72 | ProgressionChord::new(Chord::new(note, quality), n) 73 | }) 74 | .collect()) 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | 82 | #[test] 83 | fn test_progression_chord_from_str() { 84 | let results = ProgressionChord::from_string("3C 2Bm 1F#aug".into()); 85 | assert!(results.is_ok()); 86 | assert_eq!( 87 | results.unwrap(), 88 | vec![ 89 | ProgressionChord::new(Chord::new(Note::new(NoteLetter::C, 0), Quality::Major), 3), 90 | ProgressionChord::new(Chord::new(Note::new(NoteLetter::B, 0), Quality::Minor), 2), 91 | ProgressionChord::new( 92 | Chord::new(Note::new(NoteLetter::F, 1), Quality::Augmented), 93 | 1 94 | ), 95 | ] 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /chordflow_desktop/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | sync::mpsc::{Receiver, Sender}, 4 | time::Instant, 5 | }; 6 | 7 | use chordflow_audio::audio::setup_audio; 8 | use chordflow_shared::{ 9 | metronome::{setup_metronome, MetronomeCommand, MetronomeEvent}, 10 | practice_state::{ConfigState, PracticState}, 11 | ModeOption, 12 | }; 13 | use components::{ 14 | config_state::ConfigStateDisplay, 15 | header::Header, 16 | metronome::MetronomeDisplay, 17 | metronome_settings::MetronomSettingsDisplay, 18 | mode_selection::ModeSelectionDisplay, 19 | play_controls::{restart, PlayControls}, 20 | practice_state::PracticeStateDisplay, 21 | }; 22 | use dioxus::{ 23 | desktop::{tao::platform::macos::WindowBuilderExtMacOS, Config, LogicalSize, WindowBuilder}, 24 | prelude::*, 25 | }; 26 | use hooks::use_metronome::use_metronome; 27 | 28 | mod components; 29 | mod hooks; 30 | 31 | const FAVICON: Asset = asset!("/assets/favicon.ico"); 32 | const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); 33 | 34 | fn main() { 35 | let window_builder = WindowBuilder::new() 36 | .with_transparent(true) 37 | .with_decorations(true) 38 | .with_focused(true) 39 | .with_resizable(true) 40 | .with_title("ChordFlow") 41 | .with_has_shadow(true) 42 | .with_movable_by_window_background(true) 43 | .with_inner_size(LogicalSize { 44 | height: 910, 45 | width: 1000, 46 | }) 47 | .with_always_on_top(false); 48 | 49 | let config = Config::default().with_window(window_builder); 50 | 51 | dioxus::LaunchBuilder::new().with_cfg(config).launch(App); 52 | } 53 | 54 | type MetronomeSignal = Signal<(Sender, Receiver)>; 55 | 56 | #[derive(PartialEq, Clone, Copy)] 57 | struct MetronomeState { 58 | bars_per_chord: usize, 59 | ticks_per_bar: usize, 60 | bpm: usize, 61 | current_bar: usize, 62 | current_tick: usize, 63 | } 64 | 65 | impl Display for MetronomeState { 66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 | write!( 68 | f, 69 | "BPM: {}, Bar: {}/{} Tick: {}/{}", 70 | self.bpm, self.current_bar, self.bars_per_chord, self.current_tick, self.ticks_per_bar 71 | ) 72 | } 73 | } 74 | 75 | #[component] 76 | fn App() -> Element { 77 | let audio_tx = use_signal(|| setup_audio(None)); 78 | let metronome = use_signal(|| setup_metronome(100, 2, 4, Instant::now)); 79 | let practice_state = use_signal(PracticState::default); 80 | let selected_mode = use_signal(|| ModeOption::Fourths); 81 | let config_state = use_signal(ConfigState::default); 82 | let metronome_state = use_signal(|| MetronomeState { 83 | bars_per_chord: 2, 84 | ticks_per_bar: 4, 85 | bpm: 100, 86 | current_bar: 0, 87 | current_tick: 0, 88 | }); 89 | 90 | use_context_provider(|| audio_tx); 91 | use_context_provider(|| metronome); 92 | use_context_provider(|| practice_state); 93 | use_context_provider(|| selected_mode); 94 | use_context_provider(|| config_state); 95 | use_context_provider(|| metronome_state); 96 | 97 | use_metronome(metronome, metronome_state, practice_state, audio_tx); 98 | 99 | let mut initial_setup = use_signal(|| true); 100 | 101 | use_effect(move || { 102 | if !*initial_setup.read() { 103 | return; 104 | } 105 | restart(); 106 | initial_setup.set(false); 107 | }); 108 | 109 | rsx! { 110 | // Global app resources 111 | document::Link { rel: "icon", href: FAVICON } 112 | document::Link { rel: "stylesheet", href: TAILWIND_CSS } 113 | body { class: " bg-tokyoNight-bg text-tokyoNight-fg w-screen h-screen", 114 | 115 | Header {} 116 | div { class: "m-2 p-2 flex-col space-y-4", 117 | 118 | 119 | div { class: "flex space-x-4", 120 | div { class: " bg-tokyoNight-bg_highlight/70 p-4 rounded-md", 121 | ModeSelectionDisplay {} 122 | } 123 | div { class: " bg-tokyoNight-bg_highlight/70 flex-1 p-4 rounded-md flex-col space-y-4", 124 | div { class: "", MetronomeDisplay {} } 125 | div { class: "", MetronomSettingsDisplay {} } 126 | div { class: "", PracticeStateDisplay {} } 127 | div { class: "", PlayControls {} } 128 | } 129 | } 130 | div { class: "flex-1 space-x-4", 131 | div { class: " bg-tokyoNight-bg_highlight/70 p-4 rounded-md", ConfigStateDisplay {} } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | os: [macos-latest] 16 | 17 | steps: 18 | - name: Clone repository 19 | uses: actions/checkout@v3 20 | 21 | - name: Install dependencies 22 | run: brew install cmake portaudio pkg-config 23 | 24 | - name: Build 25 | uses: actions-rs/cargo@v1 26 | with: 27 | use-cross: true 28 | command: test 29 | 30 | tui: 31 | needs: test 32 | name: TUI 33 | runs-on: ${{ matrix.os }} 34 | 35 | strategy: 36 | matrix: 37 | include: 38 | - os: macos-latest 39 | target: x86_64-apple-darwin 40 | - os: macos-latest 41 | target: aarch64-apple-darwin 42 | 43 | steps: 44 | - name: Clone repository 45 | uses: actions/checkout@v3 46 | 47 | - name: Install dependencies 48 | run: brew install cmake portaudio pkg-config 49 | 50 | - name: Install Rust 51 | uses: dtolnay/rust-toolchain@stable 52 | with: 53 | targets: ${{ matrix.target }} 54 | 55 | - name: Build 56 | uses: actions-rs/cargo@v1 57 | with: 58 | use-cross: true 59 | command: build 60 | args: --release --target ${{ matrix.target }} 61 | 62 | - name: Get Version 63 | shell: bash 64 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 65 | 66 | - name: Build Archive 67 | shell: bash 68 | run: | 69 | mkdir artifacts 70 | binary_name="chordflow_tui" 71 | app_name="${binary_name}-${{ env.VERSION }}-${{ matrix.target }}" 72 | tar -czf artifacts/${app_name}.tar.gz target/${{ matrix.target }}/release/${binary_name} 73 | 74 | - name: Upload Release Artifacts 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: binaries-tui-${{ matrix.target }} 78 | path: artifacts/ 79 | 80 | desktop: 81 | name: Desktop-MacOS 82 | needs: test 83 | runs-on: ${{ matrix.os }} 84 | 85 | strategy: 86 | matrix: 87 | include: 88 | - os: macos-latest 89 | target: x86_64-apple-darwin 90 | - os: macos-latest 91 | target: aarch64-apple-darwin 92 | 93 | steps: 94 | - name: Clone repository 95 | uses: actions/checkout@v3 96 | 97 | - name: Install dependencies 98 | run: brew install cmake portaudio pkg-config 99 | 100 | - name: Install Rust 101 | uses: dtolnay/rust-toolchain@stable 102 | with: 103 | targets: ${{ matrix.target }} 104 | 105 | - name: Install Dioxus-CLI 106 | shell: bash 107 | run: cargo install dioxus-cli 108 | 109 | - name: Get Version 110 | shell: bash 111 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 112 | 113 | - name: Build Archive 114 | shell: bash 115 | working-directory: ./chordflow_desktop 116 | run: | 117 | mkdir ../artifacts 118 | dx bundle --release 119 | dmg_name="ChordFlow-${{ env.VERSION }}-${{ matrix.target }}.dmg" 120 | mv ../target/dx/chordflow_desktop/bundle/macos/bundle/dmg/ChordflowDesktop_*.dmg ../artifacts/${dmg_name} 121 | 122 | - name: Upload Release Artifacts 123 | uses: actions/upload-artifact@v4 124 | with: 125 | name: binaries-desktop-macos-${{ matrix.target }} 126 | path: artifacts/ 127 | 128 | 129 | release: 130 | needs: [desktop, tui] 131 | runs-on: ubuntu-latest 132 | 133 | permissions: 134 | contents: write 135 | 136 | 137 | steps: 138 | - name: Get Version 139 | shell: bash 140 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 141 | 142 | - name: Download macOS Intel TUI Artifacts 143 | uses: actions/download-artifact@v4 144 | with: 145 | name: binaries-tui-x86_64-apple-darwin 146 | path: artifacts/ 147 | 148 | - name: Download macOS Arch TUI Artifacts 149 | uses: actions/download-artifact@v4 150 | with: 151 | name: binaries-tui-aarch64-apple-darwin 152 | path: artifacts/ 153 | 154 | - name: Download macOS Intel Desktop Artifacts 155 | uses: actions/download-artifact@v4 156 | with: 157 | name: binaries-desktop-macos-x86_64-apple-darwin 158 | path: artifacts/ 159 | 160 | - name: Download macOS Arch Desktop Artifacts 161 | uses: actions/download-artifact@v4 162 | with: 163 | name: binaries-desktop-macos-aarch64-apple-darwin 164 | path: artifacts/ 165 | 166 | - name: Show artifacts 167 | shell: bash 168 | run: ls -la artifacts 169 | 170 | - name: Create GitHub Release 171 | if: startsWith(github.ref, 'refs/tags/') 172 | uses: softprops/action-gh-release@v2 173 | with: 174 | files: | 175 | artifacts/chordflow_tui-${{ env.VERSION }}-x86_64-apple-darwin.tar.gz 176 | artifacts/chordflow_tui-${{ env.VERSION }}-aarch64-apple-darwin.tar.gz 177 | artifacts/ChordFlow-${{ env.VERSION }}-x86_64-apple-darwin.dmg 178 | artifacts/ChordFlow-${{ env.VERSION }}-aarch64-apple-darwin.dmg 179 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/note.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | use strum::{AsRefStr, Display, EnumCount, EnumIter, FromRepr, IntoEnumIterator}; 5 | 6 | use super::{accidental::Accidental, interval::Interval}; 7 | 8 | #[derive( 9 | Default, Clone, Copy, Debug, EnumIter, AsRefStr, PartialEq, EnumCount, FromRepr, Eq, Display, 10 | )] 11 | pub enum NoteLetter { 12 | #[default] 13 | C, 14 | D, 15 | E, 16 | F, 17 | G, 18 | A, 19 | B, 20 | } 21 | 22 | impl NoteLetter { 23 | pub fn to_index(self) -> i32 { 24 | match self { 25 | NoteLetter::C => 0, 26 | NoteLetter::D => 1, 27 | NoteLetter::E => 2, 28 | NoteLetter::F => 3, 29 | NoteLetter::G => 4, 30 | NoteLetter::A => 5, 31 | NoteLetter::B => 6, 32 | } 33 | } 34 | pub fn from_letter_index(idx: i32) -> Self { 35 | match idx { 36 | 0 => NoteLetter::C, 37 | 1 => NoteLetter::D, 38 | 2 => NoteLetter::E, 39 | 3 => NoteLetter::F, 40 | 4 => NoteLetter::G, 41 | 5 => NoteLetter::A, 42 | 6 => NoteLetter::B, 43 | _ => panic!("Invalid note index"), 44 | } 45 | } 46 | pub fn to_semitones(self) -> i32 { 47 | match self { 48 | NoteLetter::C => 0, 49 | NoteLetter::D => 2, 50 | NoteLetter::E => 4, 51 | NoteLetter::F => 5, 52 | NoteLetter::G => 7, 53 | NoteLetter::A => 9, 54 | NoteLetter::B => 11, 55 | } 56 | } 57 | 58 | pub fn from_string(s: &str) -> NoteLetter { 59 | match s.to_uppercase().as_str() { 60 | "C" => NoteLetter::C, 61 | "D" => NoteLetter::D, 62 | "E" => NoteLetter::E, 63 | "F" => NoteLetter::F, 64 | "G" => NoteLetter::G, 65 | "A" => NoteLetter::A, 66 | "B" => NoteLetter::B, 67 | _ => panic!("Invalid note letter"), 68 | } 69 | } 70 | } 71 | 72 | #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] 73 | pub struct Note { 74 | pub letter: NoteLetter, 75 | pub accidentals: i32, 76 | } 77 | 78 | impl Display for Note { 79 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 80 | let mut accidendal = "".to_string(); 81 | if self.accidentals > 0 { 82 | accidendal = "#".repeat(self.accidentals as usize) 83 | } 84 | if self.accidentals < 0 { 85 | accidendal = "b".repeat(-self.accidentals as usize) 86 | }; 87 | write!(f, "{}{}", self.letter, accidendal) 88 | } 89 | } 90 | 91 | impl Note { 92 | pub fn new(letter: NoteLetter, accidentals: i32) -> Note { 93 | Note { 94 | letter, 95 | accidentals, 96 | } 97 | } 98 | pub fn to_semitones(self) -> i32 { 99 | self.letter.to_semitones() + self.accidentals 100 | } 101 | 102 | pub fn add_interval(&self, interval: Interval) -> Note { 103 | let new_semitones = 104 | (self.letter.to_semitones() + self.accidentals + interval.to_semitones()) % 12; 105 | let new_letter_index = (self.letter.to_index() + interval.to_index()) % 7; 106 | let new_letter = NoteLetter::from_letter_index(new_letter_index); 107 | 108 | let remaining_semitones = new_semitones - new_letter.to_semitones(); 109 | Note::new(new_letter, remaining_semitones) 110 | } 111 | } 112 | 113 | pub fn generate_all_roots() -> Vec { 114 | NoteLetter::iter() 115 | .cartesian_product(Accidental::iter()) 116 | .filter(|(note, accidental)| { 117 | let is_b_sharp = note == &NoteLetter::B && accidental == &Accidental::Sharp; 118 | let is_c_flat = note == &NoteLetter::C && accidental == &Accidental::Flat; 119 | let is_e_sharp = note == &NoteLetter::E && accidental == &Accidental::Sharp; 120 | let is_f_flat = note == &NoteLetter::F && accidental == &Accidental::Flat; 121 | !is_c_flat && !is_e_sharp && !is_b_sharp && !is_f_flat 122 | }) 123 | .map(|(note, accidental)| Note::new(note, accidental.to_semitones())) 124 | .collect() 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::*; 130 | 131 | #[test] 132 | fn test_add_interval() { 133 | let note = Note::new(NoteLetter::C, 0); 134 | let intervals = Interval::iter(); 135 | 136 | let actual_notes = vec![ 137 | Note::new(NoteLetter::C, 0), 138 | Note::new(NoteLetter::D, -1), 139 | Note::new(NoteLetter::D, 0), 140 | Note::new(NoteLetter::E, -1), 141 | Note::new(NoteLetter::E, 0), 142 | Note::new(NoteLetter::F, 0), 143 | Note::new(NoteLetter::F, 1), 144 | Note::new(NoteLetter::F, 1), 145 | Note::new(NoteLetter::G, -1), 146 | Note::new(NoteLetter::G, 0), 147 | Note::new(NoteLetter::A, -1), 148 | Note::new(NoteLetter::A, 0), 149 | Note::new(NoteLetter::B, -1), 150 | Note::new(NoteLetter::B, 0), 151 | ]; 152 | 153 | for (interval, actual) in intervals.zip(actual_notes) { 154 | let new_note = note.add_interval(interval); 155 | assert_eq!(new_note, actual); 156 | } 157 | assert_eq!( 158 | Note::new(NoteLetter::F, 1).add_interval(Interval::PerfectFifth), 159 | Note::new(NoteLetter::C, 1) 160 | ) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /chordflow_audio/src/audio.rs: -------------------------------------------------------------------------------- 1 | use std::{env, fs::File, io::Write, path::PathBuf, sync::mpsc, thread, time::Duration}; 2 | 3 | use chordflow_music_theory::chord::Chord; 4 | use fluidlite::{Settings, Synth}; 5 | use log::debug; 6 | use rodio::{buffer::SamplesBuffer, OutputStream, Sink}; 7 | 8 | const SAMPLE_RATE: usize = 44100; 9 | 10 | pub struct Audio { 11 | _stream: OutputStream, 12 | pub synth: Synth, 13 | pub sink: Sink, 14 | } 15 | 16 | pub fn play_chord_with_ticks( 17 | synth: &mut Synth, 18 | notes: &[u32], 19 | chord_duration_ms: u64, 20 | ticks_per_bar: usize, 21 | ) -> Vec { 22 | let mut buffer = vec![0.0; chord_duration_ms as usize * SAMPLE_RATE * 2 / 1000]; 23 | 24 | // Play chord 25 | for note in notes { 26 | synth.note_on(0, *note, 100).unwrap(); 27 | } 28 | 29 | synth.write(buffer.as_mut_slice()).unwrap(); 30 | 31 | // Play metronome ticks (woodblock sound) every quarter note 32 | let tick_interval = chord_duration_ms / ticks_per_bar as u64; 33 | for i in 0..ticks_per_bar { 34 | let tick_time = i as u64 * tick_interval; 35 | play_tick(synth, tick_time, &mut buffer); 36 | } 37 | 38 | // Turn off chord 39 | for note in notes { 40 | synth.note_off(0, *note).unwrap(); 41 | } 42 | 43 | buffer 44 | } 45 | 46 | pub fn play_tick(synth: &mut Synth, tick_time: u64, buffer: &mut [f32]) { 47 | let tick_note = 76; // High Woodblock in General MIDI 48 | let velocity = 120; 49 | 50 | synth.note_on(9, tick_note, velocity).unwrap(); // Channel 9 = Percussion 51 | let mut tick_buffer = vec![0.0; SAMPLE_RATE * 2 / 10]; // Small buffer for the tick (~100ms) 52 | 53 | synth.write(tick_buffer.as_mut_slice()).unwrap(); 54 | synth.note_off(9, tick_note).unwrap(); 55 | 56 | // Mix tick buffer into the main buffer at the correct time 57 | let start_sample = (tick_time as usize * SAMPLE_RATE * 2 / 1000).min(buffer.len()); 58 | (0..tick_buffer.len()).for_each(|i| { 59 | let idx = start_sample + i; 60 | if idx < buffer.len() { 61 | buffer[idx] += tick_buffer[i]; 62 | } 63 | }); 64 | } 65 | 66 | #[derive(Clone, PartialEq)] 67 | pub enum AudioCommand { 68 | PlayChord((Chord, u64, usize)), 69 | Play, 70 | Pause, 71 | } 72 | 73 | pub fn setup_audio(soundfont_path: Option) -> mpsc::Sender { 74 | let (tx, rx) = mpsc::channel(); 75 | let mut synth = create_synth(soundfont_path); 76 | 77 | thread::spawn(move || { 78 | let (_stream, stream_handle) = 79 | OutputStream::try_default().expect("Failed to create audio output stream"); 80 | let sink = Sink::try_new(&stream_handle).expect("Failed to create Rodio sink"); 81 | sink.play(); 82 | 83 | loop { 84 | while let Ok(command) = rx.try_recv() { 85 | match command { 86 | AudioCommand::PlayChord((chord, duration, ticks_per_bar)) => { 87 | sink.stop(); 88 | let notes = chord_to_midi(chord); 89 | let buffer = 90 | play_chord_with_ticks(&mut synth, ¬es, duration, ticks_per_bar); 91 | let source = SamplesBuffer::new(2, SAMPLE_RATE as u32, buffer); 92 | sink.append(source); 93 | sink.play(); 94 | } 95 | AudioCommand::Pause => { 96 | if !sink.is_paused() { 97 | sink.pause(); 98 | } 99 | } 100 | AudioCommand::Play => { 101 | if sink.is_paused() { 102 | sink.play(); 103 | } 104 | } 105 | } 106 | } 107 | thread::sleep(Duration::from_millis(1)); 108 | } 109 | }); 110 | 111 | tx 112 | } 113 | 114 | pub fn create_synth(soundfont_path: Option) -> fluidlite::Synth { 115 | let settings = Settings::new().unwrap(); 116 | 117 | let synth = Synth::new(settings).expect("Failed to create synthesizer"); 118 | synth 119 | .sfload(soundfont_path.unwrap_or(extract_soundfont()), true) 120 | .unwrap(); 121 | synth 122 | } 123 | 124 | fn extract_soundfont() -> PathBuf { 125 | let mut path = env::temp_dir(); 126 | path.push("guitar_practice_soundfont.sf2"); // Use a fixed filename 127 | debug!("{:?}", path); 128 | 129 | if !path.exists() { 130 | // Load SoundFont bytes 131 | let soundfont_bytes = include_bytes!("../assets/TimGM6mb.sf2"); 132 | 133 | // Create and write file 134 | let mut file = File::create(&path).expect("Failed to create temp SoundFont file"); 135 | file.write_all(soundfont_bytes) 136 | .expect("Failed to write SoundFont file"); 137 | } 138 | 139 | path 140 | } 141 | 142 | pub fn create_audio_sink() -> (rodio::Sink, OutputStream) { 143 | let (_stream, stream_handle) = 144 | OutputStream::try_default().expect("Failed to create audio output stream"); 145 | ( 146 | Sink::try_new(&stream_handle).expect("Failed to create Rodio sink"), 147 | _stream, 148 | ) 149 | } 150 | 151 | pub fn note_to_midi(semitones_from_c: i32) -> u32 { 152 | ((semitones_from_c % 12) + 60) as u32 153 | } 154 | 155 | pub fn chord_to_midi(chord: Chord) -> Vec { 156 | chord 157 | .to_c_based_semitones() 158 | .into_iter() 159 | .map(note_to_midi) 160 | .collect() 161 | } 162 | -------------------------------------------------------------------------------- /chordflow_shared/src/metronome.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::mpsc, 3 | thread, 4 | time::{Duration, Instant}, 5 | }; 6 | 7 | pub struct Metronome { 8 | pub num_bars: usize, 9 | pub num_ticks: usize, 10 | pub bpm: usize, 11 | 12 | pub timer_source: Box Instant + Send>, 13 | pub last_tick_time: Instant, 14 | 15 | pub current_tick: usize, 16 | pub current_bar: usize, 17 | pub is_running: bool, 18 | pub pause_time: Option, 19 | } 20 | 21 | impl Metronome { 22 | pub fn new( 23 | bpm: usize, 24 | num_bars: usize, 25 | num_beats: usize, 26 | timer_source: impl Fn() -> Instant + 'static + Send, 27 | ) -> Self { 28 | Metronome { 29 | last_tick_time: timer_source(), 30 | timer_source: Box::new(timer_source), 31 | bpm, 32 | current_bar: 0, 33 | current_tick: 0, 34 | num_bars, 35 | num_ticks: num_beats, 36 | is_running: false, 37 | pause_time: None, 38 | } 39 | } 40 | 41 | pub fn start(&mut self) { 42 | self.is_running = true; 43 | self.last_tick_time = (self.timer_source)(); 44 | } 45 | 46 | pub fn has_bar_ended(&self) -> bool { 47 | self.current_tick % self.num_ticks == 0 48 | } 49 | 50 | pub fn has_cycle_ended(&self) -> bool { 51 | self.has_bar_ended() && self.current_bar % self.num_bars == 0 52 | } 53 | 54 | pub fn tick(&mut self) -> usize { 55 | self.current_tick += 1; 56 | if self.current_tick % self.num_ticks == 0 { 57 | self.current_bar = (self.current_bar + 1) % self.num_bars; 58 | self.current_tick = 0; 59 | } 60 | self.current_tick 61 | } 62 | 63 | pub fn increase_bpm(&mut self, delta: usize) { 64 | self.bpm = self.bpm.saturating_add(delta) 65 | } 66 | pub fn decrease_bpm(&mut self, delta: usize) { 67 | self.bpm = self.bpm.saturating_sub(delta) 68 | } 69 | 70 | pub fn get_tick_duration(&self) -> Duration { 71 | Duration::from_millis((60_000 / self.bpm) as u64) 72 | } 73 | 74 | pub fn reset(&mut self) { 75 | self.current_bar = 0; 76 | self.current_tick = 0; 77 | self.reset_timers(); 78 | } 79 | 80 | pub fn reset_timers(&mut self) { 81 | self.last_tick_time = (self.timer_source)(); 82 | } 83 | } 84 | 85 | pub struct NoteDuration { 86 | pub duration_per_quarter_note: u64, 87 | pub duration_per_bar: u64, 88 | } 89 | 90 | pub fn calculate_duration_per_bar(bpm: usize, ticks_per_bar: usize) -> NoteDuration { 91 | let duration_per_quarter_note = 60000u64 / bpm as u64; 92 | let duration_per_bar = duration_per_quarter_note * ticks_per_bar as u64; 93 | NoteDuration { 94 | duration_per_quarter_note, 95 | duration_per_bar, 96 | } 97 | } 98 | 99 | pub enum MetronomeCommand { 100 | DecreaseBpm(usize), 101 | IncreaseBpm(usize), 102 | Reset, 103 | SetBars(usize), 104 | Pause, 105 | Play, 106 | } 107 | 108 | pub enum MetronomeEvent { 109 | BarComplete(usize), 110 | CycleComplete, 111 | Tick(usize), 112 | } 113 | 114 | pub fn setup_metronome( 115 | bpm: usize, 116 | num_bars: usize, 117 | num_beats: usize, 118 | timer_source: impl Fn() -> Instant + 'static + Send + Copy, 119 | ) -> ( 120 | mpsc::Sender, 121 | mpsc::Receiver, 122 | ) { 123 | let (tx_command, rx_command) = mpsc::channel(); 124 | let (tx_event, rx_event) = mpsc::channel(); 125 | 126 | thread::spawn(move || { 127 | let mut metronome = Metronome::new(bpm, num_bars, num_beats, timer_source); 128 | let mut running = true; 129 | 130 | metronome.last_tick_time = timer_source(); 131 | loop { 132 | while let Ok(command) = rx_command.try_recv() { 133 | match command { 134 | MetronomeCommand::SetBars(n) => metronome.num_bars = n, 135 | MetronomeCommand::Reset => metronome.reset(), 136 | MetronomeCommand::IncreaseBpm(delta) => metronome.increase_bpm(delta), 137 | MetronomeCommand::DecreaseBpm(delta) => metronome.decrease_bpm(delta), 138 | MetronomeCommand::Pause => { 139 | if !running { 140 | continue; 141 | } 142 | running = false; 143 | metronome.pause_time = Some((timer_source)()) 144 | } 145 | MetronomeCommand::Play => { 146 | if let Some(paused_at) = metronome.pause_time.take() { 147 | let pause_duration = (timer_source)().duration_since(paused_at); 148 | metronome.last_tick_time += pause_duration; // Adjust for pause duration 149 | } 150 | running = true; 151 | } 152 | } 153 | } 154 | 155 | if !running { 156 | continue; 157 | } 158 | 159 | let tick_duration = metronome.get_tick_duration(); 160 | let elapsed = (timer_source)().duration_since(metronome.last_tick_time); 161 | 162 | if elapsed >= tick_duration { 163 | let current_tick = metronome.tick(); 164 | let _ = tx_event.send(MetronomeEvent::Tick(current_tick)); 165 | if metronome.has_cycle_ended() { 166 | let _ = tx_event.send(MetronomeEvent::CycleComplete); 167 | } 168 | if metronome.has_bar_ended() { 169 | let _ = tx_event.send(MetronomeEvent::BarComplete(metronome.current_bar)); 170 | } 171 | 172 | // Reset the timer 173 | metronome.last_tick_time = (timer_source)(); 174 | } else { 175 | // Sleep for a short duration to avoid busy waiting 176 | // But keep it short to maintain responsiveness 177 | let sleep_duration = std::cmp::min( 178 | tick_duration.saturating_sub(elapsed), 179 | Duration::from_millis(1), 180 | ); 181 | thread::sleep(sleep_duration); 182 | } 183 | } 184 | }); 185 | 186 | (tx_command, rx_event) 187 | } 188 | -------------------------------------------------------------------------------- /chordflow_tui/src/main.rs: -------------------------------------------------------------------------------- 1 | use chordflow_shared::cli::parse_cli; 2 | use chordflow_shared::metronome::{ 3 | calculate_duration_per_bar, setup_metronome, MetronomeCommand, MetronomeEvent, 4 | }; 5 | use chordflow_shared::practice_state::ConfigState; 6 | use log::LevelFilter; 7 | use log4rs::append::file::FileAppender; 8 | use log4rs::config::{Appender, Config, Root}; 9 | 10 | use log4rs::encode::pattern::PatternEncoder; 11 | use std::io; 12 | use std::sync::mpsc::{Receiver, Sender}; 13 | use std::time::{Duration, Instant}; 14 | 15 | use chordflow_audio::audio::{setup_audio, AudioCommand}; 16 | use chordflow_music_theory::quality::Quality; 17 | use chordflow_shared::{mode::Mode, practice_state::PracticState, DiatonicOption, ModeOption}; 18 | use strum::{AsRefStr, EnumCount, FromRepr, IntoEnumIterator}; 19 | 20 | mod keymap; 21 | mod ui; 22 | 23 | use crossterm::event::{self, Event}; 24 | use keymap::handle_keys; 25 | use ratatui::DefaultTerminal; 26 | use strum::Display; 27 | use strum_macros::EnumIter; 28 | use ui::render_ui; 29 | 30 | #[cfg(debug_assertions)] 31 | fn setup_logging() { 32 | let file_path = "tui.log"; 33 | let logfile = FileAppender::builder() 34 | // Pattern: https://docs.rs/log4rs/*/log4rs/encode/pattern/index.html 35 | .encoder(Box::new(PatternEncoder::new("{l} - {m}\n"))) 36 | .build(file_path) 37 | .unwrap(); 38 | 39 | let config = Config::builder() 40 | .appender(Appender::builder().build("logfile", Box::new(logfile))) 41 | .build( 42 | Root::builder() 43 | .appender("logfile") 44 | .build(LevelFilter::Debug), 45 | ) 46 | .unwrap(); 47 | 48 | let _handle = log4rs::init_config(config); 49 | } 50 | 51 | fn main() -> io::Result<()> { 52 | let cli = parse_cli(); 53 | 54 | #[cfg(debug_assertions)] 55 | setup_logging(); 56 | 57 | let mut terminal = ratatui::init(); 58 | let tx_audio = setup_audio(cli.soundfont); 59 | let mut app = App::new(tx_audio, cli.bpm, cli.bars_per_chord, cli.ticks_per_bar); 60 | 61 | app.run(&mut terminal)?; 62 | ratatui::restore(); 63 | 64 | Ok(()) 65 | } 66 | #[derive(Clone, Copy, Debug, EnumIter, Display, AsRefStr, PartialEq, EnumCount, FromRepr)] 67 | enum AppTab { 68 | Mode, 69 | Config, 70 | Playback, 71 | } 72 | 73 | struct App { 74 | exit: bool, 75 | selected_tab: AppTab, 76 | selected_mode: ModeOption, 77 | 78 | bars_per_chord: usize, 79 | ticks_per_bar: usize, 80 | bpm: usize, 81 | current_bar: usize, 82 | current_tick: usize, 83 | 84 | config_state: ConfigState, 85 | 86 | random_qualities_cursor: Quality, 87 | custom_input_buffer: String, 88 | practice_state: PracticState, 89 | 90 | tx_audio: Sender, 91 | tx_metronome: Sender, 92 | rx_metronome: Receiver, 93 | } 94 | 95 | impl App { 96 | fn new( 97 | tx_audio: Sender, 98 | bpm: usize, 99 | bars_per_chord: usize, 100 | ticks_per_bar: usize, 101 | ) -> Self { 102 | let (tx_metronome, rx_metronome) = 103 | setup_metronome(bpm, bars_per_chord, ticks_per_bar, Instant::now); 104 | Self { 105 | exit: false, 106 | selected_tab: AppTab::Playback, 107 | selected_mode: ModeOption::Fourths, 108 | config_state: ConfigState::default(), 109 | random_qualities_cursor: Quality::Major, 110 | custom_input_buffer: String::new(), 111 | practice_state: PracticState::default(), 112 | bars_per_chord, 113 | ticks_per_bar, 114 | current_bar: 0, 115 | current_tick: 0, 116 | bpm, 117 | tx_audio, 118 | tx_metronome, 119 | rx_metronome, 120 | } 121 | } 122 | 123 | fn next_item(&mut self, current_item: T) -> usize 124 | where 125 | T: EnumCount + IntoEnumIterator + PartialEq, 126 | { 127 | let current_position = T::iter().position(|t| t == current_item).unwrap(); 128 | let next_position: usize = (current_position + 1) % T::COUNT; 129 | next_position 130 | } 131 | 132 | fn prev_item(&mut self, current_item: T) -> usize 133 | where 134 | T: EnumCount + IntoEnumIterator + PartialEq, 135 | { 136 | let current_position = T::iter().position(|t| t == current_item).unwrap(); 137 | let prev_position: usize = if current_position == 0 { 138 | T::COUNT - 1 139 | } else { 140 | current_position - 1 141 | }; 142 | prev_position 143 | } 144 | 145 | fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { 146 | let _ = self.tx_audio.send(AudioCommand::PlayChord(( 147 | self.practice_state.current_chord, 148 | calculate_duration_per_bar(self.bpm, self.ticks_per_bar).duration_per_bar, 149 | self.ticks_per_bar, 150 | ))); 151 | 152 | while !self.exit { 153 | terminal.draw(|f| render_ui(f, self))?; 154 | self.handle_events()?; 155 | self.update(); 156 | } 157 | Ok(()) 158 | } 159 | 160 | fn handle_events(&mut self) -> io::Result<()> { 161 | if event::poll(Duration::from_millis(10))? { 162 | if let Event::Key(key) = event::read()? { 163 | handle_keys(key, self); 164 | } 165 | } 166 | Ok(()) 167 | } 168 | 169 | fn update(&mut self) { 170 | while let Ok(event) = self.rx_metronome.try_recv() { 171 | match event { 172 | MetronomeEvent::CycleComplete => { 173 | if let Mode::Custom(Some(p)) = &self.practice_state.mode { 174 | self.bars_per_chord = 175 | p.chords[self.practice_state.next_progression_chord_idx].bars; 176 | } 177 | let _ = self 178 | .tx_metronome 179 | .send(MetronomeCommand::SetBars(self.bars_per_chord)); 180 | let _ = self.tx_metronome.send(MetronomeCommand::Reset); 181 | self.practice_state.next_chord(); 182 | self.current_bar = 0; 183 | self.current_tick = 0; 184 | } 185 | MetronomeEvent::BarComplete(b) => { 186 | let _ = self.tx_audio.send(AudioCommand::PlayChord(( 187 | self.practice_state.current_chord, 188 | calculate_duration_per_bar(self.bpm, self.ticks_per_bar).duration_per_bar, 189 | self.ticks_per_bar, 190 | ))); 191 | self.current_bar = b; 192 | self.current_tick = 0; 193 | } 194 | MetronomeEvent::Tick(t) => self.current_tick = t, 195 | }; 196 | } 197 | } 198 | } 199 | 200 | fn sync_metronome_bars(app: &mut App) { 201 | if let Mode::Custom(Some(p)) = &app.practice_state.mode { 202 | app.bars_per_chord = p.chords[app.practice_state.next_progression_chord_idx].bars; 203 | } 204 | let _ = app 205 | .tx_metronome 206 | .send(MetronomeCommand::SetBars(app.bars_per_chord)); 207 | } 208 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/config_state.rs: -------------------------------------------------------------------------------- 1 | use chordflow_music_theory::{note::generate_all_roots, quality::Quality}; 2 | use chordflow_shared::{ 3 | practice_state::ConfigState, 4 | progression::{Progression, ProgressionChord}, 5 | DiatonicOption, 6 | }; 7 | use dioxus::prelude::*; 8 | use strum::IntoEnumIterator; 9 | 10 | use crate::components::buttons::{Button, ToggleButton}; 11 | 12 | #[component] 13 | pub fn ConfigStateDisplay() -> Element { 14 | let mut config_state: Signal = use_context(); 15 | let mut progression_input: Signal = use_signal(|| "".to_string()); 16 | let mut progression_error: Signal = use_signal(|| "".to_string()); 17 | let mut config_state: Signal = use_context(); 18 | 19 | rsx! { 20 | div { class: "flex-col space-y-4", 21 | SingleConfigStateDisplay { 22 | title: "Circle of Fourths", 23 | children: rsx! { 24 | div { class: "flex space-x-2 text-sm", 25 | for q in Quality::iter() { 26 | ToggleButton { 27 | onclick: move |_| { 28 | config_state.write().fourths_selected_quality = q; 29 | }, 30 | is_selected: q == config_state.read().fourths_selected_quality, 31 | text: q.name(), 32 | } 33 | } 34 | } 35 | }, 36 | } 37 | SingleConfigStateDisplay { 38 | title: "Diatonic Progression", 39 | children: rsx! { 40 | div { class: "flex space-x-4 text-sm items-center", 41 | div { class: "flex space-x-2", 42 | for q in DiatonicOption::iter() { 43 | ToggleButton { 44 | onclick: move |_| { 45 | config_state.write().diatonic_option = q; 46 | }, 47 | is_selected: q == config_state.read().diatonic_option, 48 | text: q.to_string(), 49 | } 50 | } 51 | } 52 | span { " | " } 53 | select { 54 | class: "select h-9", 55 | onchange: move |e| { 56 | let index = e.value().parse::().unwrap(); 57 | config_state.write().diatonic_root = generate_all_roots()[index]; 58 | }, 59 | for (i , root) in generate_all_roots().into_iter().enumerate() { 60 | option { 61 | label: root.to_string(), 62 | value: i, 63 | selected: root == config_state.read().diatonic_root, 64 | } 65 | } 66 | } 67 | } 68 | }, 69 | } 70 | 71 | SingleConfigStateDisplay { 72 | title: "Random Chords", 73 | children: rsx! { 74 | div { class: "flex space-x-2 text-sm", 75 | for q in Quality::iter() { 76 | ToggleButton { 77 | onclick: move |_| { 78 | if config_state.read().random_selected_qualities.contains(&q) { 79 | config_state.write().random_selected_qualities.retain(|s| *s != q); 80 | } else { 81 | config_state.write().random_selected_qualities.push(q); 82 | } 83 | }, 84 | is_selected: config_state.read().random_selected_qualities.contains(&q), 85 | text: q.name(), 86 | } 87 | } 88 | } 89 | }, 90 | } 91 | SingleConfigStateDisplay { 92 | title: "Custom Progression", 93 | children: rsx! { 94 | div { 95 | span { "Format: " } 96 | span { class: "text-tokyoNight-magenta", "[bars]" } 97 | span { class: "text-tokyoNight-yellow", "[note]" } 98 | span { class: "text-tokyoNight-teal", "[quality]" } 99 | span { " | Example: " } 100 | span { class: "text-tokyoNight-magenta", "3C " } 101 | span { class: "text-tokyoNight-yellow", "2Bm " } 102 | span { class: "text-tokyoNight-teal", "1F#+ " } 103 | } 104 | div { class: "flex space-x-2 text-sm items-center", 105 | input { 106 | class: "border-[1px] border-tokyoNight-comment shadow-lg text-tokyoNight-blue p-2 bg-tokyoNight-bg", 107 | value: "{progression_input}", 108 | oninput: move |event| progression_input.set(event.value()), 109 | } 110 | Button { 111 | onclick: move |_| { 112 | let progression = ProgressionChord::from_string( 113 | progression_input.read().to_string(), 114 | ); 115 | if let Ok(p) = progression { 116 | config_state.write().progression = Some(Progression { chords: p }); 117 | progression_error.set("".to_string()); 118 | } else { 119 | progression_error 120 | .set(format!("Failed to parse {}", progression_input.read())) 121 | } 122 | }, 123 | text: "Parse", 124 | } 125 | span { 126 | class: match config_state.read().progression { 127 | Some(_) => "text-tokyoNight-magenta font-bold tracking-wide", 128 | None => "", 129 | }, 130 | { 131 | if let Some(p) = &config_state.read().progression { 132 | p.to_string() 133 | } else { 134 | "No valid progression".to_string() 135 | } 136 | } 137 | } 138 | span { class: "text-tokyoNight-magenta2", "{progression_error}" } 139 | } 140 | }, 141 | } 142 | } 143 | } 144 | } 145 | 146 | #[component] 147 | pub fn SingleConfigStateDisplay(title: String, children: Element) -> Element { 148 | rsx! { 149 | div { class: "flex-col bg-tokyoNight-bg p-2 space-y-2 rounded-md", 150 | div { class: "font-semibold tracking-wide", {title} } 151 | {children} 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /chordflow_tui/src/keymap.rs: -------------------------------------------------------------------------------- 1 | use chordflow_audio::audio::AudioCommand; 2 | use chordflow_music_theory::{note::generate_all_roots, quality::Quality}; 3 | use chordflow_shared::{ 4 | metronome::{calculate_duration_per_bar, MetronomeCommand}, 5 | mode::update_mode_from_state, 6 | progression::{Progression, ProgressionChord}, 7 | DiatonicOption, ModeOption, 8 | }; 9 | use crossterm::event::KeyCode; 10 | use crossterm::event::KeyEvent; 11 | 12 | use crate::{sync_metronome_bars, App, AppTab}; 13 | 14 | pub fn handle_keys(key: KeyEvent, app: &mut App) { 15 | match key.code { 16 | KeyCode::Left => { 17 | app.selected_tab = AppTab::from_repr(app.prev_item::(app.selected_tab)).unwrap() 18 | } 19 | KeyCode::Right => { 20 | app.selected_tab = AppTab::from_repr(app.next_item::(app.selected_tab)).unwrap() 21 | } 22 | KeyCode::Esc => app.exit = true, 23 | KeyCode::Char('q') => app.exit = true, 24 | KeyCode::F(1) => { 25 | let has_changed = update_mode_from_state( 26 | &app.selected_mode, 27 | &mut app.practice_state, 28 | &app.config_state, 29 | ); 30 | 31 | sync_metronome_bars(app); 32 | if has_changed { 33 | let _ = app.tx_metronome.send(MetronomeCommand::Reset); 34 | app.current_bar = 0; 35 | app.current_tick = 0; 36 | let _ = app.tx_audio.send(AudioCommand::PlayChord(( 37 | app.practice_state.current_chord, 38 | calculate_duration_per_bar(app.bpm, app.ticks_per_bar).duration_per_bar, 39 | app.ticks_per_bar, 40 | ))); 41 | } 42 | } 43 | _ => {} 44 | } 45 | match app.selected_tab { 46 | AppTab::Playback => match key.code { 47 | KeyCode::Up => { 48 | app.bpm += 2; 49 | let _ = app.tx_metronome.send(MetronomeCommand::IncreaseBpm(2)); 50 | sync_metronome_bars(app); 51 | } 52 | KeyCode::Down => { 53 | app.bpm -= 2; 54 | let _ = app.tx_metronome.send(MetronomeCommand::DecreaseBpm(2)); 55 | sync_metronome_bars(app); 56 | } 57 | KeyCode::Char('r') => { 58 | app.practice_state.reset(); 59 | app.current_bar = 0; 60 | app.current_tick = 0; 61 | let _ = app.tx_metronome.send(MetronomeCommand::Reset); 62 | let _ = app.tx_audio.send(AudioCommand::PlayChord(( 63 | app.practice_state.current_chord, 64 | calculate_duration_per_bar(app.bpm, app.ticks_per_bar).duration_per_bar, 65 | app.ticks_per_bar, 66 | ))); 67 | } 68 | _ => {} 69 | }, 70 | 71 | AppTab::Mode => match key.code { 72 | KeyCode::Up => { 73 | app.selected_mode = 74 | ModeOption::from_repr(app.prev_item::(app.selected_mode)).unwrap() 75 | } 76 | KeyCode::Down => { 77 | app.selected_mode = 78 | ModeOption::from_repr(app.next_item::(app.selected_mode)).unwrap() 79 | } 80 | _ => {} 81 | }, 82 | AppTab::Config => match app.selected_mode { 83 | ModeOption::Fourths => match key.code { 84 | KeyCode::Up => { 85 | app.config_state.fourths_selected_quality = Quality::from_repr( 86 | app.prev_item::(app.config_state.fourths_selected_quality), 87 | ) 88 | .unwrap(); 89 | } 90 | KeyCode::Down => { 91 | app.config_state.fourths_selected_quality = Quality::from_repr( 92 | app.next_item::(app.config_state.fourths_selected_quality), 93 | ) 94 | .unwrap(); 95 | } 96 | _ => {} 97 | }, 98 | ModeOption::Random => match key.code { 99 | KeyCode::Up => { 100 | app.random_qualities_cursor = 101 | Quality::from_repr(app.prev_item::(app.random_qualities_cursor)) 102 | .unwrap(); 103 | } 104 | KeyCode::Down => { 105 | app.random_qualities_cursor = 106 | Quality::from_repr(app.next_item::(app.random_qualities_cursor)) 107 | .unwrap(); 108 | } 109 | KeyCode::Char(' ') => { 110 | if app 111 | .config_state 112 | .random_selected_qualities 113 | .contains(&app.random_qualities_cursor) 114 | { 115 | if app.config_state.random_selected_qualities.len() > 1 { 116 | app.config_state 117 | .random_selected_qualities 118 | .retain(|&x| x != app.random_qualities_cursor); 119 | } 120 | } else { 121 | app.config_state 122 | .random_selected_qualities 123 | .push(app.random_qualities_cursor); 124 | } 125 | } 126 | _ => {} 127 | }, 128 | ModeOption::Diatonic => match key.code { 129 | KeyCode::Down => { 130 | let all_roots = generate_all_roots(); 131 | let position = all_roots 132 | .iter() 133 | .position(|&x| x == app.config_state.diatonic_root); 134 | let next_position = (position.unwrap() + 1) % all_roots.len(); 135 | 136 | app.config_state.diatonic_root = all_roots[next_position]; 137 | } 138 | KeyCode::Up => { 139 | let all_roots = generate_all_roots(); 140 | let position = all_roots 141 | .iter() 142 | .position(|&x| x == app.config_state.diatonic_root); 143 | let next_position = if position.unwrap() == 0 { 144 | all_roots.len() - 1 145 | } else { 146 | position.unwrap() - 1 147 | }; 148 | app.config_state.diatonic_root = all_roots[next_position]; 149 | } 150 | KeyCode::Tab => { 151 | app.config_state.diatonic_option = DiatonicOption::from_repr( 152 | app.next_item::(app.config_state.diatonic_option), 153 | ) 154 | .unwrap(); 155 | } 156 | _ => {} 157 | }, 158 | ModeOption::Custom => match key.code { 159 | KeyCode::Enter => { 160 | let progression = 161 | ProgressionChord::from_string(app.custom_input_buffer.clone()); 162 | app.config_state.progression = match progression { 163 | Ok(progression) => Some(Progression { 164 | chords: progression, 165 | }), 166 | _ => None, 167 | } 168 | } 169 | KeyCode::Backspace => { 170 | app.custom_input_buffer.pop(); 171 | } 172 | KeyCode::Char(c) => { 173 | app.custom_input_buffer.push(c); 174 | } 175 | _ => {} 176 | }, 177 | }, 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /chordflow_shared/src/practice_state.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Not; 2 | 3 | use chordflow_music_theory::{ 4 | chord::Chord, 5 | interval::Interval, 6 | note::{generate_all_roots, Note, NoteLetter}, 7 | quality::Quality, 8 | scale::Scale, 9 | util::random_chord, 10 | }; 11 | use rand::{rng, seq::IndexedRandom}; 12 | use strum::IntoEnumIterator; 13 | 14 | use crate::{mode::Mode, progression::Progression, DiatonicOption}; 15 | 16 | #[derive(Clone, PartialEq)] 17 | pub struct ConfigState { 18 | pub fourths_selected_quality: Quality, 19 | pub progression: Option, 20 | pub random_selected_qualities: Vec, 21 | pub diatonic_root: Note, 22 | pub diatonic_option: DiatonicOption, 23 | } 24 | impl Default for ConfigState { 25 | fn default() -> Self { 26 | ConfigState { 27 | fourths_selected_quality: Quality::default(), 28 | progression: Option::default(), 29 | random_selected_qualities: Quality::iter().collect(), 30 | diatonic_root: Note::default(), 31 | diatonic_option: DiatonicOption::default(), 32 | } 33 | } 34 | } 35 | 36 | #[derive(PartialEq, Clone)] 37 | pub struct PracticState { 38 | pub current_chord: Chord, 39 | pub next_chord: Chord, 40 | pub mode: Mode, 41 | pub next_scale_interval: Interval, 42 | pub current_progression_chord_idx: usize, 43 | pub next_progression_chord_idx: usize, 44 | } 45 | 46 | impl PracticState { 47 | pub fn set_mode(&mut self, mode: Mode) -> bool { 48 | if mode == self.mode { 49 | return false; 50 | } 51 | self.mode = mode; 52 | self.reset(); 53 | true 54 | } 55 | 56 | pub fn next_chord(&mut self) { 57 | self.current_chord = self.next_chord; 58 | if let Mode::Custom(Some(p)) = &self.mode { 59 | self.current_progression_chord_idx = 60 | (self.current_progression_chord_idx + 1) % p.chords.len(); 61 | } 62 | self.next_chord = self 63 | .generate_next_chord(self.current_chord, self.mode.clone()) 64 | .unwrap(); 65 | } 66 | 67 | pub fn generate_next_chord(&mut self, chord: Chord, mode: Mode) -> Option { 68 | match mode { 69 | Mode::Fourths(_) => { 70 | let mut next_note = chord.root.add_interval(Interval::PerfectFourth); 71 | if next_note == Note::new(NoteLetter::G, -1) { 72 | next_note = Note::new(NoteLetter::F, 1); 73 | } 74 | Some(Chord::new(next_note, chord.quality)) 75 | } 76 | Mode::Random(qualities) => { 77 | if qualities.is_empty() { 78 | Some(random_chord(None)) 79 | } else { 80 | Some(random_chord(Some(qualities))) 81 | } 82 | } 83 | Mode::Custom(Some(progression)) => { 84 | self.next_progression_chord_idx = 85 | (self.current_progression_chord_idx + 1) % progression.chords.len(); 86 | Some(progression.chords[self.next_progression_chord_idx].chord) 87 | } 88 | Mode::Custom(None) => None, 89 | Mode::Diatonic(scale, option) => { 90 | let interval = 91 | next_diatonic_scale_interval(&option, &scale, &self.next_scale_interval); 92 | let quality = calculate_chord_quality_in_scale(&scale, &interval); 93 | 94 | let next_note = scale.root.add_interval(interval); 95 | self.next_scale_interval = interval; 96 | Some(Chord::new(next_note, quality)) 97 | } 98 | } 99 | } 100 | 101 | pub fn reset(&mut self) { 102 | let mut rand = rng(); 103 | self.current_chord = match &self.mode { 104 | Mode::Fourths(q) => Chord::new(Note::new(NoteLetter::B, 0), *q), 105 | Mode::Random(qs) => { 106 | let note = *generate_all_roots().choose(&mut rand).unwrap(); 107 | Chord::new(note, *qs.choose(&mut rand).unwrap()) 108 | } 109 | Mode::Diatonic(scale, _) => { 110 | self.next_scale_interval = scale.intervals[0]; 111 | Chord::new(scale.root, Quality::Major) 112 | } 113 | Mode::Custom(p) => p.clone().expect("woops").chords[0].chord, 114 | }; 115 | self.next_chord = self 116 | .generate_next_chord(self.current_chord, self.mode.clone()) 117 | .unwrap(); 118 | } 119 | } 120 | 121 | fn next_diatonic_scale_interval( 122 | option: &DiatonicOption, 123 | scale: &Scale, 124 | current_scale_interval: &Interval, 125 | ) -> Interval { 126 | let mut rand = rng(); 127 | match option { 128 | DiatonicOption::Incemental => { 129 | let index = scale 130 | .intervals 131 | .iter() 132 | .position(|f| f == current_scale_interval) 133 | .unwrap(); 134 | let next_index = (index + 1) % scale.intervals.len(); 135 | scale.intervals[next_index] 136 | } 137 | DiatonicOption::Random => *scale.intervals.choose(&mut rand).unwrap(), 138 | } 139 | } 140 | 141 | fn calculate_chord_quality_in_scale(scale: &Scale, interval: &Interval) -> Quality { 142 | let new_scale_index = scale.intervals.iter().position(|f| f == interval).unwrap() as i32; 143 | let new_chord_indexes: Vec = 144 | vec![new_scale_index, new_scale_index + 2, new_scale_index + 4] 145 | .into_iter() 146 | .map(|i| normalize(i, 7)) 147 | .map(|i| scale.intervals[i as usize].to_semitones()) 148 | .collect(); 149 | let zero_based_chord_indexes = new_chord_indexes 150 | .iter() 151 | .map(|i| normalize(i - new_chord_indexes[0], 12)) 152 | .collect::>(); 153 | Quality::from_intervals(zero_based_chord_indexes) 154 | } 155 | 156 | fn normalize(interval: i32, base: i32) -> i32 { 157 | (interval + base) % base 158 | } 159 | 160 | impl Default for PracticState { 161 | fn default() -> Self { 162 | let mode = Mode::Fourths(Quality::Major); 163 | let current_chord = Chord::new(Note::new(NoteLetter::B, 0), Quality::Major); 164 | let next_chord = Chord::new(Note::new(NoteLetter::E, 0), Quality::Major); 165 | PracticState { 166 | mode, 167 | current_chord, 168 | next_chord, 169 | next_scale_interval: Interval::Unison, 170 | next_progression_chord_idx: 0, 171 | current_progression_chord_idx: 0, 172 | } 173 | } 174 | } 175 | #[cfg(test)] 176 | mod tests { 177 | 178 | use chordflow_music_theory::scale::ScaleType; 179 | 180 | use super::*; 181 | 182 | #[test] 183 | fn test_calculate_chord_quality_in_scale() { 184 | assert_eq!( 185 | calculate_chord_quality_in_scale( 186 | &Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic), 187 | &Interval::MajorThird 188 | ), 189 | Quality::Minor 190 | ); 191 | assert_eq!( 192 | calculate_chord_quality_in_scale( 193 | &Scale::new(Note::new(NoteLetter::F, 1), ScaleType::Diatonic), 194 | &Interval::PerfectFourth 195 | ), 196 | Quality::Major 197 | ); 198 | 199 | let c_major = Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic); 200 | let real_qualities = vec![ 201 | Quality::Major, 202 | Quality::Minor, 203 | Quality::Minor, 204 | Quality::Major, 205 | Quality::Major, 206 | Quality::Minor, 207 | Quality::Diminished, 208 | ]; 209 | for (interval, quality) in c_major.intervals.iter().zip(real_qualities) { 210 | assert_eq!( 211 | calculate_chord_quality_in_scale(&c_major, interval), 212 | quality 213 | ); 214 | } 215 | } 216 | 217 | #[test] 218 | fn test_next_diatonic_chord() { 219 | let c_major = Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic); 220 | 221 | let actual_intervals = vec![ 222 | Interval::MajorSecond, 223 | Interval::MajorThird, 224 | Interval::PerfectFourth, 225 | Interval::PerfectFifth, 226 | Interval::MajorSixth, 227 | Interval::MajorSeventh, 228 | Interval::Unison, 229 | ]; 230 | for (interval, real_interval) in c_major.intervals.iter().zip(actual_intervals) { 231 | assert_eq!( 232 | next_diatonic_scale_interval(&DiatonicOption::Incemental, &c_major, interval), 233 | real_interval 234 | ) 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /chordflow_tui/src/ui.rs: -------------------------------------------------------------------------------- 1 | use chordflow_music_theory::{note::generate_all_roots, quality::Quality}; 2 | use ratatui::{ 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::{Line, Span}, 6 | widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Tabs}, 7 | Frame, 8 | }; 9 | use strum::IntoEnumIterator; 10 | 11 | use crate::{App, AppTab, DiatonicOption, ModeOption}; 12 | 13 | pub fn render_ui(f: &mut Frame, app: &App) { 14 | let chunks = Layout::default() 15 | .direction(Direction::Vertical) 16 | .constraints( 17 | [ 18 | Constraint::Length(3), // Tabs 19 | Constraint::Min(10), // Content Area 20 | Constraint::Length(10), // Content Area 21 | ] 22 | .as_ref(), 23 | ) 24 | .split(f.area()); 25 | 26 | let keymap_chunks = Layout::default() 27 | .direction(Direction::Horizontal) 28 | .constraints( 29 | [ 30 | Constraint::Percentage(50), // Tabs 31 | Constraint::Percentage(50), // Content Area 32 | ] 33 | .as_ref(), 34 | ) 35 | .split(chunks[2]); 36 | 37 | // Tabs 38 | let tab_titles = AppTab::iter(); 39 | let tabs = Tabs::new( 40 | tab_titles 41 | .map(|t| t.as_ref().to_string()) 42 | .map(Span::from) 43 | .collect::>(), 44 | ) 45 | .block(Block::default().borders(Borders::ALL).title(" Tabs ")) 46 | .highlight_style( 47 | Style::default() 48 | .fg(Color::Yellow) 49 | .add_modifier(Modifier::BOLD), 50 | ) 51 | .select(AppTab::iter().position(|t| t == app.selected_tab).unwrap()); 52 | 53 | f.render_widget(tabs, chunks[0]); 54 | 55 | f.render_widget( 56 | render_local_keymap(vec![ 57 | "( q / Esc ) : Quit", 58 | "( ← / → ) : Navigate tabs", 59 | "( F1 ) : Apply configurations", 60 | ]), 61 | keymap_chunks[0], 62 | ); 63 | 64 | // Content Area 65 | match app.selected_tab { 66 | AppTab::Mode => { 67 | render_mode_tab(f, chunks[1], app); 68 | } 69 | AppTab::Config => render_config_tab(app, f, chunks[1], keymap_chunks[1]), 70 | AppTab::Playback => render_playback_tab(app, f, chunks[1], keymap_chunks[1]), 71 | } 72 | } 73 | 74 | fn render_mode_tab(f: &mut Frame, area: ratatui::layout::Rect, app: &App) { 75 | let items: Vec = ModeOption::iter() 76 | .map(|mode| { 77 | let style = if mode == app.selected_mode { 78 | Style::default() 79 | .fg(Color::Yellow) 80 | .add_modifier(Modifier::BOLD) 81 | } else { 82 | Style::default() 83 | }; 84 | ListItem::new(Span::styled(mode.as_ref().to_string(), style)) 85 | }) 86 | .collect(); 87 | 88 | let list = List::new(items) 89 | .block( 90 | Block::default() 91 | .borders(Borders::ALL) 92 | .title(" Select Mode "), 93 | ) 94 | .highlight_style( 95 | Style::default() 96 | .fg(Color::Yellow) 97 | .add_modifier(Modifier::BOLD), 98 | ); 99 | 100 | f.render_widget(list, area); 101 | } 102 | 103 | fn render_config_tab(app: &App, f: &mut Frame, area: ratatui::layout::Rect, keymap_chunks: Rect) { 104 | match app.selected_mode { 105 | ModeOption::Fourths => render_fourths_config(f, area, app, keymap_chunks), 106 | ModeOption::Random => render_random_config(f, area, app, keymap_chunks), 107 | ModeOption::Custom => render_custom_config(f, area, app, keymap_chunks), 108 | ModeOption::Diatonic => render_diatonic_config(f, area, app, keymap_chunks), 109 | } 110 | } 111 | 112 | fn render_diatonic_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 113 | let all_roots = generate_all_roots(); 114 | f.render_widget( 115 | render_local_keymap(vec![ 116 | "( ) : Select mode", 117 | "( ↑ / ↓ ) : Select root major", 118 | ]), 119 | keymap_chunks, 120 | ); 121 | let items: Vec = DiatonicOption::iter() 122 | .map(|mode| { 123 | let style = if mode == app.config_state.diatonic_option { 124 | Style::default() 125 | .fg(Color::Yellow) 126 | .add_modifier(Modifier::BOLD) 127 | } else { 128 | Style::default() 129 | }; 130 | ListItem::new(Span::styled(mode.as_ref().to_string(), style)) 131 | }) 132 | .collect(); 133 | 134 | let roots: Vec = all_roots 135 | .iter() 136 | .map(|note| { 137 | let style = if note == &app.config_state.diatonic_root { 138 | Style::default() 139 | .fg(Color::Yellow) 140 | .add_modifier(Modifier::BOLD) 141 | } else { 142 | Style::default() 143 | }; 144 | ListItem::new(Span::styled(format!("{}", note), style)) 145 | }) 146 | .collect(); 147 | 148 | let chunks = Layout::default() 149 | .direction(Direction::Vertical) 150 | .constraints([Constraint::Length(5), Constraint::Min(10)].as_ref()) 151 | .split(area); 152 | 153 | let list = List::new(items) 154 | .block( 155 | Block::default() 156 | .borders(Borders::ALL) 157 | .title(" Select Diatonic Progression "), 158 | ) 159 | .highlight_style( 160 | Style::default() 161 | .fg(Color::Yellow) 162 | .add_modifier(Modifier::BOLD), 163 | ); 164 | 165 | let root_list = List::new(roots) 166 | .block( 167 | Block::default() 168 | .borders(Borders::ALL) 169 | .title(" Select Root "), 170 | ) 171 | .highlight_style( 172 | Style::default() 173 | .fg(Color::Yellow) 174 | .add_modifier(Modifier::BOLD), 175 | ); 176 | 177 | f.render_widget(list, chunks[0]); 178 | f.render_widget(root_list, chunks[1]); 179 | } 180 | 181 | fn render_custom_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 182 | f.render_widget( 183 | render_local_keymap(vec!["( Enter ) : Parse input"]), 184 | keymap_chunks, 185 | ); 186 | let chunks = Layout::default() 187 | .direction(Direction::Vertical) 188 | .constraints([Constraint::Length(3), Constraint::Min(10)].as_ref()) 189 | .split(area); 190 | 191 | let input = Paragraph::new( 192 | Line::from(app.custom_input_buffer.clone()).style(Style::default().fg(Color::White)), 193 | ) 194 | .block( 195 | Block::default() 196 | .border_type(BorderType::Rounded) 197 | .borders(Borders::ALL) 198 | .title(" Input "), 199 | ); 200 | 201 | let progression = match app.config_state.progression.clone() { 202 | Some(progression) => progression 203 | .chords 204 | .iter() 205 | .flat_map(|c| { 206 | vec![ 207 | Span::from(c.bars.to_string()).style(Style::default().fg(Color::Yellow)), 208 | Span::from("x"), 209 | Span::from(c.chord.to_string().to_string()).style( 210 | Style::default() 211 | .fg(Color::Yellow) 212 | .add_modifier(Modifier::BOLD), 213 | ), 214 | Span::from(" "), 215 | ] 216 | }) 217 | .collect::>(), 218 | None => { 219 | vec![Span::from("Invalid progression".to_string()) 220 | .style(Style::default().fg(Color::Red))] 221 | } 222 | }; 223 | 224 | let progression_block = Paragraph::new(Line::from(progression.clone())) 225 | .alignment(Alignment::Center) 226 | .block( 227 | Block::default() 228 | .borders(Borders::ALL) 229 | .title(" Parsed Progression "), 230 | ); 231 | 232 | f.render_widget(input, chunks[0]); 233 | f.render_widget(progression_block, chunks[1]); 234 | } 235 | 236 | fn render_random_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 237 | f.render_widget( 238 | render_local_keymap(vec![ 239 | "( ↑ / ↓ ) : Select quality", 240 | "( ) : Toggle selection", 241 | ]), 242 | keymap_chunks, 243 | ); 244 | let items: Vec = Quality::iter() 245 | .map(|quality| { 246 | let prefix = if app 247 | .config_state 248 | .random_selected_qualities 249 | .contains(&quality) 250 | { 251 | "[✔]" 252 | } else { 253 | "[ ]" 254 | }; 255 | let style = if quality == app.random_qualities_cursor { 256 | Style::default() 257 | .fg(Color::Yellow) 258 | .add_modifier(Modifier::BOLD) 259 | } else { 260 | Style::default() 261 | }; 262 | ListItem::new(Span::styled( 263 | format!("{} {}", prefix, quality.name()), 264 | style, 265 | )) 266 | }) 267 | .collect(); 268 | 269 | let list = List::new(items) 270 | .block( 271 | Block::default() 272 | .borders(Borders::ALL) 273 | .title(" Select Qualities "), 274 | ) 275 | .highlight_style( 276 | Style::default() 277 | .fg(Color::Yellow) 278 | .add_modifier(Modifier::BOLD), 279 | ); 280 | 281 | f.render_widget(list, area); 282 | } 283 | 284 | fn render_local_keymap(lst: Vec<&str>) -> List<'_> { 285 | let local_keys: Vec = lst.iter().map(|&key| ListItem::new(key)).collect(); 286 | let local_key_list = List::new(local_keys) 287 | .block(Block::default().borders(Borders::ALL).title(" KeyMap ")) 288 | .style(Style::default().fg(Color::Yellow)); 289 | local_key_list 290 | } 291 | 292 | fn render_fourths_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 293 | f.render_widget( 294 | render_local_keymap(vec!["( ↑ / ↓ ) : Select quality"]), 295 | keymap_chunks, 296 | ); 297 | 298 | let items: Vec = Quality::iter() 299 | .map(|selection| { 300 | let style = if selection == app.config_state.fourths_selected_quality { 301 | Style::default() 302 | .fg(Color::Yellow) 303 | .add_modifier(Modifier::BOLD) 304 | } else { 305 | Style::default() 306 | }; 307 | ListItem::new(Span::styled(selection.name(), style)) 308 | }) 309 | .collect(); 310 | 311 | let list = List::new(items) 312 | .block( 313 | Block::default() 314 | .borders(Borders::ALL) 315 | .title(" Select Quality "), 316 | ) 317 | .highlight_style( 318 | Style::default() 319 | .fg(Color::Yellow) 320 | .add_modifier(Modifier::BOLD), 321 | ); 322 | 323 | f.render_widget(list, area); 324 | } 325 | 326 | fn render_playback_tab(app: &App, f: &mut Frame, area: ratatui::layout::Rect, keymap_chunks: Rect) { 327 | f.render_widget( 328 | render_local_keymap(vec![ 329 | "( ↑ / ↓ ) : Increase / Decrease BPM with 2", 330 | ("( r ) : Restart"), 331 | ]), 332 | keymap_chunks, 333 | ); 334 | 335 | let chunks = Layout::default() 336 | .direction(Direction::Vertical) 337 | .constraints( 338 | [ 339 | Constraint::Length(6), 340 | Constraint::Length(6), 341 | Constraint::Min(10), 342 | ] 343 | .as_ref(), 344 | ) 345 | .split(area); 346 | 347 | let metronome_display = generate_metronome_display(app); 348 | 349 | let debug_text = format!("Bar: {} Beat: {}", app.current_bar, app.current_tick); 350 | 351 | let metronome_paragraph = Paragraph::new(vec![ 352 | Line::from(vec![ 353 | Span::styled("Speed: ".to_string(), Style::default()), 354 | Span::styled(format!("{} ", app.bpm), Style::default().fg(Color::Yellow)), 355 | Span::styled("BPM".to_string(), Style::default()), 356 | ]) 357 | .alignment(Alignment::Left), 358 | Line::from(""), 359 | Line::from(metronome_display).alignment(Alignment::Center), 360 | #[cfg(debug_assertions)] 361 | Line::from(debug_text), 362 | ]) 363 | .block( 364 | Block::default() 365 | .borders(Borders::ALL) 366 | .title(" Metronome ") 367 | .border_type(BorderType::Rounded) 368 | .title_alignment(Alignment::Center), 369 | ); 370 | 371 | f.render_widget(metronome_paragraph, chunks[0]); 372 | 373 | let current_chord = Line::from(Span::styled( 374 | format!("Current chord: {}", app.practice_state.current_chord), 375 | Style::default().fg(Color::Cyan), 376 | )); 377 | 378 | let next_chord = Line::from(Span::styled( 379 | format!("Next chord: {}", app.practice_state.next_chord), 380 | Style::default(), 381 | )); 382 | 383 | let chord_paragraph = Paragraph::new(vec![ 384 | Line::from(vec![ 385 | Span::styled("Mode: ".to_string(), Style::default()), 386 | Span::styled( 387 | format!("{}", app.practice_state.mode), 388 | Style::default().fg(Color::Yellow), 389 | ), 390 | ]), 391 | Line::from(""), 392 | current_chord, 393 | next_chord, 394 | ]) 395 | .block( 396 | Block::default() 397 | .borders(Borders::ALL) 398 | .title("🎵 Chords 🎵") 399 | .title_alignment(Alignment::Center), 400 | ) 401 | .alignment(Alignment::Left); 402 | 403 | f.render_widget(chord_paragraph, chunks[1]); 404 | } 405 | 406 | fn generate_metronome_display(app: &App) -> String { 407 | let mut metronome_display = String::new(); 408 | 409 | for bar in 0..app.bars_per_chord { 410 | if bar > 0 { 411 | metronome_display.push_str(" | "); // Separate bars 412 | } 413 | for tick in 0..app.ticks_per_bar { 414 | if bar < app.current_bar || (bar == app.current_bar && tick <= app.current_tick) { 415 | metronome_display.push('⬛'); 416 | } else { 417 | metronome_display.push('⬜'); 418 | } 419 | } 420 | } 421 | 422 | metronome_display 423 | } 424 | -------------------------------------------------------------------------------- /chordflow_desktop/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | *, ::before, ::after { 2 | --tw-border-spacing-x: 0; 3 | --tw-border-spacing-y: 0; 4 | --tw-translate-x: 0; 5 | --tw-translate-y: 0; 6 | --tw-rotate: 0; 7 | --tw-skew-x: 0; 8 | --tw-skew-y: 0; 9 | --tw-scale-x: 1; 10 | --tw-scale-y: 1; 11 | --tw-pan-x: ; 12 | --tw-pan-y: ; 13 | --tw-pinch-zoom: ; 14 | --tw-scroll-snap-strictness: proximity; 15 | --tw-gradient-from-position: ; 16 | --tw-gradient-via-position: ; 17 | --tw-gradient-to-position: ; 18 | --tw-ordinal: ; 19 | --tw-slashed-zero: ; 20 | --tw-numeric-figure: ; 21 | --tw-numeric-spacing: ; 22 | --tw-numeric-fraction: ; 23 | --tw-ring-inset: ; 24 | --tw-ring-offset-width: 0px; 25 | --tw-ring-offset-color: #fff; 26 | --tw-ring-color: rgb(59 130 246 / 0.5); 27 | --tw-ring-offset-shadow: 0 0 #0000; 28 | --tw-ring-shadow: 0 0 #0000; 29 | --tw-shadow: 0 0 #0000; 30 | --tw-shadow-colored: 0 0 #0000; 31 | --tw-blur: ; 32 | --tw-brightness: ; 33 | --tw-contrast: ; 34 | --tw-grayscale: ; 35 | --tw-hue-rotate: ; 36 | --tw-invert: ; 37 | --tw-saturate: ; 38 | --tw-sepia: ; 39 | --tw-drop-shadow: ; 40 | --tw-backdrop-blur: ; 41 | --tw-backdrop-brightness: ; 42 | --tw-backdrop-contrast: ; 43 | --tw-backdrop-grayscale: ; 44 | --tw-backdrop-hue-rotate: ; 45 | --tw-backdrop-invert: ; 46 | --tw-backdrop-opacity: ; 47 | --tw-backdrop-saturate: ; 48 | --tw-backdrop-sepia: ; 49 | --tw-contain-size: ; 50 | --tw-contain-layout: ; 51 | --tw-contain-paint: ; 52 | --tw-contain-style: ; 53 | } 54 | 55 | ::backdrop { 56 | --tw-border-spacing-x: 0; 57 | --tw-border-spacing-y: 0; 58 | --tw-translate-x: 0; 59 | --tw-translate-y: 0; 60 | --tw-rotate: 0; 61 | --tw-skew-x: 0; 62 | --tw-skew-y: 0; 63 | --tw-scale-x: 1; 64 | --tw-scale-y: 1; 65 | --tw-pan-x: ; 66 | --tw-pan-y: ; 67 | --tw-pinch-zoom: ; 68 | --tw-scroll-snap-strictness: proximity; 69 | --tw-gradient-from-position: ; 70 | --tw-gradient-via-position: ; 71 | --tw-gradient-to-position: ; 72 | --tw-ordinal: ; 73 | --tw-slashed-zero: ; 74 | --tw-numeric-figure: ; 75 | --tw-numeric-spacing: ; 76 | --tw-numeric-fraction: ; 77 | --tw-ring-inset: ; 78 | --tw-ring-offset-width: 0px; 79 | --tw-ring-offset-color: #fff; 80 | --tw-ring-color: rgb(59 130 246 / 0.5); 81 | --tw-ring-offset-shadow: 0 0 #0000; 82 | --tw-ring-shadow: 0 0 #0000; 83 | --tw-shadow: 0 0 #0000; 84 | --tw-shadow-colored: 0 0 #0000; 85 | --tw-blur: ; 86 | --tw-brightness: ; 87 | --tw-contrast: ; 88 | --tw-grayscale: ; 89 | --tw-hue-rotate: ; 90 | --tw-invert: ; 91 | --tw-saturate: ; 92 | --tw-sepia: ; 93 | --tw-drop-shadow: ; 94 | --tw-backdrop-blur: ; 95 | --tw-backdrop-brightness: ; 96 | --tw-backdrop-contrast: ; 97 | --tw-backdrop-grayscale: ; 98 | --tw-backdrop-hue-rotate: ; 99 | --tw-backdrop-invert: ; 100 | --tw-backdrop-opacity: ; 101 | --tw-backdrop-saturate: ; 102 | --tw-backdrop-sepia: ; 103 | --tw-contain-size: ; 104 | --tw-contain-layout: ; 105 | --tw-contain-paint: ; 106 | --tw-contain-style: ; 107 | } 108 | 109 | /* 110 | ! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com 111 | */ 112 | 113 | /* 114 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 115 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 116 | */ 117 | 118 | *, 119 | ::before, 120 | ::after { 121 | box-sizing: border-box; 122 | /* 1 */ 123 | border-width: 0; 124 | /* 2 */ 125 | border-style: solid; 126 | /* 2 */ 127 | border-color: #e5e7eb; 128 | /* 2 */ 129 | } 130 | 131 | ::before, 132 | ::after { 133 | --tw-content: ''; 134 | } 135 | 136 | /* 137 | 1. Use a consistent sensible line-height in all browsers. 138 | 2. Prevent adjustments of font size after orientation changes in iOS. 139 | 3. Use a more readable tab size. 140 | 4. Use the user's configured `sans` font-family by default. 141 | 5. Use the user's configured `sans` font-feature-settings by default. 142 | 6. Use the user's configured `sans` font-variation-settings by default. 143 | 7. Disable tap highlights on iOS 144 | */ 145 | 146 | html, 147 | :host { 148 | line-height: 1.5; 149 | /* 1 */ 150 | -webkit-text-size-adjust: 100%; 151 | /* 2 */ 152 | -moz-tab-size: 4; 153 | /* 3 */ 154 | -o-tab-size: 4; 155 | tab-size: 4; 156 | /* 3 */ 157 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 158 | /* 4 */ 159 | font-feature-settings: normal; 160 | /* 5 */ 161 | font-variation-settings: normal; 162 | /* 6 */ 163 | -webkit-tap-highlight-color: transparent; 164 | /* 7 */ 165 | } 166 | 167 | /* 168 | 1. Remove the margin in all browsers. 169 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 170 | */ 171 | 172 | body { 173 | margin: 0; 174 | /* 1 */ 175 | line-height: inherit; 176 | /* 2 */ 177 | } 178 | 179 | /* 180 | 1. Add the correct height in Firefox. 181 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 182 | 3. Ensure horizontal rules are visible by default. 183 | */ 184 | 185 | hr { 186 | height: 0; 187 | /* 1 */ 188 | color: inherit; 189 | /* 2 */ 190 | border-top-width: 1px; 191 | /* 3 */ 192 | } 193 | 194 | /* 195 | Add the correct text decoration in Chrome, Edge, and Safari. 196 | */ 197 | 198 | abbr:where([title]) { 199 | -webkit-text-decoration: underline dotted; 200 | text-decoration: underline dotted; 201 | } 202 | 203 | /* 204 | Remove the default font size and weight for headings. 205 | */ 206 | 207 | h1, 208 | h2, 209 | h3, 210 | h4, 211 | h5, 212 | h6 { 213 | font-size: inherit; 214 | font-weight: inherit; 215 | } 216 | 217 | /* 218 | Reset links to optimize for opt-in styling instead of opt-out. 219 | */ 220 | 221 | a { 222 | color: inherit; 223 | text-decoration: inherit; 224 | } 225 | 226 | /* 227 | Add the correct font weight in Edge and Safari. 228 | */ 229 | 230 | b, 231 | strong { 232 | font-weight: bolder; 233 | } 234 | 235 | /* 236 | 1. Use the user's configured `mono` font-family by default. 237 | 2. Use the user's configured `mono` font-feature-settings by default. 238 | 3. Use the user's configured `mono` font-variation-settings by default. 239 | 4. Correct the odd `em` font sizing in all browsers. 240 | */ 241 | 242 | code, 243 | kbd, 244 | samp, 245 | pre { 246 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 247 | /* 1 */ 248 | font-feature-settings: normal; 249 | /* 2 */ 250 | font-variation-settings: normal; 251 | /* 3 */ 252 | font-size: 1em; 253 | /* 4 */ 254 | } 255 | 256 | /* 257 | Add the correct font size in all browsers. 258 | */ 259 | 260 | small { 261 | font-size: 80%; 262 | } 263 | 264 | /* 265 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 266 | */ 267 | 268 | sub, 269 | sup { 270 | font-size: 75%; 271 | line-height: 0; 272 | position: relative; 273 | vertical-align: baseline; 274 | } 275 | 276 | sub { 277 | bottom: -0.25em; 278 | } 279 | 280 | sup { 281 | top: -0.5em; 282 | } 283 | 284 | /* 285 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 286 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 287 | 3. Remove gaps between table borders by default. 288 | */ 289 | 290 | table { 291 | text-indent: 0; 292 | /* 1 */ 293 | border-color: inherit; 294 | /* 2 */ 295 | border-collapse: collapse; 296 | /* 3 */ 297 | } 298 | 299 | /* 300 | 1. Change the font styles in all browsers. 301 | 2. Remove the margin in Firefox and Safari. 302 | 3. Remove default padding in all browsers. 303 | */ 304 | 305 | button, 306 | input, 307 | optgroup, 308 | select, 309 | textarea { 310 | font-family: inherit; 311 | /* 1 */ 312 | font-feature-settings: inherit; 313 | /* 1 */ 314 | font-variation-settings: inherit; 315 | /* 1 */ 316 | font-size: 100%; 317 | /* 1 */ 318 | font-weight: inherit; 319 | /* 1 */ 320 | line-height: inherit; 321 | /* 1 */ 322 | letter-spacing: inherit; 323 | /* 1 */ 324 | color: inherit; 325 | /* 1 */ 326 | margin: 0; 327 | /* 2 */ 328 | padding: 0; 329 | /* 3 */ 330 | } 331 | 332 | /* 333 | Remove the inheritance of text transform in Edge and Firefox. 334 | */ 335 | 336 | button, 337 | select { 338 | text-transform: none; 339 | } 340 | 341 | /* 342 | 1. Correct the inability to style clickable types in iOS and Safari. 343 | 2. Remove default button styles. 344 | */ 345 | 346 | button, 347 | input:where([type='button']), 348 | input:where([type='reset']), 349 | input:where([type='submit']) { 350 | -webkit-appearance: button; 351 | /* 1 */ 352 | background-color: transparent; 353 | /* 2 */ 354 | background-image: none; 355 | /* 2 */ 356 | } 357 | 358 | /* 359 | Use the modern Firefox focus style for all focusable elements. 360 | */ 361 | 362 | :-moz-focusring { 363 | outline: auto; 364 | } 365 | 366 | /* 367 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 368 | */ 369 | 370 | :-moz-ui-invalid { 371 | box-shadow: none; 372 | } 373 | 374 | /* 375 | Add the correct vertical alignment in Chrome and Firefox. 376 | */ 377 | 378 | progress { 379 | vertical-align: baseline; 380 | } 381 | 382 | /* 383 | Correct the cursor style of increment and decrement buttons in Safari. 384 | */ 385 | 386 | ::-webkit-inner-spin-button, 387 | ::-webkit-outer-spin-button { 388 | height: auto; 389 | } 390 | 391 | /* 392 | 1. Correct the odd appearance in Chrome and Safari. 393 | 2. Correct the outline style in Safari. 394 | */ 395 | 396 | [type='search'] { 397 | -webkit-appearance: textfield; 398 | /* 1 */ 399 | outline-offset: -2px; 400 | /* 2 */ 401 | } 402 | 403 | /* 404 | Remove the inner padding in Chrome and Safari on macOS. 405 | */ 406 | 407 | ::-webkit-search-decoration { 408 | -webkit-appearance: none; 409 | } 410 | 411 | /* 412 | 1. Correct the inability to style clickable types in iOS and Safari. 413 | 2. Change font properties to `inherit` in Safari. 414 | */ 415 | 416 | ::-webkit-file-upload-button { 417 | -webkit-appearance: button; 418 | /* 1 */ 419 | font: inherit; 420 | /* 2 */ 421 | } 422 | 423 | /* 424 | Add the correct display in Chrome and Safari. 425 | */ 426 | 427 | summary { 428 | display: list-item; 429 | } 430 | 431 | /* 432 | Removes the default spacing and border for appropriate elements. 433 | */ 434 | 435 | blockquote, 436 | dl, 437 | dd, 438 | h1, 439 | h2, 440 | h3, 441 | h4, 442 | h5, 443 | h6, 444 | hr, 445 | figure, 446 | p, 447 | pre { 448 | margin: 0; 449 | } 450 | 451 | fieldset { 452 | margin: 0; 453 | padding: 0; 454 | } 455 | 456 | legend { 457 | padding: 0; 458 | } 459 | 460 | ol, 461 | ul, 462 | menu { 463 | list-style: none; 464 | margin: 0; 465 | padding: 0; 466 | } 467 | 468 | /* 469 | Reset default styling for dialogs. 470 | */ 471 | 472 | dialog { 473 | padding: 0; 474 | } 475 | 476 | /* 477 | Prevent resizing textareas horizontally by default. 478 | */ 479 | 480 | textarea { 481 | resize: vertical; 482 | } 483 | 484 | /* 485 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 486 | 2. Set the default placeholder color to the user's configured gray 400 color. 487 | */ 488 | 489 | input::-moz-placeholder, textarea::-moz-placeholder { 490 | opacity: 1; 491 | /* 1 */ 492 | color: #9ca3af; 493 | /* 2 */ 494 | } 495 | 496 | input::placeholder, 497 | textarea::placeholder { 498 | opacity: 1; 499 | /* 1 */ 500 | color: #9ca3af; 501 | /* 2 */ 502 | } 503 | 504 | /* 505 | Set the default cursor for buttons. 506 | */ 507 | 508 | button, 509 | [role="button"] { 510 | cursor: pointer; 511 | } 512 | 513 | /* 514 | Make sure disabled buttons don't get the pointer cursor. 515 | */ 516 | 517 | :disabled { 518 | cursor: default; 519 | } 520 | 521 | /* 522 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 523 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 524 | This can trigger a poorly considered lint error in some tools but is included by design. 525 | */ 526 | 527 | img, 528 | svg, 529 | video, 530 | canvas, 531 | audio, 532 | iframe, 533 | embed, 534 | object { 535 | display: block; 536 | /* 1 */ 537 | vertical-align: middle; 538 | /* 2 */ 539 | } 540 | 541 | /* 542 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 543 | */ 544 | 545 | img, 546 | video { 547 | max-width: 100%; 548 | height: auto; 549 | } 550 | 551 | /* Make elements with the HTML hidden attribute stay hidden by default */ 552 | 553 | [hidden]:where(:not([hidden="until-found"])) { 554 | display: none; 555 | } 556 | 557 | .m-2 { 558 | margin: 0.5rem; 559 | } 560 | 561 | .mt-8 { 562 | margin-top: 2rem; 563 | } 564 | 565 | .inline-block { 566 | display: inline-block; 567 | } 568 | 569 | .flex { 570 | display: flex; 571 | } 572 | 573 | .hidden { 574 | display: none; 575 | } 576 | 577 | .h-8 { 578 | height: 2rem; 579 | } 580 | 581 | .h-9 { 582 | height: 2.25rem; 583 | } 584 | 585 | .h-screen { 586 | height: 100vh; 587 | } 588 | 589 | .w-60 { 590 | width: 15rem; 591 | } 592 | 593 | .w-8 { 594 | width: 2rem; 595 | } 596 | 597 | .w-full { 598 | width: 100%; 599 | } 600 | 601 | .w-screen { 602 | width: 100vw; 603 | } 604 | 605 | .flex-1 { 606 | flex: 1 1 0%; 607 | } 608 | 609 | .cursor-not-allowed { 610 | cursor: not-allowed; 611 | } 612 | 613 | .flex-col { 614 | flex-direction: column; 615 | } 616 | 617 | .items-center { 618 | align-items: center; 619 | } 620 | 621 | .justify-center { 622 | justify-content: center; 623 | } 624 | 625 | .gap-2 { 626 | gap: 0.5rem; 627 | } 628 | 629 | .space-x-1 > :not([hidden]) ~ :not([hidden]) { 630 | --tw-space-x-reverse: 0; 631 | margin-right: calc(0.25rem * var(--tw-space-x-reverse)); 632 | margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse))); 633 | } 634 | 635 | .space-x-2 > :not([hidden]) ~ :not([hidden]) { 636 | --tw-space-x-reverse: 0; 637 | margin-right: calc(0.5rem * var(--tw-space-x-reverse)); 638 | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); 639 | } 640 | 641 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 642 | --tw-space-x-reverse: 0; 643 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 644 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 645 | } 646 | 647 | .space-y-1 > :not([hidden]) ~ :not([hidden]) { 648 | --tw-space-y-reverse: 0; 649 | margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); 650 | margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); 651 | } 652 | 653 | .space-y-2 > :not([hidden]) ~ :not([hidden]) { 654 | --tw-space-y-reverse: 0; 655 | margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); 656 | margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); 657 | } 658 | 659 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { 660 | --tw-space-y-reverse: 0; 661 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 662 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)); 663 | } 664 | 665 | .rounded-full { 666 | border-radius: 9999px; 667 | } 668 | 669 | .rounded-md { 670 | border-radius: 0.375rem; 671 | } 672 | 673 | .border-\[1px\] { 674 | border-width: 1px; 675 | } 676 | 677 | .border-tokyoNight-comment { 678 | --tw-border-opacity: 1; 679 | border-color: rgb(86 95 137 / var(--tw-border-opacity, 1)); 680 | } 681 | 682 | .bg-tokyoNight-bg { 683 | --tw-bg-opacity: 1; 684 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 685 | } 686 | 687 | .bg-tokyoNight-bg_highlight\/70 { 688 | background-color: rgb(41 46 66 / 0.7); 689 | } 690 | 691 | .bg-tokyoNight-blue { 692 | --tw-bg-opacity: 1; 693 | background-color: rgb(122 162 247 / var(--tw-bg-opacity, 1)); 694 | } 695 | 696 | .bg-tokyoNight-fg_dark { 697 | --tw-bg-opacity: 1; 698 | background-color: rgb(169 177 214 / var(--tw-bg-opacity, 1)); 699 | } 700 | 701 | .bg-tokyoNight-orange { 702 | --tw-bg-opacity: 1; 703 | background-color: rgb(255 158 100 / var(--tw-bg-opacity, 1)); 704 | } 705 | 706 | .p-2 { 707 | padding: 0.5rem; 708 | } 709 | 710 | .p-3 { 711 | padding: 0.75rem; 712 | } 713 | 714 | .p-4 { 715 | padding: 1rem; 716 | } 717 | 718 | .align-middle { 719 | vertical-align: middle; 720 | } 721 | 722 | .font-mono { 723 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 724 | } 725 | 726 | .font-sans { 727 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 728 | } 729 | 730 | .text-2xl { 731 | font-size: 1.5rem; 732 | line-height: 2rem; 733 | } 734 | 735 | .text-3xl { 736 | font-size: 1.875rem; 737 | line-height: 2.25rem; 738 | } 739 | 740 | .text-4xl { 741 | font-size: 2.25rem; 742 | line-height: 2.5rem; 743 | } 744 | 745 | .text-5xl { 746 | font-size: 3rem; 747 | line-height: 1; 748 | } 749 | 750 | .text-8xl { 751 | font-size: 6rem; 752 | line-height: 1; 753 | } 754 | 755 | .text-lg { 756 | font-size: 1.125rem; 757 | line-height: 1.75rem; 758 | } 759 | 760 | .text-sm { 761 | font-size: 0.875rem; 762 | line-height: 1.25rem; 763 | } 764 | 765 | .text-xl { 766 | font-size: 1.25rem; 767 | line-height: 1.75rem; 768 | } 769 | 770 | .font-bold { 771 | font-weight: 700; 772 | } 773 | 774 | .font-semibold { 775 | font-weight: 600; 776 | } 777 | 778 | .tracking-wide { 779 | letter-spacing: 0.025em; 780 | } 781 | 782 | .text-tokyoNight-blue { 783 | --tw-text-opacity: 1; 784 | color: rgb(122 162 247 / var(--tw-text-opacity, 1)); 785 | } 786 | 787 | .text-tokyoNight-fg { 788 | --tw-text-opacity: 1; 789 | color: rgb(192 202 245 / var(--tw-text-opacity, 1)); 790 | } 791 | 792 | .text-tokyoNight-magenta { 793 | --tw-text-opacity: 1; 794 | color: rgb(187 154 247 / var(--tw-text-opacity, 1)); 795 | } 796 | 797 | .text-tokyoNight-magenta2 { 798 | --tw-text-opacity: 1; 799 | color: rgb(255 0 124 / var(--tw-text-opacity, 1)); 800 | } 801 | 802 | .text-tokyoNight-orange { 803 | --tw-text-opacity: 1; 804 | color: rgb(255 158 100 / var(--tw-text-opacity, 1)); 805 | } 806 | 807 | .text-tokyoNight-teal { 808 | --tw-text-opacity: 1; 809 | color: rgb(26 188 156 / var(--tw-text-opacity, 1)); 810 | } 811 | 812 | .text-tokyoNight-yellow { 813 | --tw-text-opacity: 1; 814 | color: rgb(224 175 104 / var(--tw-text-opacity, 1)); 815 | } 816 | 817 | .shadow-lg { 818 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 819 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 820 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 821 | } 822 | 823 | .transition-colors { 824 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 825 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 826 | transition-duration: 150ms; 827 | } 828 | 829 | .button { 830 | border-radius: 0.375rem; 831 | border-width: 1px; 832 | --tw-border-opacity: 1; 833 | border-color: rgb(86 95 137 / var(--tw-border-opacity, 1)); 834 | padding: 0.5rem; 835 | --tw-text-opacity: 1; 836 | color: rgb(122 162 247 / var(--tw-text-opacity, 1)); 837 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 838 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 839 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 840 | transition-property: all; 841 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 842 | transition-duration: 150ms; 843 | } 844 | 845 | .button:hover { 846 | --tw-border-opacity: 1; 847 | border-color: rgb(26 27 38 / var(--tw-border-opacity, 1)); 848 | --tw-bg-opacity: 1; 849 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 850 | --tw-text-opacity: 1; 851 | color: rgb(192 202 245 / var(--tw-text-opacity, 1)); 852 | } 853 | 854 | .button:active { 855 | --tw-shadow: 0 0 #0000; 856 | --tw-shadow-colored: 0 0 #0000; 857 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 858 | } 859 | 860 | .selected-button { 861 | border-radius: 0.375rem; 862 | --tw-bg-opacity: 1; 863 | background-color: rgb(86 95 137 / var(--tw-bg-opacity, 1)); 864 | padding: 0.5rem; 865 | font-weight: 600; 866 | --tw-text-opacity: 1; 867 | color: rgb(22 22 30 / var(--tw-text-opacity, 1)); 868 | --tw-shadow: 0 0 #0000; 869 | --tw-shadow-colored: 0 0 #0000; 870 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 871 | transition-property: all; 872 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 873 | transition-duration: 150ms; 874 | } 875 | 876 | .select { 877 | border-radius: 0.375rem; 878 | border-width: 1px; 879 | --tw-border-opacity: 1; 880 | border-color: rgb(86 95 137 / var(--tw-border-opacity, 1)); 881 | --tw-bg-opacity: 1; 882 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 883 | padding: 0.5rem; 884 | --tw-text-opacity: 1; 885 | color: rgb(122 162 247 / var(--tw-text-opacity, 1)); 886 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 887 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 888 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 889 | transition-property: all; 890 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 891 | transition-duration: 150ms; 892 | } 893 | 894 | .select:hover { 895 | --tw-border-opacity: 1; 896 | border-color: rgb(26 27 38 / var(--tw-border-opacity, 1)); 897 | --tw-bg-opacity: 1; 898 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 899 | --tw-text-opacity: 1; 900 | color: rgb(192 202 245 / var(--tw-text-opacity, 1)); 901 | } 902 | 903 | .select:active { 904 | --tw-shadow: 0 0 #0000; 905 | --tw-shadow-colored: 0 0 #0000; 906 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 907 | } 908 | 909 | @media (min-width: 768px) { 910 | .md\:block { 911 | display: block; 912 | } 913 | 914 | .md\:w-60 { 915 | width: 15rem; 916 | } 917 | 918 | .md\:space-y-4 > :not([hidden]) ~ :not([hidden]) { 919 | --tw-space-y-reverse: 0; 920 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 921 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)); 922 | } 923 | } 924 | 925 | @media (min-width: 1024px) { 926 | .lg\:flex { 927 | display: flex; 928 | } 929 | } 930 | --------------------------------------------------------------------------------