Repository: subalterngames/cacophony Branch: main Commit: 34a997d884aa Files: 162 Total size: 656.6 KB Directory structure: gitextract_lgvougrd/ ├── .github/ │ └── workflows/ │ ├── build.yaml │ └── test.yaml ├── .gitignore ├── .vscode/ │ └── settings.json ├── Cargo.toml ├── LICENSE ├── README.md ├── audio/ │ ├── Cargo.toml │ ├── src/ │ │ ├── command.rs │ │ ├── conn.rs │ │ ├── decayer.rs │ │ ├── export/ │ │ │ ├── export_setting.rs │ │ │ ├── export_state.rs │ │ │ ├── export_type.rs │ │ │ ├── exportable.rs │ │ │ ├── metadata.rs │ │ │ └── multi_file_suffix.rs │ │ ├── export.rs │ │ ├── exporter.rs │ │ ├── lib.rs │ │ ├── midi_event_queue.rs │ │ ├── play_state.rs │ │ ├── player.rs │ │ ├── program.rs │ │ ├── synth_state.rs │ │ ├── timed_midi_event.rs │ │ └── types.rs │ └── tests/ │ └── CT1MBGMRSV1.06.sf2 ├── changelog.md ├── common/ │ ├── Cargo.toml │ └── src/ │ ├── args.rs │ ├── config.rs │ ├── edit_mode.rs │ ├── font.rs │ ├── fraction.rs │ ├── index.rs │ ├── indexed_values.rs │ ├── input_state.rs │ ├── lib.rs │ ├── midi_track.rs │ ├── music.rs │ ├── music_panel_field.rs │ ├── note.rs │ ├── open_file/ │ │ ├── child_paths.rs │ │ ├── extension.rs │ │ ├── file_and_directory.rs │ │ ├── file_or_directory.rs │ │ └── open_file_type.rs │ ├── open_file.rs │ ├── panel_type.rs │ ├── paths.rs │ ├── paths_state.rs │ ├── piano_roll_mode.rs │ ├── select_mode.rs │ ├── sizes.rs │ ├── state.rs │ ├── time.rs │ ├── u64_or_f32.rs │ └── view.rs ├── data/ │ ├── CT1MBGMRSV1.06.sf2 │ ├── attribution.txt │ ├── config.ini │ ├── icon │ └── text.csv ├── html/ │ ├── index.html │ ├── install.html │ ├── limitations.html │ ├── manifesto.html │ ├── me.html │ ├── privacy.html │ ├── roadmap.html │ ├── style.css │ └── user_guide.html ├── input/ │ ├── Cargo.toml │ └── src/ │ ├── debug_input_event.rs │ ├── input_event.rs │ ├── keys.rs │ ├── lib.rs │ ├── midi_binding.rs │ ├── midi_conn.rs │ ├── note_on.rs │ └── qwerty_binding.rs ├── io/ │ ├── Cargo.toml │ └── src/ │ ├── abc123.rs │ ├── export_panel.rs │ ├── export_settings_panel.rs │ ├── import_midi.rs │ ├── io_command.rs │ ├── lib.rs │ ├── links_panel.rs │ ├── music_panel.rs │ ├── open_file_panel.rs │ ├── panel.rs │ ├── piano_roll/ │ │ ├── edit.rs │ │ ├── edit_mode_deltas.rs │ │ ├── piano_roll_panel.rs │ │ ├── piano_roll_sub_panel.rs │ │ ├── select.rs │ │ ├── time.rs │ │ └── view.rs │ ├── piano_roll.rs │ ├── popup.rs │ ├── quit_panel.rs │ ├── save.rs │ ├── snapshot.rs │ └── tracks_panel.rs ├── py/ │ ├── build.py │ ├── itch_changelog.py │ └── macroquad_icon_creator.py ├── render/ │ ├── Cargo.toml │ └── src/ │ ├── color_key.rs │ ├── drawable.rs │ ├── export_panel.rs │ ├── export_settings_panel.rs │ ├── field_params/ │ │ ├── boolean.rs │ │ ├── boolean_corners.rs │ │ ├── key_input.rs │ │ ├── key_list.rs │ │ ├── key_list_corners.rs │ │ ├── key_width.rs │ │ ├── label.rs │ │ ├── label_rectangle.rs │ │ ├── label_ref.rs │ │ ├── line.rs │ │ ├── list.rs │ │ ├── panel_background.rs │ │ ├── rectangle.rs │ │ ├── rectangle_pixel.rs │ │ ├── util.rs │ │ └── width.rs │ ├── field_params.rs │ ├── lib.rs │ ├── links_panel.rs │ ├── main_menu.rs │ ├── music_panel.rs │ ├── open_file_panel.rs │ ├── page.rs │ ├── page_position.rs │ ├── panel.rs │ ├── panels.rs │ ├── piano_roll_panel/ │ │ ├── multi_track.rs │ │ ├── piano_roll_rows.rs │ │ ├── top_bar.rs │ │ ├── viewable_notes.rs │ │ └── volume.rs │ ├── piano_roll_panel.rs │ ├── popup.rs │ ├── quit_panel.rs │ ├── renderer.rs │ ├── tracks_panel.rs │ └── types.rs ├── src/ │ └── main.rs ├── test_files/ │ ├── child_paths/ │ │ ├── test_0.cac │ │ ├── test_1.cac │ │ └── test_2.CAC │ └── ubuntu20.04/ │ └── speechd.conf └── text/ ├── Cargo.toml └── src/ ├── lib.rs ├── tooltips.rs ├── tts.rs ├── tts_string.rs └── value_map.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yaml ================================================ name: build on: workflow_dispatch: inputs: version: description: Build version required: True jobs: build-ubuntu: strategy: matrix: include: - os: ubuntu-20.04 version: 20 feature: speech_dispatcher_0_9 - os: ubuntu-22.04 version: 22 feature: speech_dispatcher_0_11 - os: ubuntu-24.04 version: 24 feature: speech_dispatcher_0_11 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - name: apt update run: sudo apt-get update - name: install run: sudo apt install wget clang libspeechd-dev pkg-config libssl-dev alsa librust-alsa-sys-dev - name: Install Rust uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: cargo build run: cargo build --release --features ${{ matrix.feature }} - name: Create release directory run: mkdir -p cacophony/data - name: Copy executable run: cp target/release/cacophony cacophony/cacophony - name: Copy data/ run: cp -R data cacophony - name: Download butler run: wget -O butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default - name: Unzip butler run: unzip butler.zip - run: chmod +x butler - name: butler login env: BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY }} run: ./butler login - name: butler push env: BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY }} run: ./butler push cacophony subalterngames/cacophony:ubuntu${{ matrix.version }} --userversion=${{ inputs.version }} build-macos: strategy: matrix: include: - os: macos-12 version: 12 - os: macos-13 version: 13 - os: macos-14 version: 14 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: Install cargo bundle run: cargo install cargo-bundle - name: cargo build run: cargo bundle --release - name: Download butler run: wget -O butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default - name: Unzip butler run: unzip butler.zip - run: chmod +x butler - name: butler login env: BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY }} run: ./butler login - name: butler push env: BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY }} run: ./butler push target/release/bundle/osx/Cacophony.app subalterngames/cacophony:macos${{ matrix.version }} --userversion=${{ inputs.version }} build-windows: name: Windows runs-on: windows-latest steps: - uses: actions/checkout@v3 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: cargo build run: cargo build --release - name: Download butler run: Invoke-WebRequest -Uri https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default -OutFile butler.zip shell: powershell - name: Unzip butler run: Expand-Archive -Path butler.zip -DestinationPath . shell: powershell - name: butler login env: BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY }} run: ./butler.exe login - name: Create release directory run: New-Item -Path cacophony -ItemType directory shell: powershell - name: Copy cacophony.exe run: Copy-Item target/release/cacophony.exe cacophony/cacophony.exe shell: powershell - name: Copy data/ run: Copy-Item -Recurse data cacophony shell: powershell - name: butler push env: BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY }} run: ./butler.exe push cacophony subalterngames/cacophony:windows --userversion=${{ inputs.version }} ================================================ FILE: .github/workflows/test.yaml ================================================ name: test on: push: branches-ignore: - main paths-ignore: - "doc/**" - ".vscode/**" - "html/**" - "data/**" - "promo/**" - "py/**" - "**.md" # Make sure CI fails on all warnings, including Clippy lints env: RUSTFLAGS: "-Dwarnings" jobs: # Check for formatting and clippy lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # Setup Rust toolchain - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable components: rustfmt override: true # Install the required dependencies - name: Install test dependencies run: sudo apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev alsa librust-alsa-sys-dev -y # Cargo fmt - uses: actions-rs/cargo@v1 with: command: fmt args: --all -- --check # Cargo clippy - uses: actions-rs/cargo@v1 with: command: clippy args: --all-targets --features speech_dispatcher_0_11 # Run test check on Linux, macOS, and Windows test: name: Test runs-on: ${{ matrix.os }} strategy: fail-fast: true matrix: os: [ubuntu-24.04, ubuntu-22.04, ubuntu-20.04, macOS-14, macOS-13, macOS-12, windows-latest] steps: # Checkout the branch being tested - uses: actions/checkout@v4 # Install rust stable - uses: dtolnay/rust-toolchain@master with: toolchain: stable # Install the required dependencies on Ubuntu - name: Install test dependencies run: sudo apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev alsa librust-alsa-sys-dev -y if: ${{ contains(matrix.os, 'ubuntu') }} # Test for Ubuntu 20.04. Copy a valid speechd.conf file and then test. - name: Test run: | sudo cp test_files/ubuntu20.04/speechd.conf /etc/speech-dispatcher/speechd.conf cargo test --all --features speech_dispatcher_0_9 -- --nocapture if: matrix.os == 'ubuntu-20.04' # Test for Ubuntu 22.04 - name: Test run: cargo test --all --features speech_dispatcher_0_11 -- --nocapture if: matrix.os == 'ubuntu-22.04' || matrix.os == 'ubuntu-24.04' # Test for windows and mac - name: Test run: cargo test --all -- --nocapture if: ${{ !contains(matrix.os, 'ubuntu') }} ================================================ FILE: .gitignore ================================================ .idea/ *.dll *.pyc temp/ scratch.py synthesizers/ */target/ target/ ms_basic.sf3 events.txt .DS_STORE py/credentials/ venv/ ================================================ FILE: .vscode/settings.json ================================================ { "rust-analyzer.linkedProjects": [ "./audio/Cargo.toml", "./common/Cargo.toml", "./input/Cargo.toml", "./io/Cargo.toml", "./render/Cargo.toml", "./text/Cargo.toml", ], } ================================================ FILE: Cargo.toml ================================================ [workspace] members = ["audio", "common", "input", "io", "render", "text"] [workspace.package] version = "0.2.7" authors = ["Esther Alter "] description = "A minimalist and ergonomic MIDI sequencer" documentation = "https://github.com/subalterngames/cacophony" edition = "2021" [workspace.dependencies] serde_json = "1.0" rust-ini = "0.18" directories = "5.0.1" midir = "0.9.1" csv = "1.2.1" cpal = "0.13.1" hound = "3.5.0" chrono = "0.4.31" vorbis-encoder = "0.1.4" oggvorbismeta = "0.1.0" strum = "0.24" strum_macros = "0.24" edit = "0.1.4" regex = "1.9.1" parking_lot = "0.12.1" num-traits = "0.2.16" webbrowser = "1.0.1" lazy_static = "1.4.0" midly = "0.5.3" flacenc = "0.3.0" metaflac = "0.2.5" mp3lame-encoder = "0.1.4" [workspace.dependencies.clap] version = "4.4.7" default-features = false features = ["derive", "string", "env", "error-context", "help", "std", "suggestions", "usage"] [workspace.dependencies.colorgrad] version = "0.6.2" default-features = false features = [] [workspace.dependencies.id3] version = "1.7.0" default-features = false features = [] [workspace.dependencies.ureq] version = "2.9.7" default-features = false features = ["tls"] [workspace.dependencies.hashbrown] version = "0.13.2" features = ["serde"] [workspace.dependencies.serde] version = "1.0.153" default-features = false features = ["derive"] [workspace.dependencies.macroquad] version = "0.4.4" default-features = false features = [] git = "https://github.com/not-fl3/macroquad.git" rev = "6d9685d" [workspace.dependencies.oxisynth] version = "0.0.3" features = [] git = "https://github.com/subalterngames/OxiSynth.git" branch = "midi_event_copy_clone" [workspace.dependencies.tts] version = "0.26.1" default-features = false [features] speech_dispatcher_0_11 = ["text/speech_dispatcher_0_11"] speech_dispatcher_0_9 = ["text/speech_dispatcher_0_9"] [package] name = "cacophony" version = "0.2.7" authors = ["Esther Alter "] description = "A minimalist and ergonomic MIDI sequencer" documentation = "https://github.com/subalterngames/cacophony" edition = "2021" [dependencies] macroquad = { workspace = true } parking_lot = { workspace = true } ureq = { workspace = true } regex = { workspace = true } rust-ini = { workspace = true } clap = { workspace = true } [dependencies.audio] path = "audio" [dependencies.common] path = "common" [dependencies.input] path = "input" [dependencies.io] path = "io" [dependencies.render] path = "render" [dependencies.text] path = "text" [package.metadata.bundle] name = "Cacophony" identifier = "com.subalterngames.cacophony" icon = ["icon/32.png", "icon/64.png", "icon/128.png", "icon/256.png"] version = "0.2.7" resources = ["data/*"] copyright = "Copyright (c) Subaltern Games LLC 2023. All rights reserved." short_description = "A minimalist and ergonomic MIDI sequencer." long_description = """ Cacophony is a minimalist and ergonomic MIDI sequencer. It's minimalist in that it doesn't have a lot of functionality MIDI sequencers have. It's ergonomic in that there is no mouse input and a very clean interface, allowing you to juggle less inputs and avoid awkward mouse motions. I made Cacophony because I want to make music in a very particular way that I couldn't find anywhere. Cacophony's mascot is Casey the Transgender Cacodemon. No official artwork of Casey exists because I don't want to be cursed. """ deb_depends = [] osx_frameworks = [] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Esther Alter Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![Cacophony!](doc/images/banner.png) **Cacophony is a minimalist and ergonomic MIDI sequencer.** It's minimalist in that it doesn't have a lot of functionality MIDI sequencers have. It's ergonomic in that there is no mouse input and a very clean interface, allowing you to juggle less inputs and avoid awkward mouse motions. ![Screenshot of Cacophony](doc/images/screenshot.jpg) [Buy Cacophony](https://subalterngames.itch.io/cacophony) (or compile it yourself). [User-end documentation.](https://subalterngames.com/cacophony) [Discord Server](https://discord.gg/fUapDXgTYj) ## How to compile I compile Cacophony with Rust 1.74.0 for Linux, MacOS, or Windows. Below is a list of operating systems I've tested: Linux: - Ubuntu 18.04 i386 with X11 - Ubuntu 18.04 x64 with X11 - Ubuntu 20.04 x64 with X11 - Ubuntu 22.04 x64 with X11 - Ubuntu 24.04 x64 with X11 MacOS: - Catalina 10.15.7 x64 - Ventura 13.2.1 Apple Silicon Windows: - Windows 10 x64 ### All platforms 1. Install Rust (stable) 2. Clone this repo ### Linux #### Debian 11 1. `apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev librust-alsa-sys-dev` 2. `cargo build --release --features speech_dispatcher_0_9` #### Debian 12 1. `apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev librust-alsa-sys-dev` 2. `cargo build --release --features speech_dispatcher_0_11` #### Ubuntu 18 1. `apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev alsa` 2. `cargo build --release --features speech_dispatcher_0_9` #### Ubuntu 20 1. `apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev alsa librust-alsa-sys-dev` 2. `cargo build --release --features speech_dispatcher_0_9` #### Ubuntu 22 and 24 1. `apt install clang cmake speech-dispatcher libspeechd-dev pkg-config libssl-dev alsa librust-alsa-sys-dev` 2. `cargo build --release --features speech_dispatcher_0_11` ### MacOS 1. `cargo install cargo-bundle` 2. `cargo bundle --release` ### Windows 1. `cargo build --release` ## Set the `data` directory Cacophony's default data directory is located at `../data`. To set the default data directory at *compile time*, set the `CACOPHONY_BUILD_DATA_DIR` enviroment variable: ```bash export CACOPHONY_BUILD_DATA_DIR=/usr/share/cacophony cargo build --release ``` ## Tests To test, just `cargo test --all`. Sometimes when debugging, it's useful to create the same initial setup every time. To do this, you can pass input events in like this: `cargo run -- --events events.txt` ...where the contents of `events.txt` is something like: ``` NextPanel AddTrack EnableSoundFontPanel SelectFile ``` ## How to run You can run Cacophony like any other application or you can use Rust's `cargo run` to compile and execute. ### Linux There are two ways to run Cacophony: 1. Copy + paste `data/` into the output directory (`target/release/`). Open a terminal in `release/` and run `./Cacophony` . 2. Instead of `cargo build --release`, run `cargo run --release` Include the `--features` listed above, for example `cargo build --release --features speech_dispatcher_0_11` on Ubuntu 22 ### MacOS There are two ways to run Cacophony: 1. After compiling, double-click `Cacophony.app` (located in `./target/release/`) 2. `cargo run --release` This will compile and launch the application but it won't create a .app ### Windows There are two ways to run Cacophony: 1. Copy + paste `data/` into the output directory (`target/release/`) and double-click `Cacophony.exe` (located in `release/`) 2. Instead of `cargo build --release`, run `cargo run --release` ## Upload Assuming that you are Esther Alter and you have the relevant credentials on your computer, you can upload the website and create itch.io builds by doing this: 1. `cd py` 2. `py -3 build.py` ================================================ FILE: audio/Cargo.toml ================================================ [package] name = "audio" version.workspace = true authors.workspace = true description.workspace = true documentation.workspace = true edition.workspace = true [dependencies] cpal = { workspace = true } hound = { workspace = true } id3 = { workspace = true } mp3lame-encoder = { workspace = true } chrono = { workspace = true } midly = { workspace = true } vorbis-encoder = { workspace = true } oggvorbismeta = { workspace = true } oxisynth = { workspace = true } serde = { workspace = true } hashbrown = { workspace = true } parking_lot = { workspace = true } flacenc = { workspace = true } metaflac = { workspace = true } [dependencies.common] path = "../common" ================================================ FILE: audio/src/command.rs ================================================ use std::path::PathBuf; /// A command for the synthesizer. #[derive(Debug, Eq, PartialEq, Clone)] pub enum Command { /// Load a SoundFont file. LoadSoundFont { channel: u8, path: PathBuf }, /// Set a program. SetProgram { channel: u8, path: PathBuf, bank_index: usize, preset_index: usize, }, /// Set the program to None. UnsetProgram { channel: u8 }, /// Set the overall gain. SetGain { gain: u8 }, } ================================================ FILE: audio/src/conn.rs ================================================ use crate::decayer::Decayer; use crate::export::{ExportState, ExportType, Exportable, MultiFileSuffix}; use crate::exporter::Exporter; use crate::play_state::PlayState; use crate::types::SharedPlayState; use crate::SharedExportState; use crate::{ midi_event_queue::MidiEventQueue, types::SharedSample, Command, Player, Program, SharedMidiEventQueue, SharedSynth, SynthState, }; use common::open_file::Extension; use common::{MidiTrack, Music, PathsState, State, Time, MAX_VOLUME}; use hashbrown::HashMap; use oxisynth::{MidiEvent, SoundFont, SoundFontId, Synth}; use parking_lot::Mutex; use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::thread::spawn; /// A convenient wrapper for a SoundFont. struct SoundFontBanks { id: SoundFontId, /// The banks and their presets. banks: HashMap>, } impl SoundFontBanks { pub fn new(font: SoundFont, synth: &mut SharedSynth) -> Self { let mut banks: HashMap> = HashMap::new(); (0u32..=128u32).for_each(|b| { let presets: Vec = (0u8..128) .filter(|p| font.preset(b, *p).is_some()) .collect(); if !presets.is_empty() { banks.insert(b, presets); } }); let mut synth = synth.lock(); let id = synth.add_font(font, true); Self { id, banks } } } /// The connects used by an external function. pub struct Conn { /// The current export state, if any. pub export_state: SharedExportState, /// The playback framerate. pub framerate: f32, /// The audio player. This is here so we don't drop it. _player: Option, /// The most recent sample. /// `render::MainMenu` uses this to for its power bars. pub sample: SharedSample, /// A shared Oxisynth synthesizer. /// The `Conn` uses this to send MIDI events and export. /// The `Player` uses this to write samples to the output buffer. synth: SharedSynth, /// A queue of scheduled MIDI events. /// The `Conn` can add to this. /// The `Player` can read this and remove events. midi_event_queue: SharedMidiEventQueue, /// A HashMap of loaded SoundFonts. Key = The path to a .sf2 file. soundfonts: HashMap, /// Metadata for all SoundFont programs. pub state: SynthState, /// Export settings. pub exporter: Exporter, /// A flag that `Player` uses to decide how to write samples to the output buffer. pub play_state: SharedPlayState, } impl Default for Conn { fn default() -> Self { // Set the synthesizer. let mut synth = Synth::default(); synth.set_gain(1.0); let synth = Arc::new(Mutex::new(synth)); // Create other shared data. let midi_event_queue = Arc::new(Mutex::new(MidiEventQueue::default())); let sample = Arc::new(Mutex::new((0.0, 0.0))); let play_state = Arc::new(Mutex::new(PlayState::NotPlaying)); // Create the player. let player_synth = Arc::clone(&synth); let player_midi_event_queue = Arc::clone(&midi_event_queue); let player_sample = Arc::clone(&sample); let player_play_state = Arc::clone(&play_state); let player = Player::new( player_midi_event_queue, player_synth, player_sample, player_play_state, ); // Get the framerate. let framerate = match &player { Some(player) => player.framerate as f32, None => 0.0, }; Self { export_state: Arc::new(Mutex::new(ExportState::NotExporting)), _player: player, framerate, sample, synth, midi_event_queue, soundfonts: HashMap::default(), state: SynthState::default(), exporter: Exporter::default(), play_state, } } } impl Conn { /// Do all note-on events created by user input on this app frame. pub fn note_ons(&mut self, state: &State, note_ons: &[[u8; 3]]) { if let Some(track) = state.music.get_selected_track() { if !note_ons.is_empty() { let mut synth = self.synth.lock(); let gain = track.gain as f32 / MAX_VOLUME as f32; for note_on in note_ons.iter() { let _ = synth.send_event(MidiEvent::NoteOn { channel: track.channel, key: note_on[1], vel: (note_on[2] as f32 * gain) as u8, }); } // Play audio. let mut play_state = self.play_state.lock(); *play_state = PlayState::Decaying; } } } /// Do all note-off events created by user input on this app frame. pub fn note_offs(&mut self, state: &State, note_offs: &[u8]) { if let Some(track) = state.music.get_selected_track() { if !note_offs.is_empty() { let mut synth = self.synth.lock(); for note_off in note_offs.iter() { let _ = synth.send_event(MidiEvent::NoteOff { channel: track.channel, key: *note_off, }); } } } } /// Execute a slice of commands sent from `io`. pub fn do_commands(&mut self, commands: &[Command]) { for command in commands.iter() { match command { Command::LoadSoundFont { channel, path } => { match &self.soundfonts.get(path) { // We already loaded this font. Some(_) => { self.set_program_default(*channel, path); } // Load the font. None => match SoundFont::load(&mut File::open(path).unwrap()) { Ok(font) => { let banks = SoundFontBanks::new(font, &mut self.synth); self.soundfonts.insert(path.clone(), banks); // Set the default program. self.set_program_default(*channel, path); // Restore the other programs. let programs = self.state.programs.clone(); for program in programs.iter().filter(|p| p.0 != channel) { if self.soundfonts.contains_key(&program.1.path) { let mut synth = self.synth.lock(); synth .program_select( *program.0, self.soundfonts[&program.1.path].id, program.1.bank, program.1.preset, ) .unwrap(); } } } Err(error) => { panic!("Failed to load SoundFont: {:?}", error) } }, } } Command::SetProgram { channel, path, bank_index, preset_index, } => { let soundfont = &self.soundfonts[path]; let banks = soundfont.banks.keys().copied().collect::>(); let bank = banks[*bank_index]; let preset = soundfont.banks[&bank][*preset_index]; let channel = *channel; self.set_program(channel, path, bank, preset, soundfont.id); } Command::UnsetProgram { channel } => { self.state.programs.remove(channel); } Command::SetGain { gain } => { let mut synth = self.synth.lock(); synth.set_gain(*gain as f32 / MAX_VOLUME as f32); self.state.gain = *gain; } } } } /// Start to play music if music isn't playing. Stop music if music is playing. pub fn set_music(&mut self, state: &State) { let play_state = *self.play_state.lock(); match play_state { PlayState::NotPlaying | PlayState::Decaying => self.start_music(state), _ => self.stop_music(&state.music), } } pub fn exporting(&self) -> bool { *self.export_state.lock() != ExportState::NotExporting } /// When a new save file is loaded or a new file is opened, stop playing music if any music is playing. pub fn on_new_file(&mut self, state: &State) { let play_state = *self.play_state.lock(); if let PlayState::Playing(_) = play_state { self.stop_music(&state.music); } } /// Schedule MIDI events and start to play music. fn start_music(&mut self, state: &State) { // Get the start time. let start = state .time .ppq_to_samples(state.time.playback, self.framerate); // Set the playback framerate. let mut synth = self.synth.lock(); synth.set_sample_rate(self.framerate); drop(synth); let mut midi_event_queue = self.midi_event_queue.lock(); // Clear the queue before adding new events. midi_event_queue.clear(); // Enqueue note events. for track in state.music.get_playable_tracks().iter() { for note in track.get_playback_notes(state.time.playback) { // Note-on event. midi_event_queue.enqueue( state.time.ppq_to_samples(note.start, self.framerate), MidiEvent::NoteOn { channel: track.channel, key: note.note, vel: note.velocity, }, ); // Note-off event. midi_event_queue.enqueue( state.time.ppq_to_samples(note.end, self.framerate), MidiEvent::NoteOff { channel: track.channel, key: note.note, }, ); } } // Sort the events by start time. midi_event_queue.sort(); drop(midi_event_queue); // Play music. let mut play_state = self.play_state.lock(); *play_state = PlayState::Playing(start); } /// Stop ongoing music. fn stop_music(&mut self, music: &Music) { let mut synth = self.synth.lock(); for track in music.midi_tracks.iter() { if synth .send_event(MidiEvent::AllNotesOff { channel: track.channel, }) .is_ok() && synth .send_event(MidiEvent::AllSoundOff { channel: track.channel, }) .is_ok() {} } drop(synth); // Let the audio decay. let mut play_state = self.play_state.lock(); *play_state = PlayState::Decaying; } /// Set the synthesizer program to a default program. fn set_program_default(&mut self, channel: u8, path: &Path) { let soundfont = &self.soundfonts[path]; // Get the bank info. let mut banks: Vec = soundfont.banks.keys().copied().collect(); banks.sort(); let bank = banks[0]; let preset = soundfont.banks[&bank][0]; // Select the default program. let id = self.soundfonts[path].id; self.set_program(channel, path, bank, preset, id); } /// Set the synthesizer program to a program. fn set_program(&mut self, channel: u8, path: &Path, bank: u32, preset: u8, id: SoundFontId) { let mut synth = self.synth.lock(); if synth.program_select(channel, id, bank, preset).is_ok() { let soundfont = &self.soundfonts[path]; // Get the bank info. let bank_index = soundfont.banks.keys().position(|&b| b == bank).unwrap(); // Get the preset info. let preset_index = soundfont.banks[&bank] .iter() .position(|&p| p == preset) .unwrap(); let preset_name = synth.channel_preset(channel).unwrap().name().to_string(); let num_banks = soundfont.banks.len(); let num_presets = soundfont.banks[&bank].len(); let program = Program { path: path.to_path_buf(), num_banks, bank_index, bank, num_presets, preset_index, preset_name, preset, }; // Remember the program. self.state.programs.insert(channel, program); } } pub fn start_export(&mut self, state: &State, paths_state: &PathsState) { let mut exportables = vec![]; let tracks = state.music.get_playable_tracks(); self.set_export_framerate(); // Export each track as a separate file. if self.exporter.multi_file { for track in tracks { let mut events = MidiEventQueue::default(); let mut t1 = 0; let gain = track.get_gain_f(); self.enqueue_track_events(track, &state.time, &mut events, &mut t1, gain); events.sort(); let suffix = Some(self.get_export_file_suffix(track)); // Add an exportable. exportables.push(Exportable { events, total_samples: t1, suffix, }); } } // Export all tracks combined. else { let mut t1 = 0; let mut events = MidiEventQueue::default(); for track in tracks { let gain = track.get_gain_f(); self.enqueue_track_events(track, &state.time, &mut events, &mut t1, gain); } events.sort(); // Add an exportable. exportables.push(Exportable { events, total_samples: t1, suffix: None, }); } let export_state = Arc::clone(&self.export_state); let synth = Arc::clone(&self.synth); let exporter = self.exporter.clone(); let path = paths_state.exports.get_path(); let player_framerate = self.framerate; spawn(move || { Self::export( exportables, export_state, synth, exporter, path, player_framerate, ) }); } fn enqueue_track_events( &self, track: &MidiTrack, time: &Time, events: &mut MidiEventQueue, t1: &mut u64, gain: f32, ) { let framerate = self.exporter.framerate.get_f(); for note in track.notes.iter() { // Note-on. events.enqueue( time.ppq_to_samples(note.start, framerate), MidiEvent::NoteOn { channel: track.channel, key: note.note, vel: (note.velocity as f32 * gain) as u8, }, ); let end = time.ppq_to_samples(note.end, framerate); // This is the last known event. if *t1 < end { *t1 = end; } events.enqueue( end, MidiEvent::NoteOff { channel: track.channel, key: note.note, }, ); } } fn export( mut exportables: Vec, export_state: SharedExportState, synth: SharedSynth, exporter: Exporter, path: PathBuf, player_framerate: f32, ) { let mut decayer = Decayer::default(); let extension: Extension = exporter.export_type.get().into(); for exportable in exportables.iter_mut() { let total_samples = exportable.total_samples; // Get the audio buffers. let mut left = vec![0.0f32; total_samples as usize]; let mut right = vec![0.0f32; total_samples as usize]; // Set the initial wav export state. Self::set_export_state_wav(exportable, &export_state, 0); let mut synth = synth.lock(); for t in 0..=total_samples { // Get and send each event at this time. for event in exportable.events.dequeue(t).iter() { let _ = synth.send_event(*event); } // Set the export state. Self::set_export_state_wav(exportable, &export_state, t); // We are iterating to `total_samples` in order to get events at t=1. if t < total_samples { let t = t as usize; (left[t], right[t]) = synth.read_next(); } } // Append decaying silence. Self::set_export_state(&export_state, ExportState::AppendingDecay); decayer.decaying = true; while decayer.decaying { decayer.decay_two_channels(&mut left, &mut right, &mut synth); } // Convert. Self::set_export_state(&export_state, ExportState::WritingToDisk); let filename = path.file_stem().unwrap().to_str().unwrap(); let extension = extension.to_str(true); let path = match &exportable.suffix { Some(suffix) => path .parent() .unwrap() .join(format!("{}_{}{}", filename, suffix, extension)) .to_path_buf(), None => path .parent() .unwrap() .join(format!("{}{}", filename, extension)) .to_path_buf(), }; let audio = [left, right]; match &exporter.export_type.get() { ExportType::Mid => { panic!("Tried exporting a .mid from the synthesizer") } // Export to a .wav file. ExportType::Wav => { exporter.wav(&path, &audio); } ExportType::MP3 => { exporter.mp3(&path, &audio); } ExportType::Ogg => { exporter.ogg(&path, &audio); } ExportType::Flac => exporter.flac(&path, &audio), } // Done. Self::set_export_state(&export_state, ExportState::Done); } Self::set_export_state(&export_state, ExportState::NotExporting); synth.lock().set_sample_rate(player_framerate); } /// Set the exporter's framerate. fn set_export_framerate(&mut self) { let framerate = self.exporter.framerate.get_f(); let mut synth = self.synth.lock(); synth.set_sample_rate(framerate); } /// Set the number of exported wav samples. fn set_export_state_wav( exportable: &Exportable, export_state: &SharedExportState, exported_samples: u64, ) { let mut export_state = export_state.lock(); *export_state = ExportState::WritingWav { total_samples: exportable.total_samples, exported_samples, } } /// Set the export state. fn set_export_state(export_state: &SharedExportState, state: ExportState) { let mut export_state = export_state.lock(); *export_state = state; } fn get_export_file_suffix(&self, track: &MidiTrack) -> String { // Get the path for this track. match self.exporter.multi_file_suffix.get() { MultiFileSuffix::Channel => track.channel.to_string(), MultiFileSuffix::Preset => self .state .programs .get(&track.channel) .unwrap() .preset_name .clone(), MultiFileSuffix::ChannelAndPreset => format!( "{}_{}", track.channel, self.state.programs.get(&track.channel).unwrap().preset_name ), } } } ================================================ FILE: audio/src/decayer.rs ================================================ use crate::SharedSynth; use oxisynth::Synth; /// Export this many bytes per decay chunk. const DECAY_CHUNK_SIZE: usize = 4096; /// Oxisynth usually doesn't zero out its audio. This is essentially an epsilon. /// This is used to detect if the export is done. const SILENCE: f32 = 1e-7; /// Write audio samples during a decay. pub(crate) struct Decayer { pub buffer: [f32; DECAY_CHUNK_SIZE], buffer_1: [f32; DECAY_CHUNK_SIZE], pub decaying: bool, } impl Default for Decayer { fn default() -> Self { Self { buffer: [0.0; DECAY_CHUNK_SIZE], buffer_1: [0.0; DECAY_CHUNK_SIZE], decaying: false, } } } impl Decayer { pub fn decay_shared(&mut self, synth: &SharedSynth, len: usize) { for sample in self.buffer[0..Self::get_len(len)].chunks_mut(2) { let mut synth = synth.lock(); synth.write(sample); } self.set_decaying(len); } pub fn decay_two_channels( &mut self, left: &mut Vec, right: &mut Vec, synth: &mut Synth, ) { // Write samples. synth.write((self.buffer.as_mut(), self.buffer_1.as_mut())); left.extend(self.buffer); right.extend(self.buffer_1); self.decaying = self.buffer.iter().any(|s| s.abs() > SILENCE) || self.buffer_1.iter().any(|s| s.abs() > SILENCE); } fn set_decaying(&mut self, len: usize) { self.decaying = self.buffer[0..Self::get_len(len)] .iter() .any(|s| s.abs() > SILENCE); } fn get_len(len: usize) -> usize { if len <= DECAY_CHUNK_SIZE { len } else { DECAY_CHUNK_SIZE } } } ================================================ FILE: audio/src/export/export_setting.rs ================================================ use serde::{Deserialize, Serialize}; /// Enum values for export settings. #[derive(Debug, Eq, PartialEq, Copy, Clone, Default, Deserialize, Serialize)] pub enum ExportSetting { #[default] Framerate, Title, Artist, Copyright, Album, TrackNumber, Genre, Comment, Mp3BitRate, Mp3Quality, OggQuality, MultiFile, MultiFileSuffix, } ================================================ FILE: audio/src/export/export_state.rs ================================================ #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum ExportState { NotExporting, /// Writing samples to a wav buffer. WritingWav { total_samples: u64, exported_samples: u64, }, /// Writing decay to a wav buffer while the audio decays. AppendingDecay, /// Converting the wav buffer to another file type and write to disk. WritingToDisk, /// Done exporting. Done, } ================================================ FILE: audio/src/export/export_type.rs ================================================ use common::open_file::Extension; use serde::{Deserialize, Serialize}; /// This determines what we're exporting to. #[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, Serialize, Default, Hash)] pub enum ExportType { #[default] Wav, Mid, MP3, Ogg, Flac, } impl From for Extension { fn from(val: ExportType) -> Self { match val { ExportType::Wav => Extension::Wav, ExportType::Mid => Extension::Mid, ExportType::MP3 => Extension::MP3, ExportType::Ogg => Extension::Ogg, ExportType::Flac => Extension::Flac, } } } ================================================ FILE: audio/src/export/exportable.rs ================================================ use crate::midi_event_queue::MidiEventQueue; pub(crate) struct Exportable { pub events: MidiEventQueue, pub total_samples: u64, pub suffix: Option, } ================================================ FILE: audio/src/export/metadata.rs ================================================ use serde::{Deserialize, Serialize}; /// The default title of the music. pub const DEFAULT_TITLE: &str = "My Music"; /// Export metadata. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct Metadata { /// The title of the music. pub title: String, /// The name of the artist. pub artist: Option, /// The name of the album. pub album: Option, /// The track number. pub track_number: Option, /// The genre. pub genre: Option, /// Misc. comments. pub comment: Option, } impl Default for Metadata { fn default() -> Self { Self { title: DEFAULT_TITLE.to_string(), artist: None, album: None, track_number: None, genre: None, comment: None, } } } ================================================ FILE: audio/src/export/multi_file_suffix.rs ================================================ use serde::{Deserialize, Serialize}; /// How should we name files of separate tracks? #[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize, Hash)] pub enum MultiFileSuffix { /// Preset name suffix. Preset, /// Channel integer suffix. Channel, /// Channel, then preset name. #[default] ChannelAndPreset, } ================================================ FILE: audio/src/export.rs ================================================ mod export_setting; mod export_state; mod export_type; mod exportable; mod metadata; mod multi_file_suffix; pub use export_setting::ExportSetting; pub use export_state::ExportState; pub use export_type::ExportType; pub(crate) use exportable::Exportable; pub use metadata::Metadata; pub use multi_file_suffix::MultiFileSuffix; ================================================ FILE: audio/src/exporter.rs ================================================ use crate::export::{ExportSetting, ExportType, Metadata, MultiFileSuffix}; use crate::{AudioBuffer, SynthState}; use chrono::Datelike; use chrono::Local; use common::IndexedValues; use common::{Index, Music, Time, U64orF32, DEFAULT_FRAMERATE, PPQ_F, PPQ_U}; use flacenc::bitsink::ByteSink; use flacenc::component::BitRepr; use flacenc::config::Encoder as FlacEncoder; use flacenc::encode_with_fixed_block_size; use flacenc::source::MemSource; use hound::{SampleFormat, WavSpec, WavWriter}; use id3::{Tag, TagLike, Version}; use metaflac::Tag as FlacTag; use midly::num::{u15, u24, u28, u4}; use midly::{ write_std, Format, Header, MetaMessage, MidiMessage, Timing, Track, TrackEvent, TrackEventKind, }; use mp3lame_encoder::*; use oggvorbismeta::*; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Read; use std::io::{Cursor, Write}; use std::path::Path; use vorbis_encoder::Encoder; /// The number of channels. const NUM_CHANNELS: usize = 2; /// Conversion factor for f32 to i16. const F32_TO_I16: f32 = 32767.5; /// An ordered list of MP3 bit rates. We can't use `IndexedValues` because this enum isn't serializable. pub const MP3_BIT_RATES: [Bitrate; 16] = [ Bitrate::Kbps8, Bitrate::Kbps16, Bitrate::Kbps24, Bitrate::Kbps32, Bitrate::Kbps40, Bitrate::Kbps48, Bitrate::Kbps64, Bitrate::Kbps80, Bitrate::Kbps96, Bitrate::Kbps112, Bitrate::Kbps128, Bitrate::Kbps160, Bitrate::Kbps192, Bitrate::Kbps224, Bitrate::Kbps256, Bitrate::Kbps320, ]; /// An ordererd list of mp3 qualities. We can't use `IndexedValues` because this enum isn't serializable. pub const MP3_QUALITIES: [Quality; 10] = [ Quality::Worst, Quality::SecondWorst, Quality::Ok, Quality::Decent, Quality::Good, Quality::Nice, Quality::VeryNice, Quality::NearBest, Quality::SecondBest, Quality::Best, ]; /// This struct contains all export settings, as well as exporter functions. /// This struct does *not* write samples to a buffer; that's handled in the `Synthesizer`'s export functions. /// Rather, this receives a buffer of f32 data, and then decides what to do with it based on the user-defined export settings. /// /// There are always two copies of the same `Exporter`: One lives in the Synthesizer thread, and one lives on the main thread. /// The user can edit the main thread `Exporter`, which is then sent to the Synthesizer thread. #[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)] pub struct Exporter { /// The framerate. pub framerate: U64orF32, /// Export metadata. pub metadata: Metadata, /// If true, write copyright info. pub copyright: bool, /// The mp3 quality index. pub mp3_bit_rate: Index, /// The mp3 quality index. pub mp3_quality: Index, /// If true, export to multiple files. pub multi_file: bool, /// Multi-file suffix setting. pub multi_file_suffix: IndexedValues, /// The .ogg file quality index. pub ogg_quality: Index, /// The export type. pub export_type: IndexedValues, /// Export settings for .mid files. pub mid_settings: IndexedValues, /// Export settings for .wav files. pub wav_settings: IndexedValues, /// Export settings for .mp3 files. pub mp3_settings: IndexedValues, /// Export settings for .ogg files. pub ogg_settings: IndexedValues, /// Export settings for .flac files. /// Use a default if the save file is pre-0.1.3 #[serde(default = "default_flac_settings")] pub flac_settings: IndexedValues, } impl Default for Exporter { fn default() -> Self { let export_type = IndexedValues::new( 0, [ ExportType::Wav, ExportType::Mid, ExportType::MP3, ExportType::Ogg, ExportType::Flac, ], ); let mid_settings = IndexedValues::new( 0, [ ExportSetting::Title, ExportSetting::Artist, ExportSetting::Copyright, ], ); let wav_settings = IndexedValues::new( 0, [ ExportSetting::Framerate, ExportSetting::MultiFile, ExportSetting::MultiFileSuffix, ], ); let mp3_settings = IndexedValues::new( 0, [ ExportSetting::Framerate, ExportSetting::Mp3Quality, ExportSetting::Mp3BitRate, ExportSetting::Title, ExportSetting::Artist, ExportSetting::Copyright, ExportSetting::Album, ExportSetting::TrackNumber, ExportSetting::Genre, ExportSetting::Comment, ExportSetting::MultiFile, ExportSetting::MultiFileSuffix, ], ); let ogg_settings = IndexedValues::new( 0, [ ExportSetting::Framerate, ExportSetting::OggQuality, ExportSetting::Title, ExportSetting::Artist, ExportSetting::Copyright, ExportSetting::Album, ExportSetting::TrackNumber, ExportSetting::Genre, ExportSetting::Comment, ExportSetting::MultiFile, ExportSetting::MultiFileSuffix, ], ); let flac_settings = default_flac_settings(); let multi_file_suffix = IndexedValues::new( 0, [ MultiFileSuffix::ChannelAndPreset, MultiFileSuffix::Preset, MultiFileSuffix::Channel, ], ); Self { framerate: U64orF32::from(DEFAULT_FRAMERATE), export_type, mp3_bit_rate: Index::new(12, MP3_BIT_RATES.len()), mp3_quality: Index::new(9, MP3_QUALITIES.len()), ogg_quality: Index::new(9, 10), wav_settings, mid_settings, mp3_settings, ogg_settings, flac_settings, multi_file_suffix, metadata: Metadata::default(), copyright: false, multi_file: false, } } } impl Exporter { /// Export to a .mid file. /// - `path` Output to this path. /// - `music` This is what we're saving. /// - `synth_state` We need this for its present names. /// - `text` This is is used for metadata. /// - `export_settings` .mid export settings. pub fn mid(&self, path: &Path, music: &Music, time: &Time, synth_state: &SynthState) { // Set the name of the music. let mut meta_messages = vec![MetaMessage::Text(self.metadata.title.as_bytes())]; let mut copyright = vec![]; // Set the tempo. meta_messages.push(MetaMessage::Tempo(u24::from( (60000000 / time.bpm.get_u()) as u32, ))); // Set the time signature. meta_messages.push(MetaMessage::TimeSignature(4, 2, 24, 8)); // Send copyright. if self.copyright { if let Some(artist) = &self.metadata.artist { copyright.append(&mut self.get_copyright(artist).as_bytes().to_vec()); meta_messages.push(MetaMessage::Copyright(©right)); } } let mut tracks = vec![]; let mut track_0 = Track::new(); for (i, midi_track) in music.midi_tracks.iter().enumerate() { if let Some(program) = synth_state.programs.get(&midi_track.channel) { // Get track 0 or start a new track. let mut track = Vec::new(); if i == 0 { for meta_message in meta_messages.iter() { track_0.push(TrackEvent { delta: 0.into(), kind: TrackEventKind::Meta(*meta_message), }) } } let channel = u4::from(midi_track.channel); // Set the program name. track.push(TrackEvent { delta: 0.into(), kind: TrackEventKind::Meta(MetaMessage::ProgramName( program.preset_name.as_bytes(), )), }); // Change the program. track.push(TrackEvent { delta: 0.into(), kind: TrackEventKind::Midi { channel, message: MidiMessage::ProgramChange { program: program.preset.into(), }, }, }); // Iterate through the notes. let mut notes = midi_track.notes.clone(); // Sort the notes by start time. notes.sort_by(|a, b| a.start.cmp(&b.start)); // Get the start and end time. let t0 = notes.iter().map(|n| n.start).min().unwrap(); // The delta is the first note. let mut dt = t0; let t1 = notes.iter().map(|n| n.end).max().unwrap(); // Iterate through all pulses. for t in t0..t1 { // Get all note-on events. for note in notes.iter().filter(|n| n.start == t) { let delta = Self::get_delta_time(&mut dt); track.push(TrackEvent { delta, kind: TrackEventKind::Midi { channel, message: MidiMessage::NoteOn { key: note.note.into(), vel: note.velocity.into(), }, }, }); } // Get all note-off events. for note in notes.iter().filter(|n| n.end == t) { let delta = Self::get_delta_time(&mut dt); track.push(TrackEvent { delta, kind: TrackEventKind::Midi { channel, message: MidiMessage::NoteOff { key: note.note.into(), vel: note.velocity.into(), }, }, }); } } // End the track. track.push(TrackEvent { delta: 0.into(), kind: TrackEventKind::Meta(MetaMessage::EndOfTrack), }); // Add the track. tracks.push(track); } } // Create the header. let header = Header::new(Format::Parallel, Timing::Metrical(u15::from(PPQ_U as u16))); // Write the file. let mut buffer: Vec = vec![]; if let Err(error) = write_std(&header, tracks.iter(), &mut buffer) { panic!("Error writing {:?} {:?}", path, error); } Self::write_file(path, &buffer); } /// Export to a .wav file. /// /// - `path` The output path. /// - `buffer` A buffer of wav data. pub(crate) fn wav(&self, path: &Path, buffer: &AudioBuffer) { // Get the spec. let spec = WavSpec { channels: NUM_CHANNELS as u16, sample_rate: self.framerate.get_u() as u32, bits_per_sample: 16, sample_format: SampleFormat::Int, }; // Write. let mut writer = WavWriter::create(path, spec).unwrap(); let mut i16_writer = writer.get_i16_writer(buffer[0].len() as u32 * (NUM_CHANNELS as u32)); for (l, r) in buffer[0].iter().zip(buffer[1].iter()) { i16_writer.write_sample(Self::to_i16(l)); i16_writer.write_sample(Self::to_i16(r)); } i16_writer.flush().unwrap(); writer.finalize().unwrap(); } /// Export to a .mp3 file. /// /// - `path` The output path. /// - `buffer` A buffer of wav data. pub(crate) fn mp3<'a, T: 'a>(&self, path: &Path, buffer: &'a [Vec; NUM_CHANNELS]) where mp3lame_encoder::DualPcm<'a, T>: mp3lame_encoder::EncoderInput, { // Create the encoder. let mut mp3_encoder = Builder::new().expect("Create LAME builder"); mp3_encoder .set_num_channels(NUM_CHANNELS as u8) .expect("Set channels"); mp3_encoder .set_sample_rate(self.framerate.get_u() as u32) .expect("Set sample rate"); mp3_encoder .set_brate(MP3_BIT_RATES[self.mp3_bit_rate.get()]) .expect("Set bitrate"); mp3_encoder .set_quality(MP3_QUALITIES[self.mp3_quality.get()]) .expect("Set quality"); // Build the encoder. let mut mp3_encoder = mp3_encoder.build().expect("To initialize LAME encoder"); // Get the input. let input = DualPcm { left: &buffer[0], right: &buffer[1], }; // Get the output buffer. let mut mp3_out_buffer = Vec::with_capacity(max_required_buffer_size(buffer[0].len())); // Get the size. let encoded_size = mp3_encoder .encode(input, mp3_out_buffer.spare_capacity_mut()) .expect("To encode"); unsafe { mp3_out_buffer.set_len(mp3_out_buffer.len().wrapping_add(encoded_size)); } let encoded_size = mp3_encoder .flush::(mp3_out_buffer.spare_capacity_mut()) .expect("To flush"); unsafe { mp3_out_buffer.set_len(mp3_out_buffer.len().wrapping_add(encoded_size)); } // Write the file. Self::write_file(path, &mp3_out_buffer); // Write the tag. let time = Local::now(); let mut tag = Tag::new(); tag.set_year(time.year()); tag.set_title(&self.metadata.title); if let Some(artist) = &self.metadata.artist { tag.set_artist(artist); } if let Some(album) = &self.metadata.album { tag.set_album(album); } if let Some(genre) = &self.metadata.genre { tag.set_genre(genre); } if let Some(comment) = &self.metadata.comment { tag.set_genre(comment); } if let Some(track_number) = &self.metadata.track_number { tag.set_track(*track_number); } if let Err(error) = tag.write_to_path(path, Version::Id3v24) { panic!("Error writing ID3 tag to {:?}: {}", path, error); } } /// Export to an .ogg file. /// /// - `path` The output path. /// - `buffer` A buffer of wav data. pub(crate) fn ogg(&self, path: &Path, buffer: &AudioBuffer) { let mut samples = vec![]; for (l, r) in buffer[0].iter().zip(buffer[1].iter()) { samples.push(Self::to_i16(l)); samples.push(Self::to_i16(r)); } let mut encoder = Encoder::new( NUM_CHANNELS as u32, self.framerate.get_u(), (self.ogg_quality.get() as f32 / 9.0) * 1.2 - 0.2, ) .expect("Error creating .ogg file encoder."); let samples = encoder .encode(&samples) .expect("Error encoding .ogg samples."); // Get a cursor. let cursor = Cursor::new(&samples); // Write the comments. let mut comments = CommentHeader::new(); comments.set_vendor("Ogg"); comments.add_tag_single("title", &self.metadata.title); comments.add_tag_single("date", &Local::now().year().to_string()); if let Some(artist) = &self.metadata.artist { comments.add_tag_single("artist", artist); if self.copyright { comments.add_tag_single("copyright", &self.get_copyright(artist)); } } if let Some(album) = &self.metadata.album { comments.add_tag_single("album", album); } if let Some(genre) = &self.metadata.genre { comments.add_tag_single("genre", genre); } if let Some(track_number) = &self.metadata.track_number { comments.add_tag_single("tracknumber", &track_number.to_string()); } if let Some(comment) = &self.metadata.genre { comments.add_tag_single("description", comment); } // Write the comments. let mut out = vec![]; replace_comment_header(cursor, comments) .read_to_end(&mut out) .expect("Error reading cursor."); // Write the file. Self::write_file(path, &out); } /// Encode to flac. pub(crate) fn flac(&self, path: &Path, buffer: &AudioBuffer) { // Convert to i32. let mut samples = vec![]; for (left, right) in buffer[0].iter().zip(buffer[1].iter()) { samples.push(Self::to_i32(left)); samples.push(Self::to_i32(right)); } let config = FlacEncoder::default(); let source = MemSource::from_samples(&samples, NUM_CHANNELS, 16, self.framerate.get_u() as usize); match encode_with_fixed_block_size(&config, source, config.block_sizes[0]) { Ok(flac_stream) => { let mut sink = ByteSink::new(); flac_stream.write(&mut sink).unwrap(); // Write the file. Self::write_file(path, sink.as_slice()); // Write the tag. let mut tag = FlacTag::read_from_path(path).unwrap(); tag.set_vorbis("title", vec![self.metadata.title.clone()]); tag.set_vorbis("date", vec![Local::now().year().to_string()]); if let Some(artist) = &self.metadata.artist { tag.set_vorbis("artist", vec![artist.clone()]); if self.copyright { tag.set_vorbis("copyright", vec![self.get_copyright(artist)]); } } if let Some(album) = &self.metadata.album { tag.set_vorbis("album", vec![album.clone()]); } if let Some(genre) = &self.metadata.genre { tag.set_vorbis("genre", vec![genre.clone()]); } if let Some(track_number) = &self.metadata.track_number { tag.set_vorbis("track_number", vec![track_number.to_string()]); } if let Some(comment) = &self.metadata.genre { tag.set_vorbis("description", vec![comment.clone()]); } // Save the tag. tag.save().unwrap(); } Err(error) => panic!("Error encoding flac: {:?}", error), } } /// Write samples to a file. fn write_file(path: &Path, samples: &[u8]) { let mut file = OpenOptions::new() .write(true) .append(false) .truncate(true) .create(true) .open(path) .expect("Error opening file {:?}"); file.write_all(samples) .expect("Failed to write samples to file."); } /// Converts a PPQ value into a MIDI time delta and resets `ppq` to zero. fn get_delta_time(ppq: &mut u64) -> u28 { // Get the dt. let dt = (*ppq as f32 / PPQ_F) as u32; // Reset the PPQ value. *ppq = 0; u28::from(dt) } /// Converts an f32 sample to an i16 sample. fn to_i16(sample: &f32) -> i16 { (sample * F32_TO_I16).floor() as i16 } /// Converts an f32 sample to an i32 sample. fn to_i32(sample: &f32) -> i32 { (sample * F32_TO_I16).floor() as i32 } /// Returns a copyright string. fn get_copyright(&self, artist: &str) -> String { format!("Copyright {} {}", Local::now().year(), artist) } } fn default_flac_settings() -> IndexedValues { IndexedValues::new( 0, [ ExportSetting::Framerate, ExportSetting::Title, ExportSetting::Artist, ExportSetting::Copyright, ExportSetting::Album, ExportSetting::TrackNumber, ExportSetting::Genre, ExportSetting::Comment, ExportSetting::MultiFile, ExportSetting::MultiFileSuffix, ], ) } ================================================ FILE: audio/src/lib.rs ================================================ //! This crate handles all audio output in Cacophony: //! //! - `Player` handles the cpal audio output stream. //! - `Conn` manages the connection between external crates (command input), the synthesizer, and the audio player. //! - `Exporter` handles all exporting to disk. //! //! Various data structs are shared in a Arc> format. These aren't a unified struct because they need to be locked at different times. //! //! As far as external crates are concerned, it's only necessary to create a new Conn: `Conn::default()`. mod command; mod conn; mod decayer; pub mod export; pub mod exporter; pub(crate) mod midi_event_queue; pub mod play_state; mod player; mod program; mod synth_state; pub(crate) mod timed_midi_event; mod types; pub use crate::command::Command; pub use crate::conn::Conn; use crate::program::Program; pub use crate::synth_state::SynthState; pub(crate) use crate::types::{AudioBuffer, SharedMidiEventQueue, SharedSynth}; pub use crate::types::{AudioMessage, CommandsMessage, SharedExportState, SharedPlayState}; use player::Player; ================================================ FILE: audio/src/midi_event_queue.rs ================================================ use super::timed_midi_event::TimedMidiEvent; use oxisynth::MidiEvent; /// A queue of timed MIDI events. #[derive(Default)] pub(crate) struct MidiEventQueue { /// The events. Assume that this is sorted. events: Vec, } impl MidiEventQueue { /// Enqueue a new MIDI event. /// /// - `time` The start time of the event in number of samples. /// - `event` The MIDI event. pub(crate) fn enqueue(&mut self, time: u64, event: MidiEvent) { // Add the event. self.events.push(TimedMidiEvent { time, event }); } pub(crate) fn get_next_time(&self) -> Option { if self.events.is_empty() { None } else { Some(self.events[0].time) } } /// Sort the list of events by start time. pub(crate) fn sort(&mut self) { self.events.sort() } /// Dequeue any events that start at `time`. pub(crate) fn dequeue(&mut self, time: u64) -> Vec { let mut midi_events = vec![]; while !self.events.is_empty() && self.events[0].time == time { midi_events.push(self.events.remove(0).event); } midi_events } /// Clear the queue. pub(crate) fn clear(&mut self) { self.events.clear() } } ================================================ FILE: audio/src/play_state.rs ================================================ #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum PlayState { /// Not playing any audio. NotPlaying, /// Playing music. There are queued events. Value: The elapsed time in samples. Playing(u64), /// There are no more events. Audio is decaying. Decaying, } ================================================ FILE: audio/src/player.rs ================================================ use crate::decayer::Decayer; use crate::play_state::PlayState; use crate::types::SharedSample; use crate::{SharedMidiEventQueue, SharedPlayState, SharedSynth}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::*; use oxisynth::Synth; const ERROR_MESSAGE: &str = "Failed to create an audio output stream: "; /// Try to start an audio stream and play audio. /// Source: https://github.com/PolyMeilex/OxiSynth/blob/master/examples/real-time/src/main.rs pub(crate) struct Player { /// The audio host. We don't want to drop it. _host: Host, /// The audio stream. We don't want to drop it. _stream: Option, /// The machine's audio framerate. pub framerate: u32, } impl Player { pub(crate) fn new( midi_event_queue: SharedMidiEventQueue, synth: SharedSynth, sample: SharedSample, play_state: SharedPlayState, ) -> Option { // Get the host. let host = default_host(); // Try to get an output device. match host.default_output_device() { None => { println!("{} Failed to get output device", ERROR_MESSAGE); None } // Try to get config info. Some(device) => match device.default_output_config() { Err(err) => { println!("{} {}", ERROR_MESSAGE, err); None } // We have a device and a config! Ok(config) => { let framerate = config.sample_rate().0; let stream_config: StreamConfig = config.into(); let channels = stream_config.channels as usize; // Try to get a stream. let stream = Player::run( channels, device, stream_config, midi_event_queue, synth, sample, play_state, ); Some(Self { _host: host, _stream: stream, framerate, }) } }, } } /// Start running the stream. fn run( channels: usize, device: Device, stream_config: StreamConfig, midi_event_queue: SharedMidiEventQueue, synth: SharedSynth, sample: SharedSample, play_state: SharedPlayState, ) -> Option { // Define the error callback. let err_callback = |err| println!("Stream error: {}", err); let two_channels = channels == 2; let mut buffer = vec![0.0; 2]; let mut sample_buffer = [0.0; 2]; let mut decayer = Decayer::default(); // Define the data callback used by cpal. Move `stream_send` into the closure. let data_callback = move |output: &mut [f32], _: &OutputCallbackInfo| { let ps = *play_state.lock(); match ps { // Assume that there is no audio and do nothing. PlayState::NotPlaying => (), // Add decay. PlayState::Decaying => { let len = output.len(); // Write the decay block. decayer.decay_shared(&synth, len); // Set the decay block. if decayer.decaying { // Copy into output. if two_channels { output.copy_from_slice(decayer.buffer[0..len].as_mut()); } else { for (out_frame, in_frame) in output .chunks_mut(channels) .zip(decayer.buffer[0..len].chunks_mut(2)) { for (id, sample) in out_frame.iter_mut().enumerate() { *sample = in_frame[id % 2]; } } } } // Done decaying. else { // Fill the output with silence. output.iter_mut().for_each(|o| *o = 0.0); let mut play_state = play_state.lock(); *play_state = PlayState::NotPlaying; } } // Playing music. PlayState::Playing(time) => { let len = output.len(); // Resize the buffers. if len > buffer.len() { buffer.resize(len, 0.0); } // Get the next sample. let mut synth = synth.lock(); let mut midi_event_queue = midi_event_queue.lock(); // Iterate through the output buffer's frames. let mut begin_decay = false; let buffer_len = len / channels; let mut t = time; for frame in output.chunks_mut(channels) { match midi_event_queue.get_next_time() { Some(next_time) => { // There are events on this frame. if t == next_time { // Dequeue events. let events = midi_event_queue.dequeue(t); // Send the MIDI events to the synth. if !events.is_empty() { for event in events { if synth.send_event(event).is_ok() {} } } } // Add the sample. // This is almost certainly more performant than the code in the `else` block. if two_channels { // Get the sample. synth.write(frame); } // Add for more than one channel. This is slower. else { synth.write(sample_buffer.as_mut_slice()); for (id, sample) in frame.iter_mut().enumerate() { *sample = sample_buffer[id % 2]; } } // Advance time. t += 1; } // There are no more events. None => { begin_decay = true; break; } } } if begin_decay { *play_state.lock() = PlayState::Decaying; Self::begin_decay( buffer[0..buffer_len].as_mut(), output, channels, two_channels, &play_state, &mut synth, ); } else { *play_state.lock() = PlayState::Playing(t); } } } // Share the first sample. let mut sample = sample.lock(); sample.0 = output[0]; sample.1 = output[1] }; // Build the cpal output stream from the stream config info and the callbacks. match device.build_output_stream(&stream_config, data_callback, err_callback) { // We have a stream! Ok(stream) => match stream.play() { Ok(_) => Some(stream), Err(_) => None, }, Err(_) => None, } } fn begin_decay( buffer: &mut [f32], output: &mut [f32], channels: usize, two_channels: bool, play_state: &SharedPlayState, synth: &mut Synth, ) { if two_channels { synth.write(output); } else { // Write decay samples. synth.write(buffer.as_mut()); for (out_frame, in_frame) in output.chunks_mut(channels).zip(buffer.chunks(2)) { for (id, sample) in out_frame.iter_mut().enumerate() { *sample = in_frame[id % 2]; } } } *play_state.lock() = PlayState::Decaying; } } ================================================ FILE: audio/src/program.rs ================================================ use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// A channel's program. #[derive(Serialize, Deserialize)] pub struct Program { /// The path to the current track's SoundFont. pub path: PathBuf, /// The total number of banks. pub num_banks: usize, /// The index of the bank in `banks`. pub bank_index: usize, /// The actual bank value. pub bank: u32, /// The total number of presets in the bank. pub num_presets: usize, /// The preset number. pub preset: u8, /// The index of the preset in `presets`. pub preset_index: usize, /// The name of the preset. pub preset_name: String, } impl Clone for Program { fn clone(&self) -> Self { Self { path: self.path.clone(), num_banks: self.num_banks, bank_index: self.bank_index, bank: self.bank, num_presets: self.num_presets, preset: self.preset, preset_index: self.preset_index, preset_name: self.preset_name.clone(), } } } ================================================ FILE: audio/src/synth_state.rs ================================================ use crate::Program; use common::MAX_VOLUME; use hashbrown::HashMap; use serde::{Deserialize, Serialize}; /// The state of the synthesizer. #[derive(Serialize, Deserialize)] pub struct SynthState { /// The program state per channel. pub programs: HashMap, /// The current gain. pub gain: u8, } impl Default for SynthState { fn default() -> Self { Self { programs: HashMap::new(), gain: MAX_VOLUME, } } } impl Clone for SynthState { fn clone(&self) -> Self { Self { programs: self.programs.clone(), gain: self.gain, } } } ================================================ FILE: audio/src/timed_midi_event.rs ================================================ use oxisynth::MidiEvent; use std::cmp::Ordering; /// A MIDI event with a start time. #[derive(Copy, Clone, Eq, PartialEq)] pub(crate) struct TimedMidiEvent { /// The event time in number of samples. pub(crate) time: u64, /// The event. pub(crate) event: MidiEvent, } impl Ord for TimedMidiEvent { fn cmp(&self, other: &Self) -> Ordering { match self.time.cmp(&other.time) { Ordering::Less => Ordering::Less, Ordering::Greater => Ordering::Greater, Ordering::Equal => match (&self.event, &other.event) { // Two note-on events are equal. ( MidiEvent::NoteOn { channel: _, key: _, vel: _, }, MidiEvent::NoteOn { channel: _, key: _, vel: _, }, ) => Ordering::Equal, // Two note-off events are equal. ( MidiEvent::NoteOff { channel: _, key: _ }, MidiEvent::NoteOff { channel: _, key: _ }, ) => Ordering::Equal, // Note-off events are always before all other events. (MidiEvent::NoteOff { channel: _, key: _ }, _) => Ordering::Less, // Note-on events are always after note-offs. ( MidiEvent::NoteOn { channel: _, key: _, vel: _, }, MidiEvent::NoteOff { channel: _, key: _ }, ) => Ordering::Greater, // Note-on events are always before all other events except note-offs. ( MidiEvent::NoteOn { channel: _, key: _, vel: _, }, _, ) => Ordering::Less, // All other events are equal. _ => Ordering::Equal, }, } } } impl PartialOrd for TimedMidiEvent { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } ================================================ FILE: audio/src/types.rs ================================================ use crate::export::ExportState; use crate::midi_event_queue::MidiEventQueue; use crate::play_state::PlayState; use crate::Command; use oxisynth::Synth; use parking_lot::Mutex; use std::sync::Arc; /// Type alias for an audio messages. pub type AudioMessage = (f32, f32); /// Type alias for a commands message. pub type CommandsMessage = Vec; /// Type alias for an audio buffer. pub(crate) type AudioBuffer = [Vec; 2]; pub(crate) type SharedSynth = Arc>; pub type SharedExportState = Arc>; pub(crate) type SharedMidiEventQueue = Arc>; pub type SharedPlayState = Arc>; pub(crate) type SharedSample = Arc>; ================================================ FILE: changelog.md ================================================ # 0.2.x ## 0.2.7 - Fixed: Note-off events at the end of a track are sometimes not detected during export - Fixed: Panic when decaying audio if the output buffer exceeds the length of the decay buffer. - Allow alphanumeric input in `--events events.txt`. ## 0.2.6 - Fixed: If you let all notes in your music play (as opposed to stopping in the middle), it frequently becomes impossible to play or add new notes. - Added a build env variable: Set `CACOPHONY_BUILD_DATA_DIR` to set the default path to the data directory. ## 0.2.5 - Dropped MacOS 11 builds on itch because there isn't a GitHub workflow runner anymore. - Added MacOS 14 builds to tests and itch uploads. - Added Ubuntu 24.04 builds to tests and itch uploads. - Fixed: Crash if a Channel Pressure or Program Change message is sent from a MIDI controller. - Fixed: Various TTS race conditions. - (Backend) Added some more tests - (Backend) Bumped ureq version (fixes a security warning) - (Backend) Bumped Cargo.lock versions (fixes a security warning) - (Backend) Removed audio/Cargo.lock (fixes a spurious security warning) - (Backend) Added tests for TTS ## 0.2.4 - Fixed: If note is played via a qwerty key press, and then an octave is changed via a qwerty key press, there won't be a note-off event. - Fixed: Cacophony can't found files (saves, soundfonts, etc.) if the file extension contains uppercase characters. - Fixed: ChildPaths sometimes doesn't set the correct directory when moving up a directory. - Fixed: It's possible to add notes while the music is playing. - Fixed: When you save a new file, the panel will list children in the save directory's parent folder. - (Backend) Fixed clippy warnings for Rust 1.78 - (Backend) The GitHub workflow for building Cacophony now uses the latest version of Rust. - (Backend) Added tests for ChildPaths. ## 0.2.3 - Optimized text and rectangle rendering, which reduces CPU usage by around 5%. ## 0.2.2 - Fixed: There was an input bug where the play/start key (spacebar) was sometimes unresponsive for the first few presses. This is because audio was still decaying from a previous play, meaning that technically the previous play was still ongoing. - Fixed: When a new file is created or when a new save file loaded, the app didn't reset correctly. - Fixed: If you try to play music and there are no tracks or no playable notes, the app starts playing music and then immediately stops. - Fixed: If you're playing music and then load a save file, the save file can't play music because the synthesizer still has MIDI events from the previous music. ## 0.2.1 - I replaced the default qwerty bindings for note input with a more "standard" layout. This information is stored in config.ini, so if you want the update, make sure to delete Documents/cacophony/config.ini if it exists (Cacophony will use the default data/config.ini instead). - The background of the export settings panel was the same color as the text, so that it just looked like a weird gray rectangle. I fixed it. ## 0.2.0 Cacophony uses a lot of CPU resources even when it's idle. It shouldn't do that! I reduced Cacophony's CPU usage by around 50%; the exact percentage varies depending on the CPU and the OS. This update is the first big CPU optimization, and probably the most significant. These are the optimizations: - In `audio`, `Synthesizer` (which no longer exists) ran a very fast infinite loop to send samples to `Player`, and the loop needlessly consumed CPU resources. I replaced this loop with a bunch of `Arc>` values that are shared between `Conn` and `Player`. As a result of removing `Synthesizer` I had to reorganize all of the exporter code. There are a lot of small changes that I'm not going to list here because let's be real, no one reads verbose changelogs, but the most noticeable change is that `Exporter` is a field in `Conn` and is no longer shared (there is no `Arc>` anywhere in the code). This change affects a *lot* of the codebase, but it's mostly just refactoring with zero functional differences. - In `input`, `Input` checked for key downs and presses very inefficently. I only had to change two lines of code to make it much faster. This optimizes CPU usage by roughly 10%. - A tiny optimization to drawing panel backgrounds. This required refactoring throughout `render`. I think that there are more tiny optimizations that could be made in `render` that cumulatively might make more of a difference. This update doesn't have any new features or bug fixes. In the future, I'm going to reserve major releases (0.x.0) for big new features, but I had to rewrite so much of Cacophony's code, and the results are such a big improvement, that I'm making this a major release anyway. # 0.1.x ## 0.1.4 - Fixed: Crash when setting the input beat to less than 1/8 ## 0.1.3 - Added .flac exporting - Fixed: Crash when attempting to add silence to the end of a non-wav multi-track export. I don't remember why I wanted to add silence in the first place, so I've removed it. - Fixed: `--events [PATH]` argument doesn't work. ## 0.1.2 - Added environment variables and command line arguments: - Add a save file path to open it at launch, for example: `./cacophony ~/Documents/cacophony/saves/my_music.cac` - `CACOPHONY_DATA_DIR` or `--data_directory` to set the data directory - `CACOPHONY_FULLCREEN` or `--fullscreen` to enable fullscreen. - (Backend) Renamed feature flags `ubuntu_18_20` and `ubuntu_22` to `speech_dispatcher_0_9` and `speech_dispatcher_0_11`, respectively - (Backend) `Paths` is now handled as a `OnceLock` - Added missing compliation requirements in README. Added compilation instructions for Debian 11 and 12. - Added this changelog ================================================ FILE: common/Cargo.toml ================================================ [package] name = "common" version.workspace = true authors.workspace = true description.workspace = true documentation.workspace = true edition.workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } rust-ini = { workspace = true } directories = { workspace = true } macroquad = { workspace = true } hashbrown = { workspace = true } num-traits = { workspace = true } clap = { workspace = true } ================================================ FILE: common/src/args.rs ================================================ use crate::get_default_data_folder; use clap::Parser; use std::path::PathBuf; /// Command-line arguments. #[derive(Parser)] #[command(author, version, about)] pub struct Args { /// Open the project from disk. #[arg(value_name = "FILE")] pub file: Option, /// Directory where Cacophony data files reside. /// /// Uses './data' if not set. #[arg(short, long, value_name = "DIR", env = "CACOPHONY_DATA_DIR", default_value = get_default_data_folder().into_os_string())] pub data_directory: PathBuf, /// Make the window fullscreen. /// /// Uses 'fullscreen' under '[RENDER]' in 'config.ini' if not set. /// /// Applied after displaying the splash-screen. #[arg(short, long, env = "CACOPHONY_FULLSCREEN")] pub fullscreen: bool, /// A path to a file of events that will be executed sequentially when the simulation starts. /// /// This is meant to be used for debugging. #[arg(short, long)] pub events: Option, } ================================================ FILE: common/src/config.rs ================================================ use crate::fraction::*; use crate::Paths; use ini::{Ini, Properties}; use serde_json::from_str; use std::fmt::Display; use std::str::FromStr; /// Load the config file. pub fn load() -> Ini { let paths = Paths::get(); let path = if paths.user_ini_path.exists() { &paths.user_ini_path } else { &paths.default_ini_path }; match Ini::load_from_file(path) { Ok(ini) => ini, Err(error) => panic!("Error loading confi.ini from {:?}: {}", path, error), } } /// Parse a string `value` and returns an enum of type `T`. fn string_to_value(value: &str) -> T where T: FromStr, ::Err: Display, { match value.parse::() { Ok(value) => value, Err(error) => panic!("Failed to parse {}", error), } } /// Parse a config key-value string pair into a value of type T. /// /// - `properties` The `Ini` properties. /// - `key` the key portion of the key-value pair. pub fn parse(properties: &Properties, key: &str) -> T where T: FromStr, ::Err: Display, { match properties.get(key) { Some(value) => string_to_value(value), None => panic!("Missing key {}", key), } } /// Parse a 1 or 0 as a boolean. pub fn parse_bool(properties: &Properties, key: &str) -> bool { match properties.get(key) { Some(value) => match value { "1" => true, "0" => false, _ => panic!("Invalid boolean value {} {}", key, value), }, None => panic!("Missing key {}", key), } } /// Parse a list of fraction strings to PPQ values. pub fn parse_fractions(properties: &Properties, key: &str) -> Vec { match properties.get(key) { Some(value) => match from_str::>(value) { Ok(value) => value.iter().map(|v| parse_float_kv(key, v)).collect(), Err(error) => panic!( "Error parsing list of fractions {} for key {}: {}", value, key, error ), }, None => panic!("Missing key {}", key), } } /// Parse a value string as a float. pub fn parse_float(properties: &Properties, key: &str) -> f32 { match properties.get(key) { Some(value) => parse_float_kv(key, value), None => panic!("Missing key {}", key), } } /// Parse a value string as a fraction. pub fn parse_fraction(properties: &Properties, key: &str) -> Fraction { match properties.get(key) { Some(value) => parse_fraction_kv(key, value), None => panic!("Missing key {}", key), } } /// Parse a value string as a float. fn parse_float_kv(key: &str, value: &str) -> f32 { // Is this formatted like a fraction, e.g. "1/2"? match value.contains('/') { true => { let nd: Vec<&str> = value.split('/').collect(); match nd[0].parse::() { Ok(n) => match nd[1].parse::() { Ok(d) => n / d, Err(error) => panic!( "Invalid denominator in fraction {} for key {}: {}", value, key, error ), }, Err(error) => panic!( "Invalid numerator in fraction {} for key {}: {}", value, key, error ), } } // Is this formated like a decimal, e.g. "0.5" or "5"? false => match value.parse::() { Ok(value) => value, Err(error) => panic!("Invalid value {} for key {}: {}", value, key, error), }, } } /// Parse a value string as a Fraction. fn parse_fraction_kv(key: &str, value: &str) -> Fraction { // Is this formatted like a fraction, e.g. "1/2"? match value.contains('/') { true => { let nd: Vec<&str> = value.split('/').collect(); match nd[0].parse::() { Ok(n) => match nd[1].parse::() { Ok(d) => Fraction::new(n, d), Err(error) => panic!( "Invalid denominator in fraction {} for key {}: {}", value, key, error ), }, Err(error) => panic!( "Invalid numerator in fraction {} for key {}: {}", value, key, error ), } } // Is this formated like a decimal, e.g. "0.5" or "5"? false => match value.parse::() { Ok(value) => Fraction::from(value), Err(error) => panic!("Invalid value {} for key {}: {}", value, key, error), }, } } ================================================ FILE: common/src/edit_mode.rs ================================================ use crate::IndexedValues; use serde::{Deserialize, Serialize}; pub type IndexedEditModes = IndexedValues; #[derive(Debug, Eq, PartialEq, Hash, Copy, Clone, Default, Deserialize, Serialize)] pub enum EditMode { /// Edit at a normal pace. What "normal" means is defined by the edit mode. #[default] Normal, /// Edit quickly; a multiple of `Normal`. Quick, /// Edit precisely. What "precisely" means is defined by the edit mode. Precise, } impl EditMode { pub fn indexed() -> IndexedEditModes { IndexedValues::new(0, [EditMode::Normal, EditMode::Quick, EditMode::Precise]) } } ================================================ FILE: common/src/font.rs ================================================ use crate::{get_bytes, Paths}; use ini::{Ini, Properties}; use macroquad::prelude::*; /// Returns the font data section in the config file. pub fn get_font_section(config: &Ini) -> &Properties { config.section(Some("FONTS")).unwrap() } /// Reads the font to a byte buffer. pub fn get_font_bytes(config: &Ini) -> Vec { get_font_from_bytes(config, "font") } /// Returns the main font. pub fn get_font(config: &Ini) -> Font { load_ttf_font_from_bytes(&get_font_bytes(config)).unwrap() } /// Returns the subtitle font. pub fn get_subtitle_font(config: &Ini) -> Font { load_ttf_font_from_bytes(&get_font_from_bytes(config, "subtitle_font")).unwrap() } /// Returns the path to a font. fn get_font_from_bytes(config: &Ini, key: &str) -> Vec { get_bytes( &Paths::get() .data_directory .join(get_font_section(config).get(key).unwrap()), ) } ================================================ FILE: common/src/fraction.rs ================================================ use crate::U64orF32; use std::fmt::Display; use std::ops::{Div, Mul}; /// A fraction has a numerator and denominator and can be multiplied or divided. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub struct Fraction { pub numerator: u64, pub denominator: u64, } impl Fraction { pub fn new(numerator: u64, denominator: u64) -> Self { Self { numerator, denominator, } } /// Flip the numerator and denominator. pub fn invert(&mut self) { std::mem::swap(&mut self.numerator, &mut self.denominator); } } impl From for Fraction { fn from(value: u64) -> Self { Self::new(value, 1) } } impl From for Fraction { fn from(value: U64orF32) -> Self { Self::new(value.get_u(), 1) } } impl Display for Fraction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}/{}", self.numerator, self.denominator) } } impl Mul for Fraction { type Output = u64; fn mul(self, rhs: u64) -> Self::Output { (rhs * self.numerator) / self.denominator } } impl Mul for u64 { type Output = u64; fn mul(self, rhs: Fraction) -> Self::Output { (self * rhs.numerator) / rhs.denominator } } impl Mul for Fraction { type Output = Fraction; fn mul(self, rhs: Fraction) -> Self::Output { Self::new( self.numerator * rhs.numerator, self.denominator * rhs.denominator, ) } } impl Mul for U64orF32 { type Output = U64orF32; fn mul(self, rhs: Fraction) -> Self::Output { Self::from(self.get_u() * rhs) } } impl Mul for Fraction { type Output = U64orF32; fn mul(self, rhs: U64orF32) -> Self::Output { Self::Output::from(self * rhs.get_u()) } } impl Div for Fraction { type Output = u64; fn div(self, rhs: u64) -> Self::Output { (rhs * self.numerator) / self.denominator } } impl Div for u64 { type Output = u64; fn div(self, rhs: Fraction) -> Self::Output { (self * rhs.denominator) / rhs.numerator } } impl Div for Fraction { type Output = Fraction; fn div(self, rhs: Fraction) -> Self::Output { Self::new( self.numerator * rhs.denominator, self.denominator * rhs.numerator, ) } } impl Div for U64orF32 { type Output = U64orF32; fn div(self, rhs: Fraction) -> Self::Output { Self::from(self.get_u() / rhs) } } impl Div for Fraction { type Output = U64orF32; fn div(self, rhs: U64orF32) -> Self::Output { Self::Output::from(self / rhs.get_u()) } } #[cfg(test)] mod tests { use crate::fraction::Fraction; use crate::U64orF32; #[test] fn fraction() { // Instantiation. let mut fr = Fraction::from(3); assert_eq!(fr.numerator, 3); assert_eq!(fr.denominator, 1); fr = Fraction::new(1, 2); assert_eq!(fr.numerator, 1); assert_eq!(fr.denominator, 2); // Inversion. fr.invert(); assert_eq!(fr.numerator, 2); assert_eq!(fr.denominator, 1); // Equality. fr.numerator = 3; fr.denominator = 5; // u64. let u = 7; assert_eq!(u * fr, 4); assert_eq!(fr * u, 4); assert_eq!(u / fr, 11); assert_eq!(fr / u, 4); // Fraction. let fr1 = Fraction::new(1, 2); let mut fr2 = fr * fr1; assert_eq!(fr2.numerator, 3); assert_eq!(fr2.denominator, 10); fr2 = fr1 * fr; assert_eq!(fr2.numerator, 3); assert_eq!(fr2.denominator, 10); fr2 = fr / fr1; assert_eq!(fr2.numerator, 6); assert_eq!(fr2.denominator, 5); fr2 = fr1 / fr; assert_eq!(fr2.numerator, 5); assert_eq!(fr2.denominator, 6); // U64orF32. let uf = U64orF32::from(7); assert_eq!((uf * fr).get_u(), 4); assert_eq!((fr * uf).get_u(), 4); assert_eq!((uf / fr).get_u(), 11); assert_eq!((fr / uf).get_u(), 4); } } ================================================ FILE: common/src/index.rs ================================================ use num_traits::int::PrimInt; use num_traits::{One, Zero}; use serde::{Deserialize, Serialize}; use std::fmt::Display; use std::ops::{AddAssign, SubAssign}; /// An `Index` is an index in a known-length array. /// The index can be incremented or decremented past the bounds of length, in which case it will loop to the start/end value. /// The index can never exceed the length. #[derive(Eq, PartialEq, Copy, Clone, Debug, Deserialize, Serialize)] pub struct Index where T: PrimInt + Display + One + Zero + AddAssign + SubAssign, { /// The index in the array. index: T, /// The length of the array. length: T, } impl Index where T: PrimInt + Display + One + Zero + AddAssign + SubAssign, { /// - `index` The index in the array. /// - `length` The size of the array. pub fn new(index: T, length: T) -> Self { Self { index, length } } /// Increment or decrement the index. /// /// If the incremented index is greater than `self.length`, `self.index` is set to 0. /// If the decremented index would be less than 0, `self.index` is set to `self.length - 1`. /// /// - `up` If true, increment. If false, decrement. pub fn increment(&mut self, up: bool) { let zero = T::zero(); let one = T::one(); if self.length == zero { return; } self.index = if up { if self.index == self.length - one { zero } else { self.index + one } } else if self.index == zero { self.length - one } else { self.index - one }; } /// Increment or decrement the index without looping around the bounds. /// /// - `up` If true, increment. If false, decrement. /// /// Returns true if we incremented. pub fn increment_no_loop(&mut self, up: bool) -> bool { let zero = T::zero(); let one = T::one(); if self.length == zero { false } else if up { if self.index < self.length - one { self.index += one; true } else { false } } else if self.index > zero { self.index -= one; true } else { false } } /// Returns the index. pub fn get(&self) -> T { self.index } /// Set `self.index` to `index`. Panics if `index` is greater than or equal to `self.length`. pub fn set(&mut self, index: T) { if index >= self.length { panic!("Index {} exceeds length {}!", index, self.length) } else { self.index = index } } /// Returns the length of the value range. pub fn get_length(&self) -> T { self.length } } impl Default for Index { fn default() -> Self { Self::new(0, 0) } } #[cfg(test)] mod tests { use crate::Index; #[test] fn index() { // Zero. let mut i = Index::default(); assert_eq!(i.index, 0); assert_eq!(i.length, 0); i.increment(true); assert_eq!(i.index, 0); assert_eq!(i.length, 0); i.increment(false); assert_eq!(i.index, 0); assert_eq!(i.length, 0); i.increment_no_loop(true); assert_eq!(i.index, 0); assert_eq!(i.length, 0); i.increment_no_loop(false); assert_eq!(i.index, 0); assert_eq!(i.length, 0); // Some. i = Index::new(1, 9); assert_eq!(i.index, 1); assert_eq!(i.index, i.get()); assert_eq!(i.length, 9); assert_eq!(i.length, i.get_length()); i.increment(false); assert_eq!(i.get(), 0); i.increment(false); assert_eq!(i.get(), 8); i.increment(true); assert_eq!(i.get(), 0); i.increment_no_loop(false); assert_eq!(i.get(), 0); } } ================================================ FILE: common/src/indexed_values.rs ================================================ use crate::Index; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; /// An `Index` with an array of values of type T and length N. #[derive(Eq, PartialEq, Copy, Clone, Debug, Deserialize, Serialize)] pub struct IndexedValues where [T; N]: Serialize + DeserializeOwned, T: Copy + Default, { /// The values as an array of type `N`. These will be accessed via `index`. values: [T; N], /// The index. The length is always `N`. pub index: Index, } impl Default for IndexedValues where [T; N]: Serialize + DeserializeOwned, T: Copy + Default, { fn default() -> Self { Self { values: [T::default(); N], index: Index::default(), } } } impl IndexedValues where [T; N]: Serialize + DeserializeOwned, T: Copy + Default, { pub fn new(index: usize, values: [T; N]) -> Self { let index = Index::new(index, values.len()); Self { values, index } } /// Returns the value at the index. pub fn get(&self) -> T { *self.get_ref() } /// Returns a reference to the value at the index. pub fn get_ref(&self) -> &T { &self.values[self.index.get()] } /// Returns a tuple: /// /// - A reference to the values array. /// - An array of booleans of whether each element is at the `self.index.get()`. pub fn get_values(&self) -> (&[T; N], [bool; N]) { let index = self.index.get(); let mut values = [false; N]; for (i, v) in values.iter_mut().enumerate() { if index == i { *v = true; break; } } (&self.values, values) } } #[cfg(test)] mod tests { use crate::IndexedValues; #[test] fn indexed_values() { let mut i = IndexedValues::new(1, [0u8, 1u8, 2u8]); assert_eq!(i.index.get(), 1); assert_eq!(i.get(), 1); i.index.increment(false); assert_eq!(i.index.get(), 0); assert_eq!(i.get(), 0); i.index.increment(false); assert_eq!(i.index.get(), 2); assert_eq!(i.get(), 2); assert_eq!(i.get_ref(), &2) } } ================================================ FILE: common/src/input_state.rs ================================================ use crate::{Index, U64orF32, MAX_VOLUME, PPQ_U}; use serde::{Deserialize, Serialize}; /// Booleans and numerical values describing the input state. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct InputState { /// If true, we will accept musical input. pub armed: bool, /// If true, we're inputting an alphanumeric string and we should ignore certain key bindings. pub alphanumeric_input: bool, /// The volume for all new notes. pub volume: Index, /// If true, we'll use the volume value. pub use_volume: bool, /// The input beat in PPQ. pub beat: U64orF32, /// If true, music is playing or exporting. #[serde(skip)] pub is_playing: bool, } impl Default for InputState { fn default() -> Self { Self { armed: false, alphanumeric_input: false, volume: Index::new(MAX_VOLUME, MAX_VOLUME + 1), use_volume: true, beat: U64orF32::from(PPQ_U), is_playing: false, } } } ================================================ FILE: common/src/lib.rs ================================================ //! This crate contains a variety of types that are shared throughout Cacophony. //! //! There are two app-state-level structs defined in this crate: //! //! 1. `State` is *most* of the app state. It contains any data that can be placed on the undo/redo stacks. Because the undo/redo stacks contain entire `State` structs, the struct needs to be as small as possible. //! 2. `PathsState` The state of directories, files, etc. defined by the user navigating through open-file dialogues. This isn't part of `State` because nothing here should go on the undo/redo stacks. //! //! There are two other state objects that aren't defined in this crate: //! //! - `SynthState` (defined in `audio`). //! - `Exporter` (defined in `audio`). //! //! `common` is designed such that any Cacophony crate can use it, but itself does not depend on any Cacophony crates. pub mod args; pub mod config; mod index; mod input_state; mod midi_track; mod music; mod note; mod panel_type; pub mod paths; mod paths_state; mod state; pub mod time; pub mod view; pub use index::Index; mod indexed_values; pub use indexed_values::IndexedValues; pub use input_state::InputState; pub use midi_track::MidiTrack; pub use music::*; pub use note::{Note, MAX_NOTE, MIN_NOTE, NOTE_NAMES}; pub use panel_type::PanelType; pub use paths::Paths; pub use state::State; use view::View; mod edit_mode; pub mod music_panel_field; pub use edit_mode::*; mod select_mode; pub use select_mode::SelectMode; mod piano_roll_mode; pub use piano_roll_mode::PianoRollMode; use std::env::current_dir; use std::fs::{metadata, File}; use std::io::Read; use std::option_env; use std::path::{Path, PathBuf}; pub mod font; pub mod open_file; pub mod sizes; pub use paths_state::PathsState; mod u64_or_f32; pub use self::time::*; pub use u64_or_f32::*; pub mod fraction; /// The version that will be printed on-screen. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The maximum volume. pub const MAX_VOLUME: u8 = 127; /// Read bytes from a file. pub fn get_bytes(path: &Path) -> Vec { let metadata = metadata(path).unwrap(); let mut f = File::open(path).unwrap(); let mut buffer = vec![0; metadata.len() as usize]; f.read_exact(&mut buffer).unwrap(); buffer } /// Default directory for looking at the 'data/' folder. pub fn get_default_data_folder() -> PathBuf { match option_env!("CACOPHONY_BUILD_DATA_DIR") { Some(dir) => PathBuf::from(dir), None => current_dir().unwrap().join("data"), } } #[cfg(debug_assertions)] pub fn get_test_config() -> ini::Ini { ini::Ini::load_from_file("../data/config.ini").unwrap() } ================================================ FILE: common/src/midi_track.rs ================================================ use crate::{Note, MAX_VOLUME}; use serde::{Deserialize, Serialize}; /// A MIDI track has some notes. #[derive(Debug, Deserialize, Serialize)] pub struct MidiTrack { /// The channel used for audio synthesis. pub channel: u8, /// A gain value (0-127) for this track. pub gain: u8, /// The notes in the track. pub notes: Vec, /// True if the track is muted. pub mute: bool, /// True if the track is soloed. pub solo: bool, } impl MidiTrack { pub fn new(channel: u8) -> Self { Self { channel, gain: MAX_VOLUME, notes: vec![], mute: false, solo: false, } } /// Returns the end time of the track in PPQ. pub fn get_end(&self) -> Option { self.notes.iter().map(|n| n.end).max() } /// Returns the track gain as a float between 0 and 1. pub fn get_gain_f(&self) -> f32 { self.gain as f32 / MAX_VOLUME as f32 } /// Returns all notes in the track that can be played (they are after t0). pub fn get_playback_notes(&self, start: u64) -> Vec { let gain = self.get_gain_f(); let mut notes = vec![]; for note in self.notes.iter().filter(|n| n.start >= start) { let mut n1 = *note; n1.velocity = (n1.velocity as f32 * gain) as u8; notes.push(n1); } notes.sort(); notes } } impl Clone for MidiTrack { fn clone(&self) -> Self { Self { channel: self.channel, gain: self.gain, notes: self.notes.clone(), mute: self.mute, solo: self.solo, } } } ================================================ FILE: common/src/music.rs ================================================ use super::midi_track::MidiTrack; use serde::{Deserialize, Serialize}; /// Tracks, notes, and metadata. #[derive(Clone, Default, Debug, Deserialize, Serialize)] pub struct Music { /// The music tracks. pub midi_tracks: Vec, /// The index of the selected track. pub selected: Option, } impl Music { /// Returns the selected track, if any. pub fn get_selected_track(&self) -> Option<&MidiTrack> { match self.selected { Some(index) => Some(&self.midi_tracks[index]), None => None, } } /// Returns a mutable reference to the selected track, if any. pub fn get_selected_track_mut(&mut self) -> Option<&mut MidiTrack> { match self.selected { Some(index) => Some(&mut self.midi_tracks[index]), None => None, } } /// Returns all tracks that can be played. pub fn get_playable_tracks(&self) -> Vec<&MidiTrack> { // Get all tracks that can play music. let tracks = match self.midi_tracks.iter().find(|t| t.solo) { // Only include the solo track. Some(solo) => vec![solo], // Only include unmuted tracks. None => self.midi_tracks.iter().filter(|t| !t.mute).collect(), }; tracks } } ================================================ FILE: common/src/music_panel_field.rs ================================================ use serde::{Deserialize, Serialize}; /// Enum values defining the music panel fields. #[derive(Debug, Default, Eq, PartialEq, Copy, Clone, Hash, Deserialize, Serialize)] pub enum MusicPanelField { #[default] Name, BPM, Gain, } ================================================ FILE: common/src/note.rs ================================================ use serde::ser::SerializeSeq; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::fmt::{Debug, Formatter, Result}; /// The MIDI value of the highest-frequency note. pub const MAX_NOTE: u8 = 127; /// The MIDI value of the lowest-frequency note. pub const MIN_NOTE: u8 = 12; /// The MIDI value for C4. pub const MIDDLE_C: u8 = 60; /// The name of each note, in order. /// Question: Why not just calculate the name from the MIDI value? /// Answer: 1) Because I couldn't find an accurate formula. 2) This is probably slightly faster. pub const NOTE_NAMES: [&str; 115] = [ "G9", "F#9", "F9", "E9", "D#9", "D9", "C#9", "C9", "B9", "A#9", "A9", "G#9", "G8", "F#8", "F8", "E8", "D#8", "D8", "C#8", "C8", "B8", "A#8", "A8", "G#8", "G7", "F#7", "F7", "E7", "D#7", "D7", "C#7", "C7", "B7", "A#7", "A7", "G#7", "G6", "F#6", "F6", "E6", "D#6", "D6", "C#6", "C6", "B6", "A#6", "A6", "G#6", "G5", "F#5", "F5", "E5", "D#5", "D5", "C#5", "C5", "B5", "A#5", "A5", "G#5", "G4", "F#4", "F4", "E4", "D#4", "D4", "C#4", "C4", "B4", "A#4", "A4", "G#4", "G3", "F#3", "F3", "E3", "D#3", "D3", "C#3", "C3", "B3", "A#3", "A3", "G#3", "G2", "F#2", "F2", "E2", "D#2", "D2", "C#2", "C2", "B2", "A#2", "A2", "G#2", "G1", "F#1", "F1", "E1", "D#1", "D1", "C#1", "C1", "B1", "A#1", "A1", "G#1", "G0", "F#0", "F0", "E0", "D#0", "D0", "C#0", ]; /// A MIDI note with a start bar time and a duration bar time. #[derive(Copy, Clone, PartialEq, Eq, Deserialize)] pub struct Note { /// The MIDI note value. pub note: u8, /// The velocity value. pub velocity: u8, /// The start time in PPQ (pulses per quarter note). pub start: u64, /// The end time in PPQ. pub end: u64, } impl Note { /// Returns the duration of the note in PPQ. pub fn get_duration(&self) -> u64 { self.end - self.start } /// Adjust the start and end times by a delta (`dt`). pub fn set_t0_by(&mut self, dt: u64, positive: bool) { if positive { self.start += dt; self.end += dt; } else { self.start -= dt; self.end -= dt; } } /// Returns the name of the note. pub fn get_name(&self) -> &str { NOTE_NAMES[127 - self.note as usize] } } impl Ord for Note { fn cmp(&self, other: &Self) -> Ordering { (self.start, self.end, self.note).cmp(&(other.start, other.end, other.note)) } } impl PartialOrd for Note { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Debug for Note { fn fmt(&self, f: &mut Formatter) -> Result { write!( f, "Note {} {} {} {}", self.note, self.velocity, self.start, self.end ) } } impl Serialize for Note { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { let mut seq = serializer.serialize_seq(Some(4)).unwrap(); seq.serialize_element(&self.note).unwrap(); seq.serialize_element(&self.velocity).unwrap(); seq.serialize_element(&self.start).unwrap(); seq.serialize_element(&self.end).unwrap(); seq.end() } } #[cfg(test)] mod tests { use crate::note::MIDDLE_C; use crate::{Note, MAX_VOLUME, PPQ_U}; use serde_json::{from_str, to_string}; #[test] fn note_duration() { let note = get_note(); assert_eq!(note.get_duration(), PPQ_U, "{}", note.get_duration()); } #[test] fn note_serialization() { let note = get_note(); let r = to_string(¬e); assert!(r.is_ok(), "{:?}", note); let s = r.unwrap(); assert_eq!(&s, "[60,127,0,192]", "{}", s); let r = from_str(&s); assert!(r.is_ok(), "{:?}", s); let note: Note = r.unwrap(); assert_eq!(note.note, MIDDLE_C, "{:?}", note); assert_eq!(note.velocity, MAX_VOLUME, "{:?}", note); assert_eq!(note.start, 0, "{:?}", note); assert_eq!(note.end, PPQ_U, "{:?}", note); } fn get_note() -> Note { Note { note: MIDDLE_C, velocity: MAX_VOLUME, start: 0, end: PPQ_U, } } } ================================================ FILE: common/src/open_file/child_paths.rs ================================================ use super::{Extension, FileOrDirectory}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// A collection of child paths and a selected child path. #[derive(Default, Clone, Deserialize, Serialize)] pub struct ChildPaths { /// The child paths from the parent directory. pub children: Vec, /// The index of the selected child in `children`, if any. pub selected: Option, } impl ChildPaths { /// Set the child paths and selection. /// /// - `directory` The current parent directory. /// - `extension` The extension of valid files. /// - `previous_directory` This is used to set the selection to the directory we just moved up from. pub fn set( &mut self, directory: &Path, extension: &Extension, previous_directory: Option, ) { // Get the paths. let children = self.get_paths_in_directory(directory, extension); // Get the folders. This sets the selection. let folders: Vec<&FileOrDirectory> = children.iter().filter(|p| !p.is_file).collect(); // Set the selection index. // Try to select the previous directory. if let Some(previous_directory) = previous_directory { self.selected = children .iter() .enumerate() .filter(|p| { p.1.stem == previous_directory .components() .last() .unwrap() .as_os_str() .to_str() .unwrap() }) .map(|p| p.0) .next(); } // Try to select a child. if self.selected.is_none() { self.selected = if children.is_empty() { None } else { match (folders.is_empty(), children.iter().any(|p| p.is_file)) { (true, false) => None, (false, false) => Some(folders.len() - 1), (_, _) => Some(0), } }; } self.children = children; } /// Get the child paths of a directory. /// /// - `directory` The current parent directory. /// - `extension` The extension of valid files. fn get_paths_in_directory( &self, directory: &Path, extension: &Extension, ) -> Vec { // Find all valid paths. let valid_paths: Vec = match directory.read_dir() { Ok(read) => read .filter(|e| e.is_ok()) .map(|e| e.unwrap().path()) .filter(|p| p.is_file() || p.read_dir().is_ok()) .collect(), Err(_) => vec![], }; // Get the files. let mut files: Vec<&PathBuf> = valid_paths .iter() .filter(|p| { p.is_file() && p.extension().is_some() && extension.to_str(false) == p.extension().unwrap().to_str().unwrap().to_lowercase() }) .collect(); files.sort(); // Get the directories. let mut folders: Vec<&PathBuf> = valid_paths.iter().filter(|p| p.is_dir()).collect(); folders.sort(); let mut paths: Vec = folders.iter().map(|f| FileOrDirectory::new(f)).collect(); paths.append(&mut files.iter().map(|f| FileOrDirectory::new(f)).collect()); paths } } #[cfg(test)] mod tests { use std::{fs::canonicalize, path::PathBuf}; use super::ChildPaths; use crate::open_file::{Extension, FileOrDirectory}; #[test] fn test_sf2_child_paths() { let sf_directory = PathBuf::from("../data"); assert!(sf_directory.exists()); let mut child_paths = ChildPaths::default(); child_paths.set(&sf_directory, &Extension::Sf2, None); assert_eq!(child_paths.children.len(), 1); let f = &child_paths.children[0]; assert!(f.is_file); assert_eq!(f.stem, "CT1MBGMRSV1.06.sf2"); assert!(child_paths.selected.is_some()); assert_eq!(child_paths.selected.unwrap(), 0); // There shouldn't be any save files. child_paths.set(&sf_directory, &Extension::Cac, None); assert!(child_paths.children.is_empty()); assert!(child_paths.selected.is_some()); assert_eq!(child_paths.selected.unwrap(), 0); let parent_directory = canonicalize(sf_directory.parent().unwrap()).unwrap(); assert_eq!( parent_directory .components() .last() .unwrap() .as_os_str() .to_str() .unwrap(), "cacophony" ); // Set a different directory. child_paths.set(&parent_directory, &Extension::Sf2, None); assert!(!child_paths.children.is_empty()); assert!(child_paths .children .iter() .filter(|c| c.is_file) .collect::>() .is_empty()); // Ignore any folders that have names beginning with a period because they won't all appear in the GitHub workflow. child_paths .children .retain(|f| match f.stem.chars().next() { Some(ch) => ch != '.', None => false, }); assert!(child_paths.children.len() > 0); assert!(child_paths.selected.is_some()); assert_eq!(child_paths.selected.unwrap(), 0); // Go "up" a directory. child_paths.set( &parent_directory, &Extension::Sf2, Some(sf_directory.clone()), ); // Test the selection. assert_eq!( child_paths.children[child_paths.selected.unwrap()].stem, "data" ); } #[test] fn test_cac_child_paths() { let cac_directory = PathBuf::from("../test_files/child_paths"); assert!(cac_directory.exists()); let mut child_paths = ChildPaths::default(); child_paths.set(&cac_directory, &Extension::Cac, None); assert_eq!(child_paths.children.len(), 3); test_cac_file(&child_paths, child_paths.selected.unwrap(), "test_0.cac"); test_cac_file(&child_paths, 1, "test_1.cac"); test_cac_file(&child_paths, 2, "test_2.CAC"); } fn test_cac_file(child_paths: &ChildPaths, index: usize, filename: &str) { let f = &child_paths.children[index]; assert!(f.is_file); assert_eq!(f.stem, filename); } } ================================================ FILE: common/src/open_file/extension.rs ================================================ use serde::{Deserialize, Serialize}; /// Enum values for file extensions used in Cacophony. #[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, Serialize)] pub enum Extension { Cac, Sf2, Wav, Mid, MP3, Ogg, Flac, } impl Extension { /// Returns the file extension associated with the export type. /// /// - `period` If true, the extension starts with a ".", e.g. ".wav". pub fn to_str(&self, period: bool) -> &str { match self { Self::Cac => { if period { ".cac" } else { "cac" } } Self::Sf2 => { if period { ".sf2" } else { "sf2" } } Self::Wav => { if period { ".wav" } else { "wav" } } Self::Mid => { if period { ".mid" } else { "mid" } } Self::MP3 => { if period { ".mp3" } else { "mp3" } } Self::Ogg => { if period { ".ogg" } else { "ogg" } } Self::Flac => { if period { ".flac" } else { "flac" } } } } } ================================================ FILE: common/src/open_file/file_and_directory.rs ================================================ use super::FileOrDirectory; use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// A directory and, optionally, a filename. #[derive(Clone, Default, Deserialize, Serialize)] pub struct FileAndDirectory { /// The file's directory. pub directory: FileOrDirectory, /// The filename, if any. pub filename: Option, } impl FileAndDirectory { /// Create from a `PathBuf` directory; we assume here that this isn't a file. pub fn new_directory(directory: PathBuf) -> Self { Self { directory: FileOrDirectory::new(&directory), filename: None, } } /// Create from a `PathBuf` that may or may not be a file. pub fn new_path(path: PathBuf) -> Self { let directory = FileOrDirectory::new(path.parent().unwrap()); let filename = Some(path.file_name().unwrap().to_str().unwrap().to_string()); Self { directory, filename, } } /// Returns the path of the directory + filename. pub fn get_path(&self) -> PathBuf { match &self.filename { Some(filename) => self.directory.path.join(filename), None => panic!("No filename for: {:?}", self.directory.path), } } /// Returns the path of the directory + filename. pub fn try_get_path(&self) -> Option { self.filename .as_ref() .map(|filename| self.directory.path.join(filename)) } /// Returns the filename if there is one or an empty string if there isn't. pub fn get_filename(&self) -> String { match &self.filename { Some(string) => string.clone(), None => String::new(), } } } ================================================ FILE: common/src/open_file/file_or_directory.rs ================================================ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// Cached data for a file or directory because is_file() is a little too slow for my taste. #[derive(Clone, Default, Deserialize, Serialize)] pub struct FileOrDirectory { /// The file. pub path: PathBuf, /// If true, this is a file. pub is_file: bool, /// The top folder or the filename. pub stem: String, } impl FileOrDirectory { pub fn new(path: &Path) -> Self { let is_file = path.is_file(); let stem = path .components() .last() .unwrap() .as_os_str() .to_str() .unwrap() .to_string(); Self { path: path.to_path_buf(), is_file, stem, } } } ================================================ FILE: common/src/open_file/open_file_type.rs ================================================ /// This defines the files we care about and what we can do with them. #[derive(Debug, Eq, PartialEq, Clone, Default, Hash)] pub enum OpenFileType { /// Read a save file. ReadSave, /// Read a SoundFont. #[default] SoundFont, /// Write a save file. WriteSave, /// Set the export path. Export, /// Import a MIDI file. ImportMidi, } ================================================ FILE: common/src/open_file.rs ================================================ mod child_paths; mod extension; mod file_and_directory; mod file_or_directory; mod open_file_type; pub use child_paths::ChildPaths; pub use extension::Extension; pub use file_and_directory::FileAndDirectory; pub use file_or_directory::FileOrDirectory; pub use open_file_type::OpenFileType; ================================================ FILE: common/src/panel_type.rs ================================================ use serde::{Deserialize, Serialize}; /// A type of panel. #[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, Deserialize, Serialize)] pub enum PanelType { MainMenu, Tracks, Music, PianoRoll, OpenFile, ExportState, ExportSettings, Quit, Links, } ================================================ FILE: common/src/paths.rs ================================================ use directories::UserDirs; use std::env::current_exe; use std::fs::{copy, create_dir_all}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; const CONFIG_FILENAME: &str = "config.ini"; /// Global reference to paths. static PATHS: OnceLock = OnceLock::new(); /// Cached file paths. Unlike `PathsState`, this is meant to only include static data. #[derive(Debug)] pub struct Paths { /// The path to the default .ini file. pub default_ini_path: PathBuf, /// The path to the user directory. pub user_directory: PathBuf, /// The path to the user-defined .ini file. Might not exist. pub user_ini_path: PathBuf, /// The path to text.csv. pub text_path: PathBuf, /// The default SoundFont directory. pub soundfonts_directory: PathBuf, /// The default save file directory. pub saves_directory: PathBuf, /// The path to the exported audio files. pub export_directory: PathBuf, /// The path to the splash image. pub splash_path: PathBuf, /// The path to the default soundfont in data/ pub default_soundfont_path: PathBuf, /// The path to the data/ directory itself. pub data_directory: PathBuf, } impl Paths { /// Setup the paths, needs to be be called at least once. pub fn init(data_directory_from_cli: &Path) { let data_directory = get_data_directory(data_directory_from_cli); PATHS.set(Self::new(&data_directory)).unwrap(); } /// Returns a new `Paths` that can be used for testing. #[cfg(debug_assertions)] pub fn get_test_paths() -> Self { Self::new(&PathBuf::from("../data")) } /// Returns a new `Paths` object. fn new(data_directory: &Path) -> Self { let user_directory = match UserDirs::new() { Some(user_dirs) => match user_dirs.document_dir() { Some(documents) => documents.join("cacophony"), None => user_dirs.home_dir().join("cacophony"), }, None => { if cfg!(windows) { PathBuf::from("C:/").join("cacophony") } else { PathBuf::from("/").join("cacophony") } } }; let user_ini_path = user_directory.join(CONFIG_FILENAME); let default_ini_path = data_directory.join(CONFIG_FILENAME); let text_path = data_directory.join("text.csv"); // Get or create the default user sub-directories. let soundfonts_directory = get_directory("soundfonts", &user_directory); let saves_directory = get_directory("saves", &user_directory); let export_directory = get_directory("exports", &user_directory); let splash_path = data_directory.join("splash.png"); let default_soundfont_path = data_directory.join("CT1MBGMRSV1.06.sf2"); Self { default_ini_path, user_directory, user_ini_path, text_path, soundfonts_directory, saves_directory, export_directory, splash_path, default_soundfont_path, data_directory: data_directory.to_path_buf(), } } /// Get a reference to the paths, panics when not initialized. pub fn get() -> &'static Self { PATHS.get().expect("Paths need to be initialzed first") } /// Create the user .ini file by copying the default .ini file. pub fn create_user_config(&self) { let path = PathBuf::from(&self.user_directory) .join(CONFIG_FILENAME) .to_str() .unwrap() .to_string(); copy(&self.default_ini_path, path).unwrap(); } } /// Returns the path to the data directory. pub fn get_data_directory(data_directory: &Path) -> PathBuf { // Try to get the directory that's passed first. if data_directory.exists() { data_directory.to_path_buf() } // Maybe we're in a .app bundle. else if cfg!(target_os = "macos") { let data_directory = current_exe() .unwrap() .parent() .unwrap() .to_path_buf() .join("../Resources/data"); if data_directory.exists() { data_directory } else { panic!("Failed to get data directory: {:?}", data_directory) } } else { panic!("Failed to get data directory: {:?}", data_directory) } } /// Returns a directory. Creates the directory if it doesn't exist. fn get_directory(folder: &str, user_directory: &Path) -> PathBuf { let directory = user_directory.join(folder); if !directory.exists() { create_dir_all(&directory).unwrap(); } directory } ================================================ FILE: common/src/paths_state.rs ================================================ use crate::open_file::*; use crate::{Index, Paths}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// User-defined, save-file-specific, path data. /// These paths aren't stored in `State` because: /// /// 1. Changes to these paths should never go on the undo stack. /// 2. This struct can be arbitrarily complex, so it shouldn't go on the undo stack. #[derive(Deserialize, Serialize, Clone, Default)] pub struct PathsState { /// When the SoundFont open-file panel is enabled, it will default to this directory. pub soundfonts: FileAndDirectory, /// When the user wants to save a file, it will be automatically written here unless they do a save-as. pub saves: FileAndDirectory, /// When the user wants to export a file, it will be exported to this path. pub exports: FileAndDirectory, /// When the user wants to import a MIDI file, this is the path. pub midis: FileAndDirectory, /// The child paths within the current working directory. #[serde(skip_serializing, skip_deserializing)] pub children: ChildPaths, /// The current open-file-type. #[serde(skip_serializing, skip_deserializing)] pub open_file_type: OpenFileType, } impl PathsState { pub fn new(paths: &Paths) -> Self { let soundfonts = FileAndDirectory::new_directory(paths.soundfonts_directory.clone()); let saves = FileAndDirectory::new_directory(paths.saves_directory.clone()); let exports = FileAndDirectory::new_directory(paths.export_directory.clone()); let midis = FileAndDirectory::new_directory(paths.user_directory.clone()); Self { soundfonts, saves, exports, midis, ..Default::default() } } /// Returns the current working directory for the open file type. pub fn get_directory(&self) -> &FileOrDirectory { match self.open_file_type { OpenFileType::Export => &self.exports.directory, OpenFileType::ReadSave | OpenFileType::WriteSave => &self.saves.directory, OpenFileType::SoundFont => &self.soundfonts.directory, OpenFileType::ImportMidi => &self.midis.directory, } } /// Returns a string of a given open-file-type's path's filename. pub fn get_filename(&self) -> Option { match self.open_file_type { OpenFileType::Export => Some(self.exports.get_filename()), OpenFileType::WriteSave => Some(self.saves.get_filename()), _ => None, } } /// Try to go up a directory. pub fn up_directory(&mut self, extension: &Extension) -> bool { match self.open_file_type { OpenFileType::Export => { Self::up_directory_type(&mut self.exports.directory, &mut self.children, extension) } OpenFileType::ReadSave | OpenFileType::WriteSave => { Self::up_directory_type(&mut self.saves.directory, &mut self.children, extension) } OpenFileType::SoundFont => Self::up_directory_type( &mut self.soundfonts.directory, &mut self.children, extension, ), OpenFileType::ImportMidi => { Self::up_directory_type(&mut self.midis.directory, &mut self.children, extension) } } } /// Try to go down a directory. pub fn down_directory(&mut self, extension: &Extension) -> bool { if self.children.children.is_empty() { false } else { match &self.children.selected { Some(selected) => { if self.children.children[*selected].is_file { false } else { let cwd0 = match &self.open_file_type { OpenFileType::Export => self.exports.directory.path.to_path_buf(), OpenFileType::ReadSave | OpenFileType::WriteSave => { self.saves.directory.path.to_path_buf() } OpenFileType::SoundFont => self.soundfonts.directory.path.to_path_buf(), OpenFileType::ImportMidi => self.midis.directory.path.to_path_buf(), }; let cwd1 = self.children.children[*selected].path.clone(); // Set the children. self.children.set(&cwd1, extension, Some(cwd0)); // Set the directory. match &self.open_file_type { OpenFileType::Export => { self.exports.directory = FileOrDirectory::new(&cwd1) } OpenFileType::ReadSave | OpenFileType::WriteSave => { self.saves.directory = FileOrDirectory::new(&cwd1) } OpenFileType::SoundFont => { self.soundfonts.directory = FileOrDirectory::new(&cwd1) } OpenFileType::ImportMidi => { self.midis.directory = FileOrDirectory::new(&cwd1) } } true } } None => false, } } } /// Try to scroll through the children. pub fn scroll(&mut self, up: bool) -> bool { if self.children.children.is_empty() { false } else if let Some(selected) = &mut self.children.selected { let mut index = Index::new(*selected, self.children.children.len()); if index.increment_no_loop(!up) { *selected = index.get(); true } else { false } } else { false } } /// Set a filename. pub fn set_filename(&mut self, filename: &str) { let f = if filename.is_empty() { None } else { Some(filename.to_string()) }; match &self.open_file_type { OpenFileType::Export => self.exports.filename = f, OpenFileType::ReadSave | OpenFileType::WriteSave => self.saves.filename = f, OpenFileType::SoundFont => (), OpenFileType::ImportMidi => self.midis.filename = f, } } /// Returns the path of the directory + filename. pub fn get_path(&self) -> PathBuf { match &self.open_file_type { OpenFileType::Export => self.exports.get_path(), OpenFileType::ReadSave | OpenFileType::WriteSave => self.saves.get_path(), OpenFileType::SoundFont => self.soundfonts.get_path(), OpenFileType::ImportMidi => self.midis.get_path(), } } /// Go up a directory, given an open-file type. fn up_directory_type( directory: &mut FileOrDirectory, children: &mut ChildPaths, extension: &Extension, ) -> bool { match &directory.path.parent() { Some(parent) => { children.set(parent, extension, Some(directory.path.to_path_buf())); *directory = FileOrDirectory::new(parent); true } None => false, } } } ================================================ FILE: common/src/piano_roll_mode.rs ================================================ use serde::{Deserialize, Serialize}; /// A sub-mode of the piano roll panel. #[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, Serialize, Hash)] pub enum PianoRollMode { Time, View, Edit, Select, } ================================================ FILE: common/src/select_mode.rs ================================================ use crate::{Music, Note}; use serde::{Deserialize, Serialize}; /// The current mode for selecting notes. #[derive(Eq, PartialEq, Debug, Serialize, Deserialize)] pub enum SelectMode { /// Select only one note. The value is an index in `track.notes`. Single(Option), /// Select many: A vec of indices. Many(Option>), } impl Clone for SelectMode { fn clone(&self) -> Self { match self { SelectMode::Single(index) => SelectMode::Single(*index), SelectMode::Many(indices) => SelectMode::Many(indices.clone()), } } } impl SelectMode { /// Converts and returns the selection as a list of indices in the selected track's `notes`. pub fn get_note_indices(&self) -> Option> { match self { // A single note is selected. SelectMode::Single(index) => index.as_ref().map(|index| vec![*index]), SelectMode::Many(indices) => indices.as_ref().cloned(), } } /// Converts and returns the selection as a list of cloned notes from the selected track's `notes`. /// /// - `music` The music. pub fn get_notes<'a>(&self, music: &'a Music) -> Option> { match music.get_selected_track() { None => None, Some(track) => match self { SelectMode::Single(index) => index.as_ref().map(|index| vec![&track.notes[*index]]), SelectMode::Many(indices) => indices .as_ref() .map(|indices| indices.iter().map(|&i| &track.notes[i]).collect()), }, } } /// Converts and returns the selection as a list of cloned notes from the selected track's `notes`. /// /// - `music` The music. pub fn get_notes_mut<'a>(&mut self, music: &'a mut Music) -> Option> { match music.get_selected_track_mut() { None => None, Some(track) => match self { SelectMode::Single(index) => match index { Some(index) => Some(vec![&mut track.notes[*index]]), None => None, }, SelectMode::Many(indices) => match indices { Some(indices) => Some( track .notes .iter_mut() .enumerate() .filter(|n| indices.contains(&n.0)) .map(|n| n.1) .collect(), ), None => None, }, }, } } } ================================================ FILE: common/src/sizes.rs ================================================ use crate::config::parse; use crate::font::*; use ini::Ini; use macroquad::prelude::*; /// The height of the main menu in grid units. pub const MAIN_MENU_HEIGHT: u32 = 3; /// The position of the music panel in grid units. pub const MUSIC_PANEL_POSITION: [u32; 2] = [0, 0]; /// The height of the music panel. pub const MUSIC_PANEL_HEIGHT: u32 = 6; /// The height of the piano roll panel's top bar. pub const PIANO_ROLL_PANEL_TOP_BAR_HEIGHT: u32 = 3; /// The width of the column of note names. pub const PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH: u32 = 3; /// The height of the piano roll volume sub-panel. pub const PIANO_ROLL_PANEL_VOLUME_HEIGHT: u32 = 5; /// The height of the prompt for the open-file panel. pub const OPEN_FILE_PANEL_PROMPT_HEIGHT: u32 = 3; /// Returns the font height. pub fn get_font_size(config: &Ini) -> u16 { parse(get_font_section(config), "font_height") } /// Returns the size of a cell in pixels (width, height). pub fn get_cell_size(config: &Ini) -> [f32; 2] { let font_size: u16 = get_font_size(config); let font = get_font(config); let size = measure_text("█", Some(&font), font_size, 1.0); [size.width, size.height] } /// Returns the window size in grid units. pub fn get_window_grid_size(config: &Ini) -> [u32; 2] { let section = config.section(Some("RENDER")).unwrap(); [ parse(section, "window_width"), parse(section, "window_height"), ] } /// Returns the window size in pixels. pub fn get_window_pixel_size(config: &Ini) -> [f32; 2] { let grid_size = get_window_grid_size(config); let cell_size = get_cell_size(config); [ cell_size[0] * grid_size[0] as f32, cell_size[1] * grid_size[1] as f32, ] } /// Returns the size of the piano roll panel. pub fn get_piano_roll_panel_position(config: &Ini) -> [u32; 2] { let tracks_panel_width = get_tracks_panel_width(config); [tracks_panel_width, MAIN_MENU_HEIGHT] } /// Returns the size of the piano roll panel. pub fn get_piano_roll_panel_size(config: &Ini) -> [u32; 2] { let tracks_panel_width = get_tracks_panel_width(config); let window_grid_size = get_window_grid_size(config); [ window_grid_size[0] - tracks_panel_width, window_grid_size[1] - MAIN_MENU_HEIGHT - PIANO_ROLL_PANEL_VOLUME_HEIGHT, ] } /// Returns the width of the tracks panel. pub fn get_tracks_panel_width(config: &Ini) -> u32 { parse( config.section(Some("RENDER")).unwrap(), "tracks_panel_width", ) } /// Returns the pixel width of all lines. pub fn get_line_width(config: &Ini) -> f32 { parse(config.section(Some("RENDER")).unwrap(), "line_width") } /// Returns the size of the piano roll viewport. pub fn get_viewport_size(config: &Ini) -> [u32; 2] { let piano_roll_panel_size = get_piano_roll_panel_size(config); let width = piano_roll_panel_size[0] - PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH - 2; let height = piano_roll_panel_size[1] - PIANO_ROLL_PANEL_TOP_BAR_HEIGHT - 2; [width, height] } /// Returns the position and dimensions of the open-file panel. pub fn get_open_file_rect(config: &Ini) -> ([u32; 2], [u32; 2]) { let window_grid_size = get_window_grid_size(config); let size = [window_grid_size[0] / 2, window_grid_size[1] / 2]; let position = [window_grid_size[0] / 2 - size[0] / 2, MAIN_MENU_HEIGHT]; (position, size) } /// Returns the position of the main menu. pub fn get_main_menu_position(config: &Ini) -> [u32; 2] { let tracks_panel_width = get_tracks_panel_width(config); [ MUSIC_PANEL_POSITION[0] + tracks_panel_width, MUSIC_PANEL_POSITION[1], ] } /// Returns the pixel width of the subtitles. pub fn get_subtitle_width(config: &Ini) -> f32 { (get_main_menu_width(config) - 2) as f32 * get_cell_size(config)[0] } /// Returns the width of main menu. pub fn get_main_menu_width(config: &Ini) -> u32 { let tracks_panel_width = get_tracks_panel_width(config); let window_grid_size = get_window_grid_size(config); window_grid_size[0] - tracks_panel_width } ================================================ FILE: common/src/state.rs ================================================ use crate::music_panel_field::MusicPanelField; use crate::{ EditMode, Index, IndexedEditModes, IndexedValues, InputState, Music, PanelType, PianoRollMode, SelectMode, Time, View, }; use ini::Ini; use serde::{Deserialize, Serialize}; /// `State` contains all app data that can go on the undo/redo stacks. /// Because the entire `State` goes on the stacks, it needs to be as small as possible. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct State { /// The music. pub music: Music, /// The viewport. pub view: View, /// The time state. pub time: Time, /// The input state. pub input: InputState, /// A list of all panels that need to be drawn. pub panels: Vec, /// The index of the focused panel. pub focus: Index, /// The currently-selected music panel field. pub music_panel_field: IndexedValues, /// The piano roll panel's current mode. pub piano_roll_mode: PianoRollMode, /// The index of the current piano roll edit mode. pub edit_mode: IndexedEditModes, /// The current selection. pub select_mode: SelectMode, /// If true, there are unsaved changes. #[serde(skip_serializing, skip_deserializing)] pub unsaved_changes: bool, } impl State { pub fn new(config: &Ini) -> State { let music = Music::default(); let view = View::new(config); let time = Time::default(); let input = InputState::default(); let panels = vec![PanelType::Music, PanelType::Tracks, PanelType::PianoRoll]; let focus = Index::new(0, panels.len()); let music_panel_field = IndexedValues::new( 0, [ MusicPanelField::Name, MusicPanelField::BPM, MusicPanelField::Gain, ], ); let piano_roll_mode = PianoRollMode::Time; let edit_mode = EditMode::indexed(); let select_mode = SelectMode::Single(None); Self { music, view, time, input, panels, focus, music_panel_field, piano_roll_mode, edit_mode, select_mode, unsaved_changes: false, } } } ================================================ FILE: common/src/time.rs ================================================ use crate::edit_mode::*; use crate::U64orF32; use serde::{Deserialize, Serialize}; use std::time::Duration; /// The default BPM. pub const DEFAULT_BPM: u64 = 120; /// Converts BPM to seconds. const BPM_TO_SECONDS: f32 = 60.0; /// Pulses per quarter note as a u64. pub const PPQ_U: u64 = 192; /// Pulses per quarter note. pub const PPQ_F: f32 = PPQ_U as f32; /// The default framerate. pub const DEFAULT_FRAMERATE: u64 = 44100; /// The time state. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Time { /// The time defining the position of the cursor. pub cursor: u64, /// The time at which playback will start. pub playback: u64, /// The beats per minute. pub bpm: U64orF32, /// The current edit mode. pub mode: IndexedEditModes, } impl Time { /// Converts pulses per quarter note into seconds. pub fn ppq_to_seconds(&self, ppq: u64) -> f32 { ppq as f32 * (BPM_TO_SECONDS / (self.bpm.get_f() * PPQ_F)) } /// Converts pulses per quarter note into a quantity of samples. pub fn ppq_to_samples(&self, ppq: u64, framerate: f32) -> u64 { (self.ppq_to_seconds(ppq) * framerate) as u64 } /// Converts pulses per quarter note into a duration pub fn ppq_to_duration(&self, ppq: u64) -> Duration { Duration::from_secs_f32(self.ppq_to_seconds(ppq)) } /// Converts a quantity of samples into pulses per quarter note. pub fn samples_to_ppq(&self, samples: u64, framerate: f32) -> u64 { ((self.bpm.get_f() * samples as f32) / (BPM_TO_SECONDS * framerate) * PPQ_F) as u64 } pub fn reset(&mut self) { self.cursor = 0; self.playback = 0; } } impl Default for Time { fn default() -> Self { Self { cursor: 0, playback: 0, bpm: U64orF32::from(DEFAULT_BPM), mode: EditMode::indexed(), } } } #[cfg(test)] mod tests { use crate::time::*; #[test] fn time() { let mut time = Time::default(); // PPQ to seconds. ppq_seconds(0, 0.0, &time); ppq_seconds(PPQ_U, 0.5, &time); ppq_seconds(288, 0.75, &time); time.bpm = U64orF32::from(60); ppq_seconds(0, 0.0, &time); ppq_seconds(PPQ_U, 1.0, &time); ppq_seconds(288, 1.5, &time); time.bpm = U64orF32::from(DEFAULT_BPM); let framerate: f32 = 44100.0; // PPQ to samples. ppq_samples(0, 0, framerate, &time); ppq_samples(PPQ_U, 22050, framerate, &time); ppq_samples(288, 33075, framerate, &time); time.bpm = U64orF32::from(60); ppq_samples(PPQ_U, 44100, framerate, &time); ppq_samples(288, 66150, framerate, &time); ppq_samples(PPQ_U, 48000, 48000.0, &time); time.bpm = U64orF32::from(DEFAULT_BPM); ppq_samples(PPQ_U, 24000, 48000.0, &time); // Samples to PPQ. samples_ppq(0, 0, framerate, &time); samples_ppq(22050, PPQ_U, framerate, &time); samples_ppq(44100, PPQ_U * 2, framerate, &time); } fn ppq_seconds(ppq: u64, f: f32, time: &Time) { let t = time.ppq_to_seconds(ppq); assert_eq!(t, f, "{} {}", t, f); } fn ppq_samples(ppq: u64, v: u64, framerate: f32, time: &Time) { let s = time.ppq_to_samples(ppq, framerate); assert_eq!(s, v, "{} {} {} {}", ppq, s, v, framerate) } fn samples_ppq(samples: u64, v: u64, framerate: f32, time: &Time) { let ppq = time.samples_to_ppq(samples, framerate); assert_eq!(ppq, v, "{} {} {}", ppq, v, samples); } } ================================================ FILE: common/src/u64_or_f32.rs ================================================ use serde::de::{Error, Visitor}; use serde::{Deserialize, Serialize}; use std::fmt::{self, Display}; /// A value that is expressed as a u64 or an f32. #[derive(Debug, PartialEq, Copy, Clone, Default)] pub struct U64orF32 { /// The value as a u64. u: u64, /// The value as an f32. f: f32, } impl U64orF32 { /// Returns the value as a u64. pub fn get_u(&self) -> u64 { self.u } /// Returns the value as an f32. pub fn get_f(&self) -> f32 { self.f } pub fn set(&mut self, value: u64) { self.u = value; self.f = value as f32; } } impl Eq for U64orF32 {} impl From for U64orF32 { fn from(value: u64) -> Self { Self { u: value, f: value as f32, } } } impl From for U64orF32 { fn from(value: f32) -> Self { let u = value as u64; Self { u, f: u as f32 } } } impl Display for U64orF32 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.u) } } impl Serialize for U64orF32 { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_u64(self.u) } } impl<'de> Visitor<'de> for U64orF32 { type Value = Self; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a u64") } fn visit_u8(self, value: u8) -> Result where E: Error, { Ok(Self::from(u64::from(value))) } fn visit_u32(self, value: u32) -> Result where E: Error, { Ok(Self::from(u64::from(value))) } fn visit_u64(self, value: u64) -> Result where E: Error, { Ok(Self::from(value)) } fn visit_f32(self, value: f32) -> Result where E: Error, { Ok(Self::from(value)) } } impl<'de> Deserialize<'de> for U64orF32 { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { deserializer.deserialize_u64(U64orF32::default()) } } #[cfg(test)] mod tests { use crate::U64orF32; use serde_json::{from_str, to_string, Error}; #[test] fn set_uf() { uf_eq(U64orF32::from(1)); uf_eq(U64orF32::from(1.0)); uf_eq(U64orF32::from(-1.0)); uf_eq(U64orF32::from(2.5)); } #[test] fn uf_serialization() { let r = to_string(&U64orF32::from(5)); assert!(r.is_ok()); let s = r.unwrap(); assert_eq!(&s, "5", "{}", s); } #[test] fn uf_deserialization() { deserialize_uf("5", U64orF32::from(5)); deserialize_uf("5", U64orF32::from(5.0)); } fn uf_eq(v: U64orF32) { assert_eq!(v.u as f32, v.f, "{:?}", v); } fn deserialize_uf(s: &str, v: U64orF32) { let r: Result = from_str(s); assert!(r.is_ok(), "{}", s); let q = r.unwrap(); assert_eq!(q, v, "{:?} {:?}", q, v); } } ================================================ FILE: common/src/view.rs ================================================ use crate::config::{parse, parse_fraction}; use crate::note::MIDDLE_C; use crate::sizes::*; use crate::{EditMode, Index, IndexedEditModes, U64orF32, MAX_NOTE, MIN_NOTE, PPQ_U}; use hashbrown::HashMap; use ini::Ini; use serde::{Deserialize, Serialize}; /// The minimum zoom time delta in PPQ. const MIN_ZOOM: u64 = PPQ_U * 2; const MAX_ZOOM: u64 = PPQ_U * 10000; /// The dimensions of the piano roll viewport. #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] pub struct View { /// The start and end time of the viewport in PPQ. pub dt: [u64; 2], /// The start and end note of the viewport. pub dn: [u8; 2], /// The current edit mode. pub mode: IndexedEditModes, /// If true, we're viewing a single track. If false, we're viewing multiple tracks. pub single_track: bool, /// The zoom time deltas. zoom_levels: Vec, /// The index of the current zoom level. zoom_index: Index, /// Zoom increments per edit mode. zoom_increments: HashMap, /// The default zoom index. initial_zoom_index: usize, #[serde(skip)] initial_dn: [u8; 2], } impl View { pub fn new(config: &Ini) -> Self { // Get the width of the viewport. let viewport_size = get_viewport_size(config); let w = viewport_size[0]; // Get the start time. let t0 = 0; // Get the end time from the size of the panel and the beats per cell. let t1 = t0 + w as u64 * PPQ_U; // Get the time delta. let dt = [t0, t1]; // Get the notes delta. let h = viewport_size[1] as u8; let n0 = MIDDLE_C + h / 2; let n1 = n0 - h; let dn = [n0, n1]; let mode = EditMode::indexed(); let section = config.section(Some("PIANO_ROLL")).unwrap(); let mut zoom_increment = parse_fraction(section, "zoom_increment"); // Invert the fraction to prevent an infinite loop. if zoom_increment.numerator > zoom_increment.denominator { zoom_increment.invert(); } let mut zoom_dt = U64orF32::from(t1 - t0); let mut zoom_levels = vec![]; // Zoom in. loop { zoom_dt = zoom_dt * zoom_increment; if zoom_dt.get_u() <= MIN_ZOOM { break; } zoom_levels.insert(0, zoom_dt); } // Current zoom. zoom_dt = U64orF32::from(t1 - t0); let zoom_index = zoom_levels.len(); zoom_levels.push(zoom_dt); // Zoom out. loop { zoom_dt = zoom_dt / zoom_increment; if zoom_dt.get_u() >= MAX_ZOOM { break; } zoom_levels.push(zoom_dt); } let zoom_levels: Vec = zoom_levels.iter().map(|z| z.get_u()).collect(); let initial_zoom_index = zoom_index; let zoom_index = Index::new(zoom_index, zoom_levels.len()); let normal_zoom = parse(section, "normal_zoom"); let quick_zoom = parse(section, "quick_zoom"); let precise_zoom = parse(section, "precise_zoom"); let mut zoom_increments = HashMap::new(); zoom_increments.insert(EditMode::Normal, normal_zoom); zoom_increments.insert(EditMode::Quick, quick_zoom); zoom_increments.insert(EditMode::Precise, precise_zoom); Self { dt, dn, mode, single_track: true, zoom_levels, zoom_index, zoom_increments, initial_zoom_index, initial_dn: dn, } } /// Returns the time delta from t1 to t0 in PPQ. pub fn get_dt(&self) -> u64 { self.dt[1] - self.dt[0] } /// Set the start time of the viewport. /// /// - `delta` The time delta. /// - `add` If true, add. If false, subtract. pub fn set_start_time_by(&mut self, dt: u64, add: bool) { let delta = self.get_dt(); self.dt = if add { [self.dt[0] + dt, self.dt[1] + dt] } else { match self.dt[0].checked_sub(dt) { Some(t0) => [t0, t0 + delta], None => [0, delta], } }; } /// Move `self.dn` up or down. /// /// - `dn` Move `self.dn` by this value. /// - `add` If true, move up. If false, move down. pub fn set_top_note_by(&mut self, dn: u8, add: bool) { self.dn = if add { // Don't go past n=1. if self.dn[0] + dn <= MAX_NOTE { [self.dn[0] + dn, self.dn[1] + dn] } // Snap to n=1. else { [MAX_NOTE, MAX_NOTE - self.get_dn()] } } else { // Don't go past n=0. if self.dn[1] - dn >= MIN_NOTE { [self.dn[0] - dn, self.dn[1] - dn] } // Snap to n=0. else { [MIN_NOTE + self.get_dn(), MIN_NOTE] } } } /// Zoom in or out. `self.dt[0]` doesn't change. /// /// - `up` If true, zoom in. If false, zoom out. pub fn zoom(&mut self, up: bool) { // Get the number of increment steps. let increments = self.zoom_increments[&self.mode.get()]; // Use modulus division to get to the next valid index. // For example, if `increments == 2` then we need to get to an index relative to `self.initial_zoom_index` that is a multiple of 2. let zi = self.zoom_index.get(); let m = (if zi > self.initial_zoom_index { zi - self.initial_zoom_index } else { self.initial_zoom_index - zi }) % increments; for _ in 0..m { if !self.zoom_index.increment_no_loop(up) { break; } } // Increment the zoom index. for _ in 0..increments { if !self.zoom_index.increment_no_loop(!up) { break; } } // Get the time delta. let dt = self.zoom_levels[self.zoom_index.get()]; self.dt = [self.dt[0], self.dt[0] + dt]; } /// Reset the view. pub fn reset(&mut self) { // Reset the zoom. self.zoom_index.set(self.initial_zoom_index); // Reset the time. let dt = self.zoom_levels[self.initial_zoom_index]; self.dt = [0, dt]; // Reset the notes. self.dn = self.initial_dn; // Single track view. self.single_track = true; } /// Returns the note delta. fn get_dn(&self) -> u8 { self.dn[0] - self.dn[1] } } #[cfg(test)] mod tests { use crate::{get_test_config, EditMode, View, PPQ_U}; const VIEW_T1: u64 = PPQ_U * 133; #[test] fn view_new() { let view = get_new_view(); assert_eq!(view.dn, [75, 45], "{:?}", view.dn); assert_eq!(view.dt, [0, VIEW_T1], "{:?}", view.dt); assert_eq!(view.mode.index.get(), 0, "{}", view.mode.index.get()); assert_eq!(view.single_track, true, "{}", view.single_track); } #[test] fn view_dt() { let mut view = get_new_view(); view.set_start_time_by(PPQ_U, false); assert_eq!(view.dt, [0, VIEW_T1], "{:?}", view.dt); let dt = PPQ_U / 2; view.set_start_time_by(dt, true); assert_eq!(view.dt, [dt, VIEW_T1 + dt], "{:?}", view.dt); view.set_top_note_by(4, true); assert_eq!(view.dn, [79, 49], "{:?}", view.dn); view.set_top_note_by(4, false); assert_eq!(view.dn, [75, 45], "{:?}", view.dn); view.dt = [0, VIEW_T1]; } #[test] fn view_dz() { let mut view = get_new_view(); // Test modes. view.mode.index.set(0); assert_eq!(view.mode.get(), EditMode::Normal); view.mode.index.set(1); assert_eq!(view.mode.get(), EditMode::Quick); view.mode.index.set(2); assert_eq!(view.mode.get(), EditMode::Precise); // Zoom in precisely. view.zoom(true); // Reset to the default zoom. view.mode.index.increment(true); assert_eq!(view.mode.get(), EditMode::Normal); view.zoom(false); assert_eq!(view.get_dt(), VIEW_T1); } fn get_new_view() -> View { View::new(&get_test_config()) } } ================================================ FILE: data/attribution.txt ================================================ Default SoundFont: Caed's Small Trash GM by Caed. https://musical-artifacts.com/artifacts?q=Caed ================================================ FILE: data/config.ini ================================================ [FONTS] # The path to the main font. font = NotoSansMono-Regular.ttf # The path to the subtitle font. subtitle_font = NotoSansMono-ExtraBold.ttf # The height of the font in pixels. font_height = 20 [RENDER] # The pixel width of all lines and rectangular borders. line_width = 2 # If the open-file dialogue enables and everything is in mirror image, try adjusting this value (0 or 1). flip_y = 1 # The width of the tracks panel in grid units. tracks_panel_width = 22 # 1 for full screen, 0 for window. fullscreen = 0 # The width of the window in grid units. window_width = 160 # The height of the window in grid units. window_height = 43 # In multi-track view, this is the height of each note in pixels. multi_track_note_height = 2 [TEXT] # This sets the column in text.csv that is used for key-value lookups. language = en [TEXT_TO_SPEECH] # If 1, show subtitles. If 0, don't. subtitles = 1 # The ID of the voice module. # This can either be the index of a voice, e.g. 0, or a language, e.g. en-US. # If the language isn't available, Casey will talk in the voice at index 0. voice_id = en-US # As in real life, gender is optional. You can omit this line from your config.ini file. # If voice_id is an index, e.g. 0, this is ignored. Otherwise, Casey will attempt to talk in language `voice_id` and gender `gender`. # Valid genders are: f and m. If you enter something else, or if the language-gender pair isn't available, Casey will opt for the first voice in language `voice_id` regardless of gender. gender = f # The rate of speech. rate_windows = 1 rate_macos = 0.5 rate_linux = 1 [UPDATE] # If true, check online when the app launches to see if there is an updated version. check_for_updates = 1 [QWERTY_BINDINGS] # Input event bindings for a qwerty keyboard. # Every input event must have a qwerty binding. # # Qwerty bindings must define the keys being pressed: # {"keys": ["F1"]} # # You can optionally can define held mods: # {"keys": ["O"], "mods": ["LeftControl"]} # # You can optionally set the sensitivity, the number of frames until the app registers a repeat event: # {"keys": ["PageUp"], "dt": 10} # If you don't set the dt value, key presses are detected only on press, not on held. # # For a list of key codes, see: /data/keycodes.txt # Text-to-speech. StatusTTS = {"keys": ["F1"]} InputTTS = {"keys": ["F2"]} AppTTS = {"keys": ["F3"]} FileTTS = {"keys": ["F4"]} StopTTS = {"keys": ["F5"]} # Enable links panel. EnableLinksPanel = {"keys": ["F9"]} # Files. NewFile = {"keys": ["N"], "mods": ["LeftControl"]} OpenFile = {"keys": ["O"], "mods": ["LeftControl"]} SaveFile = {"keys": ["S"], "mods": ["LeftControl"]} SaveFileAs = {"keys": ["S"], "mods": ["LeftControl", "LeftShift"]} ExportFile = {"keys": ["E"], "mods": ["LeftControl"]} ImportMidi = {"keys": ["I"], "mods": ["LeftControl"]} EditConfig = {"keys": ["W"], "mods": ["LeftControl"]} # Cycle between panels. NextPanel = {"keys": ["PageUp"], "dt": 10} PreviousPanel = {"keys": ["PageDown"], "dt": 10} # Alphanumeric input. ToggleAlphanumericInput = {"keys": ["Return"]} # Quit. Quit = {"keys": ["Q"], "mods": ["LeftControl"]} # Undo/redo. Undo = {"keys": ["Z"], "mods": ["LeftControl"]} Redo = {"keys": ["Y"], "mods": ["LeftControl"]} # Music panel. NextMusicPanelField = {"keys": ["Down"], "dt": 10} PreviousMusicPanelField = {"keys": ["Up"], "dt": 10} IncreaseMusicGain = {"keys": ["Right"], "dt": 1} DecreaseMusicGain = {"keys": ["Left"], "dt": 1} # Tracks panel. AddTrack = {"keys": ["="]} RemoveTrack = {"keys": ["-"]} NextTrack = {"keys": ["Down"], "dt": 10} PreviousTrack = {"keys": ["Up"], "dt": 10} PreviousPreset = {"keys": ["["], "dt": 10} NextPreset = {"keys": ["]"], "dt": 10} PreviousBank = {"keys": [";"], "dt": 10} NextBank = {"keys": ["'"], "dt": 10} IncreaseTrackGain = {"keys": ["."], "dt": 1} DecreaseTrackGain = {"keys": [","], "dt": 1} EnableSoundFontPanel = {"keys": ["Return"]} Mute = {"keys": ["M"]} Solo = {"keys": ["S"]} # Open file panel. UpDirectory = {"keys": ["Left"]} DownDirectory = {"keys": ["Right"]} SelectFile = {"keys": ["Return"]} NextPath = {"keys": ["Down"], "dt": 10} PreviousPath = {"keys": ["Up"], "dt": 10} CloseOpenFile = {"keys": ["Escape"]} CycleExportType = {"keys": ["Tab"]} # Export settings. PreviousExportSetting = {"keys": ["Up"]} NextExportSetting = {"keys": ["Down"]} PreviousExportSettingValue = {"keys": ["Left"]} NextExportSettingValue = {"keys": ["Right"]} ToggleExportSettingBoolean = {"keys": ["Return"]} # Piano roll. PianoRollCycleMode = {"keys": ["Tab"]} PianoRollSetTime = {"keys": ["1"]} PianoRollSetView = {"keys": ["2"]} PianoRollSetSelect = {"keys": ["3"]} PianoRollSetEdit = {"keys": ["4"]} PianoRollToggleTracks = {"keys": ["Backspace"]} Arm = {"keys": ["Return"]} InputBeatLeft = {"keys": ["["], "dt": 10} InputBeatRight = {"keys": ["]"], "dt": 10} IncreaseInputVolume = {"keys": ["'"], "dt": 1} DecreaseInputVolume = {"keys": [";"], "dt": 1} ToggleInputVolume = {"keys": ["Backslash"]} PlayStop = {"keys": ["Space"]} PianoRollPreviousTrack = {"keys": ["Up"], "dt": 5} PianoRollNextTrack = {"keys": ["Down"], "dt": 5} # Piano roll - view mode. ViewLeft = {"keys": ["Left"], "dt": 5} ViewRight = {"keys": ["Right"], "dt": 5} ViewUp = {"keys": ["Up"], "dt": 5} ViewDown = {"keys": ["Down"], "dt": 5} ViewStart = {"keys": ["Home"]} ViewEnd = {"keys": ["End"]} ViewZoomIn = {"keys": ["Up"], "mods": ["LeftShift"], "dt": 10} ViewZoomOut = {"keys": ["Down"], "mods": ["LeftShift"], "dt": 10} ViewZoomDefault = {"keys": ["Home"], "mods": ["LeftShift"]} # Piano roll - time mode. TimeCursorLeft = {"keys": ["Left"], "dt": 5} TimeCursorRight = {"keys": ["Right"], "dt": 5} TimeCursorStart = {"keys": ["Home"]} TimeCursorEnd = {"keys": ["End"]} TimePlaybackLeft = {"keys": ["Left"], "mods": ["LeftShift"], "dt": 5} TimePlaybackRight = {"keys": ["Right"], "mods": ["LeftShift"], "dt": 5} TimePlaybackStart = {"keys": ["Home"], "mods": ["LeftShift"]} TimePlaybackEnd = {"keys": ["End"], "mods": ["LeftShift"]} TimeCursorPlayback = {"keys": ["Home"], "mods": ["LeftControl"]} TimePlaybackCursor = {"keys": ["Home"], "mods": ["LeftControl", "LeftShift"]} TimeCursorBeat = {"keys": ["Insert"]} TimePlaybackBeat = {"keys": ["Insert"], "mods": ["LeftShift"]} # Piano roll - edit mode. EditStartLeft = {"keys": ["Left"], "dt": 5} EditStartRight = {"keys": ["Right"], "dt": 5} EditDurationLeft = {"keys": ["Left"], "mods": ["LeftShift"], "dt": 5} EditDurationRight = {"keys": ["Right"], "mods": ["LeftShift"], "dt": 5} EditPitchUp = {"keys": ["Up"], "dt": 5} EditPitchDown = {"keys": ["Down"], "dt": 5} EditVolumeUp = {"keys": ["Up"], "mods": ["LeftShift"], "dt": 1} EditVolumeDown = {"keys": ["Down"], "mods": ["LeftShift"], "dt": 1} # Piano roll - select mode. SelectStartLeft = {"keys": ["Left"], "dt": 5} SelectStartRight = {"keys": ["Right"], "dt": 5} SelectEndLeft = {"keys": ["Left"], "mods": ["LeftShift"], "dt": 2} SelectEndRight = {"keys": ["Right"], "mods": ["LeftShift"], "dt": 2} SelectAll = {"keys": ["A"], "mods": ["LeftControl"]} SelectNone = {"keys": ["Escape"]} # Copy, cut, paste, delete. CopyNotes = {"keys": ["C"], "mods": ["LeftControl"]} CutNotes = {"keys": ["X"], "mods": ["LeftControl"]} PasteNotes = {"keys": ["V"], "mods": ["LeftControl"]} DeleteNotes = {"keys": ["Delete"]} # Quit panel. QuitPanelYes = {"keys": ["Y"]} QuitPanelNo = {"keys": ["N"]} # Links panel. WebsiteUrl = {"keys": ["1"]} DiscordUrl = {"keys": ["2"]} GitHubUrl = {"keys": ["3"]} CloseLinksPanel = {"keys": ["Escape"]} # Qwerty note input. C = {"keys": ["A"]} CSharp = {"keys": ["W"]} D = {"keys": ["S"]} DSharp = {"keys": ["E"]} E = {"keys": ["D"]} F = {"keys": ["F"]} FSharp = {"keys": ["T"]} G = {"keys": ["G"]} GSharp = {"keys": ["Y"]} A = {"keys": ["H"]} ASharp = {"keys": ["U"]} B = {"keys": ["J"]} OctaveUp = {"keys": ["Z"]} OctaveDown = {"keys": ["X"]} # MIDI input: two bytes, a time delta (frames, can be positive or negative), and an optional alias (used in text-to-speech). [MIDI_BINDINGS] # Cycle panels. NextPanel = {"bytes": [176, 16], "dt": 10, "alias": "Knob 1"} PreviousPanel = {"bytes": [176, 16], "dt": -10, "alias": "Knob 1"} # Music panel. NextMusicPanelField = {"bytes": [176, 17], "dt": 10, "alias": "Knob 2"} PreviousMusicPanelField = {"bytes": [176, 17], "dt": -10, "alias": "Knob 2"} IncreaseMusicGain = {"bytes": [176, 18], "dt": -1, "alias": "Knob 3"} DecreaseMusicGain = {"bytes": [176, 18], "dt": 1, "alias": "Knob 3"} # Tracks panel. NextTrack = {"bytes": [176, 17], "dt": 10, "alias": "Knob 2"} PreviousTrack = {"bytes": [176, 17], "dt": -10, "alias": "Knob 2"} PreviousPreset = {"bytes": [176, 18], "dt": -10, "alias": "Knob 3"} NextPreset = {"bytes": [176, 18], "dt": 10, "alias": "Knob 3"} PreviousBank = {"bytes": [176, 19], "dt": -10, "alias": "Knob 4"} NextBank = {"bytes": [176, 19], "dt": 10, "alias": "Knob 4"} DecreaseTrackGain = {"bytes": [176, 20], "dt": -1, "alias": "Knob 5"} IncreaseTrackGain = {"bytes": [176, 20], "dt": 1, "alias": "Knob 5"} # Open file panel. UpDirectory = {"bytes": [176, 17], "dt": 10, "alias": "Knob 2"} DownDirectory = {"bytes": [176, 17], "dt": -10, "alias": "Knob 2"} NextPath = {"bytes": [176, 18], "dt": -10, "alias": "Knob 3"} PreviousPath = {"bytes": [176, 18], "dt": 10, "alias": "Knob 3"} CycleExportType = {"bytes": [176, 19], "dt": 10, "alias": "Knob 4"} # Export settings. PreviousExportSetting = {"bytes": [176, 17], "dt": 10, "alias": "Knob 2"} NextExportSetting = {"bytes": [176, 17], "dt": -10, "alias": "Knob 2"} PreviousExportSettingValue = {"bytes": [176, 18], "dt": -10, "alias": "Knob 3"} NextExportSettingValue = {"bytes": [176, 18], "dt": 10, "alias": "Knob 3"} # Piano roll. InputBeatLeft = {"bytes": [176, 21], "dt": -5, "alias": "Knob 6"} InputBeatRight = {"bytes": [176, 21], "dt": 5, "alias": "Knob 6"} IncreaseInputVolume = {"bytes": [176, 22], "dt": 1, "alias": "Knob 7"} DecreaseInputVolume = {"bytes": [176, 22], "dt": -1, "alias": "Knob 7"} PianoRollPreviousTrack = {"bytes": [176, 23], "dt": -10, "alias": "Knob 8"} PianoRollNextTrack = {"bytes": [176, 23], "dt": 10, "alias": "Knob 8"} # Piano roll - view mode. ViewLeft = {"bytes": [176, 17], "dt": -5, "alias": "Knob 2"} ViewRight = {"bytes": [176, 17], "dt": 5, "alias": "Knob 2"} ViewUp = {"bytes": [176, 18], "dt": -2, "alias": "Knob 3"} ViewDown = {"bytes": [176, 18], "dt": 2, "alias": "Knob 3"} ViewZoomIn = {"bytes": [176, 19], "dt": 10, "alias": "Knob 4"} ViewZoomOut = {"bytes": [176, 19], "dt": -10, "alias": "Knob 4"} # Piano roll - time mode. TimeCursorLeft = {"bytes": [176, 17], "dt": -2, "alias": "Knob 2"} TimeCursorRight = {"bytes": [176, 17], "dt": 2, "alias": "Knob 2"} TimePlaybackLeft = {"bytes": [176, 18], "dt": -2, "alias": "Knob 3"} TimePlaybackRight = {"bytes": [176, 18], "dt": 2, "alias": "Knob 3"} # Piano roll - edit mode. EditStartLeft = {"bytes": [176, 17], "dt": 5, "alias": "Knob 2"} EditStartRight = {"bytes": [176, 17], "dt": -5, "alias": "Knob 2"} EditDurationLeft = {"bytes": [176, 18], "dt": -5, "alias": "Knob 3"} EditDurationRight = {"bytes": [176, 18], "dt": 5, "alias": "Knob 3"} EditPitchUp = {"bytes": [176, 19], "dt": -5, "alias": "Knob 4"} EditPitchDown = {"bytes": [176, 19], "dt": 5, "alias": "Knob 4"} EditVolumeUp = {"bytes": [176, 20], "dt": -1, "alias": "Knob 5"} EditVolumeDown = {"bytes": [176, 20], "dt": 1, "alias": "Knob 5"} # Piano roll - select mode. SelectStartLeft = {"bytes": [176, 17], "dt": 5, "alias": "Knob 2"} SelectStartRight = {"bytes": [176, 17], "dt": -5, "alias": "Knob 2"} SelectEndLeft = {"bytes": [176, 18], "dt": -5, "alias": "Knob 3"} SelectEndRight = {"bytes": [176, 18], "dt": 5, "alias": "Knob 3"} [PIANO_ROLL] # Multiply the beat by this factor to get the quick time. quick_time_factor = 4 # In precise mode, move the view left and right by this beat length. precise_time = 1/32 # In normal mode, move the view up and down by this many half-steps. normal_note = 1 # In quick mode, move the viewport up and down by this many half-steps. quick_note = 12 # In precise mode, move the view up and down by this many half-steps. precise_note = 1 # In normal mode, edit volume by this delta. normal_volume = 1 # In quick mode, edit volume by this delta. quick_volume = 10 # In precise mode, edit volume by this delta. precise_volume = 1 # The beats that the user can cycle through in piano roll mode. beats = ["1/32", "1/16", "1/8", "1/4", "1/3", "1/2", "1", "1.5", "2", "3", "4", "5", "6", "7", "8"] # The value of the default beat. This must exist in `beats`. default_beat = 1 # The baseline zoom increment. zoom_increment = 7/8 # In normal mode, increment by this factor. This must be an integer. normal_zoom = 2 # In quick mode, increment by this factor. This must be an integer. quick_zoom = 4 # In precise mode, increment by this factor. This must be an integer. precise_zoom = 1 [COLOR_ALIASES] # Add as many color aliases as you want! A color alias must have a unique key and a value formatted like [0, 255, 0]. black = [39, 41, 50] pink = [245, 169, 184] dusty_pink = [181, 86, 144] white = [200, 200, 200] blue_light = [91, 206, 250] light_red = [224, 108, 117] red = [166, 52, 70] green = [53, 206, 141] yellow_light = [229, 192, 123] magenta = [143, 120, 221] cyan = [86, 182, 194] gray = [76, 82, 99] gray_light = [92, 99, 112] pale_red_light = [199, 106, 109] pale_red_dark = [55, 47, 55] khaki_light = [199, 149, 105] khaki_dark = [63, 57, 58] pale_yellow_light = [220, 213, 145] pale_yellow_dark = [66, 66, 64] sea_green_light = [146, 221, 194] sea_green_dark = [55, 68, 71] sky_blue_light = [146, 173, 221] sky_blue_dark = [49, 54, 67] magenta_light = [194, 146, 221] magenta_dark = [54, 51, 67] subtitle_background = [0, 0, 0] [COLORS] # Don't change the key names! # You can change the values to either a color literal such as [0, 255, 0], or to one of the color aliases defined above. Background = black NoFocus = gray FocusDefault = pink Key = white Value = blue_light True = green False = red Arrow = dusty_pink TextFieldBG = gray_light Note = magenta NoteSelected = dusty_pink NotePlaying = blue_light TimeCursor = cyan TimePlayback = blue_light Subtitle = dusty_pink Separator = gray_light TextInput = dusty_pink SelectedNotesBackground = gray_light Track0Focus = pale_red_light Track0NoFocus = pale_red_dark Track1Focus = khaki_light Track1NoFocus = khaki_dark Track2Focus = pale_yellow_light Track2NoFocus = pale_yellow_dark Track3Focus = sea_green_light Track3NoFocus = sea_green_dark Track4Focus = sky_blue_light Track4NoFocus = sky_blue_dark Track5Focus = magenta_light Track5NoFocus = magenta_dark SubtitleBackground = subtitle_background ================================================ FILE: data/text.csv ================================================ key,en Space_SPOKEN,space Apostrophe_SPOKEN,apostrophe Comma_SPOKEN,comma Minus_SPOKEN,minus Period_SPOKEN,period Slash_SPOKEN,slash Key0_SPOKEN,0 Key1_SPOKEN,1 Key2_SPOKEN,2 Key3_SPOKEN,3 Key4_SPOKEN,4 Key5_SPOKEN,5 Key6_SPOKEN,6 Key7_SPOKEN,7 Key8_SPOKEN,8 Key9_SPOKEN,9 Semicolon_SPOKEN,semicolon Equal_SPOKEN,equals A_SPOKEN,a B_SPOKEN,b C_SPOKEN,c D_SPOKEN,d E_SPOKEN,e F_SPOKEN,f G_SPOKEN,g H_SPOKEN,h I_SPOKEN,i J_SPOKEN,j K_SPOKEN,k L_SPOKEN,l M_SPOKEN,m N_SPOKEN,n O_SPOKEN,o P_SPOKEN,p Q_SPOKEN,q R_SPOKEN,r S_SPOKEN,s T_SPOKEN,t U_SPOKEN,u V_SPOKEN,v W_SPOKEN,w X_SPOKEN,x Y_SPOKEN,y Z_SPOKEN,z LeftBracket_SPOKEN,left bracket Backslash_SPOKEN,backslash RightBracket_SPOKEN,right bracket GraveAccent_SPOKEN,grave World1_SPOKEN,world 1 World2_SPOKEN,world 2 Escape_SPOKEN,escape Enter_SPOKEN,enter Tab_SPOKEN,tab Backspace_SPOKEN,backspace Insert_SPOKEN,insert Delete_SPOKEN,delete Right_SPOKEN,right Left_SPOKEN,left Down_SPOKEN,down Up_SPOKEN,up PageUp_SPOKEN,page up PageDown_SPOKEN,page down Home_SPOKEN,home End_SPOKEN,end CapsLock_SPOKEN,caps lock ScrollLock_SPOKEN,scroll lock NumLock_SPOKEN,num lock PrintScreen_SPOKEN,print screen Pause_SPOKEN,pause F1_SPOKEN,f1 F2_SPOKEN,f2 F3_SPOKEN,f3 F4_SPOKEN,f4 F5_SPOKEN,f5 F6_SPOKEN,f6 F7_SPOKEN,f7 F8_SPOKEN,f8 F9_SPOKEN,f9 F10_SPOKEN,f10 F11_SPOKEN,f11 F12_SPOKEN,f12 F13_SPOKEN,f13 F14_SPOKEN,f14 F15_SPOKEN,f15 F16_SPOKEN,f16 F17_SPOKEN,f17 F18_SPOKEN,f18 F19_SPOKEN,f19 F20_SPOKEN,f20 F21_SPOKEN,f21 F22_SPOKEN,f22 F23_SPOKEN,f23 F24_SPOKEN,f24 F25_SPOKEN,f25 Kp0_SPOKEN,keypad 0 Kp1_SPOKEN,keypad 1 Kp2_SPOKEN,keypad 2 Kp3_SPOKEN,keypad 3 Kp4_SPOKEN,keypad 4 Kp5_SPOKEN,keypad 5 Kp6_SPOKEN,keypad 6 Kp7_SPOKEN,keypad 7 Kp8_SPOKEN,keypad 8 Kp9_SPOKEN,keypad 9 KpDecimal_SPOKEN,keypad decimal KpDivide_SPOKEN,keypad divide KpMultiply_SPOKEN,keypad multiply KpSubtract_SPOKEN,keypad subtract KpAdd_SPOKEN,keypad add KpEnter_SPOKEN,keypad enter KpEqual_SPOKEN,keypad equal LeftShift_SPOKEN,left shift LeftControl_SPOKEN,left control LeftAlt_SPOKEN,left alt LeftSuper_SPOKEN,left super RightShift_SPOKEN,right shift RightControl_SPOKEN,right control RightAlt_SPOKEN,right alt RightSuper_SPOKEN,right super Menu_SPOKEN,menu Unknown_SPOKEN,unknown Space_SEEN,Space Apostrophe_SEEN,' Comma_SEEN,"," Minus_SEEN,- Period_SEEN,. Slash_SEEN,/ Key0_SEEN,0 Key1_SEEN,1 Key2_SEEN,2 Key3_SEEN,3 Key4_SEEN,4 Key5_SEEN,5 Key6_SEEN,6 Key7_SEEN,7 Key8_SEEN,8 Key9_SEEN,9 Semicolon_SEEN,; Equal_SEEN,= A_SEEN,A B_SEEN,B C_SEEN,C D_SEEN,D E_SEEN,E F_SEEN,F G_SEEN,G H_SEEN,H I_SEEN,I J_SEEN,J K_SEEN,K L_SEEN,L M_SEEN,M N_SEEN,N O_SEEN,O P_SEEN,P Q_SEEN,Q R_SEEN,R S_SEEN,S T_SEEN,T U_SEEN,U V_SEEN,V W_SEEN,W X_SEEN,X Y_SEEN,Y Z_SEEN,Z LeftBracket_SEEN,[ Backslash_SEEN,\ RightBracket_SEEN,] GraveAccent_SEEN,` World1_SEEN,World 1 World2_SEEN,World 2 Escape_SEEN,Esc Enter_SEEN,Enter Tab_SEEN,Tab Backspace_SEEN,Backspace Insert_SEEN,Insert Delete_SEEN,Delete Right_SEEN,Right Left_SEEN,Left Down_SEEN,Down Up_SEEN,Up PageUp_SEEN,PageUp PageDown_SEEN,PageDown Home_SEEN,Home End_SEEN,End CapsLock_SEEN,CapsLock ScrollLock_SEEN,ScrollLock NumLock_SEEN,NumLock PrintScreen_SEEN,PrintScreen Pause_SEEN,Pause F1_SEEN,F1 F2_SEEN,F2 F3_SEEN,F3 F4_SEEN,F4 F5_SEEN,F5 F6_SEEN,F6 F7_SEEN,F7 F8_SEEN,F8 F9_SEEN,F9 F10_SEEN,F10 F11_SEEN,F11 F12_SEEN,F12 F13_SEEN,F13 F14_SEEN,F14 F15_SEEN,F15 F16_SEEN,F16 F17_SEEN,F17 F18_SEEN,F18 F19_SEEN,F19 F20_SEEN,F20 F21_SEEN,F21 F22_SEEN,F22 F23_SEEN,F23 F24_SEEN,F24 F25_SEEN,F25 Kp0_SEEN,Keypad 0 Kp1_SEEN,Keypad 1 Kp2_SEEN,Keypad 2 Kp3_SEEN,Keypad 3 Kp4_SEEN,Keypad 4 Kp5_SEEN,Keypad 5 Kp6_SEEN,Keypad 6 Kp7_SEEN,Keypad 7 Kp8_SEEN,Keypad 8 Kp9_SEEN,Keypad 9 KpDecimal_SEEN,Keypad . KpDivide_SEEN,Keypad / KpMultiply_SEEN,Keypad * KpSubtract_SEEN,Keypad - KpAdd_SEEN,Keypad + KpEnter_SEEN,Keypad Enter KpEqual_SEEN,Keypad = LeftShift_SEEN,LShift LeftControl_SEEN,LCtrl LeftAlt_SEEN,LAlt LeftSuper_SEEN,LSuper RightShift_SEEN,RShift RightControl_SEEN,RCtrl RightAlt_SEEN,RAlt RightSuper_SEEN,RSuper Menu_SEEN,Menu Unknown_SEEN,? NOTE_NAMES,"C0, C#0, D0, D#0, E0, F0, F#0, G0, G#0, A0, A#0, B0, C1, C#1, D1, D#1, E1, F1, F#1, G1, G#1, A1, A#1, B1, C2, C#2, D2, D#2, E2, F2, F#2, G2, G#2, A2, A#2, B2, C3, C#3, D3, D#3, E3, F3, F#3, G3, G#3, A3, A#3, B3, C4, C#4, D4, D#4, E4, F4, F#4, G4, G#4, A4, A#4, B4, C5, C#5, D5, D#5, E5, F5, F#5, G5, G#5, A5, A#5, B5, C6, C#6, D6, D#6, E6, F6, F#6, G6, G#6, A6, A#6, B6, C7, C#7, D7, D#7, E7, F7, F#7, G7, G#7, A7, A#7, B7, C8, C#8, D8, D#8, E8, F8, F#8, G8, G#8, A8, A#8, B8, C9, C#9, D9, D#9, E9, F9, F#9, G9" TIME_TTS,\0 minutes and \1 seconds TIME_TTS_HOURS,"\0 hours, \1 minutes, and \2 seconds" OR, or MIDI_CONTROL,MIDI control \0 channel \1 APP_TTS_0,Hello world. I am Casey the Cacodemon. APP_TTS_1,\0 to ask me about the panel status. \1 to ask me about the input keys. \2 to ask me about files. APP_TTS_2,\0 to quit. APP_TTS_3,\0 and \1 to cycle panels. APP_TTS_4,\0 or \1 to undo or redo. APP_TTS_5,\0 to ask me to stop talking. APP_TTS_6,\0 to open a panel with helpful website links. FILE_TTS_0,\0 for new music. FILE_TTS_1,\0 to open a file. FILE_TTS_2,\0 to save. \1 to save as. FILE_TTS_3,\0 to export. FILE_TTS_4,\0 to import a MIDI file. FILE_TTS_5,\0 to edit the config file. MUSIC_PANEL_STATUS_TTS,This music is named \0. The BPM is \1. The gain is \2. MUSIC_PANEL_INPUT_TTS,\0 and \1 to scroll. NAME,name BPM,BPM GAIN,gain MUSIC_PANEL_INPUT_TTS_BPM_ABC123,Type to set the beats per minute. \0 to finish. MUSIC_PANEL_INPUT_TTS_BPM_NO_ABC123,\0 to enable input and then type to set the beats per minute. MUSIC_PANEL_INPUT_TTS_BPM,Type to set the beats per minute. MUSIC_PANEL_INPUT_TTS_GAIN,\0 and \1 to set the gain. MUSIC_PANEL_INPUT_TTS_NAME_ABC123,Type the name of the music. \0 to finish. MUSIC_PANEL_INPUT_TTS_NAME_NO_ABC123,\0 to enable input and then type the name of the music. TRACKS_PANEL_STATUS_TTS_NO_SELECTION,There are no tracks. TRACKS_PANEL_STATUS_TTS_PREFIX,Track \0 is selected. TRACKS_PANEL_STATUS_TTS_SOUNDFONT,The preset is \0. The bank is \1. The gain is \2. The sound font is \3. TRACKS_PANEL_STATUS_TTS_MUTED,This track is muted. TRACKS_PANEL_STATUS_TTS_SOLOED,This track is soloed. TRACKS_PANEL_STATUS_TTS_NO_SOUNDFONT,This track does not have a sound font. TRACKS_PANEL_INPUT_TTS_ADD,\0 to add a track. TRACKS_PANEL_INPUT_TTS_TRACK_PREFIX_0,\0 to remove the track. TRACKS_PANEL_INPUT_TTS_TRACK_PREFIX_1,\0 and \1 to scroll. TRACKS_PANEL_INPUT_TTS_TRACK_PREFIX_2,\0 to load a sound font. TRACKS_PANEL_INPUT_TTS_TRACK_SUFFIX_0,\0 and \1 to set the preset. TRACKS_PANEL_INPUT_TTS_TRACK_SUFFIX_1,\0 and \1 to set the bank. TRACKS_PANEL_INPUT_TTS_TRACK_SUFFIX_2,\0 and \1 to set the gain. TRACKS_PANEL_INPUT_TTS_MUTE,\0 to mute. TRACKS_PANEL_INPUT_TTS_UNMUTE,\0 to unmute. TRACKS_PANEL_INPUT_TTS_SOLO,\0 to solo. TRACKS_PANEL_INPUT_TTS_UNSOLO,\0 to unsolo. OPEN_FILE_PANEL_STATUS_TTS_CWD,The current directory is \0. FOLDER,folder \0 FILE,file \0 OPEN_FILE_PANEL_UP,MORE ^ OPEN_FILE_PANEL_DOWN,MORE v OPEN_FILE_PANEL_UP_DOWN,MORE ^v OPEN_FILE_PANEL_TITLE_SOUNDFONT,Load SoundFont OPEN_FILE_PANEL_TITLE_READ_SAVE,Load Save OPEN_FILE_PANEL_TITLE_WRITE_SAVE,Save OPEN_FILE_PANEL_TITLE_EXPORT,Export OPEN_FILE_PANEL_TITLE_IMPORT_MIDI,Import MIDI OPEN_FILE_PANEL_STATUS_TTS_SELECTION,You selected \0. OPEN_FILE_PANEL_STATUS_TTS_NO_SELECTION,This directory is empty. OPEN_FILE_PANEL_STATUS_TTS_EXPORT,The file type is \0. OPEN_FILE_PANEL_INPUT_TTS_UP_DIRECTORY,\0 to go up to directory %0. OPEN_FILE_PANEL_INPUT_TTS_SCROLL, \0 and \1 to scroll. OPEN_FILE_PANEL_INPUT_TTS_CYCLE_EXPORT, \0 to set the export file to %0. OPEN_FILE_PANEL_INPUT_TTS_DOWN_DIRECTORY,\0 to open folder %0. OPEN_FILE_PANEL_INPUT_TTS_READ_SAVE,\0 to load save file %0. OPEN_FILE_PANEL_INPUT_TTS_EXPORT,\0 to load audio file %0. OPEN_FILE_PANEL_INPUT_TTS_SOUNDFONT,\0 to load sound font %0. OPEN_FILE_PANEL_INPUT_TTS_WRITE_SAVE,\0 to write save file %0. OPEN_FILE_PANEL_INPUT_TTS_IMPORT_MIDI,\0 to import MIDI file %0. OPEN_FILE_PANEL_INPUT_TTS_CLOSE,\0 to close. PIANO_ROLL_PANEL_TTS_NO_TRACK,You cannot use this panel until you have added a track and loaded a sound font. PIANO_ROLL_PANEL_STATUS_TTS_MODE,The piano roll mode is \0. PIANO_ROLL_PANEL_STATUS_TTS_SINGLE_TRACK,You are viewing track \0. PIANO_ROLL_PANEL_STATUS_TTS_MULTI_TRACK,You are viewing multiple tracks. Track \0 is selected. PIANO_ROLL_PANEL_STATUS_TTS_ARMED,"The track is armed. New notes will be \0 beats and volume \1." PIANO_ROLL_PANEL_STATUS_TTS_VOLUME,\0 if you use qwerty input otherwise the MIDI velocity value. PIANO_ROLL_PANEL_STATUS_TTS_NOT_ARMED,"The track is not armed." PIANO_ROLL_PANEL_STATUS_TTS_PIANO_ROLL_MODE,The piano roll mode is \0. PIANO_ROLL_PANEL_STATUS_TTS_EDIT_MODE,The edit mode is \0. PIANO_ROLL_PANEL_STATUS_TTS_NO_SELECTION,No notes are selected. PIANO_ROLL_PANEL_STATUS_TTS_SELECTED_SINGLE,The selected note has a pitch of \0 and starts at beat \1. PIANO_ROLL_PANEL_STATUS_TTS_SELECTED_MANY,The selected notes start at beat \0 and end at beat \1. PIANO_ROLL_PANEL_STATUS_TTS_TIME,"The cursor is at \0. Playback will start at \1." PIANO_ROLL_PANEL_STATUS_TTS_VIEW,The view is from beats \0 to \1 and pitches \2 to \3. PIANO_ROLL_PANEL_INPUT_TTS_PLAY,\0 to play music. PIANO_ROLL_PANEL_INPUT_TTS_SINGLE_TRACK,\0 to view a single track. PIANO_ROLL_PANEL_INPUT_TTS_MULTI_TRACK,\0 to view multiple tracks. PIANO_ROLL_PANEL_INPUT_TTS_TRACK_SCROLL,\0 and \1 to select a track. PIANO_ROLL_PANEL_INPUT_TTS_NOT_ARMED,\0 to arm the track. PIANO_ROLL_PANEL_INPUT_TTS_ARMED,\0 to disarm the track. \1 and \2 to set the input beat. PIANO_ROLL_PANEL_INPUT_TTS_NOTES,"\0, \1, \2, \3, \4, \5, \6, \7, \8, \9, \10, and \11 to play notes. \12 and \13 to change octave." PIANO_ROLL_PANEL_INPUT_TTS_DO_NOT_USE_VOLUME,\0 and \1 to set the input volume. \2 to start using MIDI input volume instead. PIANO_ROLL_PANEL_INPUT_TTS_USE_VOLUME,\0 to make all new notes have the input volume value. PIANO_ROLL_PANEL_INPUT_TTS_MODES,"\0, \1, \2, or \3 to set the mode to time, view, select, or edit." PIANO_ROLL_PANEL_INPUT_TTS_COPY_CUT,\0 or \1 to copy or cut the selected notes. PIANO_ROLL_PANEL_INPUT_TTS_PASTE,\0 to paste notes. PIANO_ROLL_PANEL_INPUT_TTS_DELETE,\0 to delete the selected notes. PIANO_ROLL_PANEL_INPUT_TTS_EDIT_MODE,\0 to set the edit mode to %0. PIANO_ROLL_PANEL_INPUT_TTS_SELECT_SINGLE,\0 and \1 to select a different note. PIANO_ROLL_PANEL_INPUT_TTS_SELECT_MANY,\0 and \1 to set the start of the selection. \2 and \3 to set the end of the selection. PIANO_ROLL_PANEL_INPUT_TTS_SELECT_ALL,\0 to select all. PIANO_ROLL_PANEL_INPUT_TTS_DESELECT,\0 to deselect. PIANO_ROLL_PANEL_INPUT_TTS_SELECT_CYCLE_TO_SINGLE,\0 to select only one note. PIANO_ROLL_PANEL_INPUT_TTS_SELECT_CYCLE_TO_MANY,\0 to select multiple notes. PIANO_ROLL_PANEL_INPUT_TTS_EDIT_0,\0 and \1 to set the pitch. PIANO_ROLL_PANEL_INPUT_TTS_EDIT_1,\0 and \1 to set the start time. PIANO_ROLL_PANEL_INPUT_TTS_EDIT_2,\0 and \1 to set the duration. PIANO_ROLL_PANEL_INPUT_TTS_EDIT_3,\0 and \1 to set the volume. PIANO_ROLL_PANEL_INPUT_TTS_TIME_0,\0 and \1 to move the cursor. PIANO_ROLL_PANEL_INPUT_TTS_TIME_1,\0 and \1 to set the cursor to the start and end. PIANO_ROLL_PANEL_INPUT_TTS_TIME_2,\0 to set the cursor to the nearest beat. PIANO_ROLL_PANEL_INPUT_TTS_TIME_3,\0 to set the cursor to the playback time. PIANO_ROLL_PANEL_INPUT_TTS_TIME_4,\0 and \1 to move the playback time. PIANO_ROLL_PANEL_INPUT_TTS_TIME_5,\0 and \1 to set the playback time to the start and end. PIANO_ROLL_PANEL_INPUT_TTS_TIME_6,\0 to set the playback time to the nearest beat. PIANO_ROLL_PANEL_INPUT_TTS_TIME_7,\0 to set the playback time to the cursor. PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_0,"\0, \1, \2, and \3 to move the view." PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_1,\0 and \1 to set the view to the start and end. PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACk_2,\0 and \1 to zoom in and out. PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_3,\0 to reset the zoom level." PIANO_ROLL_PANEL_INPUT_TTS_VIEW_MULTI_TRACK_0,\0 and \1 to move the view. PIANO_ROLL_PANEL_INPUT_TTS_VIEW_MULTI_TRACK_1,\0 and \1 to set the view to the start and end." PIANO_ROLL_MODE_TIME,Time PIANO_ROLL_MODE_VIEW,View PIANO_ROLL_MODE_SELECT,Select PIANO_ROLL_MODE_EDIT,Edit FRACTION_TTS_ONE_THIRTY_SECOND,one thirty-second FRACTION_TTS_ONE_SIXTEENTH,one sixteenth FRACTION_TTS_ONE_EIGHTH,one eighth FRACTION_TTS_ONE_SIXTH,one sixth FRACTION_TTS_ONE_FOURTH,one fourth FRACTION_TTS_ONE_THIRD,one third FRACTION_TTS_ONE_HALF,one half FRACTION_TTS_ONE_AND_A_HALF,one and a half EDIT_MODE_NORMAL,Normal EDIT_MODE_QUICK,Quick EDIT_MODE_PRECISE,Precise ERROR,ERROR: \0 TRUE,Y FALSE,N TITLE_MAIN_MENU,Cacophony TITLE_MUSIC,Music TITLE_TRACKS,Tracks TITLE_PIANO_ROLL,Piano Roll TITLE_OPEN_FILE,Open File TITLE_EXPORT_STATE,Exporting... TITLE_EXPORT_SETTINGS,Settings TITLE_QUIT,Really quit? TITLE_LINKS,Open a link in your browser TITLE_BPM,BPM TITLE_GAIN,Gain MAIN_MENU_HELP,Help: MAIN_MENU_STATUS,\0 Status MAIN_MENU_INPUT,\0 Input MAIN_MENU_APP,\0 App MAIN_MENU_FILE,\0 File MAIN_MENU_STOP,\0 Stop MAIN_MENU_ONLINE,\0 Links MAIN_MENU_UPDATE,Update available: v\0 TRACKS_PANEL_BANK,Bank TRACKS_PANEL_GAIN,Gain TRACKS_PANEL_MUTE,M TRACKS_PANEL_SOLO,S TRACKS_PANEL_TRACK_TITLE,Track \0 PIANO_ROLL_PANEL_TOP_BAR_ARMED,Armed PIANO_ROLL_PANEL_TOP_BAR_BEAT,Beat PIANO_ROLL_PANEL_TOP_BAR_USE_VOLUME,Use Volume PIANO_ROLL_PANEL_TOP_BAR_VOLUME,Volume PIANO_ROLL_PANEL_TOP_BAR_TIME,Time PIANO_ROLL_PANEL_TOP_BAR_VIEW,View PIANO_ROLL_PANEL_TOP_BAR_SELECT,Select PIANO_ROLL_PANEL_TOP_BAR_EDIT,Edit PIANO_ROLL_PANEL_EDIT_MODE_NORMAL,Edit Mode: Normal PIANO_ROLL_PANEL_EDIT_MODE_QUICK,Edit Mode: Quick PIANO_ROLL_PANEL_EDIT_MODE_PRECISE,Edit Mode: Precise PIANO_ROLL_PANEL_EDIT_MODE_SINGLE,Edit Mode: Single PIANO_ROLL_PANEL_EDIT_MODE_MANY,Edit Mode: Many PIANO_ROLL_PANEL_VIEW_DT,View: \0 to \1 PIANO_ROLL_PANEL_CURSOR_TIME,Cursor: \0 PIANO_ROLL_PANEL_PLAYBACK_TIME,Playback: \0 PIANO_ROLL_PANEL_SELECTED_SINGLE,Selected: \0 at \1 PIANO_ROLL_PANEL_SELECTED_MANY,Selected: \0 to \1 PIANO_ROLL_PANEL_SELECTED_NONE,Selected: None PIANO_ROLL_PANEL_VOLUME_TITLE,Volume EXPORT_SETTINGS_PANEL_STATUS_TTS_FRAMERATE,Framerate is selected. EXPORT_SETTINGS_PANEL_STATUS_TTS_TITLE_NO_ABC123,The title is %0. \0 to edit. EXPORT_SETTINGS_PANEL_STATUS_TTS_TITLE_ABC123,The title is \0. You can edit it. EXPORT_SETTINGS_PANEL_STATUS_TTS_ARTIST_NO_ABC123,The artist is %0. \0 to edit. EXPORT_SETTINGS_PANEL_STATUS_TTS_ARTIST,The artist is \0. You can edit it. EXPORT_SETTINGS_PANEL_STATUS_TTS_COPYRIGHT_ENABLED,Copyright is enabled. \0 to disable. EXPORT_SETTINGS_PANEL_STATUS_TTS_COPYRIGHT_DISABLED,Copyright is disabled. \0 to enable. EXPORT_SETTINGS_PANEL_STATUS_TTS_ALBUM_NO_ABC123,The album title is %0. \0 to edit. EXPORT_SETTINGS_PANEL_STATUS_TTS_ALBUM,The album title is \0. You can edit it. EXPORT_SETTINGS_PANEL_STATUS_TTS_GENRE_NO_ABC123,The genre is %0. \0 to edit. EXPORT_SETTINGS_PANEL_STATUS_TTS_GENRE,The genre is \0. You can edit it. EXPORT_SETTINGS_PANEL_STATUS_TTS_COMMENT_NO_ABC123,The comment is %0. \0 to edit. EXPORT_SETTINGS_PANEL_STATUS_TTS_COMMENT_ABC123,The comment is \0. You can edit it. EXPORT_SETTINGS_PANEL_STATUS_TTS_BIT_RATE,The bit rate is \0 KBPS. EXPORT_SETTINGS_PANEL_STATUS_TTS_QUALITY,The quality is \0. EXPORT_SETTINGS_PANEL_STATUS_TTS_TRACK_NUMBER,The track number is \0. EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_ENABLED,Multi file export is enabled. \0 to disable. EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_DISABLED,Multi file export is disabled. \0 to enable. EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_PRESET,Each exported file will use the track's preset as its suffix. EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_CHANNEL,Each exported file will use the track's channel as its suffix. EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_CHANNEL_AND_PRESET,Each exported file will use the track's channel and preset as its suffix. EXPORT_SETTINGS_PANEL_STATUS_TTS_COPYRIGHT_DISABLED,Copyright is disabled. \0 to enable. EXPORT_SETTINGS_PANEL_INPUT_TTS_FRAMERATE,\0 and \1 to set the framerate. EXPORT_SETTINGS_PANEL_INPUT_TTS_TITLE_ABC123,Type to set the title. \0 to finish. EXPORT_SETTINGS_PANEL_INPUT_TTS_TITLE_NO_ABC123,\0 to start editing the title. EXPORT_SETTINGS_PANEL_INPUT_TTS_ARTIST_ABC123,Type to set the artist. \0 to finish. EXPORT_SETTINGS_PANEL_INPUT_TTS_ARTIST_NO_ABC123,\0 to start editing the artist. EXPORT_SETTINGS_PANEL_INPUT_TTS_COPYRIGHT,\0 to toggle copyright. EXPORT_SETTINGS_PANEL_INPUT_TTS_ALBUM_ABC123,Type to set the album title. \0 to finish. EXPORT_SETTINGS_PANEL_INPUT_TTS_ALBUM_NO_ABC123,\0 to start editing the album. EXPORT_SETTINGS_PANEL_INPUT_TTS_GENRE_ABC123,Type to set the genre. \0 to finish. EXPORT_SETTINGS_PANEL_INPUT_TTS_GENRE_NO_ABC123,\0 to start editing the genre. EXPORT_SETTINGS_PANEL_INPUT_TTS_COMMENT_ABC123,Type to add a comment. \0 to finish. EXPORT_SETTINGS_PANEL_INPUT_TTS_COMMENT_NO_ABC123,\0 to start adding a comment. EXPORT_SETTINGS_PANEL_INPUT_TTS_TRACK_NUMBER,\0 and \1 to set the track number. EXPORT_SETTINGS_PANEL_INPUT_TTS_MP3_BIT_RATE,\0 and \1 to set the bit rate. EXPORT_SETTINGS_PANEL_INPUT_TTS_QUALITY,\0 and \1 to set the quality. EXPORT_SETTINGS_PANEL_INPUT_TTS_SCROLL,\0 and \1 to scroll. EXPORT_SETTINGS_PANEL_INPUT_TTS_MULTI_FILE,\0 to toggle multi file export. EXPORT_SETTINGS_PANEL_INPUT_TTS_MULTI_FILE_SUFFIX,\0 and \1 to set the file name suffix. NONE,none EXPORT_SETTINGS_PANEL_FRAMERATE,Framerate EXPORT_SETTINGS_PANEL_MP3_BIT_RATE,Bit rate EXPORT_SETTINGS_PANEL_QUALITY,Quality EXPORT_SETTINGS_PANEL_TITLE,Title EXPORT_SETTINGS_PANEL_ARTIST,Artist EXPORT_SETTINGS_PANEL_COPYRIGHT,Copyright EXPORT_SETTINGS_PANEL_ALBUM,Album EXPORT_SETTINGS_PANEL_TRACK_NUMBER,Track Number EXPORT_SETTINGS_PANEL_GENRE,Genre EXPORT_SETTINGS_PANEL_COMMENT,Comment EXPORT_SETTINGS_PANEL_MULTI_FILE,Export tracks as separate files EXPORT_SETTINGS_PANEL_MULTI_FILE_SUFFIX,Filename suffix pattern EXPORT_SETTINGS_PANEL_FILE_SUFFIX_PRESET,Preset EXPORT_SETTINGS_PANEL_FILE_SUFFIX_CHANNEL,Channel EXPORT_SETTINGS_PANEL_FILE_SUFFIX_CHANNEL_AND_PRESET,Channel and Preset QUIT_PANEL_INPUT_TTS,You have unsaved changes. \0 to quit. \1 to go back to the app. QUIT_PANEL_YES,\0 Yes QUIT_PANEL_NO,\0 No LINKS_PANEL_WEBSITE,\0 Cacophony's website LINKS_PANEL_DISCORD,\0 Discord server LINKS_PANEL_GITHUB,\0 GitHub repo LINKS_PANEL_INPUT_TTS_0,Open links in your web browser. LINKS_PANEL_INPUT_TTS_1,\0 to open the Cacophony website. LINKS_PANEL_INPUT_TTS_2,\0 to open an invite link to the Cacophony Discord server. LINKS_PANEL_INPUT_TTS_3,\0 to open an Cacophony repo. LINKS_PANEL_INPUT_TTS_4,\0 to close this panel. EXPORT_PANEL_APPENDING_DECAY,Appending decay... EXPORT_PANEL_WRITING,Writing to disk... ================================================ FILE: html/index.html ================================================ Cacophony

Cacophony is a minimalist and ergonomic MIDI sequencer. It's minimalist in that it doesn't have a lot of functionality MIDI sequencers have. It's ergonomic in that there is no mouse input and a very clean interface, allowing you to juggle less inputs and avoid awkward mouse motions.

Cacophony is open source. You can buy it, or you can compile it for free.

  

Features

  • MIDI sequencer with support for SoundFonts.
  • Doesn't try to emulate a real-life studio. If you're an amateur and just want to make music without needing to learn pro software, Cacophony might be for you.
  • Qwerty and MIDI input only. There is no mouse input.
  • An attractive, customizable ASCII interface.
  • Accessible. The entire interface can be read by a text-to-speech engine.
  • Export to wav, mp3, ogg, flac, or mid. Optionally, export each track separately.
  • Open source.
  • Nothing else. Cacophony does what it does, and it doesn't try to do anything else.

Platforms

There are builds on itch.io for Linux, MacOS, and Windows. If you don't see your Linux distro or MacOS version listed, you'll need to compile it yourself.

Cacophony can run smoothly on a 20+ year old laptop with 32bit Ubuntu.

Mascot

Cacophony's mascot is Casey the Transgender Cacodemon. No official artwork of Casey exists because I don't want to be cursed.

================================================ FILE: html/install.html ================================================

How to Install

Requirements

Cacophony runs on Linux, MacOS, and Windows. Want a mobile version? It ain't happening.

Cacophony should compile on any modern computer. I've tested it on amd64 and Apple Silicon. The oldest computer I've successfully compiled Cacopohony on is an x86 T61 Thinkpad running Ubuntu 18.

You can optionally use any number of MIDI controllers with Cacophony.

If you want to buy Cacophony, you can do so on itch.io and then download it.

You can also compile Cacophony for free.

Linux

If you're downloading from itch.io, make sure you download the correct version: ubuntu-20 for Ubuntu 20.04, etc. If you want to run Cacophony on another distro, let me know on Discord.

Make sure that you have Speech Dispatcher installed: apt install speech-dispatcher

If you want to use a MIDI controller, you may need to add yourself as an audio user. Open a terminal, type this, and press enter:

sudo usermod -a -G audio replacethiswithyourusername

Don't forget to have fun.

MacOS

If you're downloading from itch.io, make sure you download the version that matches the version of your MacOS.

The first time you try running Cacophony you might be greeted with an error about Cacophony.app being damaged. It's not damaged. The actual problem is that Apple wants me to pay money to get the app code signed.

To "fix" Cacophony.app:

  1. Move Cacophony.app into your home directory (the one above Documents/ and Downloads/). To get there, open Finder and press: Command LShift h
  2. Open a new terminal, type this, and press enter:

    xattr -r -d com.apple.quarantine Cacophony.app

  3. You can now double-click Cacophony.app. You can move it into Applications/ or wherever, if you want. Don't forget to have fun.

Windows

Double-click to run. It's that easy! Don't forget to have fun.

================================================ FILE: html/limitations.html ================================================

Limitations

This is a non-exhaustive list of features that you might expect Cacophony to have that it'll never have. By design, Cacophony lacks many features common to audio software. If you want those features, I encourage you to use Cacophony in combination with other software.

Can't import wav data

Adding wav-data-importing functionality to Cacophony would be a huge undertaking and make the application too complex for me to maintain.

Non-quantized input

Quantized input solves a lot of UX problems and data serialization. (Basically, I'm unwilling to serialize floats.)

Only SoundFonts

Why? Because they're easy.

No VSTs or other plugins

As far as I know you can't actually write a general audio plugin framework because the plugins vary too much. Pro DAWs apparently have custom code per VST, which is well beyond what I can support.

No mouse support

I won't add it.

No audio effects, filters, etc.

Do it in Audacity.

No live recording

No.

No mobile app

Cacophony's UX is completely orthagonal to how mobile apps work.

No browser app

I MIGHT make this some day, but I don't think too much about it.

================================================ FILE: html/manifesto.html ================================================

Design Manifesto

The overall goal is that Cacophony should feel as close as possible to improvising on an actual instrument and writing notes on physical paper with a physical pencil. There should be as little as possible between you and writing music.

Cacophony should be useable by amateurs who just want to make something simple and don't want to learn professional software.

1. Clean Interface

Cacophony looks like a TUI because I think it's a good design constraint. If everything has to be rendered as an ASCII grid, then the app's UI always has to be clean. Any superfluous information will take up precious space.

Every DAW I've seen seems, by design, to embrace a cluterred interface. I'm a programmer and I routinely use incredibly complex IDEs but DAWs have me stumped. I wanted to make something that is much, much easier on the eyes.

2. Accessibility

As it turns out, making an app accessible is another great way to ensure that the interface is clean. Cacophony by default uses a font that by design is easy on the eyes and can support a lot of languages and a high-contrast color scheme. And, almost everything on-screen can be described by an external text-to-speech engine. Audio-only tooltips are usually frowned upon because there's no way to know if the user has audio on. However, this is a music-making program so we *do* know that the user has audio on.

I anticipate one major downside of making screen readers a primary part of the overall UX: the text-to-speech engine is external, which makes Cacophony more susceptible to bitrot. However, this is probably true of many other accessibility decisions in many other apps.

3. Ergonomic UX

Cacophony is qwerty-and-MIDI input *only*. Between this an the helpful screen-reader, the best-case scenario for Cacophony is that it reaches a state at which experienced users hardly ever need to look at the screen.

Cacophony doesn't offer mouse support because it feels very awakware to me to have to shift between my MIDI controller, my computer keyboard, and my mouse. I feel like I can handle two input devices at a time, so I opted for qwerty and MIDI.

4. Limited Goals

Many DAWs are attempting to simulate actual studio setups or synthesizers. That's probably useful if you want to actually make a career out of making music, but I don't want to make a career out of making music, I just want to make music. So, Cacophony doesn't make any attempt to emulate any "real-world" audio production verbs.

5. Music Notation Input

I really like how music notation programs such as MuseScore handle note input so Cacophony handles note input similarly, despite being a MIDI sequencer.

6. Open Source

I'm wary of ever adapting to a closed-source DAW that I subscribe to rather than actually own. I want to know that if I make some music, I can edit it whenever I want. In five years, on a different computer, I want to be able to open up the same file. Most DAWs just don't seem optimized for that kind of long-term workflow. I also want to know that if I ever need to drop this project, anyone can fork the repo and no one will need to lose access to their music.

7. Rust

Rust felt like a good choice for this project because I wanted a language that was fast with excellent threading support that wouldn't let me make dumb memory management mistakes.

8. Privacy

Read this.

================================================ FILE: html/me.html ================================================

Me

I'm Esther Alter. My pronouns are she/her. This is my website.

I'm a software engineer and I'm new to audio. I mostly program in C# and Python. This is my first big Rust project.

I fully recognize that I've made a lot of unorthodox design decisions in a domain that I don't know much about. Suggestions and feedback are welcome but don't be a fucking jerk about it.

================================================ FILE: html/privacy.html ================================================

Privacy

Cacophony doesn't send me any user data. I can't promise that the program is totally private for a few reasons, namely that it uses an external text-to-speech engine. But I *can* promise that *I* don't know anything about you or what you're doing. Consequently, I won't know if you found bugs or UX problems unless you tell me on either GitHub or Discord.

Cacophony never connects to the Internet except for a single HTTP request at launch that it uses to check if a new version is available. The HTTP request doesn't send me any information. You can prevent this HTTP request by editing the config file.

================================================ FILE: html/roadmap.html ================================================

Roadmap

I really don't want to add new features to Cacophony. For a list of features I *won't* add, read this.

Fix bugs, I guess?

I don't know if this is something you're supposed to add to a roadmap?

Cacophony is a wee baby alpha and I'm just one developer. There are probably lots of bugs that need fixing. When you find them, please let me know.

More Languages

If you'd like to translate Cacophony, please do so!! The language look-up table is in cacophony/data/text.csv

Better .sf3 support

...maybe?? Does anyone actually want this??

================================================ FILE: html/style.css ================================================ @font-face { font-family: 'NotoSansMono'; src: url('fonts/noto/NotoSansMono-Regular.ttf'); } @font-face { font-family: 'NotoSansMonoBold'; src: url('fonts/noto/NotoSansMono-ExtraBold.ttf'); } @font-face { font-family: 'NotoSansMono_Condensed-Light'; src: url('fonts/noto/NotoSansMono_Condensed-Light.ttf') } * { -webkit-text-size-adjust: none; } html { scroll-behavior: smooth; } body { font-family: 'NotoSansMono', monospace; font-size: 18px; color: #c8c8c8; background-color: #272932; margin: 0; line-height: 24px; margin-bottom: 24px; } h1 { font-family: 'NotoSansMonoBold', monospace; } h2 { font-family: 'NotoSansMonoBold', monospace; } a { color: #f5a9b8; } li { margin-bottom: 18px; } code { font-family: 'NotoSansMono_Condensed-Light', monospace; color: #272932; background-color: #5bcefa; } .sidenav { height: 100%; width: 14%; position: fixed; z-index: 1; top: 0; left: 0; background-color: #272932; overflow-x: hidden; padding-top: 1vh; border-color: #f5a9b8; border-radius: 5px; border-style: solid; } .sidenav p { padding-left: 1vw; } .main { margin-left: 16%; margin-right: 30%; padding: 0px 10px; } .main .banner { text-align: center; } .main .banner img { width: 100%; } .main .download { text-align: left; } .button { font-family: 'NotoSansMonoBold', monospace; background-color: #f5a9b8; border: none; color: #272932; padding: 15px 48px; margin-bottom: 30px; text-align: left; text-decoration: none; display: inline-block; font-size: 24px; } .button a { color: #272932; } .button .subtitle { font-size: 12px; padding-bottom: 6px; } @media only screen and (max-device-aspect-ratio: 99 / 100) and (orientation: portrait) { a { font-size:1.5vh; line-height: 3vh; margin-bottom: 3vh; } p { font-size: 1.5vh; line-height: 3vh; margin-bottom: 3vh; } code { font-size: 1.25vh; } body { font-size: 1.5vh; line-height: 3vh; margin-bottom: 3vh; } .main { margin-left: 24%; margin-right: 0%; padding: 0px 10px; } .button { padding: 6px 24px; margin-bottom: 20px; font-size: 1.5vh; } .button .subtitle { font-size: 1vh; padding-bottom: 6px; } .sidenav { width: 22%; } } @media only screen and (max-height: 650px) and (orientation: landscape) { a { font-size: 2vw; line-height: 4vw; margin-bottom: 4vw; } p { font-size: 2vw; line-height: 4vw; margin-bottom: 4vw; } code { font-size: 2vw; } body { font-size: 2vw; line-height: 4vw; margin-bottom: 4vw; } .main { margin-left: 22%; margin-right: 0%; padding: 0px 10px; } .button { padding: 6px 24px; margin-bottom: 20px; font-size: 2vw; } .button .subtitle { font-size: 1vw; padding-bottom: 6px; } .sidenav { width: 22%; } } ================================================ FILE: html/user_guide.html ================================================

User Guide

How do I run this?

Want to know how install Cacophony? Read this.

On Windows and MacOS, just double-click the app.

On Linux, you can't launch Cacophony by double-clicking the app. I don't know why. Sorry. To run Cacophony, open a terminal, type each of these, press enter each time:

  1. cd path_to/cacophony (replace path_to with the actual path)
  2. ./cacophony

What even is this?

Cacophony is an terminal-esque MIDI sequencer. By "terminal-esque" I mean that it looks like a terminal shell and doesn't support mouse input. By "MIDI sequencer" I mean that the music you're going to make is defined by MIDI messages.

You aren't going to record audio. You going to input MIDI messages which will be processed when the music plays or is exported. A MIDI message is several numbers that a synthesizer or computer program interprets as messages. How they interpret these messages is up to them, though there is a MIDI standard that everyone is supposed to follow. In Cacophony, MIDI messages can be interpreted as note-on/off events or to control the app.

In Cacophony, music is divided into tracks, and tracks are divided into notes. A note is a discrete pitch, volume, start time, and duration. A note is *not* audio. It's just some data that will tell the app how to generate audio.

Every track needs a SoundFont in order to convert notes to audio. A SoundFont is a file with instruments, called "presets", that are divided into "banks". A SoundFont can take a MIDI event (or a Cacophony note, which is essentially the same thing) and use that to "render" audio.

To use Cacophony, you need to first download at least one SoundFont. I like this site. Move the .sf2 file you just downloaded to Documents/cacophony/soundfonts/

When you save your music, the save file will include the music/track/note data (similar to a .mid) file, along with the filepaths to the SoundFonts you've assigned to each track and some other data too.

How do I do anything?

You can only use your MIDI controller and your qwerty keyboard. You can't use your mouse.

There aren't any tooltips because that would go against Cacophony's design principles. Instead, you've got Casey the Cacodemon, who I've hooked up to your computer's text-to-speech engine. Press F1, F2, F3, or F4 and Casey will tell you what to do.

Before doing anything else, I recommend pressing F3 to get an overview of the app and F4 to learn how to save, export, etc. You'll use F1 and F2 often; they're contextual and they'll tell you how to use the panel or widget you've selected, *what* it is you've selected, etc.

The best way to use Cacophony is to listen to Casey and gradually learn the controls.

If you want to get started right away, try this: First, download a SoundFont and move it to the directory, like I recommended in the previous section. Then launch Cacophony and do this:

  1. PageUp Cycle to the Tracks Panel
  2. = Add a new track
  3. Return Show the open-file dialogue to load a SoundFont
  4. Return Select the SoundFont
  5. PageUp Cycle to the Piano Roll Panel
  6. Return Arm the track
  7. Play notes with your MIDI controller or your qwerty keyboard. Not sure how? Press F2 to ask Casey.

I'm going to given you a few more useful pointers but I'm not going to tell you the controls. Ask Casey.

Cacophony lets you add notes whenever you want and doesn't support live recording; it won't do anything until you enter a new note. You can set the next note's duration. To add silence between notes, go to Time and move the cursor. The cursor defines where the next note starts.

Cacophony has several volume controls. The Music Panel has an overall gain value. Each track has its own gain value. When you input notes, you can either use the velocity value from messages originating from a MIDI controller or you can manually set the volume of the next note.

When you like how it sounds, you can export your music to wav, ogg, mp3, or mid. You can export each track separately if you want.

Cacophony's interface can be controlled from a MIDI controller. By default, the tooltips are for my own MPK Mini III. More on this in a moment:

Can I customize Cacophony?

Cacophony's font, color scheme, every keyboard binding, and lots of other settings, are in a config file. To edit the config file, first copy it from cacophony/data/config.ini to Documents/cacophony/config.ini (the app will always prefer the one in Documents/ if it exists). Feel free to experiment with editing the file. If you mess up you can always copy+paste the default file again.

For MIDI bindings to work correctly, your MIDI controller needs to support Inc/Dec input, e.g. the values are always 1 or 127. Sorry, I know that's weird, but it makes it a lot easier to support input that is meant to wrap around values (e.g. cycling around panels). For my own controller, I used the MPK Mini III Program Editor to set each knob to be Inc/Dec.

Command line arguments

If you launch Cacophony from the terminal, you can add the path of a save file to open it at launch: ./cacophony ~/Documents/cacophony/saves/my_music.cac

You can set the data directory as an environment variable. This is useful if you're on Linux and you don't want to cd cacophony to run the app:

For example: export CACOPHONY_DATA_DIR=~/cacophony/data && ./~/cacophony/cacophony

Or, you can do this: ./~/cacophony/cacophony --data_directory ~/cacophony/data

To enable fullscreen, you can edit the config file or do this:

export CACOPHONY_FULLSCREEN=1 && ./cacophony or ./cacophony --fullscreen

What if I still need help?

Ask Casey for help whenever you get stuck.

Oh, you want help from a *human*?? In *this* economy??

Well, fine. If you want to report a bug, the best way to do so is to create a GitHub Issue. You can also join the Discord and ask for help.

================================================ FILE: input/Cargo.toml ================================================ [package] name = "input" version.workspace = true authors.workspace = true description.workspace = true documentation.workspace = true edition.workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } hashbrown = { workspace = true } macroquad = { workspace = true } rust-ini = { workspace = true } midir = { workspace = true } parking_lot = { workspace = true } clap = { workspace = true } [dev-dependencies] midly = { workspace = true } [dependencies.common] path = "../common" ================================================ FILE: input/src/debug_input_event.rs ================================================ use crate::InputEvent; pub(crate) enum DebugInputEvent { InputEvent(InputEvent), Alphanumeric(char), } ================================================ FILE: input/src/input_event.rs ================================================ use strum_macros::EnumString; /// Input events from either a qwerty keyboard or a MIDI controller. #[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, EnumString)] pub enum InputEvent { // Cycle panels. NextPanel, PreviousPanel, // Alphanumeric input. ToggleAlphanumericInput, // TTS. StatusTTS, InputTTS, AppTTS, FileTTS, StopTTS, // Enable links panel. EnableLinksPanel, // Undo-redo. Undo, Redo, // Files. OpenFile, NewFile, SaveFile, SaveFileAs, ExportFile, ImportMidi, EditConfig, // Quit. Quit, // Music panel. NextMusicPanelField, PreviousMusicPanelField, IncreaseMusicGain, DecreaseMusicGain, // Tracks panel. AddTrack, RemoveTrack, NextTrack, PreviousTrack, EnableSoundFontPanel, PreviousPreset, NextPreset, PreviousBank, NextBank, IncreaseTrackGain, DecreaseTrackGain, Mute, Solo, // Open file panel. UpDirectory, DownDirectory, SelectFile, NextPath, PreviousPath, CloseOpenFile, CycleExportType, // Export settings. PreviousExportSetting, NextExportSetting, PreviousExportSettingValue, NextExportSettingValue, ToggleExportSettingBoolean, // Piano roll. PianoRollCycleMode, PianoRollSetTime, PianoRollSetView, PianoRollSetSelect, PianoRollSetEdit, PianoRollToggleTracks, Arm, InputBeatLeft, InputBeatRight, IncreaseInputVolume, DecreaseInputVolume, ToggleInputVolume, PlayStop, PianoRollPreviousTrack, PianoRollNextTrack, // Piano roll - view mode. ViewLeft, ViewRight, ViewUp, ViewDown, ViewStart, ViewEnd, ViewZoomIn, ViewZoomOut, ViewZoomDefault, // Piano roll - time mode. TimeCursorLeft, TimeCursorRight, TimeCursorStart, TimeCursorEnd, TimePlaybackLeft, TimePlaybackRight, TimePlaybackStart, TimePlaybackEnd, TimeCursorPlayback, TimePlaybackCursor, TimeCursorBeat, TimePlaybackBeat, // Piano roll - edit mode. EditStartLeft, EditStartRight, EditDurationLeft, EditDurationRight, EditPitchUp, EditPitchDown, EditVolumeUp, EditVolumeDown, // Piano roll - select mode. SelectStartLeft, SelectStartRight, SelectEndLeft, SelectEndRight, SelectAll, SelectNone, // Copy, cut, paste, delete. CopyNotes, CutNotes, PasteNotes, DeleteNotes, // Quit Panel. QuitPanelYes, QuitPanelNo, // Links panel. WebsiteUrl, DiscordUrl, GitHubUrl, CloseLinksPanel, // Qwerty note input. C, CSharp, D, DSharp, E, F, FSharp, G, GSharp, A, ASharp, B, OctaveUp, OctaveDown, /// Debug. #[cfg(debug_assertions)] NotesOff, } ================================================ FILE: input/src/keys.rs ================================================ use macroquad::input::KeyCode; pub const KEYS: [KeyCode; 121] = [ KeyCode::Space, KeyCode::Apostrophe, KeyCode::Comma, KeyCode::Minus, KeyCode::Period, KeyCode::Slash, KeyCode::Key0, KeyCode::Key1, KeyCode::Key2, KeyCode::Key3, KeyCode::Key4, KeyCode::Key5, KeyCode::Key6, KeyCode::Key7, KeyCode::Key8, KeyCode::Key9, KeyCode::Semicolon, KeyCode::Equal, KeyCode::A, KeyCode::B, KeyCode::C, KeyCode::D, KeyCode::E, KeyCode::F, KeyCode::G, KeyCode::H, KeyCode::I, KeyCode::J, KeyCode::K, KeyCode::L, KeyCode::M, KeyCode::N, KeyCode::O, KeyCode::P, KeyCode::Q, KeyCode::R, KeyCode::S, KeyCode::T, KeyCode::U, KeyCode::V, KeyCode::W, KeyCode::X, KeyCode::Y, KeyCode::Z, KeyCode::LeftBracket, KeyCode::Backslash, KeyCode::RightBracket, KeyCode::GraveAccent, KeyCode::World1, KeyCode::World2, KeyCode::Escape, KeyCode::Enter, KeyCode::Tab, KeyCode::Backspace, KeyCode::Insert, KeyCode::Delete, KeyCode::Right, KeyCode::Left, KeyCode::Down, KeyCode::Up, KeyCode::PageUp, KeyCode::PageDown, KeyCode::Home, KeyCode::End, KeyCode::CapsLock, KeyCode::ScrollLock, KeyCode::NumLock, KeyCode::PrintScreen, KeyCode::Pause, KeyCode::F1, KeyCode::F2, KeyCode::F3, KeyCode::F4, KeyCode::F5, KeyCode::F6, KeyCode::F7, KeyCode::F8, KeyCode::F9, KeyCode::F10, KeyCode::F11, KeyCode::F12, KeyCode::F13, KeyCode::F14, KeyCode::F15, KeyCode::F16, KeyCode::F17, KeyCode::F18, KeyCode::F19, KeyCode::F20, KeyCode::F21, KeyCode::F22, KeyCode::F23, KeyCode::F24, KeyCode::F25, KeyCode::Kp0, KeyCode::Kp1, KeyCode::Kp2, KeyCode::Kp3, KeyCode::Kp4, KeyCode::Kp5, KeyCode::Kp6, KeyCode::Kp7, KeyCode::Kp8, KeyCode::Kp9, KeyCode::KpDecimal, KeyCode::KpDivide, KeyCode::KpMultiply, KeyCode::KpSubtract, KeyCode::KpAdd, KeyCode::KpEnter, KeyCode::KpEqual, KeyCode::LeftShift, KeyCode::LeftControl, KeyCode::LeftAlt, KeyCode::LeftSuper, KeyCode::RightShift, KeyCode::RightControl, KeyCode::RightAlt, KeyCode::RightSuper, KeyCode::Menu, KeyCode::Unknown, ]; /// The keycodes for the mods. pub(crate) const MODS: [KeyCode; 7] = [ KeyCode::LeftControl, KeyCode::RightControl, KeyCode::LeftAlt, KeyCode::RightAlt, KeyCode::LeftShift, KeyCode::RightShift, KeyCode::CapsLock, ]; pub(crate) const ALPHANUMERIC_INPUT_MODS: [KeyCode; 2] = [KeyCode::Backspace, KeyCode::CapsLock]; ================================================ FILE: input/src/lib.rs ================================================ //! This crate handles all user input. //! //! - `InputEvent` is an enum defining an event triggered by user input, e.g. a decrease in track volume. //! - `Input` maps raw qwerty keycode and raw MIDI messages (control bindings) to input events. It updates per frame, reading input and storing new events. mod debug_input_event; mod input_event; mod keys; mod midi_binding; mod midi_conn; mod note_on; mod qwerty_binding; use common::args::Args; use common::{State, MAX_NOTE, MIN_NOTE}; use debug_input_event::DebugInputEvent; use hashbrown::HashMap; use ini::Ini; pub use input_event::InputEvent; pub use keys::KEYS; use keys::{ALPHANUMERIC_INPUT_MODS, MODS}; use macroquad::input::*; use midi_binding::MidiBinding; use midi_conn::MidiConn; use note_on::NoteOn; pub use qwerty_binding::QwertyBinding; use serde_json::from_str; use std::fs::File; use std::io::Read; use std::str::FromStr; const MAX_OCTAVE: u8 = 9; /// Only these events are allowed during alphanumeric input. const ALLOWED_DURING_ALPHANUMERIC_INPUT: [InputEvent; 12] = [ InputEvent::Quit, InputEvent::AppTTS, InputEvent::StatusTTS, InputEvent::InputTTS, InputEvent::FileTTS, InputEvent::ToggleAlphanumericInput, InputEvent::UpDirectory, InputEvent::DownDirectory, InputEvent::SelectFile, InputEvent::NextPath, InputEvent::PreviousPath, InputEvent::CloseOpenFile, ]; /// Note-on events generated by a qwerty keyboard, and the index of each key on a C scale. const QWERTY_NOTE_EVENTS: [(InputEvent, u8); 12] = [ (InputEvent::G, 7), (InputEvent::FSharp, 6), (InputEvent::F, 5), (InputEvent::E, 4), (InputEvent::DSharp, 3), (InputEvent::D, 2), (InputEvent::CSharp, 1), (InputEvent::C, 0), (InputEvent::B, 11), (InputEvent::ASharp, 10), (InputEvent::A, 9), (InputEvent::GSharp, 8), ]; /// Don't allow these when typing a filename. const ILLEGAL_FILENAME_CHARACTERS: [char; 23] = [ '!', '@', '#', '$', '%', '^', '&', '*', '=', '+', '{', '}', '\\', '|', ':', '"', '\'', '<', '>', '/', '\n', '\r', '\t', ]; /// Listens for user input from qwerty and MIDI devices and records the current input state. #[derive(Default)] pub struct Input { /// Events that began on this frame (usually due to a key press or MIDI controller message). events: Vec, /// The MIDI connection. midi_conn: Option, /// Note-on MIDI messages. These will be sent immediately to the synthesizer to be played. pub note_on_messages: Vec<[u8; 3]>, /// Note-off MIDI messages. These will be sent immediately to the synthesizer. pub note_off_keys: Vec, /// Note-on events that don't have corresponding off events. note_on_events: Vec, /// Notes that were added after all note-off events are done. pub new_notes: Vec<[u8; 3]>, /// Input events generated by MIDI input. midi_events: HashMap, /// Input events generated by qwerty input. qwerty_events: HashMap, /// The octave for qwerty input. qwerty_octave: u8, /// Was backspace pressed on this frame? backspace: bool, /// Characters pressed on this frame. pub pressed_chars: Vec, /// Debug input events. debug_inputs: Vec, /// The MIDI time counter. time_counter: i16, } impl Input { pub fn new(config: &Ini, args: &Args) -> Self { // Get the audio connections. let midi_conn = MidiConn::new(); // Get qwerty events. let mut qwerty_events: HashMap = HashMap::new(); // Get the qwerty input mapping. let keyboard_input = config.section(Some("QWERTY_BINDINGS")).unwrap(); for kv in keyboard_input.iter() { let k_input = Input::parse_qwerty_binding(kv.0, kv.1); qwerty_events.insert(k_input.0, k_input.1); } // Get MIDI events. let mut midi_events: HashMap = HashMap::new(); // Get the qwerty input mapping. let midi_input = config.section(Some("MIDI_BINDINGS")).unwrap(); for kv in midi_input.iter() { let k_input = Input::parse_midi_binding(kv.0, kv.1); midi_events.insert(k_input.0, k_input.1); } let mut debug_inputs = vec![]; if let Some(events) = &args.events { match File::open(events) { Ok(mut file) => { let mut s = String::new(); file.read_to_string(&mut s).unwrap(); let lines = s.split('\n'); for line in lines { match line.trim().parse::() { Ok(e) => debug_inputs.push(DebugInputEvent::InputEvent(e)), Err(_) => line .trim() .chars() .for_each(|c| debug_inputs.push(DebugInputEvent::Alphanumeric(c))), } } } Err(error) => panic!("Failed to open file {:?}: {}", &events, error), } } Self { midi_conn, qwerty_events, midi_events, qwerty_octave: 4, debug_inputs, ..Default::default() } } /// Update the input state: /// /// 1. Clear all note and event frame data. /// 2. Check for pressed characters and add them to `self.pressed_characters. /// 3. Check all pressed keys and all qwerty bindings and register new events accordingly. /// 4. Remove some events during alphanumeric input. /// 5. Poll the MIDI connection, if any. /// /// If a MIDI connection polled: /// /// 1. Compare all polled MIDI events to MIDI bindings and register new events accordingly. /// 2. Add note messages to the list for playing notes. /// 3. Store new note-on events. /// 4. If all note-ons have had a corresponding note-off, add them to the new notes lists. pub fn update(&mut self, state: &State) { // Clear the old new notes. self.new_notes.clear(); self.note_on_messages.clear(); self.note_off_keys.clear(); // QWERTY INPUT. // Was backspace pressed? self.backspace = is_key_pressed(KeyCode::Backspace); // Get the pressed characters. self.pressed_chars.clear(); while let Some(c) = get_char_pressed() { self.pressed_chars.push(c); } // Get all pressed keys. let pressed = get_keys_pressed(); // Get all held keys. let down = get_keys_down(); // Update the qwerty key bindings. self.qwerty_events .iter_mut() .for_each(|q| q.1.update(&pressed, &down, state.input.alphanumeric_input)); // Get the key presses. let mut events: Vec = self .qwerty_events .iter() .filter(|q| q.1.pressed) .map(|q| *q.0) .collect(); // DEBUG. if cfg!(debug_assertions) && !&self.debug_inputs.is_empty() { match self.debug_inputs.remove(0) { // Push an event. DebugInputEvent::InputEvent(e) => events.push(e), // Push a char. DebugInputEvent::Alphanumeric(c) => self.pressed_chars.push(c), } } // Qwerty note input. for (_, note_index) in QWERTY_NOTE_EVENTS .iter() .filter(|(e, _)| events.contains(e)) { self.qwerty_note(*note_index, state); } // Octave up. if events.contains(&InputEvent::OctaveUp) && self.qwerty_octave < MAX_OCTAVE { self.clear_notes_on_qwerty_octave(); self.qwerty_octave += 1; } // Octave down. if events.contains(&InputEvent::OctaveDown) && self.qwerty_octave > 0 { self.clear_notes_on_qwerty_octave(); self.qwerty_octave -= 1; } // Qwerty note-off. for (_, qwerty_note_off) in QWERTY_NOTE_EVENTS.iter().filter(|(e, _)| { self.qwerty_events[e] .keys .iter() .all(|k| is_key_released(*k)) && self.qwerty_events[e] .mods .iter() .all(|k| is_key_released(*k)) }) { self.note_off_keys.push(self.get_pitch(*qwerty_note_off)); } #[cfg(debug_assertions)] self.listen_for_note_offs(); // Remove events during alphanumeric input. if state.input.alphanumeric_input { events.retain(|e| ALLOWED_DURING_ALPHANUMERIC_INPUT.contains(e)); } self.events = events; // MIDI INPUT. if let Some(midi_conn) = &mut self.midi_conn { // Poll for MIDI events. let mut midi = midi_conn.buffer.lock(); // Append MIDI events. for mde in self.midi_events.iter_mut() { if mde.1.update(&midi, self.time_counter) { self.events.push(*mde.0); } } // Increment the time counter. self.time_counter += 1; if self.time_counter >= 255 { self.time_counter = 0; } // Get note-on and note-off events. let volume = state.input.volume.get(); for midi in midi.iter() { // Note-on. if midi[0] >= 144 && midi[0] <= 159 && midi[1] > MIN_NOTE && midi[2] <= MAX_NOTE { // Set the volume. let midi = if state.input.use_volume { [midi[0], midi[1], volume] } else { *midi }; // Remember the note-on for piano roll input. if !state.input.is_playing { if state.input.armed { self.note_on_events.push(NoteOn::new(&midi)); } // Copy this note to the immediate note-on array. self.note_on_messages.push(midi); } } // Note-off. if midi[0] >= 128 && midi[0] <= 143 { self.note_off_keys.push(midi[1]); if state.input.armed && !state.input.is_playing { // Find the corresponding note. for note_on in self.note_on_events.iter_mut() { // Same key. Note-off. if note_on.note[1] == midi[1] { note_on.off = true; } } } } } // If all note-ons are off, add them to the `notes` buffer as notes. if !self.note_on_events.is_empty() && self.note_on_events.iter().all(|n| n.off) { for note_on in self.note_on_events.iter() { self.new_notes.push(note_on.note); } self.note_on_events.clear(); } // Clear the MIDI buffer. midi.clear(); } } /// Returns true if the event happened. pub fn happened(&self, event: &InputEvent) -> bool { self.events.contains(event) } /// Reads the qwerty and MIDI bindings for an event. pub fn get_bindings( &self, event: &InputEvent, ) -> (Option<&QwertyBinding>, Option<&MidiBinding>) { (self.qwerty_events.get(event), self.midi_events.get(event)) } /// Modify a string with qwerty input from this frame. Allow alphanumeric input. pub fn modify_string_abc123(&self, string: &mut String) -> bool { self.modify_string( string, &self .pressed_chars .iter() .filter(|c| Self::is_valid_char(c)) .copied() .collect::>(), ) } /// Modify a filename string with qwerty input from this frame. Allow alphanumeric input. pub fn modify_filename_abc123(&self, string: &mut String) -> bool { self.modify_string( string, &self .pressed_chars .iter() .filter(|c| Self::is_valid_char(c) && !ILLEGAL_FILENAME_CHARACTERS.contains(c)) .copied() .collect::>(), ) } /// Returns true if the user can input this character. fn is_valid_char(c: &char) -> bool { c.is_alphanumeric() || c.is_ascii_punctuation() || c.is_whitespace() } /// Modify a u64 value. pub fn modify_u64(&self, value: &mut u64) -> bool { self.modify_value(value, 0) } /// Modify a value with qwerty input from this frame. Allow numeric input. fn modify_value(&self, value: &mut T, default_value: T) -> bool where T: ToString + FromStr, { // Convert the value to a string. let mut string = value.to_string(); // Modify the string. let modified = self.modify_string( &mut string, &self .pressed_chars .iter() .filter(|c| c.is_ascii_digit()) .copied() .collect::>(), ); // Try to get a value. match T::from_str(string.as_str()) { Ok(v) => *value = v, Err(_) => *value = default_value, } modified } /// Modify a string with qwerty input from this frame. fn modify_string(&self, string: &mut String, chars: &[char]) -> bool { // Delete the last character. if self.backspace { string.pop().is_some() // Add new characters. } else if !chars.is_empty() { for ch in chars.iter() { string.push(*ch); } true } else { false } } /// Parse a qwerty binding from a key-value pair of strings (i.e. from a config file). fn parse_qwerty_binding(key: &str, value: &str) -> (InputEvent, QwertyBinding) { match key.parse::() { Ok(input_key) => (input_key, QwertyBinding::deserialize(value)), Err(error) => panic!("Invalid input key {}: {}", key, error), } } // Parse a MIDI binding from a key-value pair of strings (i.e. from a config file). fn parse_midi_binding(key: &str, value: &str) -> (InputEvent, MidiBinding) { match key.parse::() { Ok(input_key) => match from_str(value) { Ok(m) => (input_key, m), Err(error) => panic!( "Failed to deserialize {} into a MidiBinding: {}", value, error ), }, Err(error) => panic!("Invalid input key {}: {}", key, error), } } /// Push a new note from qwerty input. fn qwerty_note(&mut self, note: u8, state: &State) { if !state.input.is_playing { let note: [u8; 3] = [144, self.get_pitch(note), state.input.volume.get()]; if state.input.armed { self.new_notes.push(note); } self.note_on_messages.push(note); } } /// Converts the note index to a MIDI note value. fn get_pitch(&self, note: u8) -> u8 { (9 - self.qwerty_octave) * 12 + note } /// When a qwerty note is pressed, followed by an octave change, clear all note-on events. fn clear_notes_on_qwerty_octave(&mut self) { // Qwerty note-off. for (_, qwerty_note_off) in QWERTY_NOTE_EVENTS.iter() { self.note_off_keys.push(self.get_pitch(*qwerty_note_off)); } } #[cfg(debug_assertions)] fn listen_for_note_offs(&mut self) { if self.happened(&InputEvent::NotesOff) { self.note_off_keys .append(&mut (MIN_NOTE..MAX_NOTE).collect()); } } } ================================================ FILE: input/src/midi_binding.rs ================================================ use serde::Deserialize; /// Bindings for MIDI input. #[derive(Clone, Deserialize)] pub struct MidiBinding { /// The two bytes defining the MIDI input device. pub bytes: [u8; 2], /// An alias name for the MIDI binding. #[serde(default)] pub alias: Option, /// A value that controls the sensitivity of the events. Check for events every `nth` consecutive inputs. The sign defines positive or negative input. dt: i16, } impl MidiBinding { /// Update the event state. Returns true if the event happened. pub(crate) fn update(&mut self, buffer: &[[u8; 3]], counter: i16) -> bool { if let Some(b) = buffer .iter() .find(|b| b[0] == self.bytes[0] && b[1] == self.bytes[1]) { // Did this trigger the event? if (self.dt > 0 && b[2] == 1) || (self.dt < 0 && b[2] == 127) { counter % self.dt.abs() == 0 } else { false } } else { false } } } ================================================ FILE: input/src/midi_conn.rs ================================================ use midir::{MidiInput, MidiInputConnection, MidiInputPort}; use parking_lot::Mutex; use std::sync::Arc; /// The MIDI connection error message. const MIDI_ERROR_MESSAGE: &str = "Couldn't connect to a MIDI input device"; /// Type alias for a growable MIDI buffer. type MidiBuffer = Arc>>; /// The MIDI connection tries to open a connection to each input device. /// /// If a connection is made, the MIDI context will listen for events. pub(crate) struct MidiConn { /// The buffer of received MIDI messages since the last frame. pub(crate) buffer: MidiBuffer, /// The MIDI connections. We need this in order to keep the connection alive. _conns: Vec>, } impl MidiConn { /// Returns a new MIDI context. Returns None if we can't find any input device, and prints a helpful message. pub(crate) fn new() -> Option { // Get the indices and names of the ports. let ports: Vec<(usize, String)> = match MidiInput::new("num ports") { Ok(midi_in) => midi_in .ports() .iter() .filter(|p| midi_in.port_name(p).is_ok()) .enumerate() .map(|(i, p)| (i, midi_in.port_name(p).unwrap())) .collect(), Err(error) => { println!("{}: {}", MIDI_ERROR_MESSAGE, error); vec![] } }; if ports.is_empty() { None } else { // The buffer than can be accessed by the `Input` struct. let buffer = Arc::new(Mutex::new(Vec::new())); let mut conns = vec![]; for (index, name) in ports { // Get a new connection. if let Ok(midi_in) = MidiInput::new(&format!("{} {}", index, name)) { let port: &MidiInputPort = &midi_in.ports()[index]; // The buffer that the MIDI input device is writing to. let data = Arc::clone(&buffer); match midi_in.connect(port, &name, Self::midi_callback, data) { Ok(c) => conns.push(c), Err(error) => { println!("{}: {}", MIDI_ERROR_MESSAGE, error); continue; } } } } if conns.is_empty() { None } else { Some(Self { buffer, _conns: conns, }) } } } /// The MIDI callback function. Send the message out of the thread. fn midi_callback(_: u64, message: &[u8], sender: &mut MidiBuffer) { const LEN: usize = 3; // There are a few 2-byte MIDI messages that need to be ignored. if message.len() != LEN { return; } let mut m = [0u8; LEN]; m.copy_from_slice(message); let mut buffer = sender.lock(); buffer.push(m); } } #[cfg(test)] mod tests { use std::sync::Arc; use super::MidiConn; use midly::{live::LiveEvent, MidiMessage}; use parking_lot::Mutex; #[test] fn midi_test() { // These messages should be ready. for midi_message in [ MidiMessage::NoteOn { key: 60.into(), vel: 120.into(), }, MidiMessage::NoteOff { key: 60.into(), vel: 120.into(), }, ] .iter() .zip([144, 128]) { let message = LiveEvent::Midi { channel: 0.into(), message: midi_message.0.clone(), }; let mut buffer_conn = Arc::new(Mutex::new(Vec::new())); let mut buffer_message = Vec::new(); message.write(&mut buffer_message).unwrap(); MidiConn::midi_callback(0, &buffer_message, &mut buffer_conn); // The message was ready. let b = buffer_conn.lock(); assert_eq!(b.len(), 1); assert_eq!(b[0], [midi_message.1, 60, 120]); } // These messages should be ignored. for ignore_message in [ MidiMessage::ChannelAftertouch { vel: 5.into() }, MidiMessage::ProgramChange { program: 0.into() }, ] { let message = LiveEvent::Midi { channel: 0.into(), message: ignore_message, }; let mut buffer_conn = Arc::new(Mutex::new(Vec::new())); let mut buffer_message = Vec::new(); message.write(&mut buffer_message).unwrap(); MidiConn::midi_callback(0, &buffer_message, &mut buffer_conn); // The message was ignored. assert_eq!(buffer_conn.lock().len(), 0); } } } ================================================ FILE: input/src/note_on.rs ================================================ /// A note-on event. When `off` is true, the event is done. pub(crate) struct NoteOn { /// The note MIDI information. pub(super) note: [u8; 3], /// If true, the note-off event occurred. pub(super) off: bool, } impl NoteOn { pub(crate) fn new(note: &[u8; 3]) -> Self { Self { note: *note, off: false, } } } ================================================ FILE: input/src/qwerty_binding.rs ================================================ use crate::{ALPHANUMERIC_INPUT_MODS, MODS}; use macroquad::input::KeyCode; use serde::Deserialize; use serde_json::{from_str, Error}; use std::collections::HashSet; /// A list of qwerty keys plus mods that define a qwerty key binding. #[derive(Clone)] pub struct QwertyBinding { /// The keys that were pressed on this frame. pub keys: Vec, /// The modifiers that are being held down. pub mods: Vec, /// All mods that are *not* part of this qwerty binding. non_mods: Vec, /// Wait this many frame for a repeat event. sensitivity: u8, /// The frame of the most recent press. frame: u8, /// If true, listen to down events. repeatable: bool, /// If true, this event is pressed. pub(crate) pressed: bool, } impl QwertyBinding { /// Deserialize a serializable version of this binding from a string. pub(crate) fn deserialize(string: &str) -> Self { let q: Result = from_str(string); match q { Ok(q) => { if q.keys.iter().any(|k| keycode_from_str(k).is_none()) || q.mods.iter().any(|k| keycode_from_str(k).is_none()) { panic!("Invalid qwerty binding: {}", string) } let keys = q .keys .iter() .map(|s| keycode_from_str(s)) .filter(|s| s.is_some()) .flatten() .collect(); let mods: Vec = q .mods .iter() .map(|s| keycode_from_str(s)) .filter(|s| s.is_some()) .flatten() .collect(); let sensitivity = q.dt; let non_mods = MODS.iter().filter(|m| !mods.contains(m)).copied().collect(); let repeatable = sensitivity > 0; Self { keys, mods, non_mods, repeatable, sensitivity, frame: 0, pressed: false, } } Err(error) => panic!( "Failed to deserialize {} into a QwertyBinding: {}", string, error ), } } /// Update the state of this key binding. /// /// The keys are pressed if: /// /// - All of the `mods` are down. /// - No other mods are down. /// - Either alphanumeric input is disabled or this key is allowed in the context of alphanumeric input. /// - All of the `keys` are pressed. /// /// They keys are down if: /// /// - All of the above is true. /// - A sufficient number of frames have elapsed. /// /// Parameters: /// /// - `pressed` The keys that were pressed on this frame. /// - `down` The keys that were held down on this frame. /// - `alphanumeric` If true, we're in alphanumeric input mode, which can affect whether we can listen for certain qwerty bindings. pub(crate) fn update( &mut self, pressed: &HashSet, down: &HashSet, alphanumeric: bool, ) { self.pressed = false; // Mods. if self.mods.iter().all(|m| down.contains(m)) && !self.non_mods.iter().any(|m| down.contains(m)) { // Pressed. if self.keys.iter().all(|k| { (!alphanumeric || !ALPHANUMERIC_INPUT_MODS.contains(k)) && pressed.contains(k) }) { self.pressed = true; self.frame = 0; } // Down. if self.repeatable && self.keys.iter().all(|k| { (!alphanumeric || !ALPHANUMERIC_INPUT_MODS.contains(k)) && down.contains(k) }) { if self.frame >= self.sensitivity { self.frame = 0; self.pressed = true; } else if self.frame == u8::MAX { self.frame = 0; } else { self.frame += 1; } } } } } /// A serializable version of a qwerty binding. #[derive(Deserialize)] struct SerializableQwertyBinding { /// The keys that were pressed on this frame. keys: Vec, /// The modifiers that are being held down as strings. #[serde(default)] mods: Vec, /// Wait this many frame for a repeat event. #[serde(default)] dt: u8, } fn keycode_from_str(s: &str) -> Option { match s { "a" => Some(KeyCode::A), "A" => Some(KeyCode::A), "b" => Some(KeyCode::B), "B" => Some(KeyCode::B), "c" => Some(KeyCode::C), "C" => Some(KeyCode::C), "d" => Some(KeyCode::D), "D" => Some(KeyCode::D), "e" => Some(KeyCode::E), "E" => Some(KeyCode::E), "f" => Some(KeyCode::F), "F" => Some(KeyCode::F), "g" => Some(KeyCode::G), "G" => Some(KeyCode::G), "h" => Some(KeyCode::H), "H" => Some(KeyCode::H), "i" => Some(KeyCode::I), "I" => Some(KeyCode::I), "j" => Some(KeyCode::J), "J" => Some(KeyCode::J), "k" => Some(KeyCode::K), "K" => Some(KeyCode::K), "l" => Some(KeyCode::L), "L" => Some(KeyCode::L), "m" => Some(KeyCode::M), "M" => Some(KeyCode::M), "n" => Some(KeyCode::N), "N" => Some(KeyCode::N), "o" => Some(KeyCode::O), "O" => Some(KeyCode::O), "p" => Some(KeyCode::P), "P" => Some(KeyCode::P), "q" => Some(KeyCode::Q), "Q" => Some(KeyCode::Q), "r" => Some(KeyCode::R), "R" => Some(KeyCode::R), "s" => Some(KeyCode::S), "S" => Some(KeyCode::S), "t" => Some(KeyCode::T), "T" => Some(KeyCode::T), "u" => Some(KeyCode::U), "U" => Some(KeyCode::U), "v" => Some(KeyCode::V), "V" => Some(KeyCode::V), "w" => Some(KeyCode::W), "W" => Some(KeyCode::W), "x" => Some(KeyCode::X), "X" => Some(KeyCode::X), "y" => Some(KeyCode::Y), "Y" => Some(KeyCode::Y), "z" => Some(KeyCode::Z), "Z" => Some(KeyCode::Z), "0" => Some(KeyCode::Key0), "Kp0" => Some(KeyCode::Kp0), "1" => Some(KeyCode::Key1), "F1" => Some(KeyCode::F1), "Kp1" => Some(KeyCode::Kp1), "2" => Some(KeyCode::Key2), "F2" => Some(KeyCode::F2), "Kp2" => Some(KeyCode::Kp2), "3" => Some(KeyCode::Key3), "F3" => Some(KeyCode::F3), "Kp3" => Some(KeyCode::Kp3), "4" => Some(KeyCode::Key4), "F4" => Some(KeyCode::F4), "Kp4" => Some(KeyCode::Kp4), "5" => Some(KeyCode::Key5), "F5" => Some(KeyCode::F5), "Kp5" => Some(KeyCode::Kp5), "6" => Some(KeyCode::Key6), "F6" => Some(KeyCode::F6), "Kp6" => Some(KeyCode::Kp6), "7" => Some(KeyCode::Key7), "F7" => Some(KeyCode::F7), "Kp7" => Some(KeyCode::Kp7), "8" => Some(KeyCode::Key8), "F8" => Some(KeyCode::F8), "Kp8" => Some(KeyCode::Kp8), "9" => Some(KeyCode::Key9), "F9" => Some(KeyCode::F9), "Kp9" => Some(KeyCode::Kp9), "F10" => Some(KeyCode::F10), "F11" => Some(KeyCode::F11), "F12" => Some(KeyCode::F12), "`" => Some(KeyCode::GraveAccent), "-" => Some(KeyCode::Minus), "=" => Some(KeyCode::Equal), "[" => Some(KeyCode::LeftBracket), "]" => Some(KeyCode::RightBracket), "Backslash" => Some(KeyCode::Backslash), ";" => Some(KeyCode::Semicolon), "'" => Some(KeyCode::Apostrophe), "," => Some(KeyCode::Comma), "." => Some(KeyCode::Period), "/" => Some(KeyCode::Slash), "Space" => Some(KeyCode::Space), "Escape" => Some(KeyCode::Escape), "Tab" => Some(KeyCode::Tab), "Backspace" => Some(KeyCode::Backspace), "Insert" => Some(KeyCode::Insert), "Delete" => Some(KeyCode::Delete), "Right" => Some(KeyCode::Right), "Left" => Some(KeyCode::Left), "Up" => Some(KeyCode::Up), "Down" => Some(KeyCode::Down), "PageUp" => Some(KeyCode::PageUp), "PageDown" => Some(KeyCode::PageDown), "Home" => Some(KeyCode::Home), "End" => Some(KeyCode::End), "CapsLock" => Some(KeyCode::CapsLock), "ScrollLock" => Some(KeyCode::ScrollLock), "NumLock" => Some(KeyCode::NumLock), "PrintScreen" => Some(KeyCode::PrintScreen), "Pause" => Some(KeyCode::Pause), "KpDecimal" => Some(KeyCode::KpDecimal), "KpDivide" => Some(KeyCode::KpDivide), "KpMultiply" => Some(KeyCode::KpMultiply), "KpSubtract" => Some(KeyCode::KpSubtract), "KpAdd" => Some(KeyCode::KpAdd), "KpEnter" => Some(KeyCode::KpEnter), "KpEqual" => Some(KeyCode::KpEqual), "LeftShift" => Some(KeyCode::LeftShift), "LeftControl" => Some(KeyCode::LeftControl), "LeftAlt" => Some(KeyCode::LeftAlt), "LeftSuper" => Some(KeyCode::LeftSuper), "RightShift" => Some(KeyCode::RightShift), "RightControl" => Some(KeyCode::RightControl), "RightAlt" => Some(KeyCode::RightAlt), "RightSuper" => Some(KeyCode::RightSuper), "Return" => Some(KeyCode::Enter), other => { dbg!("Warning! Invalid key code: {}", other); None } } } ================================================ FILE: io/Cargo.toml ================================================ [package] name = "io" version.workspace = true authors.workspace = true description.workspace = true documentation.workspace = true edition.workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } edit = { workspace = true } hashbrown = { workspace = true } rust-ini = { workspace = true } webbrowser = { workspace = true } midly = { workspace = true } regex = { workspace = true } [dependencies.audio] path = "../audio" [dependencies.common] path = "../common" [dependencies.input] path = "../input" [dependencies.text] path = "../text" ================================================ FILE: io/src/abc123.rs ================================================ use crate::panel::*; use audio::exporter::Exporter; use common::U64orF32; /// A type that can be modified by user alphanumeric input. pub(crate) trait AlphanumericModifiable { /// Returns true if the value is "valid" i.e. we don't need to set it to a default. fn is_valid(&self) -> bool; /// Modify the value. fn modify(&mut self, input: &Input) -> bool; } impl AlphanumericModifiable for String { fn is_valid(&self) -> bool { !self.is_empty() } fn modify(&mut self, input: &Input) -> bool { input.modify_string_abc123(self) } } impl AlphanumericModifiable for Option { fn is_valid(&self) -> bool { self.is_some() } fn modify(&mut self, input: &Input) -> bool { let mut value = match self { Some(string) => string.clone(), None => String::new(), }; if input.modify_string_abc123(&mut value) { *self = if value.is_empty() { None } else { Some(value) }; true } else { false } } } impl AlphanumericModifiable for u64 { fn is_valid(&self) -> bool { *self > 0 } fn modify(&mut self, input: &Input) -> bool { input.modify_u64(self) } } impl AlphanumericModifiable for U64orF32 { fn is_valid(&self) -> bool { self.get_u() > 0 } fn modify(&mut self, input: &Input) -> bool { let mut u = self.get_u(); if input.modify_u64(&mut u) { self.set(u); true } else { false } } } /// Handle alphanumeric input for the exporter. /// /// - `f` A closure to modify a string, e.g. `|e| &mut e.metadata.title`. /// - `input` The input state. This is used to check if alphanumeric input is allowed. /// - `exporter` The exporter state. pub(crate) fn update_exporter(mut f: F, input: &Input, exporter: &mut Exporter) -> bool where F: FnMut(&mut Exporter) -> &mut T, T: Clone + AlphanumericModifiable, { let value = f(exporter); value.modify(input) } /// Do something with an exporter when alphanumeric input is disabled. /// /// - `f` A closure to modify a string, e.g. `|e| &mut e.metadata.title`. /// - `input` The input state. This is used to check if alphanumeric input is allowed. /// - `exporter` The exporter state. /// - `default_value` If the current value of `f` isn't valid, set it to this. pub(crate) fn on_disable_exporter(mut f: F, exporter: &mut Exporter, default_value: T) where F: FnMut(&mut Exporter) -> &mut T, T: Clone + AlphanumericModifiable, { let v = f(exporter); // If the value is empty, set a default value. if !v.is_valid() { *v = default_value; } } /// Handle alphanumeric input for the app state. /// /// /// - `f` A closure to modify a string, e.g. `|e| &mut e.metadata.title`. /// - `state` The app state. /// - `input` The input state. This is used to check if alphanumeric input is allowed. /// /// Returns a snapshot. pub(crate) fn update_state(mut f: F, state: &mut State, input: &Input) -> Option where F: FnMut(&mut State) -> &mut T, T: Clone + AlphanumericModifiable, { let mut value = f(state).clone(); if value.modify(input) { Some(Snapshot::from_state_value(f, value, state)) } else { None } } pub(crate) fn on_disable_state(mut f: F, state: &mut State, default_value: T) where F: FnMut(&mut State) -> &mut T, T: Clone + AlphanumericModifiable, { let v = f(state); // Don't allow an empty value. if !v.is_valid() { *v = default_value; } } ================================================ FILE: io/src/export_panel.rs ================================================ use crate::panel::*; use audio::export::ExportState; use common::PanelType; /// Are we done yet? #[derive(Default)] pub(crate) struct ExportPanel { /// The previous panels. panels: Vec, /// The previous focus. focus: usize, } impl ExportPanel { /// Enable this panel. pub fn enable(&mut self, state: &mut State, panels: &[PanelType], focus: usize) { self.panels = panels.to_vec(); self.focus = focus; state.panels = vec![PanelType::ExportState]; state.focus.set(0); } } impl Panel for ExportPanel { fn update( &mut self, state: &mut State, conn: &mut Conn, _: &Input, _: &mut TTS, _: &Text, _: &mut PathsState, ) -> Option { // We're done. let export_state = conn.export_state.lock(); if *export_state == ExportState::NotExporting { state.panels.clone_from(&self.panels); state.focus.set(self.focus); } None } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { false } } ================================================ FILE: io/src/export_settings_panel.rs ================================================ use crate::abc123::{on_disable_exporter, update_exporter}; use crate::panel::*; use audio::export::{ExportSetting, ExportType, MultiFileSuffix}; use audio::exporter::{Exporter, MP3_BIT_RATES}; use audio::Conn; use common::{IndexedValues, U64orF32}; use serde::de::DeserializeOwned; use serde::Serialize; /// All possible audio framerates. const FRAMERATES: [u64; 3] = [22050, 44100, 48000]; /// Set the values of export settings. #[derive(Default)] pub(crate) struct ExportSettingsPanel { tooltips: Tooltips, } impl ExportSettingsPanel { /// Returns the text-to-speech status string for an alphanumeric field. /// /// - `tooltips` The tooltips handler. /// - `if_true` The text key to use if alphanumeric input is enabled. /// - `if_false` The text key to use if alphanumeric input isn't enabled. /// - `value` The value string, if any. If none, a default string will be used. /// - `state` The app state. /// - `input` The input state. /// - `text` The text state. fn get_status_abc123_tts( tooltips: &mut Tooltips, if_true: &str, if_false: &str, value: &Option, state: &State, input: &Input, text: &Text, ) -> TtsString { let n = text.get_ref("NONE"); let value = match value { Some(value) => value.as_str(), None => n, }; if state.input.alphanumeric_input { TtsString::from(text.get_with_values(if_true, &[value])) } else { tooltips.get_tooltip_with_values( if_false, &[InputEvent::ToggleAlphanumericInput], &[value], input, text, ) } } /// Returns the text-to-speech status string for an boolean field. /// /// - `tooltips` The tooltips handler. /// - `if_true` The text key to use if the boolean is true. /// - `if_false` The text key to use if the boolean is false. /// - `value` The value. /// - `input` The input state. /// - `text` The text state. fn get_status_bool_tts( tooltips: &mut Tooltips, if_true: &str, if_false: &str, value: bool, input: &Input, text: &Text, ) -> TtsString { tooltips.get_tooltip( if value { if_true } else { if_false }, &[InputEvent::ToggleExportSettingBoolean], input, text, ) } /// Returns the text-to-speech input string for scrolling. /// /// - `tooltips` The tooltips handler. /// - `input` The input state. /// - `text` The text state. fn get_input_scroll_tts(tooltips: &mut Tooltips, input: &Input, text: &Text) -> TtsString { tooltips.get_tooltip( "EXPORT_SETTINGS_PANEL_INPUT_TTS_SCROLL", &[ InputEvent::PreviousExportSetting, InputEvent::NextExportSetting, ], input, text, ) } /// Returns the text-to-speech input string for an alphanumeric field. /// /// - `tooltips` The tooltips handler. /// - `if_true` The text key to use if alphanumeric input is enabled. /// - `if_false` The text key to use if alphanumeric input isn't enabled. /// - `state` The app state. /// - `input` The input state. /// - `text` The text state. fn get_input_abc123_tts( tooltips: &mut Tooltips, if_true: &str, if_false: &str, state: &State, input: &Input, text: &Text, ) -> Vec { if state.input.alphanumeric_input { vec![tooltips .get_tooltip(if_true, &[InputEvent::ToggleAlphanumericInput], input, text) .clone()] } else { vec![ tooltips .get_tooltip( if_false, &[InputEvent::ToggleAlphanumericInput], input, text, ) .clone(), Self::get_input_scroll_tts(tooltips, input, text), ] } } /// Returns the text-to-speech input string cycling a field's value up or down. /// /// - `tooltips` The tooltips handler. /// - `key` The text key. /// - `input` The input state. /// - `text` The text state. fn get_input_lr_tts( tooltips: &mut Tooltips, key: &str, input: &Input, text: &Text, ) -> Vec { vec![ tooltips.get_tooltip( key, &[ InputEvent::PreviousExportSettingValue, InputEvent::NextExportSettingValue, ], input, text, ), Self::get_input_scroll_tts(tooltips, input, text), ] } /// Set the export framerate. /// /// - `exporter` The exporter. This will have its framerate set. /// - `up` Increment or decrement along the `FRAMERATES` array. fn set_framerate(exporter: &mut Exporter, up: bool) { let i = FRAMERATES .iter() .position(|f| *f == exporter.framerate.get_u()) .unwrap(); let mut index = Index::new(i, FRAMERATES.len()); index.increment(up); exporter.framerate = U64orF32::from(FRAMERATES[index.get()]); } /// Set the track number. /// /// - `exporter` The exporter. This will have its framerate set. /// - `up` Add or subtract the frame number. fn set_track_number(exporter: &mut Exporter, up: bool) { exporter.metadata.track_number = if up { match &exporter.metadata.track_number { Some(n) => Some(n + 1), None => Some(0), } } else { match &exporter.metadata.track_number { Some(n) => n.checked_sub(1), None => None, } }; } /// Set an `Index` field within an `Exporter`. /// /// - `f` A closure that returns a mutable reference to an `Index`. /// - `input` The input state. /// - `exporter` The exporter. fn set_index(mut f: F, input: &Input, exporter: &mut Exporter) where F: FnMut(&mut Exporter) -> &mut Index, { if input.happened(&InputEvent::PreviousExportSettingValue) { f(exporter).increment(false); } else if input.happened(&InputEvent::NextExportSettingValue) { f(exporter).increment(true); } } /// Update settings for a given export type. /// /// - `f` A closure that returns a mutable reference to an `IndexValues` of export settings (corresponding to the export type). /// - `tooltips` The tooltips handler. /// - `state` The app state. /// - `input` The input state. /// - `tts` Text-to-speech. /// - `text` The text state. /// - `exporter` The exporter. This will have its framerate set. fn update_settings( mut f: F, state: &mut State, tooltips: &mut Tooltips, input: &Input, tts: &mut TTS, text: &Text, exporter: &mut Exporter, ) -> Option where F: FnMut(&mut Exporter) -> &mut IndexedValues, [ExportSetting; N]: Serialize + DeserializeOwned, { // Status TTS. if input.happened(&InputEvent::StatusTTS) { let s = match &f(exporter).get() { ExportSetting::Framerate => { TtsString::from(text.get("EXPORT_SETTINGS_PANEL_STATUS_TTS_FRAMERATE")) } ExportSetting::Title => Self::get_status_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_TITLE_ABC123", "EXPORT_SETTINGS_PANEL_STATUS_TTS_TITLE_NO_ABC123", &Some(exporter.metadata.title.clone()), state, input, text, ), ExportSetting::Artist => Self::get_status_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_ARTIST", "EXPORT_SETTINGS_PANEL_STATUS_TTS_ARTIST_NO_ABC123", &exporter.metadata.artist, state, input, text, ), ExportSetting::Copyright => Self::get_status_bool_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_COPYRIGHT_ENABLED", "EXPORT_SETTINGS_PANEL_STATUS_TTS_COPYRIGHT_DISABLED", exporter.copyright, input, text, ), ExportSetting::Album => Self::get_status_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_ALBUM_ABC123", "EXPORT_SETTINGS_PANEL_STATUS_TTS_ALBUM_NO_ABC123", &exporter.metadata.album, state, input, text, ), ExportSetting::Genre => Self::get_status_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_GENRE_ABC123", "EXPORT_SETTINGS_PANEL_STATUS_TTS_GENRE_NO_ABC123", &exporter.metadata.genre, state, input, text, ), ExportSetting::Comment => Self::get_status_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_COMMENT_ABC123", "EXPORT_SETTINGS_PANEL_STATUS_TTS_COMMENT_NO_ABC123", &exporter.metadata.comment, state, input, text, ), ExportSetting::Mp3BitRate => TtsString::from( text.get_with_values( "EXPORT_SETTINGS_PANEL_STATUS_TTS_BIT_RATE", &[ &((MP3_BIT_RATES[exporter.mp3_bit_rate.get()] as u16) as u32 * 1000) .to_string(), ], ), ), ExportSetting::Mp3Quality => TtsString::from(text.get_with_values( "EXPORT_SETTINGS_PANEL_STATUS_TTS_QUALITY", &[&exporter.mp3_quality.get().to_string()], )), ExportSetting::OggQuality => TtsString::from(text.get_with_values( "EXPORT_SETTINGS_PANEL_STATUS_TTS_QUALITY", &[&exporter.ogg_quality.get().to_string()], )), ExportSetting::TrackNumber => TtsString::from(text.get_with_values( "EXPORT_SETTINGS_PANEL_STATUS_TTS_TRACK_NUMBER", &[&match exporter.metadata.track_number { Some(track_number) => track_number.to_string(), None => text.get("NONE"), }], )), ExportSetting::MultiFile => Self::get_status_bool_tts( tooltips, "EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_ENABLED", "EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_DISABLED", exporter.multi_file, input, text, ), ExportSetting::MultiFileSuffix => { let key = match &exporter.multi_file_suffix.get() { MultiFileSuffix::Preset => { "EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_PRESET" } MultiFileSuffix::Channel => { "EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_CHANNEL" } MultiFileSuffix::ChannelAndPreset => { "EXPORT_SETTINGS_PANEL_STATUS_TTS_MULTI_FILE_CHANNEL_AND_PRESET" } }; TtsString::from(text.get_ref(key)) } }; tts.enqueue(s); } // Input TTS. else if input.happened(&InputEvent::InputTTS) { let s = match &f(exporter).get() { ExportSetting::Framerate => Self::get_input_lr_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_FRAMERATE", input, text, ), ExportSetting::Title => Self::get_input_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_TITLE_ABC123", "EXPORT_SETTINGS_PANEL_INPUT_TTS_TITLE_NO_ABC123", state, input, text, ), ExportSetting::Artist => Self::get_input_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_ARTIST_ABC123", "EXPORT_SETTINGS_PANEL_INPUT_TTS_ARTIST_NO_ABC123", state, input, text, ), ExportSetting::Copyright => vec![ tooltips .get_tooltip( "EXPORT_SETTINGS_PANEL_INPUT_TTS_COPYRIGHT", &[InputEvent::ToggleExportSettingBoolean], input, text, ) .clone(), Self::get_input_scroll_tts(tooltips, input, text), ], ExportSetting::Album => Self::get_input_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_ALBUM_ABC123", "EXPORT_SETTINGS_PANEL_INPUT_TTS_ALBUM_NO_ABC123", state, input, text, ), ExportSetting::Genre => Self::get_input_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_GENRE_ABC123", "EXPORT_SETTINGS_PANEL_INPUT_TTS_GENRE_NO_ABC123", state, input, text, ), ExportSetting::Comment => Self::get_input_abc123_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_COMMENT_ABC123", "EXPORT_SETTINGS_PANEL_INPUT_TTS_COMMENT_NO_ABC123", state, input, text, ), ExportSetting::TrackNumber => Self::get_input_lr_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_TRACK_NUMBER", input, text, ), ExportSetting::Mp3BitRate => Self::get_input_lr_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_MP3_BIT_RATE", input, text, ), ExportSetting::Mp3Quality | ExportSetting::OggQuality => Self::get_input_lr_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_QUALITY", input, text, ), ExportSetting::MultiFile => vec![ tooltips .get_tooltip( "EXPORT_SETTINGS_PANEL_INPUT_TTS_MULTI_FILE", &[InputEvent::ToggleExportSettingBoolean], input, text, ) .clone(), Self::get_input_scroll_tts(tooltips, input, text), ], ExportSetting::MultiFileSuffix => Self::get_input_lr_tts( tooltips, "EXPORT_SETTINGS_PANEL_INPUT_TTS_MULTI_FILE_SUFFIX", input, text, ), }; tts.enqueue(s); } // Previous setting. else if input.happened(&InputEvent::PreviousExportSetting) { let s = f(exporter); s.index.increment(false); } // Next setting. else if input.happened(&InputEvent::NextExportSetting) { let s = f(exporter); s.index.increment(true); } else { match &f(exporter).get() { // Framerate. ExportSetting::Framerate => { if input.happened(&InputEvent::PreviousExportSettingValue) { Self::set_framerate(exporter, false); } else if input.happened(&InputEvent::NextExportSettingValue) { Self::set_framerate(exporter, true); } } ExportSetting::Copyright => { if input.happened(&InputEvent::ToggleExportSettingBoolean) { exporter.copyright = !exporter.copyright; } } ExportSetting::TrackNumber => { if input.happened(&InputEvent::PreviousExportSettingValue) { Self::set_track_number(exporter, false); } else if input.happened(&InputEvent::NextExportSettingValue) { Self::set_track_number(exporter, true); } } ExportSetting::Mp3BitRate => { Self::set_index(|e| &mut e.mp3_bit_rate, input, exporter); } ExportSetting::Mp3Quality => { Self::set_index(|e| &mut e.mp3_quality, input, exporter); } ExportSetting::OggQuality => { Self::set_index(|e| &mut e.ogg_quality, input, exporter); } ExportSetting::MultiFile => { if input.happened(&InputEvent::ToggleExportSettingBoolean) { exporter.multi_file = !exporter.multi_file; } } ExportSetting::MultiFileSuffix => { Self::set_index( |e: &mut Exporter| &mut e.multi_file_suffix.index, input, exporter, ); } _ => (), } } None } /// Update settings for a given export type during alphanumeric input. /// /// - `f` A closure that returns a mutable reference to an `IndexValues` of export settings (corresponding to the export type). /// - `input` The input state. /// - `exporter` The exporter. This will have its framerate set. fn update_settings_abc123( mut f: F, input: &Input, exporter: &mut Exporter, ) -> bool where F: FnMut(&mut Exporter) -> &mut IndexedValues, [ExportSetting; N]: Serialize + DeserializeOwned, { match &f(exporter).get() { ExportSetting::Title => update_exporter(|e| &mut e.metadata.title, input, exporter), ExportSetting::Artist => update_exporter(|e| &mut e.metadata.artist, input, exporter), ExportSetting::Album => update_exporter(|e| &mut e.metadata.album, input, exporter), ExportSetting::Genre => update_exporter(|e| &mut e.metadata.genre, input, exporter), ExportSetting::Comment => update_exporter(|e| &mut e.metadata.comment, input, exporter), _ => false, } } /// Do something when alphanumeric input is disabled. /// /// - `f` A closure that returns a mutable reference to an `IndexValues` of export settings (corresponding to the export type). /// - `exporter` The exporter. This will have its framerate set. fn disable_abc123(mut f: F, exporter: &mut Exporter) where F: FnMut(&mut Exporter) -> &mut IndexedValues, [ExportSetting; N]: Serialize + DeserializeOwned, { match &f(exporter).get() { ExportSetting::Title => { on_disable_exporter(|e| &mut e.metadata.title, exporter, "My Music".to_string()) } ExportSetting::Artist => { on_disable_exporter(|e| &mut e.metadata.artist, exporter, None) } ExportSetting::Album => on_disable_exporter(|e| &mut e.metadata.album, exporter, None), ExportSetting::Genre => on_disable_exporter(|e| &mut e.metadata.genre, exporter, None), ExportSetting::Comment => { on_disable_exporter(|e| &mut e.metadata.comment, exporter, None) } _ => (), } } /// Returns true if we can toggle alphanumeric input. /// /// - `f` A closure that returns a mutable reference to an `IndexValues` of export settings (corresponding to the export type). /// - `exporter` The exporter. This will have its framerate set. fn allow_abc123(f: F, exporter: &Exporter) -> bool where F: Fn(&Exporter) -> &IndexedValues, [ExportSetting; N]: Serialize + DeserializeOwned, { matches!( &f(exporter).get(), ExportSetting::Title | ExportSetting::Artist | ExportSetting::Album | ExportSetting::Genre | ExportSetting::Comment, ) } } impl Panel for ExportSettingsPanel { fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, _: &mut PathsState, ) -> Option { // Close this. if input.happened(&InputEvent::CloseOpenFile) { return Some(Snapshot::from_io_commands(vec![IOCommand::CloseOpenFile])); } let export_type = conn.exporter.export_type.get(); match export_type { ExportType::Mid => Self::update_settings( |e| &mut e.mid_settings, state, &mut self.tooltips, input, tts, text, &mut conn.exporter, ), ExportType::MP3 => Self::update_settings( |e| &mut e.mp3_settings, state, &mut self.tooltips, input, tts, text, &mut conn.exporter, ), ExportType::Ogg => Self::update_settings( |e| &mut e.ogg_settings, state, &mut self.tooltips, input, tts, text, &mut conn.exporter, ), ExportType::Flac => Self::update_settings( |e| &mut e.flac_settings, state, &mut self.tooltips, input, tts, text, &mut conn.exporter, ), ExportType::Wav => Self::update_settings( |e| &mut e.wav_settings, state, &mut self.tooltips, input, tts, text, &mut conn.exporter, ), } } fn update_abc123( &mut self, _: &mut State, input: &Input, conn: &mut Conn, ) -> (Option, bool) { let updated = match conn.exporter.export_type.get() { ExportType::Mid => { Self::update_settings_abc123(|e| &mut e.mid_settings, input, &mut conn.exporter) } ExportType::MP3 => { Self::update_settings_abc123(|e| &mut e.mp3_settings, input, &mut conn.exporter) } ExportType::Ogg => { Self::update_settings_abc123(|e| &mut e.ogg_settings, input, &mut conn.exporter) } ExportType::Flac => { Self::update_settings_abc123(|e| &mut e.flac_settings, input, &mut conn.exporter) } ExportType::Wav => { Self::update_settings_abc123(|e| &mut e.wav_settings, input, &mut conn.exporter) } }; (None, updated) } fn on_disable_abc123(&mut self, _: &mut State, conn: &mut Conn) { match conn.exporter.export_type.get() { ExportType::Mid => Self::disable_abc123(|e| &mut e.mid_settings, &mut conn.exporter), ExportType::MP3 => Self::disable_abc123(|e| &mut e.mp3_settings, &mut conn.exporter), ExportType::Ogg => Self::disable_abc123(|e| &mut e.ogg_settings, &mut conn.exporter), ExportType::Wav => Self::disable_abc123(|e| &mut e.wav_settings, &mut conn.exporter), ExportType::Flac => Self::disable_abc123(|e| &mut e.flac_settings, &mut conn.exporter), }; } fn allow_alphanumeric_input(&self, _: &State, conn: &Conn) -> bool { match conn.exporter.export_type.get() { ExportType::Mid => Self::allow_abc123(|e| &e.mid_settings, &conn.exporter), ExportType::MP3 => Self::allow_abc123(|e| &e.mp3_settings, &conn.exporter), ExportType::Ogg => Self::allow_abc123(|e| &e.ogg_settings, &conn.exporter), ExportType::Wav => Self::allow_abc123(|e| &e.wav_settings, &conn.exporter), ExportType::Flac => Self::allow_abc123(|e| &e.flac_settings, &conn.exporter), } } fn allow_play_music(&self) -> bool { false } } ================================================ FILE: io/src/import_midi.rs ================================================ use audio::{Command, Conn}; use common::{MidiTrack, Music, Note, Paths, State, U64orF32}; use midly::{MetaMessage, MidiMessage, Smf, Timing, TrackEventKind}; use std::fs::read; use std::path::Path; use std::str::from_utf8; pub(crate) fn import(path: &Path, state: &mut State, conn: &mut Conn) { let bytes = read(path).unwrap(); let smf = Smf::parse(&bytes).unwrap(); let timing = match smf.header.timing { Timing::Metrical(v) => v.as_int() as f32, Timing::Timecode(fps, t) => fps.as_f32() / t as f32, }; let mut music = Music::default(); let paths = Paths::get(); for (i, track_events) in smf.tracks.iter().enumerate() { // Create a new track. let c = i as u8; let mut track = MidiTrack::new(c); // Load the default SoundFont. conn.do_commands(&[Command::LoadSoundFont { channel: c, path: paths.default_soundfont_path.clone(), }]); let mut time = 0; // A list of note-on events that need corresponding note-off messages. let mut note_ons = vec![]; // Iterate through this track's events. for track_event in track_events { time += track_event.delta.as_int() as u64; match track_event.kind { TrackEventKind::Escape(_) | TrackEventKind::SysEx(_) => (), TrackEventKind::Meta(message) => match message { MetaMessage::Copyright(data) => { if let Ok(copyright) = from_utf8(data) { conn.exporter.copyright = true; conn.exporter.metadata.artist = Some(copyright.to_string()); } } MetaMessage::Tempo(data) => { state.time.bpm = U64orF32::from(60000000 / data.as_int() as u64); } MetaMessage::TimeSignature(n, d, c, b) => { // This SHOULD always be correct. If not, there might be an error with how n is used. let q = (n as f32 / 2f32.powf(d as f32)) * (timing / (c * b) as f32); state.time.bpm = U64orF32::from(state.time.bpm.get_f() * q); } MetaMessage::Text(data) => { if let Ok(text) = from_utf8(data) { conn.exporter.metadata.comment = Some(text.to_string()) } } _ => (), }, TrackEventKind::Midi { channel: _, message, } => { match message { MidiMessage::NoteOn { key, vel } => { note_ons.push((key, vel, time)); } MidiMessage::NoteOff { key, vel } => { let (index, note_on) = note_ons .iter() .enumerate() .find(|(_, n)| n.0 == key) .unwrap(); // Add a note. track.notes.push(Note { note: note_on.0.as_int(), velocity: u8::max(vel.as_int(), note_on.1.as_int()), start: note_on.2, end: time, }); // Remove the note-on event. note_ons.remove(index); } // Set the preset. MidiMessage::ProgramChange { program } => { conn.do_commands(&[Command::SetProgram { channel: track.channel, path: paths.default_soundfont_path.clone(), bank_index: conn .state .programs .get(&track.channel) .unwrap() .bank_index, preset_index: program.as_int() as usize, }]); } _ => (), } } } } music.midi_tracks.push(track); } // Remove empty tracks. music.midi_tracks.retain(|t| !t.notes.is_empty()); // Select the first track. if !music.midi_tracks.is_empty() { music.selected = Some(0); } state.music = music; } ================================================ FILE: io/src/io_command.rs ================================================ use common::open_file::OpenFileType; /// Commands for the IO struct. #[derive(Clone)] pub(crate) enum IOCommand { /// Enable the open-file panel. EnableOpenFile(OpenFileType), /// Begin to export. Export, /// Close the open-file panel. CloseOpenFile, /// Quit the application. Quit, } pub(crate) type IOCommands = Option>; ================================================ FILE: io/src/lib.rs ================================================ //! This crate handles essentially all of Cacophony's functionality except the rendering (see the `render` crate). //! //! The only public struct is `IO`. //! //! Per frame, `IO` listens for user input via an `Input` (see the `input` crate), and then does any of the following: //! //! - Update `State` (see the `common` crate), for example add a new track. //! - Update `Conn` (see the `audio` crate), for example to play notes. //! - Send an internal `IOCommand` to itself. //! - Play text-to-speech audio (see the `text` crate). //! //! Certain operations will create a copy of the current `State` which will be added to an undo stack. //! Undoing an action reverts the app to that state, pops it from the undo stack, and pushes it to the redo stack. //! //! `IO` divides input listening into discrete panels, e.g. the music panel and the tracks panel. //! Each panel implements the `Panel` trait. use audio::export::ExportState; use audio::play_state::PlayState; use audio::Conn; use common::{InputState, Music, PanelType, Paths, PathsState, SelectMode, State}; use edit::edit_file; use hashbrown::HashMap; use ini::Ini; use input::{Input, InputEvent}; use std::path::Path; use text::{Enqueable, Text, Tooltips, TtsString, TTS}; mod export_panel; mod import_midi; mod io_command; mod music_panel; mod panel; mod piano_roll; mod save; mod snapshot; mod tracks_panel; use io_command::IOCommand; use io_command::IOCommands; use music_panel::MusicPanel; mod open_file_panel; use common::open_file::{FileAndDirectory, OpenFileType}; use export_panel::ExportPanel; use export_settings_panel::ExportSettingsPanel; use open_file_panel::OpenFilePanel; use panel::Panel; use piano_roll::PianoRollPanel; use save::Save; use snapshot::Snapshot; use tracks_panel::TracksPanel; mod abc123; mod export_settings_panel; mod quit_panel; use quit_panel::QuitPanel; mod links_panel; mod popup; use links_panel::LinksPanel; /// The maximum size of the undo stack. const MAX_UNDOS: usize = 100; /// Parse user input and apply it to the application's various states as needed: /// /// - Play ad-hoc notes. /// - Modify the `State` and push the old version to the undo stack. /// - Modify the `PathsState`. /// - Modify the `Conn`. pub struct IO { /// A stack of snapshots that can be popped to undo an action. undo: Vec, /// A stack of snapshots that can be popped to redo an action. redo: Vec, /// Top-level text-to-speech lookups. tts: HashMap>, /// The music panel. music_panel: MusicPanel, /// The tracks panel. tracks_panel: TracksPanel, /// The open-file panel. open_file_panel: OpenFilePanel, /// The piano roll panel. piano_roll_panel: PianoRollPanel, /// The export panel. export_panel: ExportPanel, /// The export settings panel. export_settings_panel: ExportSettingsPanel, /// The quit panel. quit_panel: QuitPanel, /// The links panel. links_panel: LinksPanel, /// The active panels prior to exporting audio. pre_export_panels: Vec, /// The index of the focused panel prior to exporting audio. pre_export_focus: usize, } impl IO { pub fn new(config: &Ini, input: &Input, input_state: &InputState, text: &mut Text) -> Self { let mut tts = HashMap::new(); let mut tooltips = Tooltips::default(); // App TTS. let app_tts = vec![ TtsString::from(text.get_ref("APP_TTS_0")), tooltips .get_tooltip( "APP_TTS_1", &[ InputEvent::StatusTTS, InputEvent::InputTTS, InputEvent::FileTTS, ], input, text, ) .clone(), tooltips .get_tooltip("APP_TTS_2", &[InputEvent::Quit], input, text) .clone(), tooltips .get_tooltip( "APP_TTS_3", &[InputEvent::PreviousPanel, InputEvent::NextPanel], input, text, ) .clone(), tooltips .get_tooltip( "APP_TTS_4", &[InputEvent::Undo, InputEvent::Redo], input, text, ) .clone(), tooltips .get_tooltip("APP_TTS_5", &[InputEvent::StopTTS], input, text) .clone(), tooltips .get_tooltip("APP_TTS_6", &[InputEvent::EnableLinksPanel], input, text) .clone(), ]; tts.insert(InputEvent::AppTTS, app_tts); // File TTS. let file_tts = vec![ tooltips .get_tooltip("FILE_TTS_0", &[InputEvent::NewFile], input, text) .clone(), tooltips .get_tooltip("FILE_TTS_1", &[InputEvent::OpenFile], input, text) .clone(), tooltips .get_tooltip( "FILE_TTS_2", &[InputEvent::SaveFile, InputEvent::SaveFileAs], input, text, ) .clone(), tooltips .get_tooltip("FILE_TTS_3", &[InputEvent::ExportFile], input, text) .clone(), tooltips .get_tooltip("FILE_TTS_4", &[InputEvent::ImportMidi], input, text) .clone(), tooltips .get_tooltip("FILE_TTS_5", &[InputEvent::EditConfig], input, text) .clone(), ]; tts.insert(InputEvent::FileTTS, file_tts); let music_panel = MusicPanel::default(); let tracks_panel = TracksPanel::default(); let open_file_panel = OpenFilePanel::default(); let piano_roll_panel = PianoRollPanel::new(&input_state.beat.get_u(), config); let export_panel = ExportPanel::default(); let export_settings_panel = ExportSettingsPanel::default(); let quit_panel = QuitPanel::default(); let links_panel = LinksPanel::default(); Self { tts, music_panel, tracks_panel, open_file_panel, piano_roll_panel, export_panel, export_settings_panel, quit_panel, links_panel, redo: vec![], undo: vec![], pre_export_panels: vec![], pre_export_focus: 0, } } /// Update the state of the app. Returns true if we're done. /// /// - `state` The state of the app. /// - `conn` The synthesizer-player connection. /// - `input` Input events, key presses, etc. /// - `tts` Text-to-speech. /// - `text` The text. /// - `paths_state` Dynamic path data. /// /// Returns: An `Snapshot`. pub fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &mut Text, paths_state: &mut PathsState, ) -> bool { if input.happened(&InputEvent::Quit) { // Enable the quit panel. if state.unsaved_changes { self.quit_panel.enable(state); } // Quit. else { return true; } } // Don't do anything while exporting. if conn.exporting() { return false; } // Alphanumeric input. if state.input.alphanumeric_input { // Get the focused panel. let panel = self.get_panel(&state.panels[state.focus.get()]); // Toggle off alphanumeric input. if panel.allow_alphanumeric_input(state, conn) { if input.happened(&InputEvent::ToggleAlphanumericInput) { let s0 = state.clone(); state.input.alphanumeric_input = false; // Do something on disable. panel.on_disable_abc123(state, conn); // There is always a snapshot (because we toggled off alphanumeric input). let snapshot = Some(Snapshot::from_states(s0, state)); // Apply the snapshot. self.apply_snapshot(snapshot, state, conn, paths_state); return false; } // Try to do alphanumeric input. else { let (snapshot, updated) = panel.update_abc123(state, input, conn); // We applied alphanumeric input. if updated { self.apply_snapshot(snapshot, state, conn, paths_state); return false; } } } } // Apply alphanumeric input. else { let panel = self.get_panel(&state.panels[state.focus.get()]); if panel.allow_alphanumeric_input(state, conn) && input.happened(&InputEvent::ToggleAlphanumericInput) { let snapshot = Some(Snapshot::from_state_value( |s| &mut s.input.alphanumeric_input, true, state, )); self.apply_snapshot(snapshot, state, conn, paths_state); return false; } else if let Some(track) = state.music.get_selected_track() { // Play notes. if !&input.note_on_messages.is_empty() && panel.allow_play_music() && conn.state.programs.get(&track.channel).is_some() { conn.note_ons(state, &input.note_on_messages); } if !&input.note_off_keys.is_empty() { conn.note_offs(state, &input.note_off_keys) } } } // New file. if input.happened(&InputEvent::NewFile) { // This prevents the previous file from being overwritten. paths_state.saves.filename = None; // Stop playing music. conn.on_new_file(state); // Reset the music. state.music = Music::default(); // Clear the selection. state.select_mode = SelectMode::Single(None); // Reset the view. state.view.reset(); // Reset the time. state.time.reset(); // Clear the undo/redo stacks. self.undo.clear(); self.redo.clear(); state.unsaved_changes = false; } // Open file. else if input.happened(&InputEvent::OpenFile) { self.open_file_panel.read_save(state, paths_state); } // Save file. else if input.happened(&InputEvent::SaveFile) { match &paths_state.saves.try_get_path() { // Save to the existing path, Some(path) => { Save::write(&path.with_extension("cac"), state, conn, paths_state); state.unsaved_changes = false; } // Set a new path. None => self.open_file_panel.write_save(state, paths_state), } } // Save to a new path. else if input.happened(&InputEvent::SaveFileAs) { self.open_file_panel.write_save(state, paths_state) } // Export. else if input.happened(&InputEvent::ExportFile) { let export_state = *conn.export_state.lock(); // We aren't exporting already. if export_state == ExportState::NotExporting { self.pre_export_focus = state.focus.get(); self.pre_export_panels.clone_from(&state.panels); self.open_file_panel.export(state, paths_state, conn) } } else if input.happened(&InputEvent::ImportMidi) { self.open_file_panel.import_midi(state, paths_state); } // Open config file. else if input.happened(&InputEvent::EditConfig) { let paths = Paths::get(); // Create a user .ini file. if !paths.user_ini_path.exists() { paths.create_user_config(); } // Edit. if edit_file(&paths.user_ini_path).is_ok() {} } // Undo. else if input.happened(&InputEvent::Undo) { if let Some(undo) = self.undo.pop() { // Get the redo state. let redo = Snapshot::from_snapshot(&undo); // Assign the undo state to the previous state. if let Some(s1) = undo.from_state { *state = s1; } // Send the commands. if let Some(commands) = undo.from_commands { conn.do_commands(&commands); } // Push to the redo stack. self.redo.push(redo); state.unsaved_changes = true; } // Redo. } else if input.happened(&InputEvent::Redo) { if let Some(redo) = self.redo.pop() { let undo = Snapshot::from_snapshot(&redo); // Assign the redo state to the current state. if let Some(s1) = redo.from_state { *state = s1; } // Send the commands. if let Some(commands) = redo.from_commands { conn.do_commands(&commands); } // Push to the undo stack. self.undo.push(undo); state.unsaved_changes = true; } } // Cycle panels. else if input.happened(&InputEvent::NextPanel) { let s0 = state.clone(); state.focus.increment(true); state.unsaved_changes = true; self.undo.push(Snapshot::from_states(s0, state)); } else if input.happened(&InputEvent::PreviousPanel) { let s0 = state.clone(); state.focus.increment(false); state.unsaved_changes = true; self.undo.push(Snapshot::from_states(s0, state)); } // App-level TTS. for tts_e in self.tts.iter() { if input.happened(tts_e.0) { tts.stop(); tts.enqueue(tts_e.1.clone()); } } // Stop talking or clear the queue for new speech. if input.happened(&InputEvent::StopTTS) || input.happened(&InputEvent::StatusTTS) || input.happened(&InputEvent::InputTTS) { tts.stop(); } // Links. if input.happened(&InputEvent::EnableLinksPanel) { self.links_panel.enable(state); return false; } // Get the focused panel. let panel = self.get_panel(&state.panels[state.focus.get()]); // Update the focuses panel and potentially get a screenshot. let snapshot = panel.update(state, conn, input, tts, text, paths_state); let (applied, need_to_quit) = self.apply_snapshot(snapshot, state, conn, paths_state); // Quit while we're ahead. if need_to_quit { return true; } // Stop doing stuff here but don't quit. else if applied { return false; } // Get the focused panel. let panel = self.get_panel(&state.panels[state.focus.get()]); // Play music. if input.happened(&InputEvent::PlayStop) && panel.allow_play_music() && !state.music.midi_tracks.is_empty() && state .music .get_playable_tracks() .iter() .any(|t| !t.get_playback_notes(state.time.playback).is_empty()) { // Toggle whether music is playing. state.input.is_playing = matches!( *conn.play_state.lock(), PlayState::NotPlaying | PlayState::Decaying ); // Start to play music. conn.set_music(state); } // No music is playing. if state.input.is_playing && matches!( *conn.play_state.lock(), PlayState::NotPlaying | PlayState::Decaying ) { state.input.is_playing = false; } // We're not done yet. false } /// Open a save file from a path. This is called from main.rs pub fn load_save( &self, save_path: &Path, state: &mut State, conn: &mut Conn, paths_state: &mut PathsState, ) { Save::read(save_path, state, conn, paths_state); // Set the saves directory. paths_state.saves = FileAndDirectory::new_path(save_path.to_path_buf()); } fn get_panel(&mut self, panel_type: &PanelType) -> &mut dyn Panel { match panel_type { PanelType::ExportSettings => &mut self.export_settings_panel, PanelType::ExportState => &mut self.export_panel, PanelType::MainMenu => panic!( "Tried to get a mutable reference to the main menu. This should never happen!" ), PanelType::Music => &mut self.music_panel, PanelType::OpenFile => &mut self.open_file_panel, PanelType::PianoRoll => &mut self.piano_roll_panel, PanelType::Tracks => &mut self.tracks_panel, PanelType::Quit => &mut self.quit_panel, PanelType::Links => &mut self.links_panel, } } /// Apply the snapshot. Apply IO commands and put a state on the undo stack. /// /// Returns: True if a state was applied, true if we need to quit. fn apply_snapshot( &mut self, snapshot: Option, state: &mut State, conn: &mut Conn, paths_state: &mut PathsState, ) -> (bool, bool) { // Push an undo state generated by the focused panel. if let Some(snapshot) = snapshot { // Execute IO commands. if let Some(io_commands) = &snapshot.io_commands { for command in io_commands { match command { // Enable the open-file panel. IOCommand::EnableOpenFile(open_file_type) => match open_file_type { OpenFileType::Export => (), OpenFileType::ReadSave => { self.open_file_panel.read_save(state, paths_state) } OpenFileType::SoundFont => { self.open_file_panel.soundfont(state, paths_state) } OpenFileType::WriteSave => { self.open_file_panel.write_save(state, paths_state) } OpenFileType::ImportMidi => { self.open_file_panel.import_midi(state, paths_state) } }, // Export. IOCommand::Export => { self.export_panel.enable( state, &self.pre_export_panels, self.pre_export_focus, ); conn.start_export(state, paths_state); } // Close the open-file panel. IOCommand::CloseOpenFile => self.open_file_panel.disable(state), // Quit the application. IOCommand::Quit => return (false, true), } } } // Push to the undo stack. if snapshot.from_state.is_some() || snapshot.from_commands.is_some() { state.unsaved_changes = true; self.push_undo(snapshot); } (true, false) } else { (false, false) } } /// Push this `UndoRedoState` to the undo stack and clear the redo stack. fn push_undo(&mut self, snapshot: Snapshot) { self.undo.push(snapshot); self.redo.clear(); // Remove an undo if there are too many. if self.undo.len() > MAX_UNDOS { self.undo.remove(0); } } } /// Try to select a track, given user input. /// /// This is here an not in a more obvious location because both `TracksPanel` and `PianoRollPanel` need it. pub(crate) fn select_track( state: &mut State, input: &Input, events: [InputEvent; 2], ) -> Option { if let Some(selected) = state.music.selected { if input.happened(&events[0]) && selected > 0 { let s0 = state.clone(); state.music.selected = Some(selected - 1); deselect(state); Some(Snapshot::from_states(s0, state)) } else if input.happened(&events[1]) && selected < state.music.midi_tracks.len() - 1 { let s0 = state.clone(); state.music.selected = Some(selected + 1); deselect(state); Some(Snapshot::from_states(s0, state)) } else { None } } else { None } } fn deselect(state: &mut State) { state.select_mode = match &state.select_mode { SelectMode::Single(_) => SelectMode::Single(None), SelectMode::Many(_) => SelectMode::Many(None), }; } ================================================ FILE: io/src/links_panel.rs ================================================ use crate::panel::*; use common::PanelType; use hashbrown::HashMap; use webbrowser::open; /// Open a link in a browser. pub(crate) struct LinksPanel { /// URLs per input event. links: HashMap, /// The popup. popup: Popup, /// The tooltips handler. tooltips: Tooltips, } impl LinksPanel { pub fn enable(&mut self, state: &mut State) { self.popup.enable(state, vec![PanelType::Links]); } } impl Default for LinksPanel { fn default() -> Self { let mut links = HashMap::new(); links.insert( InputEvent::WebsiteUrl, "https://subalterngames.com/cacophony".to_string(), ); links.insert( InputEvent::DiscordUrl, "https://discord.gg/fUapDXgTYj".to_string(), ); links.insert( InputEvent::GitHubUrl, "https://github.com/subalterngames/cacophony".to_string(), ); let popup = Popup::default(); let tooltips = Tooltips::default(); Self { links, popup, tooltips, } } } impl Panel for LinksPanel { fn update( &mut self, state: &mut State, _: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, _: &mut PathsState, ) -> Option { if input.happened(&InputEvent::InputTTS) { tts.enqueue(TtsString::from(text.get_ref("LINKS_PANEL_INPUT_TTS_0"))); tts.enqueue(self.tooltips.get_tooltip( "LINKS_PANEL_INPUT_TTS_1", &[InputEvent::WebsiteUrl], input, text, )); tts.enqueue(self.tooltips.get_tooltip( "LINKS_PANEL_INPUT_TTS_2", &[InputEvent::DiscordUrl], input, text, )); tts.enqueue(self.tooltips.get_tooltip( "LINKS_PANEL_INPUT_TTS_2", &[InputEvent::GitHubUrl], input, text, )); tts.enqueue(self.tooltips.get_tooltip( "LINKS_PANEL_INPUT_TTS_3", &[InputEvent::CloseLinksPanel], input, text, )); } else { // Try to open links. for (input_event, url) in self.links.iter() { if input.happened(input_event) && open(url).is_ok() { return None; } } // Disable the popup. if input.happened(&InputEvent::CloseLinksPanel) { self.popup.disable(state); } } None } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { false } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } } ================================================ FILE: io/src/music_panel.rs ================================================ use crate::abc123::{on_disable_exporter, on_disable_state, update_exporter, update_state}; use crate::panel::*; use common::music_panel_field::*; use common::{U64orF32, DEFAULT_BPM, MAX_VOLUME}; /// Set global music values. #[derive(Default)] pub(crate) struct MusicPanel { tooltips: Tooltips, } impl MusicPanel { /// Increment the current gain. Returns a new undo state. fn set_gain(conn: &mut Conn, up: bool) -> Option { // Get undo commands. let gain0 = vec![Command::SetGain { gain: conn.state.gain, }]; // Increment/decrement the gain. let mut index = Index::new(conn.state.gain, MAX_VOLUME + 1); index.increment(up); let gain = index.get(); let gain1 = vec![Command::SetGain { gain }]; // Send the commands. Some(Snapshot::from_commands(gain0, gain1, conn)) } } impl Panel for MusicPanel { fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, _: &mut PathsState, ) -> Option { // Cycle fields. if input.happened(&InputEvent::NextMusicPanelField) { Some(Snapshot::from_state( |s| s.music_panel_field.index.increment(true), state, )) } else if input.happened(&InputEvent::PreviousMusicPanelField) { Some(Snapshot::from_state( |s| s.music_panel_field.index.increment(false), state, )) } // Panel TTS. else if input.happened(&InputEvent::StatusTTS) { tts.enqueue(text.get_with_values( "MUSIC_PANEL_STATUS_TTS", &[ &conn.exporter.metadata.title, &state.time.bpm.to_string(), &conn.state.gain.to_string(), ], )); None } // Sub-panel TTS. else if input.happened(&InputEvent::InputTTS) { let scroll = self.tooltips.get_tooltip( "MUSIC_PANEL_INPUT_TTS", &[ InputEvent::PreviousMusicPanelField, InputEvent::NextMusicPanelField, ], input, text, ); let tts_strings = match state.music_panel_field.get_ref() { MusicPanelField::BPM => { let key = if state.input.alphanumeric_input { "MUSIC_PANEL_INPUT_TTS_BPM_ABC123" } else { "MUSIC_PANEL_INPUT_TTS_BPM_NO_ABC123" }; let mut tts_strings = vec![]; tts_strings.push(self.tooltips.get_tooltip( key, &[InputEvent::ToggleAlphanumericInput], input, text, )); if !state.input.alphanumeric_input { tts_strings.push(scroll); } tts_strings } MusicPanelField::Gain => { vec![ self.tooltips.get_tooltip( "MUSIC_PANEL_INPUT_TTS_GAIN", &[InputEvent::DecreaseMusicGain, InputEvent::IncreaseMusicGain], input, text, ), scroll, ] } MusicPanelField::Name => { let key = if state.input.alphanumeric_input { "MUSIC_PANEL_INPUT_TTS_NAME_ABC123" } else { "MUSIC_PANEL_INPUT_TTS_NAME_NO_ABC123" }; let mut tts_strings = vec![self.tooltips.get_tooltip( key, &[InputEvent::ToggleAlphanumericInput], input, text, )]; if !state.input.alphanumeric_input { tts_strings.push(scroll); } tts_strings } }; tts.enqueue(tts_strings); None } else { // Field-specific actions. match state.music_panel_field.get_ref() { // Modify the BPM. MusicPanelField::BPM => None, // Set the gain. MusicPanelField::Gain => { if input.happened(&InputEvent::DecreaseMusicGain) { MusicPanel::set_gain(conn, false) } else if input.happened(&InputEvent::IncreaseMusicGain) { MusicPanel::set_gain(conn, true) } else { None } } // Modify the name. MusicPanelField::Name => None, } } } fn update_abc123( &mut self, state: &mut State, input: &Input, conn: &mut Conn, ) -> (Option, bool) { match state.music_panel_field.get_ref() { MusicPanelField::BPM => { let snapshot = update_state(|s| &mut s.time.bpm, state, input); let updated = snapshot.is_some(); (snapshot, updated) } MusicPanelField::Gain => (None, false), MusicPanelField::Name => ( None, update_exporter(|e| &mut e.metadata.title, input, &mut conn.exporter), ), } } fn on_disable_abc123(&mut self, state: &mut State, conn: &mut Conn) { match state.music_panel_field.get_ref() { MusicPanelField::BPM => { on_disable_state(|s| &mut s.time.bpm, state, U64orF32::from(DEFAULT_BPM)) } MusicPanelField::Gain => (), MusicPanelField::Name => on_disable_exporter( |e| &mut e.metadata.title, &mut conn.exporter, "My Music".to_string(), ), } } fn allow_alphanumeric_input(&self, state: &State, _: &Conn) -> bool { match state.music_panel_field.get_ref() { MusicPanelField::BPM => true, MusicPanelField::Gain => false, MusicPanelField::Name => true, } } fn allow_play_music(&self) -> bool { true } } ================================================ FILE: io/src/open_file_panel.rs ================================================ use super::import_midi::import; use crate::panel::*; use crate::Save; use audio::export::ExportType; use audio::exporter::Exporter; use common::open_file::*; use common::PanelType; use text::get_file_name_no_ex; /// Data for an open-file panel. #[derive(Default)] pub struct OpenFilePanel { /// Popup handler. popup: Popup, /// Tooltips handler. tooltips: Tooltips, } impl OpenFilePanel { /// Enable the panel. fn enable( &mut self, open_file_type: OpenFileType, state: &mut State, paths_state: &mut PathsState, ) { let mut panels = vec![PanelType::OpenFile]; // Show export settings. if open_file_type == OpenFileType::Export { panels.push(PanelType::ExportSettings); } self.popup.enable(state, panels); // Set the file type. paths_state.open_file_type = open_file_type; } /// Enable the panel for loading SoundFonts. pub fn soundfont(&mut self, state: &mut State, paths_state: &mut PathsState) { let open_file_type = OpenFileType::SoundFont; paths_state.children.set( &paths_state.soundfonts.directory.path, &Extension::Sf2, None, ); self.enable(open_file_type, state, paths_state); } /// Enable the panel for setting the save path to be read from. pub fn read_save(&mut self, state: &mut State, paths_state: &mut PathsState) { self.enable_as_save(OpenFileType::ReadSave, state, paths_state); } /// Enable the panel for setting the save path to be written to. pub fn write_save(&mut self, state: &mut State, paths_state: &mut PathsState) { self.enable_as_save(OpenFileType::WriteSave, state, paths_state); } /// Enable a panel for setting the export path. pub fn export(&mut self, state: &mut State, paths_state: &mut PathsState, conn: &Conn) { let extension = conn.exporter.export_type.get().into(); let open_file_type = OpenFileType::Export; paths_state .children .set(&paths_state.exports.directory.path, &extension, None); self.enable(open_file_type, state, paths_state); } /// Enable a panel for importing a MIDI file. pub fn import_midi(&mut self, state: &mut State, paths_state: &mut PathsState) { paths_state .children .set(&paths_state.midis.directory.path, &Extension::Mid, None); self.enable(OpenFileType::ImportMidi, state, paths_state); } fn get_extension(&self, paths_state: &PathsState, exporter: &Exporter) -> Extension { match paths_state.open_file_type { OpenFileType::Export => exporter.export_type.get().into(), OpenFileType::ReadSave | OpenFileType::WriteSave => Extension::Cac, OpenFileType::SoundFont => Extension::Sf2, OpenFileType::ImportMidi => Extension::Mid, } } fn enable_as_save( &mut self, open_file_type: OpenFileType, state: &mut State, paths_state: &mut PathsState, ) { paths_state .children .set(&paths_state.saves.directory.path, &Extension::Cac, None); self.enable(open_file_type, state, paths_state); } /// Disable this panel. pub fn disable(&self, state: &mut State) { self.popup.disable(state); } } impl Panel for OpenFilePanel { fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, paths_state: &mut PathsState, ) -> Option { match &paths_state.open_file_type { OpenFileType::SoundFont | OpenFileType::ReadSave => (), _ => { // Get a modifiable filename. let mut filename = match &paths_state.get_filename() { Some(filename) => filename.clone(), None => String::new(), }; // Modify the path. if input.modify_filename_abc123(&mut filename) { paths_state.set_filename(&filename); return None; } } } // Status TTS. if input.happened(&InputEvent::StatusTTS) { // Current working directory. let mut s = text.get_with_values( "OPEN_FILE_PANEL_STATUS_TTS_CWD", &[&paths_state.get_directory().stem], ); s.push(' '); // Export file type. if paths_state.open_file_type == OpenFileType::Export { let e = conn.exporter.export_type.get(); let extension: Extension = e.into(); let export_type = extension.to_str(false); s.push_str( &text.get_with_values("OPEN_FILE_PANEL_STATUS_TTS_EXPORT", &[export_type]), ); s.push(' '); } // Selection. match paths_state.children.selected { Some(selected) => { let path = &paths_state.children.children[selected]; let name = if path.is_file { text.get_with_values("FILE", &[get_file_name_no_ex(&path.path)]) } else { text.get_with_values("FILE", &[&path.stem]) }; s.push_str( &text.get_with_values("OPEN_FILE_PANEL_STATUS_TTS_SELECTION", &[&name]), ); } _ => s.push_str(text.get_ref("OPEN_FILE_PANEL_STATUS_TTS_NO_SELECTION")), } tts.enqueue(s); } // Input TTS. else if input.happened(&InputEvent::InputTTS) { let mut tts_strings = vec![]; // Up directory. if let Some(parent) = paths_state.get_directory().path.parent() { tts_strings.push(self.tooltips.get_tooltip_with_values( "OPEN_FILE_PANEL_INPUT_TTS_UP_DIRECTORY", &[InputEvent::UpDirectory], &[&FileOrDirectory::new(parent).stem], input, text, )) } // Scroll. if paths_state.children.children.len() > 1 { tts_strings.push(self.tooltips.get_tooltip( "OPEN_FILE_PANEL_INPUT_TTS_SCROLL", &[InputEvent::PreviousPath, InputEvent::NextPath], input, text, )); } // Set export type. if paths_state.open_file_type == OpenFileType::Export { let mut index = conn.exporter.export_type; index.index.increment(true); let e = index.get(); let extension: Extension = e.into(); let next_export_type = extension.to_str(false); tts_strings.push(self.tooltips.get_tooltip_with_values( "OPEN_FILE_PANEL_INPUT_TTS_CYCLE_EXPORT", &[InputEvent::CycleExportType], &[next_export_type], input, text, )); } // Selection. if let Some(selected) = paths_state.children.selected { let events = vec![InputEvent::SelectFile]; let path = &paths_state.children.children[selected]; match path.is_file { // Select. true => { let open_file_key = match paths_state.open_file_type { OpenFileType::ReadSave => "OPEN_FILE_PANEL_INPUT_TTS_READ_SAVE", OpenFileType::Export => "OPEN_FILE_PANEL_INPUT_TTS_EXPORT", OpenFileType::SoundFont => "OPEN_FILE_PANEL_INPUT_TTS_SOUNDFONT", OpenFileType::WriteSave => "OPEN_FILE_PANEL_INPUT_TTS_WRITE_SAVE", OpenFileType::ImportMidi => "OPEN_FILE_PANEL_INPUT_TTS_IMPORT_MIDI", }; tts_strings.push(self.tooltips.get_tooltip_with_values( open_file_key, &events, &[get_file_name_no_ex(&path.path)], input, text, )); } // Down directory. false => tts_strings.push(self.tooltips.get_tooltip_with_values( "OPEN_FILE_PANEL_INPUT_TTS_DOWN_DIRECTORY", &[InputEvent::DownDirectory], &[&path.stem], input, text, )), } } // Close. tts_strings.push(self.tooltips.get_tooltip( "OPEN_FILE_PANEL_INPUT_TTS_CLOSE", &[InputEvent::CloseOpenFile], input, text, )); tts.enqueue(tts_strings); } // Go up a directory. else if input.happened(&InputEvent::UpDirectory) { paths_state.up_directory(&self.get_extension(paths_state, &conn.exporter)); } // Go down a directory. else if input.happened(&InputEvent::DownDirectory) { paths_state.down_directory(&self.get_extension(paths_state, &conn.exporter)); } // Scroll up. else if input.happened(&InputEvent::PreviousPath) { paths_state.scroll(true); } // Scroll down. else if input.happened(&InputEvent::NextPath) { paths_state.scroll(false); } // Export type. else if paths_state.open_file_type == OpenFileType::Export && input.happened(&InputEvent::CycleExportType) { // Set the extension. conn.exporter.export_type.index.increment(true); // Set the children. paths_state.children.set( &paths_state.exports.directory.path, &conn.exporter.export_type.get().into(), None, ); } // We selected something. else if input.happened(&InputEvent::SelectFile) { // Do something with the selected file. match &paths_state.open_file_type { // Load a save file. OpenFileType::ReadSave => { if let Some(selected) = paths_state.children.selected { // Disable the panel. self.disable(state); // Stop the music. conn.on_new_file(state); // Get the path. let path = paths_state.children.children[selected].path.clone(); // Read the save file. Save::read(&path, state, conn, paths_state); // Set the saves directory. paths_state.saves = FileAndDirectory::new_path(path); } } // Load a SoundFont. OpenFileType::SoundFont => { if let Some(selected) = paths_state.children.selected { // Disable the panel. self.disable(state); if paths_state.children.children[selected].is_file { // Get the selected track's channel. let channel = state.music.get_selected_track().unwrap().channel; // To revert: unset the program. let c0 = vec![Command::UnsetProgram { channel }]; // A command to load the SoundFont. let c1 = vec![Command::LoadSoundFont { channel, path: paths_state.children.children[selected].path.clone(), }]; return Some(Snapshot::from_commands(c0, c1, conn)); } } } // Write a save file. OpenFileType::WriteSave => { // There is a filename. if let Some(filename) = &paths_state.saves.filename { // Disable the panel. self.disable(state); // Append the extension. let mut filename = filename.clone(); filename.push_str(".cac"); state.unsaved_changes = false; // Write. Save::write( &paths_state.saves.directory.path.join(filename), state, conn, paths_state, ); } } // Write an export file. OpenFileType::Export => { // There is a filename. if let Some(filename) = &paths_state.exports.filename { // Disable the panel. self.disable(state); // Append the extension. let mut filename = filename.clone(); filename.push_str( >::into(conn.exporter.export_type.get()) .to_str(true), ); // Export to a .mid file. if conn.exporter.export_type.get() == ExportType::Mid { conn.exporter.mid( &paths_state.exports.directory.path.join(filename), &state.music, &state.time, &conn.state, ); } // Export an audio file. else { return Some(Snapshot::from_io_commands(vec![IOCommand::Export])); } } } OpenFileType::ImportMidi => { if let Some(selected) = paths_state.children.selected { let path = paths_state.children.children[selected].path.clone(); import(&path, state, conn); state.unsaved_changes = true; self.disable(state); } } } } // Close this. else if input.happened(&InputEvent::CloseOpenFile) { self.disable(state); } None } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { // There is alphanumeric input in this struct, obviously, but we won't handle it here because we don't need to toggle it on/off. (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { false } } ================================================ FILE: io/src/panel.rs ================================================ pub(crate) use crate::io_command::IOCommand; pub(crate) use crate::popup::Popup; pub(crate) use crate::Snapshot; pub(crate) use audio::{Command, Conn}; pub(crate) use common::{Index, PathsState, State}; pub(crate) use input::{Input, InputEvent}; pub(crate) use text::{Enqueable, Text, Tooltips, TtsString, TTS}; /// A panel can be updated, and can update the rest of the app state. pub(crate) trait Panel { /// Apply panel-specific updates to the state. /// /// - `state` The state of the app. /// - `conn` The synthesizer-player connection. /// - `input` Input events, key presses, etc. /// - `tts` Text-to-speech. /// - `text` The text. /// - `paths_state` Dynamic path data. /// /// Returns: An `Snapshot`. fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, paths_state: &mut PathsState, ) -> Option; /// Apply panel-specific updates to the state if alphanumeric input is enabled. /// /// - `state` The state of the app. /// - `input` Input events, key presses, etc. /// - `conn` The audio connection. /// /// Returns: An `Snapshot` and true if something (potentially not included in the snaphot) updated. fn update_abc123( &mut self, state: &mut State, input: &Input, conn: &mut Conn, ) -> (Option, bool); /// Do something when alphanumeric input is disabled. /// /// - `state` The state of the app. /// - `conn` The audio connection. fn on_disable_abc123(&mut self, state: &mut State, conn: &mut Conn); /// If true, allow the user to toggle alphanumeric input. /// /// - `state` The state. /// - `conn` The audio connection. fn allow_alphanumeric_input(&self, state: &State, conn: &Conn) -> bool; /// Returns true if we can play music. fn allow_play_music(&self) -> bool; } ================================================ FILE: io/src/piano_roll/edit.rs ================================================ use super::{ get_cycle_edit_mode_input_tts, get_edit_mode_status_tts, get_no_selection_status_tts, EditModeDeltas, PianoRollSubPanel, }; use crate::panel::*; use common::{MAX_NOTE, MAX_VOLUME, MIN_NOTE}; use ini::Ini; /// Edit selected notes. pub(super) struct Edit { /// The edit mode deltas. deltas: EditModeDeltas, tooltips: Tooltips, } impl Edit { pub fn new(config: &Ini) -> Self { Self { deltas: EditModeDeltas::new(config), tooltips: Tooltips::default(), } } } impl Panel for Edit { fn update( &mut self, state: &mut State, _: &mut Conn, input: &Input, _: &mut TTS, _: &Text, _: &mut PathsState, ) -> Option { // Do nothing if there is no track. if state.music.selected.is_none() { None } // Cycle the mode. else if input.happened(&InputEvent::PianoRollCycleMode) { Some(Snapshot::from_state( |s| s.edit_mode.index.increment(true), state, )) } else { let mode = state.edit_mode.get_ref(); let s0 = state.clone(); // Are there notes we can edit? match state.select_mode.get_notes_mut(&mut state.music) { Some(mut notes) => { // Move the notes left. if input.happened(&InputEvent::EditStartLeft) { let dt = self.deltas.get_dt(mode, &state.input); // Don't let any notes go to t=0. if !notes.iter().any(|n| n.start.checked_sub(dt).is_none()) { notes.iter_mut().for_each(|n| n.set_t0_by(dt, false)); Some(Snapshot::from_states(s0, state)) } else { None } } // Move the notes right. else if input.happened(&InputEvent::EditStartRight) { let dt = self.deltas.get_dt(mode, &state.input); notes.iter_mut().for_each(|n| n.set_t0_by(dt, true)); Some(Snapshot::from_states(s0, state)) } // Shorten the duration. else if input.happened(&InputEvent::EditDurationLeft) { let dt = self.deltas.get_dt(mode, &state.input); // Don't let any notes go to dt<=0. if notes .iter() .all(|n| n.get_duration().checked_sub(dt).is_some()) { notes.iter_mut().for_each(|n| n.end -= dt); Some(Snapshot::from_states(s0, state)) } else { None } } // Lengthen the notes. else if input.happened(&InputEvent::EditDurationRight) { let dt = self.deltas.get_dt(mode, &state.input); notes.iter_mut().for_each(|n| n.end += dt); Some(Snapshot::from_states(s0, state)) } // Move the notes up. else if input.happened(&InputEvent::EditPitchUp) { let dn = self.deltas.get_dn(mode); // Don't let any notes go to dn>=max. if notes.iter().all(|n| (n.note + dn) <= MAX_NOTE) { notes.iter_mut().for_each(|n| n.note += dn); Some(Snapshot::from_states(s0, state)) } else { None } } // Move the notes down. else if input.happened(&InputEvent::EditPitchDown) { let dn = self.deltas.get_dn(mode); // Don't let any notes go to dn<=0. if notes.iter().all(|n| (n.note - dn) >= MIN_NOTE) { notes.iter_mut().for_each(|n| n.note -= dn); Some(Snapshot::from_states(s0, state)) } else { None } } // Increase the volume. else if input.happened(&InputEvent::EditVolumeUp) { let dv = self.deltas.get_dv(mode); // Don't let any notes go to dv>=max. if notes.iter().all(|n| (n.velocity + dv) <= MAX_VOLUME) { notes.iter_mut().for_each(|n| n.velocity += dv); Some(Snapshot::from_states(s0, state)) } else { None } } // Decrease the volume. else if input.happened(&InputEvent::EditVolumeDown) { let dv = self.deltas.get_dv(mode); // Don't let any notes go to dv<=0. if notes.iter().all(|n| (n.velocity as i8 - dv as i8) >= 0) { notes.iter_mut().for_each(|n| n.velocity -= dv); Some(Snapshot::from_states(s0, state)) } else { None } } else { None } } None => None, } } } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { true } } impl PianoRollSubPanel for Edit { fn get_status_tts(&mut self, state: &State, text: &Text) -> Vec { vec![get_edit_mode_status_tts(state.edit_mode.get_ref(), text)] } fn get_input_tts(&mut self, state: &State, input: &Input, text: &Text) -> Vec { let mut tts_strings = match state.select_mode.get_note_indices() { Some(_) => vec![ self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_EDIT_0", &[InputEvent::EditPitchUp, InputEvent::EditPitchDown], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_EDIT_1", &[InputEvent::EditStartLeft, InputEvent::EditStartRight], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_EDIT_2", &[InputEvent::EditDurationLeft, InputEvent::EditDurationRight], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_EDIT_3", &[InputEvent::EditVolumeUp, InputEvent::EditVolumeDown], input, text, ), ], None => vec![get_no_selection_status_tts(text)], }; tts_strings.push(get_cycle_edit_mode_input_tts( &mut self.tooltips, &state.edit_mode, input, text, )); tts_strings } } ================================================ FILE: io/src/piano_roll/edit_mode_deltas.rs ================================================ use common::config::{parse, parse_float}; use common::{EditMode, InputState, PPQ_F}; use ini::Ini; /// Delta factors and values for edit modes. pub(super) struct EditModeDeltas { /// Multiply the beat by this factor to get the quick time. quick_time_factor: u64, /// In precise mode, move the view left and right by this PPQ value. precise_time: u64, /// In normal mode, move the viewport up and down by this many half-steps. normal_note: u8, /// In quick mode, move the viewport up and down by this many half-steps. quick_note: u8, /// In precise mode, move the view up and down by this many half-steps. precise_note: u8, /// In normal mode, edit volume by this delta. normal_volume: u8, /// In quick mode, edit volume by this delta. quick_volume: u8, /// In precise mode, edit volume by this delta. precise_volume: u8, } impl EditModeDeltas { pub(super) fn new(config: &Ini) -> Self { let section = config.section(Some("PIANO_ROLL")).unwrap(); let quick_time_factor: u64 = parse(section, "quick_time_factor"); let precise_time: u64 = (parse_float(section, "precise_time") * PPQ_F) as u64; let normal_note: u8 = parse(section, "normal_note"); let quick_note: u8 = parse(section, "quick_note"); let precise_note: u8 = parse(section, "precise_note"); let normal_volume: u8 = parse(section, "normal_volume"); let quick_volume: u8 = parse(section, "quick_volume"); let precise_volume: u8 = parse(section, "precise_volume"); Self { quick_time_factor, precise_time, normal_note, quick_note, precise_note, normal_volume, quick_volume, precise_volume, } } /// Returns the delta for time. pub(super) fn get_dt(&self, mode: &EditMode, input: &InputState) -> u64 { match mode { EditMode::Normal => input.beat.get_u(), EditMode::Quick => input.beat.get_u() * self.quick_time_factor, EditMode::Precise => self.precise_time, } } /// Returns the delta for notes. pub(super) fn get_dn(&self, mode: &EditMode) -> u8 { match mode { EditMode::Normal => self.normal_note, EditMode::Quick => self.quick_note, EditMode::Precise => self.precise_note, } } /// Returns the delta for volume. pub(super) fn get_dv(&self, mode: &EditMode) -> u8 { match mode { EditMode::Normal => self.normal_volume, EditMode::Quick => self.quick_volume, EditMode::Precise => self.precise_volume, } } } #[cfg(test)] mod tests { use super::EditModeDeltas; use common::{get_test_config, PPQ_U}; #[test] fn edit_mode_deltas() { let e = EditModeDeltas::new(&get_test_config()); assert_eq!(e.quick_time_factor, 4, "{}", e.quick_time_factor); assert_eq!(e.precise_time, PPQ_U / 32, "{}", e.precise_time); assert_eq!(e.normal_note, 1, "{}", e.normal_note); assert_eq!(e.quick_note, 12, "{}", e.quick_note); assert_eq!(e.precise_note, 1, "{}", e.precise_note); assert_eq!(e.normal_volume, 1, "{}", e.normal_volume); assert_eq!(e.quick_volume, 10, "{}", e.quick_volume); assert_eq!(e.precise_volume, 1, "{}", e.precise_volume); } } ================================================ FILE: io/src/piano_roll/piano_roll_panel.rs ================================================ use super::*; use crate::panel::*; use crate::select_track; use common::config::parse_fractions; use common::{Index, Note, PianoRollMode, SelectMode, U64orF32, PPQ_F}; use ini::Ini; const TRACK_SCROLL_EVENTS: [InputEvent; 2] = [ InputEvent::PianoRollPreviousTrack, InputEvent::PianoRollNextTrack, ]; /// The piano roll. /// This is divided into different "modes" for convenience, where each mode is actually a panel. pub struct PianoRollPanel { /// The edit mode. edit: Edit, /// The select mode. select: Select, /// The time mode. time: Time, /// The view mode. view: View, /// The beats that we can potentially input as PPQ values. beats: Vec, /// The index of the current beat. beat: Index, /// A buffer of copied notes. copied_notes: Vec, /// The tooltips handler. tooltips: Tooltips, } impl PianoRollPanel { pub fn new(beat: &u64, config: &Ini) -> Self { let edit = Edit::new(config); let select = Select::default(); let time = Time::new(config); let view = View::new(config); // Load the beats. let section = config.section(Some("PIANO_ROLL")).unwrap(); let mut beats: Vec = parse_fractions(section, "beats") .iter() .map(|f| (*f * PPQ_F) as u64) .collect(); // Is the input beat in the list? let beat_index = match beats.iter().position(|b| b == beat) { Some(position) => position, None => { beats.push(*beat); beats.len() - 1 } }; let beat = Index::new(beat_index, beats.len()); Self { edit, select, time, view, beats, beat, copied_notes: vec![], tooltips: Tooltips::default(), } } /// Set the input beat. fn set_input_beat(&mut self, up: bool, state: &mut State) -> Option { let s0 = state.clone(); // Increment the beat. self.beat.increment(up); // Set the input beat. state.input.beat = U64orF32::from(self.beats[self.beat.get()]); Some(Snapshot::from_states(s0, state)) } /// Set the piano roll mode. fn set_mode(mode: PianoRollMode, state: &mut State) -> Option { let s0 = state.clone(); state.piano_roll_mode = mode; Some(Snapshot::from_states(s0, state)) } /// Returns the text-to-speech string that will be said if there is no valid track. fn tts_no_track(text: &Text) -> Vec { vec![TtsString::from( text.get_ref("PIANO_ROLL_PANEL_TTS_NO_TRACK"), )] } /// Returns the sub-panel corresponding to the current piano roll mode. fn get_sub_panel<'a>(&'a mut self, state: &State) -> &'a mut dyn PianoRollSubPanel { match state.piano_roll_mode { PianoRollMode::Edit => &mut self.edit, PianoRollMode::Select => &mut self.select, PianoRollMode::Time => &mut self.time, PianoRollMode::View => &mut self.view, } } /// Copy the selected notes to the copy buffer. fn copy_notes(&mut self, state: &State) { if let Some(notes) = state.select_mode.get_notes(&state.music) { self.copied_notes = notes.iter().map(|&n| *n).collect() } } /// Delete notes from the track. fn delete_notes(state: &mut State) -> Option { // Clone the state. let s0 = state.clone(); if let Some(indices) = state.select_mode.get_note_indices() { if let Some(track) = state.music.get_selected_track_mut() { // Remove the notes. track.notes = track .notes .iter() .enumerate() .filter(|n| !indices.contains(&n.0)) .map(|n| *n.1) .collect(); // Deselect. state.select_mode = match &state.select_mode { SelectMode::Single(_) => SelectMode::Single(None), SelectMode::Many(_) => SelectMode::Many(None), }; // Return the undo state. return Some(Snapshot::from_states(s0, state)); } } None } } impl Panel for PianoRollPanel { fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, paths_state: &mut PathsState, ) -> Option { // Select a track. if !state.view.single_track { if let Some(snapshot) = select_track(state, input, TRACK_SCROLL_EVENTS) { return Some(snapshot); } } // Do nothing. if state.music.selected.is_none() { None } // Add notes. else if state.input.armed && !input.new_notes.is_empty() { // Clone the state. let s0 = state.clone(); let track = state.music.get_selected_track_mut().unwrap(); match conn.state.programs.get(&track.channel) { Some(_) => { // Get the notes. let notes: Vec = input .new_notes .iter() .map(|n| Note { note: n[1], velocity: n[2], start: state.time.cursor, end: state.time.cursor + state.input.beat.get_u(), }) .collect(); // Add the notes. track.notes.extend(notes.iter().copied()); // Move the cursor. state.time.cursor += state.input.beat.get_u(); Some(Snapshot::from_states(s0, state)) } None => None, } } // Status TTS. else if input.happened(&InputEvent::StatusTTS) { let mut tts_strings = vec![]; match state.music.get_selected_track() { Some(track) => match conn.state.programs.get(&track.channel) { Some(_) => { // The piano roll mode. tts_strings.push(TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_PIANO_ROLL_MODE", &[text.get_piano_roll_mode(&state.piano_roll_mode)], ))); match state.input.armed { // The beat and the volume. true => { let beat = text.get_ppq_tts(&state.input.beat.get_u()); let v = state.input.volume.get().to_string(); let volume = if state.input.use_volume { v } else { text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_VOLUME", &[&v], ) }; tts_strings.push(TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_ARMED", &[&beat, &volume], ))); } // Not armed. false => tts_strings.push(TtsString::from( text.get_ref("PIANO_ROLL_PANEL_STATUS_TTS_NOT_ARMED"), )), } // How many tracks? let tracks_key = if state.view.single_track { "PIANO_ROLL_PANEL_STATUS_TTS_SINGLE_TRACK" } else { "PIANO_ROLL_PANEL_STATUS_TTS_MULTI_TRACK" }; tts_strings.push(TtsString::from(text.get_with_values( tracks_key, &[&state.music.selected.unwrap().to_string()], ))); // Panel-specific status. tts_strings .append(&mut self.get_sub_panel(state).get_status_tts(state, text)); } None => tts_strings.append(&mut PianoRollPanel::tts_no_track(text)), }, None => tts_strings.append(&mut PianoRollPanel::tts_no_track(text)), }; tts.enqueue(tts_strings); None } // Input TTS. else if input.happened(&InputEvent::InputTTS) { let s = match state.music.get_selected_track() { Some(track) => match conn.state.programs.get(&track.channel) { // Here we go... Some(_) => { let mut tts_strings = vec![self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_PLAY", &[InputEvent::PlayStop], input, text, )]; // Armed state, beat, volume. match state.input.armed { true => { tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_ARMED", &[ InputEvent::Arm, InputEvent::InputBeatLeft, InputEvent::InputBeatRight, ], input, text, )); match state.input.use_volume { true => tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_DO_NOT_USE_VOLUME", &[ InputEvent::DecreaseInputVolume, InputEvent::IncreaseInputVolume, InputEvent::ToggleInputVolume, ], input, text, )), false => tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_USE_VOLUME", &[InputEvent::ToggleInputVolume], input, text, )), } } false => tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_NOT_ARMED", &[InputEvent::Arm], input, text, )), } // Notes. tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_NOTES", &[ InputEvent::C, InputEvent::CSharp, InputEvent::D, InputEvent::DSharp, InputEvent::E, InputEvent::F, InputEvent::FSharp, InputEvent::G, InputEvent::GSharp, InputEvent::A, InputEvent::ASharp, InputEvent::B, InputEvent::OctaveUp, InputEvent::OctaveDown, ], input, text, )); // Toggle tracks. let tracks_key = if state.view.single_track { "PIANO_ROLL_PANEL_INPUT_TTS_MULTI_TRACK" } else { "PIANO_ROLL_PANEL_INPUT_TTS_SINGLE_TRACK" }; tts_strings.push(self.tooltips.get_tooltip( tracks_key, &[InputEvent::PianoRollToggleTracks], input, text, )); // Multi-track scroll. if !state.view.single_track { tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TRACK_SCROLL", &[ InputEvent::PianoRollPreviousTrack, InputEvent::PianoRollNextTrack, ], input, text, )); } // Change the mode. tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_MODES", &[ InputEvent::PianoRollSetTime, InputEvent::PianoRollSetView, InputEvent::PianoRollSetSelect, InputEvent::PianoRollSetEdit, ], input, text, )); // Cut, copy. let selected_some = state.select_mode.get_note_indices().is_some(); if selected_some { tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_COPY_CUT", &[InputEvent::CopyNotes, InputEvent::CutNotes], input, text, )); } // Paste. if !self.copied_notes.is_empty() { tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_PASTE", &[InputEvent::PasteNotes], input, text, )); } // Delete. if selected_some { tts_strings.push(self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_DELETE", &[InputEvent::DeleteNotes], input, text, )); } // Sub-panel inputs. tts_strings.append( &mut self.get_sub_panel(state).get_input_tts(state, input, text), ); tts_strings } None => PianoRollPanel::tts_no_track(text), }, None => PianoRollPanel::tts_no_track(text), }; tts.enqueue(s); None } // Copy notes. else if input.happened(&InputEvent::CopyNotes) { self.copy_notes(state); None } // Cut notes. else if input.happened(&InputEvent::CutNotes) { // Copy. self.copy_notes(state); // Delete. PianoRollPanel::delete_notes(state) } // Delete notes. else if input.happened(&InputEvent::DeleteNotes) { PianoRollPanel::delete_notes(state) } // Paste notes. else if input.happened(&InputEvent::PasteNotes) { if !self.copied_notes.is_empty() { // Clone the state. let s0 = state.clone(); if let Some(track) = state.music.get_selected_track_mut() { // Get the minimum start time. let min_time = self .copied_notes .iter() .min_by(|a, b| a.start.cmp(&b.start)) .unwrap() .start; // Adjust the start and end time. let mut notes = self.copied_notes.to_vec(); notes.iter_mut().for_each(|n| { let dt = n.end - n.start; n.start = (n.start - min_time) + state.time.cursor; n.end = n.start + dt; }); // Add the notes. track.notes.append(&mut notes); // Return the undo state. Some(Snapshot::from_states(s0, state)) } else { None } } else { None } } // Toggle arm. else if input.happened(&InputEvent::Arm) { let s0 = state.clone(); state.input.armed = !state.input.armed; Some(Snapshot::from_states(s0, state)) } // Toggle tracks view. else if input.happened(&InputEvent::PianoRollToggleTracks) { Some(Snapshot::from_state_value( |s| &mut s.view.single_track, !state.view.single_track, state, )) } // Set the input beat. else if input.happened(&InputEvent::InputBeatLeft) { self.set_input_beat(false, state) } else if input.happened(&InputEvent::InputBeatRight) { self.set_input_beat(true, state) } // Set the volume. else if input.happened(&InputEvent::ToggleInputVolume) { Some(Snapshot::from_state_value( |s| &mut s.input.use_volume, !state.input.use_volume, state, )) } else if input.happened(&InputEvent::DecreaseInputVolume) { Some(Snapshot::from_state( |s| s.input.volume.increment(false), state, )) } else if input.happened(&InputEvent::IncreaseInputVolume) { Some(Snapshot::from_state( |s| s.input.volume.increment(true), state, )) } // Set the mode. else if input.happened(&InputEvent::PianoRollSetEdit) { PianoRollPanel::set_mode(PianoRollMode::Edit, state) } else if input.happened(&InputEvent::PianoRollSetSelect) { PianoRollPanel::set_mode(PianoRollMode::Select, state) } else if input.happened(&InputEvent::PianoRollSetTime) { PianoRollPanel::set_mode(PianoRollMode::Time, state) } else if input.happened(&InputEvent::PianoRollSetView) { PianoRollPanel::set_mode(PianoRollMode::View, state) } else { // Sub-panel actions. let mode = state.piano_roll_mode; match mode { PianoRollMode::Edit => self.edit.update(state, conn, input, tts, text, paths_state), PianoRollMode::Select => { self.select .update(state, conn, input, tts, text, paths_state) } PianoRollMode::Time => self.time.update(state, conn, input, tts, text, paths_state), PianoRollMode::View => self.view.update(state, conn, input, tts, text, paths_state), } } } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { true } } ================================================ FILE: io/src/piano_roll/piano_roll_sub_panel.rs ================================================ use crate::panel::*; use common::{EditMode, IndexedEditModes}; /// A sub-panel (a mode) of the piano roll panel. pub(crate) trait PianoRollSubPanel { /// Returns the status text-to-speech text. fn get_status_tts(&mut self, state: &State, text: &Text) -> Vec; /// Returns the input text-to-speech text. fn get_input_tts(&mut self, state: &State, input: &Input, text: &Text) -> Vec; } /// Returns the edit mode text-to-speech string. pub(crate) fn get_edit_mode_status_tts(mode: &EditMode, text: &Text) -> TtsString { TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_EDIT_MODE", &[text.get_edit_mode(mode)], )) } /// Returns text-to-speech to cycle from one mode to another. pub(crate) fn get_cycle_edit_mode_input_tts( tooltips: &mut Tooltips, mode: &IndexedEditModes, input: &Input, text: &Text, ) -> TtsString { let mut m1 = *mode; m1.index.increment(true); tooltips.get_tooltip_with_values( "PIANO_ROLL_PANEL_INPUT_TTS_EDIT_MODE", &[InputEvent::PianoRollCycleMode], &[text.get_edit_mode(m1.get_ref())], input, text, ) } /// Returns the text-to-speech string if no notes are selected. pub(crate) fn get_no_selection_status_tts(text: &Text) -> TtsString { TtsString::from(text.get("PIANO_ROLL_PANEL_STATUS_TTS_NO_SELECTION")) } ================================================ FILE: io/src/piano_roll/select.rs ================================================ use super::{get_no_selection_status_tts, PianoRollSubPanel}; use crate::panel::*; use common::time::Time; use common::{MidiTrack, Note, SelectMode}; /// Select notes. #[derive(Default)] pub(super) struct Select { tooltips: Tooltips, } impl Select { /// Returns the index of the note closest (and before) the cursor. fn get_note_index_closest_to_before_cursor(notes: &[Note], time: &Time) -> Option { notes .iter() .enumerate() .filter(|n| n.1.start < time.cursor) .max_by(|a, b| a.1.cmp(b.1)) .map(|max| max.0) } /// Returns the index of the note closest (and after) the cursor. fn get_note_index_closest_to_after_cursor(notes: &[Note], time: &Time) -> Option { notes .iter() .enumerate() .filter(|n| n.1.start >= time.cursor) .min_by(|a, b| a.1.cmp(b.1)) .map(|max| max.0) } /// Returns the first note in a selection defined by `indices`. fn get_first_selected_note<'a>( track: &'a MidiTrack, indices: &[usize], ) -> Option<(usize, &'a Note)> { track .notes .iter() .enumerate() .filter(|n| indices.contains(&n.0)) .min_by(|a, b| a.1.cmp(b.1)) } /// Returns the last note in a selection defined by `indices`. fn get_last_selected_note<'a>( track: &'a MidiTrack, indices: &[usize], ) -> Option<(usize, &'a Note)> { track .notes .iter() .enumerate() .filter(|n| indices.contains(&n.0)) .max_by(|a, b| a.1.cmp(b.1)) } } impl Panel for Select { fn update( &mut self, state: &mut State, _: &mut Conn, input: &Input, _: &mut TTS, _: &Text, _: &mut PathsState, ) -> Option { match state.music.get_selected_track() { None => None, Some(track) => { // Cycle the select mode. if input.happened(&InputEvent::PianoRollCycleMode) { let s0 = state.clone(); let mode = state.select_mode.clone(); state.select_mode = match mode { SelectMode::Single(index) => match index { Some(index) => SelectMode::Many(Some(vec![index])), None => SelectMode::Many(None), }, SelectMode::Many(indices) => match indices { Some(indices) => match indices.is_empty() { true => SelectMode::Single(None), false => SelectMode::Single(Some(indices[0])), }, None => SelectMode::Single(None), }, }; Some(Snapshot::from_states(s0, state)) } // Move the selection start leftwards. else if input.happened(&InputEvent::SelectStartLeft) { let s0 = state.clone(); match &mut state.select_mode { SelectMode::Single(index) => match index { // Get the prior note. Some(index) => { let note = &track.notes[*index]; if let Some(prior_note) = track .notes .iter() .enumerate() .filter(|n| n.1.lt(note)) .max_by(|a, b| a.1.cmp(b.1)) { *index = prior_note.0; return Some(Snapshot::from_states(s0, state)); } } // Select the note closest to the cursor. None => { if let Some(index) = Select::get_note_index_closest_to_before_cursor( &track.notes, &state.time, ) { state.select_mode = SelectMode::Single(Some(index)); return Some(Snapshot::from_states(s0, state)); } } }, // Are there selected indices? SelectMode::Many(indices) => match indices { // Is there a max selected index? Some(indices) => { // There is a first selected note. if let Some(first_selected_note) = Select::get_first_selected_note(track, indices) { // There is a prior note. if let Some(prior_note) = track .notes .iter() .enumerate() .filter(|n| n.1.lt(first_selected_note.1)) .max_by(|a, b| a.1.cmp(b.1)) { // Add the prior note. indices.push(prior_note.0); return Some(Snapshot::from_states(s0, state)); } } } // Select the note closest to the cursor. None => { // Is there a note near the cursor? if let Some(index) = Select::get_note_index_closest_to_before_cursor( &track.notes, &state.time, ) { state.select_mode = SelectMode::Many(Some(vec![index])); return Some(Snapshot::from_states(s0, state)); } } }, } return None; } // Move the selection start rightwards. else if input.happened(&InputEvent::SelectStartRight) { let s0 = state.clone(); match &mut state.select_mode { SelectMode::Single(index) => match index { Some(index) => { let note = &track.notes[*index]; // Get the next note. if let Some(next_note) = track .notes .iter() .enumerate() .filter(|n| n.1.gt(note)) .min_by(|a, b| a.1.cmp(b.1)) { *index = next_note.0; return Some(Snapshot::from_states(s0, state)); } } // Select the note closest to the cursor. None => { if let Some(index) = Select::get_note_index_closest_to_after_cursor( &track.notes, &state.time, ) { state.select_mode = SelectMode::Single(Some(index)); return Some(Snapshot::from_states(s0, state)); } } }, // Remove the first note. SelectMode::Many(indices) => match indices { Some(indices) => { if indices.len() <= 1 { return None; } // There is a first selected note. if let Some(first_selected_note) = Select::get_first_selected_note(track, indices) { // Remove the note. indices.retain(|n| *n != first_selected_note.0); return Some(Snapshot::from_states(s0, state)); } } // Select the note closest to the cursor. None => { if let Some(index) = Select::get_note_index_closest_to_before_cursor( &track.notes, &state.time, ) { state.select_mode = SelectMode::Many(Some(vec![index])); return Some(Snapshot::from_states(s0, state)); } } }, } return None; } // Deselect. else if input.happened(&InputEvent::SelectNone) { let s0 = state.clone(); let mode = state.select_mode.clone(); state.select_mode = match mode { SelectMode::Single(_) => SelectMode::Single(None), SelectMode::Many(_) => SelectMode::Many(None), }; return Some(Snapshot::from_states(s0, state)); } // Select all. else if input.happened(&InputEvent::SelectAll) { let indices = track.notes.iter().enumerate().map(|n| n.0).collect(); let s0 = state.clone(); state.select_mode = SelectMode::Many(Some(indices)); return Some(Snapshot::from_states(s0, state)); } // Adjust the end of the selection. else if let SelectMode::Many(indices) = &state.select_mode { // Remove a note at the end. if input.happened(&InputEvent::SelectEndLeft) { match indices { Some(indices) => { if indices.len() <= 1 { return None; } match Select::get_last_selected_note(track, indices) { Some(last_selected_note) => { let s0 = state.clone(); // Remove the note. let mut indices = indices.clone(); indices.retain(|n| *n != last_selected_note.0); state.select_mode = SelectMode::Many(Some(indices)); return Some(Snapshot::from_states(s0, state)); } None => return None, } } None => { // Select the note closest to the cursor. if let Some(index) = Select::get_note_index_closest_to_after_cursor( &track.notes, &state.time, ) { let s0 = state.clone(); state.select_mode = SelectMode::Many(Some(vec![index])); return Some(Snapshot::from_states(s0, state)); } None } } } // Add a note at the end. else if input.happened(&InputEvent::SelectEndRight) { match indices { Some(indices) => { match Select::get_last_selected_note(track, indices) { // Get the next note. Some(last_selected_note) => match track .notes .iter() .enumerate() .filter(|n| n.1.gt(last_selected_note.1)) .min_by(|a, b| a.1.cmp(b.1)) { Some(next_note) => { let s0 = state.clone(); // Remove the note. let mut indices = indices.clone(); indices.push(next_note.0); state.select_mode = SelectMode::Many(Some(indices)); return Some(Snapshot::from_states(s0, state)); } None => return None, }, None => return None, } } None => { if let Some(index) = Select::get_note_index_closest_to_after_cursor( &track.notes, &state.time, ) { let s0 = state.clone(); state.select_mode = SelectMode::Many(Some(vec![index])); return Some(Snapshot::from_states(s0, state)); } } } None } else { None } } else { None } } } } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { true } } impl PianoRollSubPanel for Select { fn get_status_tts(&mut self, state: &State, text: &Text) -> Vec { let tts_string = match &state.select_mode { SelectMode::Single(index) => match index { Some(index) => match state.select_mode.get_notes(&state.music) { Some(notes) => { let note = notes[*index]; TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_SELECTED_SINGLE", &[¬e.note.to_string(), &text.get_ppq_tts(¬e.start)], )) } None => TtsString::from(text.get_error("The selected note doesn't exist.")), }, None => get_no_selection_status_tts(text), }, SelectMode::Many(_) => match state.select_mode.get_notes(&state.music) { Some(notes) => match notes.iter().map(|n| n.start).min() { Some(min) => match notes.iter().map(|n| n.end).max() { Some(max) => TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_SELECTED_MANY", &[&text.get_ppq_tts(&min), &text.get_ppq_tts(&max)], )), None => TtsString::from( text.get_error("There is no end time to the selection."), ), }, None => { TtsString::from(text.get_error("There is no start time to the selection.")) } }, None => TtsString::from(text.get_error("The selected notes don't exist.")), }, }; vec![tts_string] } fn get_input_tts(&mut self, state: &State, input: &Input, text: &Text) -> Vec { let (mut tts_strings, selected) = match &state.select_mode { SelectMode::Single(index) => match index { Some(_) => ( vec![self .tooltips .get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_SELECT_SINGLE", &[InputEvent::SelectStartLeft, InputEvent::SelectStartRight], input, text, ) .clone()], true, ), None => (vec![get_no_selection_status_tts(text)], false), }, SelectMode::Many(indices) => match indices { Some(_) => ( vec![self .tooltips .get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_SELECT_MANY", &[ InputEvent::SelectStartLeft, InputEvent::SelectStartRight, InputEvent::SelectEndLeft, InputEvent::SelectEndRight, InputEvent::SelectNone, InputEvent::SelectAll, InputEvent::PianoRollCycleMode, ], input, text, ) .clone()], true, ), None => (vec![get_no_selection_status_tts(text)], false), }, }; if state.select_mode.get_note_indices().is_some() { tts_strings.push( self.tooltips .get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_SELECT_ALL", &[InputEvent::SelectAll], input, text, ) .clone(), ); } if selected { tts_strings.push( self.tooltips .get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_DESELECT", &[InputEvent::SelectNone], input, text, ) .clone(), ); } let cycle_key = match state.select_mode { SelectMode::Single(_) => "PIANO_ROLL_PANEL_INPUT_TTS_SELECT_CYCLE_TO_MANY", SelectMode::Many(_) => "PIANO_ROLL_PANEL_INPUT_TTS_SELECT_CYCLE_TO_SINGLE", }; tts_strings.push( self.tooltips .get_tooltip(cycle_key, &[InputEvent::PianoRollCycleMode], input, text) .clone(), ); tts_strings } } ================================================ FILE: io/src/piano_roll/time.rs ================================================ use super::{get_edit_mode_status_tts, EditModeDeltas, PianoRollSubPanel}; use crate::panel::*; use ini::Ini; /// The piano roll time sub-panel. pub(super) struct Time { /// Time values and deltas. deltas: EditModeDeltas, tooltips: Tooltips, } impl Time { pub fn new(config: &Ini) -> Self { Self { deltas: EditModeDeltas::new(config), tooltips: Tooltips::default(), } } /// Get the end time in PPQ for a cursor. fn get_end(state: &State) -> u64 { match state.music.get_selected_track() { None => panic!("This should never happen!"), Some(track) => match track.get_end() { Some(t1) => t1, None => state.view.dt[1], }, } } /// Move the cursor time. fn set_cursor(&self, state: &mut State, add: bool) -> Option { let s0 = state.clone(); let mode = state.time.mode.get_ref(); let dt = self.deltas.get_dt(mode, &state.input); if add { state.time.cursor += dt; } else { state.time.cursor = state.time.cursor.saturating_sub(dt); } Some(Snapshot::from_states(s0, state)) } /// Move the playback time. fn set_playback(&self, state: &mut State, add: bool) -> Option { let s0 = state.clone(); let mode = state.time.mode.get_ref(); let dt = self.deltas.get_dt(mode, &state.input); if add { state.time.playback += dt; } else { state.time.playback = state.time.playback.saturating_sub(dt); } Some(Snapshot::from_states(s0, state)) } /// Round a time off to the nearest beat. fn get_nearest_beat(t: u64, state: &State) -> u64 { ((t as f32 / state.input.beat.get_f()).ceil() * state.input.beat.get_f()) as u64 } } impl Panel for Time { fn update( &mut self, state: &mut State, _: &mut Conn, input: &Input, _: &mut TTS, _: &Text, _: &mut PathsState, ) -> Option { // Do nothing if there is no track. if state.music.selected.is_none() { None } // Cycle the mode. else if input.happened(&InputEvent::PianoRollCycleMode) { Some(Snapshot::from_state( |s| s.time.mode.index.increment(true), state, )) } // Move the cursor. else if input.happened(&InputEvent::TimeCursorStart) { Some(Snapshot::from_state_value(|s| &mut s.time.cursor, 0, state)) } else if input.happened(&InputEvent::TimeCursorEnd) { Some(Snapshot::from_state_value( |s| &mut s.time.cursor, Time::get_end(state), state, )) } else if input.happened(&InputEvent::TimeCursorLeft) { self.set_cursor(state, false) } else if input.happened(&InputEvent::TimeCursorRight) { self.set_cursor(state, true) } else if input.happened(&InputEvent::TimeCursorPlayback) { Some(Snapshot::from_state_value( |s: &mut State| &mut s.time.cursor, state.time.playback, state, )) } else if input.happened(&InputEvent::TimeCursorBeat) { Some(Snapshot::from_state_value( |s: &mut State| &mut s.time.cursor, Time::get_nearest_beat(state.time.cursor, state), state, )) } // Move the playback. else if input.happened(&InputEvent::TimePlaybackStart) { Some(Snapshot::from_state_value( |s: &mut State| &mut s.time.playback, 0, state, )) } else if input.happened(&InputEvent::TimePlaybackEnd) { Some(Snapshot::from_state_value( |s: &mut State| &mut s.time.playback, Time::get_end(state), state, )) } else if input.happened(&InputEvent::TimePlaybackLeft) { self.set_playback(state, false) } else if input.happened(&InputEvent::TimePlaybackRight) { self.set_playback(state, true) } else if input.happened(&InputEvent::TimePlaybackCursor) { Some(Snapshot::from_state_value( |s: &mut State| &mut s.time.playback, state.time.cursor, state, )) } else if input.happened(&InputEvent::TimePlaybackBeat) { Some(Snapshot::from_state_value( |s| &mut s.time.playback, Time::get_nearest_beat(state.time.playback, state), state, )) } else { None } } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { true } } impl PianoRollSubPanel for Time { fn get_status_tts(&mut self, state: &State, text: &Text) -> Vec { let mut s = vec![get_edit_mode_status_tts(state.time.mode.get_ref(), text)]; s.push(TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_TIME", &[ &text.get_ppq_tts(&state.time.cursor), &text.get_ppq_tts(&state.time.playback), ], ))); s } fn get_input_tts(&mut self, _: &State, input: &Input, text: &Text) -> Vec { vec![ self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_0", &[InputEvent::TimeCursorLeft, InputEvent::TimeCursorRight], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_1", &[InputEvent::TimeCursorStart, InputEvent::TimeCursorEnd], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_2", &[InputEvent::TimeCursorBeat], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_3", &[InputEvent::TimeCursorPlayback], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_4", &[InputEvent::TimePlaybackLeft, InputEvent::TimePlaybackRight], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_5", &[InputEvent::TimePlaybackStart, InputEvent::TimePlaybackEnd], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_6", &[InputEvent::TimePlaybackBeat], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_TIME_7", &[InputEvent::TimePlaybackCursor], input, text, ), ] } } ================================================ FILE: io/src/piano_roll/view.rs ================================================ use super::{ get_cycle_edit_mode_input_tts, get_edit_mode_status_tts, EditModeDeltas, PianoRollSubPanel, }; use crate::panel::*; use common::sizes::get_viewport_size; use ini::Ini; use text::Tooltips; /// The piano roll view sub-panel. pub(super) struct View { /// Time values and deltas. deltas: EditModeDeltas, /// The default viewport dt. dt_0: u64, tooltips: Tooltips, } impl View { pub fn new(config: &Ini) -> Self { let viewport_size = get_viewport_size(config); let dt_0 = viewport_size[0] as u64; Self { deltas: EditModeDeltas::new(config), dt_0, tooltips: Tooltips::default(), } } /// Increment or decrement the top note of the view. fn set_top_note_by(&self, state: &mut State, add: bool) -> Option { let mode = state.view.mode.get(); Some(Snapshot::from_state( |s| s.view.set_top_note_by(self.deltas.get_dn(&mode), add), state, )) } /// Increment or decrement the start time. fn set_start_time_by(&self, state: &mut State, add: bool) -> Option { let s0 = state.clone(); state.view.set_start_time_by( self.deltas.get_dt(state.view.mode.get_ref(), &state.input), add, ); Some(Snapshot::from_states(s0, state)) } /// Zoom in or out. fn zoom(&self, state: &mut State, zoom_in: bool) -> Option { let s0 = state.clone(); state.view.zoom(zoom_in); Some(Snapshot::from_states(s0, state)) } } impl Panel for View { fn update( &mut self, state: &mut State, _: &mut Conn, input: &Input, _: &mut TTS, _: &Text, _: &mut PathsState, ) -> Option { // Do nothing if there is no track. if state.music.selected.is_none() { None } // Cycle the mode. else if input.happened(&InputEvent::PianoRollCycleMode) { Some(Snapshot::from_state( |s| s.view.mode.index.increment(true), state, )) } // Move the view to t0. else if input.happened(&InputEvent::ViewStart) { Some(Snapshot::from_state_value( |s| &mut s.view.dt, [0, state.view.get_dt()], state, )) } // Move the view to t1. else if input.happened(&InputEvent::ViewEnd) { let dt = state.view.get_dt(); let track = state.music.get_selected_track().unwrap(); match track.get_end() { // Move the view to the end. Some(max) => Some(Snapshot::from_state_value( |s| &mut s.view.dt, [max, max + dt], state, )), // Move the view one viewport's dt rightwards. None => Some(Snapshot::from_state_value( |s| &mut s.view.dt, [dt, dt * 2], state, )), } } // Move the view leftwards. else if input.happened(&InputEvent::ViewLeft) { self.set_start_time_by(state, false) } // Move the view rightwards. else if input.happened(&InputEvent::ViewRight) { self.set_start_time_by(state, true) } // Move the view upwards. else if state.view.single_track && input.happened(&InputEvent::ViewUp) { self.set_top_note_by(state, true) } // Move the view downwards. else if state.view.single_track && input.happened(&InputEvent::ViewDown) { self.set_top_note_by(state, false) } // Zoom in. else if input.happened(&InputEvent::ViewZoomIn) { self.zoom(state, true) } // Zoom out. else if input.happened(&InputEvent::ViewZoomOut) { self.zoom(state, false) } // Zoom default. else if input.happened(&InputEvent::ViewZoomDefault) { Some(Snapshot::from_state_value( |s| &mut s.view.dt, [state.view.dt[0], state.view.dt[0] + self.dt_0], state, )) } else { None } } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { true } } impl PianoRollSubPanel for View { fn get_status_tts(&mut self, state: &State, text: &Text) -> Vec { let mut s = vec![TtsString::from(text.get_with_values( "PIANO_ROLL_PANEL_STATUS_TTS_VIEW", &[ &text.get_ppq_tts(&state.view.dt[0]), &text.get_ppq_tts(&state.view.dt[1]), text.get_note_name(state.view.dn[0]), text.get_note_name(state.view.dn[1]), ], ))]; s.push(get_edit_mode_status_tts(state.view.mode.get_ref(), text)); s } fn get_input_tts(&mut self, state: &State, input: &Input, text: &Text) -> Vec { let mut s = if state.view.single_track { vec![self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_0", &[ InputEvent::ViewUp, InputEvent::ViewDown, InputEvent::ViewLeft, InputEvent::ViewRight, ], input, text, )] } else { vec![ self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_VIEW_MULTI_TRACK_0", &[InputEvent::ViewLeft, InputEvent::ViewRight], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_VIEW_MULTI_TRACK_1", &[InputEvent::ViewStart, InputEvent::ViewEnd], input, text, ), ] }; s.append(&mut vec![ self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_1", &[InputEvent::ViewStart, InputEvent::ViewEnd], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_2", &[InputEvent::ViewZoomIn, InputEvent::ViewZoomOut], input, text, ), self.tooltips.get_tooltip( "PIANO_ROLL_PANEL_INPUT_TTS_VIEW_SINGLE_TRACK_3", &[InputEvent::ViewZoomDefault], input, text, ), ]); s.push(get_cycle_edit_mode_input_tts( &mut self.tooltips, &state.view.mode, input, text, )); s } } ================================================ FILE: io/src/piano_roll.rs ================================================ mod edit_mode_deltas; mod view; use edit_mode_deltas::EditModeDeltas; mod edit; mod piano_roll_panel; mod piano_roll_sub_panel; mod select; mod time; use self::edit::Edit; pub(crate) use piano_roll_panel::PianoRollPanel; pub(crate) use piano_roll_sub_panel::PianoRollSubPanel; pub(super) use piano_roll_sub_panel::{ get_cycle_edit_mode_input_tts, get_edit_mode_status_tts, get_no_selection_status_tts, }; use select::Select; use time::Time; use view::View; ================================================ FILE: io/src/popup.rs ================================================ use common::{Index, PanelType, State}; /// A popup needs to store the panels that were active before it was enabled, and re-enable them when the popup is disabled. #[derive(Default)] pub(crate) struct Popup { /// The index of the panel that had focus prior to this popup being enabled. focus: usize, /// The active panels prior to this popup being enabled. panels: Vec, } impl Popup { /// Enable the panel. Store the state of the active panels. Set the state's active panels. pub fn enable(&mut self, state: &mut State, panels: Vec) { self.focus = state.focus.get(); self.panels.clone_from(&state.panels); state.panels = panels; state.focus = Index::new(0, state.panels.len()); } /// Disable the panel. Set the state's active panels. pub fn disable(&self, state: &mut State) { state.panels.clone_from(&self.panels); state.focus = Index::new(self.focus, self.panels.len()); } } ================================================ FILE: io/src/quit_panel.rs ================================================ use crate::panel::*; use common::PanelType; /// Are you sure you want to quit? #[derive(Default)] pub(crate) struct QuitPanel { popup: Popup, tooltips: Tooltips, } impl QuitPanel { pub fn enable(&mut self, state: &mut State) { self.popup.enable(state, vec![PanelType::Quit]); } } impl Panel for QuitPanel { fn update( &mut self, state: &mut State, _: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, _: &mut PathsState, ) -> Option { if input.happened(&InputEvent::QuitPanelYes) { Some(Snapshot::from_io_commands(vec![IOCommand::Quit])) } else { if input.happened(&InputEvent::InputTTS) { tts.enqueue(self.tooltips.get_tooltip( "QUIT_PANEL_INPUT_TTS", &[InputEvent::QuitPanelYes, InputEvent::QuitPanelNo], input, text, )); } else if input.happened(&InputEvent::QuitPanelNo) { self.popup.disable(state); } None } } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { false } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } } ================================================ FILE: io/src/save.rs ================================================ use audio::exporter::Exporter; use audio::*; use common::{PathsState, State}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{from_str, to_string, Error}; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; const READ_ERROR: &str = "Error reading file: "; const WRITE_ERROR: &str = "Error writing file: "; /// Serializable save data. #[derive(Deserialize, Serialize)] pub(crate) struct Save { /// The app state. state: State, /// The synthesizer state. synth_state: SynthState, /// The paths state. paths_state: PathsState, /// The exporter state. exporter: Exporter, /// The version string. #[serde(default = "default_version")] version: String, } impl Save { /// Write this state to a file. /// /// - `path` The path we will write to. /// - `state` The app state. /// - `conn` The audio connection. Its `SynthState` will be serialized. /// - `paths_state` The paths state. pub fn write(path: &PathBuf, state: &State, conn: &Conn, paths_state: &PathsState) { // Convert the state to something that can be serialized. let save = Save { state: state.clone(), synth_state: conn.state.clone(), paths_state: paths_state.clone(), exporter: conn.exporter.clone(), version: common::VERSION.to_string(), }; // Try to open the file. match OpenOptions::new() .write(true) .append(false) .truncate(true) .create(true) .open(path) { Ok(mut file) => { let s = match to_string(&save) { Ok(s) => s, Err(error) => panic!("{} {}", WRITE_ERROR, error), }; if let Err(error) = file.write(s.as_bytes()) { panic!("{} {}", WRITE_ERROR, error) } } Err(error) => panic!("{} {}", WRITE_ERROR, error), } } /// Load a file and deserialize. /// /// - `path` The path we read from. /// - `state` The app state, which will be set to a deserialized version. /// - `conn` The audio connection. Its `SynthState` will be set via commands derived from a deserialized version. /// - `paths_state` The paths state, which will be set to a deserialized version. pub fn read(path: &Path, state: &mut State, conn: &mut Conn, paths_state: &mut PathsState) { match File::open(path) { Ok(mut file) => { let mut string = String::new(); match file.read_to_string(&mut string) { Ok(_) => { // Repair the save file if needed. let string = Self::fix_no_flac(&string); let q: Result = from_str(&string); match q { Ok(s) => { // Set the app state. *state = s.state; // Set the paths. *paths_state = s.paths_state; // Set the exporter. conn.exporter = s.exporter; // Set the synthesizer. // Set the gain. let mut commands = vec![Command::SetGain { gain: s.synth_state.gain, }]; // Load each SoundFont. for program in s.synth_state.programs.iter() { if !program.1.path.exists() { continue; } let channel = *program.0; commands.push(Command::LoadSoundFont { channel, path: program.1.path.clone(), }); } // Set each program. // Load each SoundFont. for program in s.synth_state.programs.iter() { if !program.1.path.exists() { continue; } let channel = *program.0; commands.push(Command::SetProgram { channel, path: program.1.path.clone(), bank_index: program.1.bank_index, preset_index: program.1.preset_index, }); } // Set the synth state. conn.state = s.synth_state; // Send the commands. conn.do_commands(&commands); } Err(error) => panic!("{} {}", READ_ERROR, error), } } Err(error) => panic!("{} {}", READ_ERROR, error), } } Err(error) => panic!("{} {}", READ_ERROR, error), } } /// Fix the export types if this is pre-0.1.3, which didn't have Flac exporting. /// This apparently isn't possible to fix in Exporter via serde. fn fix_no_flac(string: &str) -> String { let re = Regex::new(r#"(("export_type":\{"values":\["Wav","Mid","MP3","Ogg"\],"index":\{"index":)([0-9]),"length":4\}\})"#).unwrap(); re.replace(string, r#""export_type":{"values":["Wav","Mid","MP3","Ogg","Flac"],"index":{"index":0,"length":5}}"#).into() } } /// Pre-0.1.3, the version isn't in the save file. This is the default version. fn default_version() -> String { "0.1.2".to_string() } ================================================ FILE: io/src/snapshot.rs ================================================ use crate::{IOCommand, IOCommands, State}; use audio::{CommandsMessage, Conn}; /// A snapshot of a state delta. #[derive(Default)] pub(crate) struct Snapshot { /// The state before changes were applied. pub(crate) from_state: Option, /// The state after changes were applied. to_state: Option, /// Commands that need to be sent to revert to the state before changes were applied. pub(crate) from_commands: Option, /// Commands that need to be sent to apply changes. to_commands: Option, /// A list of commands to send to the `IO` state. pub(crate) io_commands: IOCommands, } impl Snapshot { /// Sets a value and returns a snapshot of the delta between two states. /// /// - `f` A function that accepts a `State` parameter and returns a mutable reference value of type T. /// - `value` The new value of `f(state)`. /// - `state` The current state. This will be cloned, then modified, to create a delta. pub fn from_state_value(mut f: F, value: T, state: &mut State) -> Self where F: FnMut(&mut State) -> &mut T, { let s0 = state.clone(); *f(state) = value; Self::from_states(s0, state) } /// Calls a function and returns a snapshot of the delta between two states. /// /// - `f` A function that accepts a `State` parameter and returns a mutable reference value of type T. /// - `state` The current state. This will be cloned, then modified, to create a delta. pub fn from_state(mut f: F, state: &mut State) -> Self where F: FnMut(&mut State), { let s0 = state.clone(); f(state); Self::from_states(s0, state) } /// Returns a snapshot of the delta between two states. /// /// - `from_state` The initial state of the delta. This is usually a clone of a `State` prior to modifying the primary `State`. /// - `to_state` The final state of the delta. This is a reference to the primary `State`. pub fn from_states(from_state: State, to_state: &mut State) -> Self { Self { from_state: Some(from_state), to_state: Some(to_state.clone()), ..Default::default() } } /// Returns a snapshot of the delta between to synth states. /// /// - `from_commands` A list of commands that will revert the `SynthState` to the initial state. /// - `to_commands` A list of commands that will set the `SynthState` to the new state. This list will be sent by the `Conn`. /// - `conn` The audio connection that will send the commands. pub fn from_commands( from_commands: CommandsMessage, to_commands: CommandsMessage, conn: &mut Conn, ) -> Self { let snapshot = Self { from_commands: Some(from_commands), to_commands: Some(to_commands.clone()), ..Default::default() }; conn.do_commands(&to_commands); snapshot } /// Returns a snapshot of the delta between two states as well as two synth states. /// /// - `from_state` The initial state of the delta. This is usually a clone of a `State` prior to modifying the primary `State`. /// - `to_state` The final state of the delta. This is a reference to the primary `State`. /// - `from_commands` A list of commands that will revert the `SynthState` to the initial state. /// - `to_commands` A list of commands that will set the `SynthState` to the new state. This list will be sent by the `Conn`. /// - `conn` The audio connection. pub fn from_states_and_commands( from_state: State, to_state: &mut State, from_commands: CommandsMessage, to_commands: CommandsMessage, conn: &mut Conn, ) -> Self { let snapshot = Self { from_state: Some(from_state), to_state: Some(to_state.clone()), from_commands: Some(from_commands), to_commands: Some(to_commands.clone()), io_commands: None, }; conn.do_commands(&to_commands); snapshot } /// Returns a snapshot that just contains IOCommands. /// /// - `io_commands` A list of IOCommands that will be processed by the `IO`. pub fn from_io_commands(io_commands: Vec) -> Self { Self { io_commands: Some(io_commands), ..Default::default() } } /// Returns a snapshot that flips the from/to of `snapshot`. This is used for undo/redo. /// /// - The Snapshot. Its `from_state` will become the returned Snapshot's `to_state` and vice-versa. Its `from_commands` will become the returned Snapshot's `to_commands` and vice-versa. pub fn from_snapshot(snapshot: &Snapshot) -> Self { Self { from_state: snapshot.to_state.clone(), to_state: snapshot.from_state.clone(), from_commands: snapshot.to_commands.clone(), to_commands: snapshot.from_commands.clone(), io_commands: None, } } } ================================================ FILE: io/src/tracks_panel.rs ================================================ use crate::panel::*; use crate::select_track; use common::open_file::OpenFileType; use common::{MidiTrack, Paths, SelectMode, MAX_VOLUME}; use std::path::PathBuf; use text::get_file_name_no_ex; const TRACK_SCROLL_EVENTS: [InputEvent; 2] = [InputEvent::PreviousTrack, InputEvent::NextTrack]; /// A list of tracks and their parameters. pub(crate) struct TracksPanel { default_soundfont_path: PathBuf, tooltips: Tooltips, } impl TracksPanel { /// Increment or decrement the preset index. Returns a new undo-redo state. fn set_preset(channel: u8, conn: &mut Conn, up: bool) -> Option { let program = conn.state.programs.get(&channel).unwrap(); let mut index = Index::new(program.preset_index, program.num_presets); index.increment(up); let preset_index = index.get(); let path = program.path.clone(); let c0 = vec![Command::SetProgram { channel, path: path.clone(), bank_index: program.bank_index, preset_index: program.preset_index, }]; let c1 = vec![Command::SetProgram { channel, path, bank_index: program.bank_index, preset_index, }]; Some(Snapshot::from_commands(c0, c1, conn)) } /// Increment or decrement the bank index, setting the preset index to 0. Returns a new undo-redo state. fn set_bank(channel: u8, conn: &mut Conn, up: bool) -> Option { let program = conn.state.programs.get(&channel).unwrap(); let bank_index_0 = program.bank_index; let mut index = Index::new(program.bank_index, program.num_banks); index.increment(up); let bank_index = index.get(); // The bank didn't change. Don't reset anything. if bank_index == bank_index_0 { None } else { let path = program.path.clone(); let c0 = vec![Command::SetProgram { channel, path: path.clone(), bank_index: program.bank_index, preset_index: program.preset_index, }]; let c1 = vec![Command::SetProgram { channel, path, bank_index, preset_index: 0, }]; Some(Snapshot::from_commands(c0, c1, conn)) } } /// Increment or decrement the track gain. Returns a new undo-redo state. fn set_gain(state: &mut State, up: bool) -> Option { let s0 = state.clone(); let track = state.music.get_selected_track_mut().unwrap(); let mut index = Index::new(track.gain, MAX_VOLUME + 1); index.increment(up); let gain = index.get(); track.gain = gain; Some(Snapshot::from_states(s0, state)) } } impl Default for TracksPanel { fn default() -> Self { let default_soundfont_path = Paths::get().default_soundfont_path.clone(); Self { default_soundfont_path, tooltips: Tooltips::default(), } } } impl Panel for TracksPanel { fn update( &mut self, state: &mut State, conn: &mut Conn, input: &Input, tts: &mut TTS, text: &Text, _: &mut PathsState, ) -> Option { // Status TTS. if input.happened(&InputEvent::StatusTTS) { match state.music.get_selected_track() { Some(track) => { // Track ? is selected. let mut s = text.get_with_values( "TRACKS_PANEL_STATUS_TTS_PREFIX", &[&track.channel.to_string()], ); s.push(' '); // Is there a SoundFont? match conn.state.programs.get(&track.channel) { // Track staus. Some(program) => { s.push_str(&text.get_with_values( "TRACKS_PANEL_STATUS_TTS_SOUNDFONT", &[ &program.preset_name, &program.bank.to_string(), &track.gain.to_string(), get_file_name_no_ex(&program.path), ], )); // Muted. if track.mute { s.push(' '); s.push_str(text.get_ref("TRACKS_PANEL_STATUS_TTS_MUTED")) } // Soloed. if track.solo { s.push(' '); s.push_str(text.get_ref("TRACKS_PANEL_STATUS_TTS_SOLOED")) } } // No SoundFont. None => s.push_str(text.get_ref("TRACKS_PANEL_STATUS_TTS_NO_SOUNDFONT")), } tts.enqueue(s) } None => tts.enqueue(text.get_ref("TRACKS_PANEL_STATUS_TTS_NO_SELECTION")), } None } // Input TTS. else if input.happened(&InputEvent::InputTTS) { let mut s = vec![self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_ADD", &[InputEvent::AddTrack], input, text, )]; // There is a selected track. if let Some(track) = state.music.get_selected_track() { s.push(self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_TRACK_PREFIX_0", &[InputEvent::RemoveTrack], input, text, )); s.push(self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_TRACK_PREFIX_1", &[InputEvent::PreviousTrack, InputEvent::NextTrack], input, text, )); s.push(self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_TRACK_PREFIX_2", &[InputEvent::EnableSoundFontPanel], input, text, )); // Is there a program? if conn.state.programs.get(&track.channel).is_some() { // Preset, bank, gain. s.push(self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_TRACK_SUFFIX_0", &[InputEvent::PreviousPreset, InputEvent::NextPreset], input, text, )); s.push(self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_TRACK_SUFFIX_1", &[InputEvent::PreviousBank, InputEvent::NextBank], input, text, )); s.push(self.tooltips.get_tooltip( "TRACKS_PANEL_INPUT_TTS_TRACK_SUFFIX_2", &[InputEvent::DecreaseTrackGain, InputEvent::IncreaseTrackGain], input, text, )); // Mute. let mute_key = if track.mute { "TRACKS_PANEL_INPUT_TTS_UNMUTE" } else { "TRACKS_PANEL_INPUT_TTS_MUTE" }; s.push( self.tooltips .get_tooltip(mute_key, &[InputEvent::Mute], input, text), ); // Solo. let solo_key = if track.solo { "TRACKS_PANEL_INPUT_TTS_UNSOLO" } else { "TRACKS_PANEL_INPUT_TTS_SOLO" }; s.push( self.tooltips .get_tooltip(solo_key, &[InputEvent::Solo], input, text), ); } // Say it. tts.enqueue(s); None } else { tts.enqueue(s); None } } // Add a track. else if input.happened(&InputEvent::AddTrack) { let s0 = state.clone(); // Get all channels currently being used. let track_channels: Vec = state.music.midi_tracks.iter().map(|t| t.channel).collect(); // Get all available channels and get the minimum availabe channel. match (0u8..255u8).filter(|c| !track_channels.contains(c)).min() { Some(channel) => { // Deselect. state.select_mode = match &state.select_mode { SelectMode::Single(_) => SelectMode::Single(None), SelectMode::Many(_) => SelectMode::Many(None), }; // Set the selection. state.music.selected = Some(state.music.midi_tracks.len()); // Add a track. state.music.midi_tracks.push(MidiTrack::new(channel)); // Set the soundfont to the default. let c0 = vec![Command::UnsetProgram { channel }]; let c1 = vec![Command::LoadSoundFont { channel, path: self.default_soundfont_path.clone(), }]; Some(Snapshot::from_states_and_commands(s0, state, c0, c1, conn)) } None => None, } } // There is a selected track. else if let Some(selected) = state.music.selected { // Remove the selected track. if input.happened(&InputEvent::RemoveTrack) { let channel = state.music.get_selected_track().unwrap().channel; let s0 = state.clone(); state.music.selected = match state.music.midi_tracks.len() { 0 => panic!("Somehow, there are zero tracks. This should never happen."), // There is only one track, so soon there will be none. 1 => None, _ => match selected { // First track. 0 => Some(0), other => Some(other - 1), }, }; // Deselect. state.select_mode = match &state.select_mode { SelectMode::Single(_) => SelectMode::Single(None), SelectMode::Many(_) => SelectMode::Many(None), }; // Remove the track. state.music.midi_tracks.retain(|t| t.channel != channel); // This track has a program that needs to be unset. match conn.state.programs.get(&channel) { Some(program) => { // Undo: Set the program. let c0 = vec![Command::SetProgram { channel, path: program.path.clone(), bank_index: program.bank_index, preset_index: program.preset_index, }]; let c1 = vec![Command::UnsetProgram { channel }]; Some(Snapshot::from_states_and_commands(s0, state, c0, c1, conn)) } None => Some(Snapshot::from_states(s0, state)), } } else if input.happened(&InputEvent::EnableSoundFontPanel) { return Some(Snapshot::from_io_commands(vec![IOCommand::EnableOpenFile( OpenFileType::SoundFont, )])); } // Select a track. else if let Some(snapshot) = select_track(state, input, TRACK_SCROLL_EVENTS) { return Some(snapshot); } // Track-specific operations. else { let track = state.music.get_selected_track().unwrap(); let channel = track.channel; // Set the program. match conn.state.programs.get(&channel) { Some(_) => { if input.happened(&InputEvent::NextPreset) { TracksPanel::set_preset(channel, conn, true) } else if input.happened(&InputEvent::PreviousPreset) { TracksPanel::set_preset(track.channel, conn, false) } else if input.happened(&InputEvent::NextBank) { TracksPanel::set_bank(track.channel, conn, true) } else if input.happened(&InputEvent::PreviousBank) { TracksPanel::set_bank(track.channel, conn, false) } else if input.happened(&InputEvent::IncreaseTrackGain) { TracksPanel::set_gain(state, true) } else if input.happened(&InputEvent::DecreaseTrackGain) { TracksPanel::set_gain(state, false) } else if input.happened(&InputEvent::Mute) { let s0 = state.clone(); let track = state.music.get_selected_track_mut().unwrap(); track.mute = !track.mute; // Un-solo. if track.mute && track.solo { track.solo = false; } Some(Snapshot::from_states(s0, state)) } else if input.happened(&InputEvent::Solo) { let s0 = state.clone(); let track = state.music.get_selected_track_mut().unwrap(); track.solo = !track.solo; // Un-mute. if track.mute && track.solo { track.mute = false; } Some(Snapshot::from_states(s0, state)) } else { None } } None => None, } } } else { None } } fn on_disable_abc123(&mut self, _: &mut State, _: &mut Conn) {} fn update_abc123( &mut self, _: &mut State, _: &Input, _: &mut Conn, ) -> (Option, bool) { (None, false) } fn allow_alphanumeric_input(&self, _: &State, _: &Conn) -> bool { false } fn allow_play_music(&self) -> bool { true } } ================================================ FILE: py/build.py ================================================ from os import chdir, getcwd from subprocess import check_output, CalledProcessError, call from pathlib import Path from ftplib import FTP, error_perm import re from github import Repository, Github def upload_directory(ftp: FTP, folder: str = None) -> None: if folder is not None: chdir(folder) ftp.cwd(folder) for fi in Path(getcwd()).resolve().iterdir(): if fi.is_file(): with fi.open("rb") as bs: ftp.storbinary(f'STOR {fi.name}', bs) print(f"Uploaded: {fi.name}") def ftp_login() -> FTP: ftp = FTP("subalterngames.com") ftp_credentials = Path("credentials/ftp.txt").read_text().split("\n") ftp.login(user=ftp_credentials[0], passwd=ftp_credentials[1]) print("Logged into FTP") return ftp def ftp_website(ftp: FTP) -> None: cwd = getcwd() root_remote = "subalterngames.com/cacophony" ftp.cwd(root_remote) print("Set cwd") chdir("../html") upload_directory(ftp) upload_directory(ftp, folder="images") upload_directory(ftp, folder="../fonts/noto") print("...Done!") ftp.cwd("/subalterngames.com/cacophony") chdir(cwd) def ftp_cwd(ftp: FTP, folder: str) -> None: try: ftp.cwd(folder) except error_perm: ftp.mkd(folder) ftp.cwd(folder) def get_version() -> str: # Compare versions. version = re.search(r'version = "(.*?)"', Path("../Cargo.toml").read_text()).group(1) try: resp = check_output(["git", "describe", "--tags", "--abbrev=0"]) latest_version = str(resp).strip() except CalledProcessError: latest_version = None if version == latest_version: print("Can't upload. Update the version.") exit() return version def tag(version: str) -> None: call(["git", "tag", version]) call(["git", "push", "origin", version]) print("Tagged.") def create_builds(version: str) -> None: # Get the repo. token: str = Path("credentials/github.txt").resolve().read_text(encoding="utf-8").strip() repo: Repository = Github(token).get_repo("subalterngames/cacophony") # Build the releases. workflow = repo.get_workflow(66524374) workflow.create_dispatch(ref="main", inputs={"version": version}) f = ftp_login() ftp_website(f) f.close() v = get_version() tag(v) create_builds(v) ================================================ FILE: py/itch_changelog.py ================================================ from pathlib import Path import re version = re.search(r'\[workspace.package]\nversion = "(.*?)"$', Path("../Cargo.toml").read_text(), flags=re.MULTILINE).group(1) changelog = re.search(f'## {version}' + r'((.|\n)*?)##', Path("../changelog.md").read_text(), flags=re.MULTILINE).group(1).strip() itch = re.sub(r'- (.*?)$', r'
  • \1
  • ', changelog, flags=re.MULTILINE) print(f"
      \n{itch}\n
    ") ================================================ FILE: py/macroquad_icon_creator.py ================================================ from pathlib import Path from PIL import Image import numpy as np icon = bytearray() source: Image.Image = Image.open(str(Path.home().joinpath("cacophony/promo/icon/256.png").resolve())) for size in [64, 32, 16]: icon.extend(np.array(source.resize((size, size), Image.Resampling.LANCZOS)).tobytes()) Path.home().joinpath("cacophony/data/icon").resolve().write_bytes(icon) ================================================ FILE: render/Cargo.toml ================================================ [package] name = "render" version.workspace = true authors.workspace = true description.workspace = true documentation.workspace = true edition.workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } rust-ini = { workspace = true } macroquad = { workspace = true } hashbrown = { workspace = true } colorgrad = { workspace = true } [dependencies.audio] path = "../audio" [dependencies.common] path = "../common" [dependencies.input] path = "../input" [dependencies.text] path = "../text" ================================================ FILE: render/src/color_key.rs ================================================ use strum_macros::EnumString; /// Enum values for color keys. #[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, EnumString)] pub enum ColorKey { Background, NoFocus, FocusDefault, Key, Value, True, False, Arrow, TextFieldBG, Note, NoteSelected, NotePlaying, TimeCursor, TimePlayback, Subtitle, Separator, TextInput, SelectedNotesBackground, Track0Focus, Track0NoFocus, Track1Focus, Track1NoFocus, Track2Focus, Track2NoFocus, Track3Focus, Track3NoFocus, Track4Focus, Track4NoFocus, Track5Focus, Track5NoFocus, SubtitleBackground, } ================================================ FILE: render/src/drawable.rs ================================================ pub(crate) use crate::Renderer; pub(crate) use audio::Conn; pub(crate) use common::{PathsState, State}; pub(crate) use input::Input; pub(crate) use text::Text; /// A drawable region. pub(crate) trait Drawable { /// Draw the panel. /// /// - `renderer` The renderer. /// - `state` The state of the app. /// - `conn` The synthesizer-player connection. /// - `text` The text. /// - `paths_state` The file paths state. fn update( &self, renderer: &Renderer, state: &State, conn: &Conn, text: &Text, paths_state: &PathsState, ); } ================================================ FILE: render/src/export_panel.rs ================================================ use crate::panel::*; use crate::Popup; use audio::export::ExportState; use macroquad::prelude::*; /// Are we done yet? pub(crate) struct ExportPanel { /// The panel. panel: Panel, /// The popup handler. pub popup: Popup, decaying_label: Label, writing_label: Label, } impl ExportPanel { pub fn new(config: &Ini, renderer: &Renderer, text: &Text) -> Self { let window_grid_size = get_window_grid_size(config); let h: u32 = 3; let y = window_grid_size[1] / 2 - 1; let w = window_grid_size[0] / 4; let x = window_grid_size[0] / 2 - w / 2; let position = [x, y]; let size = [w, h]; let panel = Panel::new(PanelType::ExportState, position, size, renderer, text); let popup = Popup::new(PanelType::ExportState); let decaying = text.get("EXPORT_PANEL_APPENDING_DECAY"); let decaying_label = Label::new( [ position[0] + size[0] / 2 - decaying.chars().count() as u32 / 2, position[1] + 1, ], decaying, renderer, ); let writing = text.get("EXPORT_PANEL_WRITING"); let writing_label = Label::new( [ position[0] + size[0] / 2 - writing.chars().count() as u32 / 2, position[1] + 1, ], writing, renderer, ); Self { panel, popup, decaying_label, writing_label, } } } impl Drawable for ExportPanel { fn update(&self, renderer: &Renderer, _: &State, conn: &Conn, _: &Text, _: &PathsState) { self.popup.update(renderer); self.panel.update(true, renderer); let export_state = conn.export_state.lock(); match *export_state { ExportState::WritingWav { total_samples, exported_samples, } => { let samples = format!("{}/{}", exported_samples, total_samples); // Draw the string. let w = samples.chars().count() as u32; let x = self.panel.background.grid_rect.position[0] + self.panel.background.grid_rect.size[0] / 2 - w / 2; let y = self.panel.background.grid_rect.position[1] + 1; let label = Label::new([x, y], samples, renderer); renderer.text(&label, &ColorKey::FocusDefault); } ExportState::AppendingDecay => { renderer.text(&self.decaying_label, &ColorKey::FocusDefault); } ExportState::WritingToDisk => { renderer.text(&self.writing_label, &ColorKey::FocusDefault); } _ => (), } } } ================================================ FILE: render/src/export_settings_panel.rs ================================================ use crate::panel::*; use crate::Focus; use audio::export::{ExportSetting, ExportType, MultiFileSuffix}; use audio::exporter::{Exporter, MP3_BIT_RATES}; use common::IndexedValues; use hashbrown::HashMap; use serde::de::DeserializeOwned; use serde::Serialize; use text::ValueMap; use util::KV_PADDING; struct SeparatorLines { framerate: Line, mp3_bit_rate: Line, ogg_quality: Line, title: Line, } /// Export settings panel. pub(crate) struct ExportSettingsPanel { /// The panel position. position: [u32; 2], /// The panel width. width: u32, /// The title label for the panel. title: Label, /// The position and size of the title in grid units. title_rect: Rectangle, /// The framerate field. framerate: KeyListCorners, /// The MP3 bit rate field. mp3_bit_rate: KeyListCorners, /// The MP3/ogg quality field. quality: KeyListCorners, /// String values of multi-file suffixes. multi_file_suffixes: ValueMap, /// Panel background sizes per export type. backgrounds: HashMap, separator_lines: SeparatorLines, } impl ExportSettingsPanel { pub fn new(config: &Ini, renderer: &Renderer, exporter: &Exporter, text: &Text) -> Self { let (open_file_position, open_file_size) = get_open_file_rect(config); let position = [ open_file_position[0], open_file_position[1] + open_file_size[1] + OPEN_FILE_PANEL_PROMPT_HEIGHT, ]; let width: u32 = open_file_size[0]; let title = text.get("TITLE_EXPORT_SETTINGS"); let title_position = [position[0] + 2, position[1]]; let title_width = title.chars().count() as u32; let title = Label::new(title_position, title, renderer); let title_rect = Rectangle::new(title_position, [title_width, 1]); let x = position[0] + 1; let y = position[1] + 1; let w = width - 2; let framerate = KeyListCorners::new( text.get("EXPORT_SETTINGS_PANEL_FRAMERATE"), [x, y], w, 5, renderer, ); let quality = KeyListCorners::new( text.get("EXPORT_SETTINGS_PANEL_QUALITY"), [x, y + 1], w, 1, renderer, ); let mp3_bit_rate = KeyListCorners::new( text.get("EXPORT_SETTINGS_PANEL_MP3_BIT_RATE"), [x, y + 2], w, 6, renderer, ); let separator_lines = SeparatorLines { framerate: Self::get_separator([x, framerate.y + 1], width, renderer), mp3_bit_rate: Self::get_separator([x, mp3_bit_rate.y + 1], width, renderer), ogg_quality: Self::get_separator([x, quality.y + 1], width, renderer), title: Self::get_separator([x, y + 1], width, renderer), }; let multi_file_suffixes = ValueMap::new( [ MultiFileSuffix::Channel, MultiFileSuffix::Preset, MultiFileSuffix::ChannelAndPreset, ], [ "EXPORT_SETTINGS_PANEL_FILE_SUFFIX_CHANNEL", "EXPORT_SETTINGS_PANEL_FILE_SUFFIX_PRESET", "EXPORT_SETTINGS_PANEL_FILE_SUFFIX_CHANNEL_AND_PRESET", ], text, ); // Calculate the background sizes per export type. let mut backgrounds = HashMap::new(); backgrounds.insert( ExportType::Wav, PanelBackground::new( position, [width, exporter.wav_settings.index.get_length() as u32 + 3], renderer, ), ); backgrounds.insert( ExportType::Mid, PanelBackground::new( position, [width, exporter.mid_settings.index.get_length() as u32 + 2], renderer, ), ); backgrounds.insert( ExportType::MP3, PanelBackground::new( position, [width, exporter.mp3_settings.index.get_length() as u32 + 4], renderer, ), ); backgrounds.insert( ExportType::Ogg, PanelBackground::new( position, [width, exporter.ogg_settings.index.get_length() as u32 + 4], renderer, ), ); backgrounds.insert( ExportType::Flac, PanelBackground::new( position, [width, exporter.flac_settings.index.get_length() as u32 + 4], renderer, ), ); Self { position, width, title, title_rect, framerate, mp3_bit_rate, quality, multi_file_suffixes, backgrounds, separator_lines, } } fn update_settings( &self, f: F, renderer: &Renderer, state: &State, text: &Text, exporter: &Exporter, focus: bool, ) where F: Fn(&Exporter) -> &IndexedValues, [ExportSetting; N]: Serialize + DeserializeOwned, { // This is used to decide where to draw separators. let export_type = exporter.export_type.get(); // Get the color of the separator line. let line_color = if focus { ColorKey::Separator } else { ColorKey::NoFocus }; // Get the start positions. let x = self.position[0] + 1; let mut y = self.position[1] + 1; if export_type == ExportType::Flac { y += 1; } let (settings, values) = f(exporter).get_values(); for (setting, value) in settings.iter().zip(values) { let setting_focus = [focus, value]; match setting { ExportSetting::Framerate => { renderer.key_list_corners( &exporter.framerate.to_string(), &self.framerate, setting_focus, ); // For .wav and .flac files, draw a separator here. y = self.framerate.y + 1; if export_type == ExportType::Wav || export_type == ExportType::Flac { renderer.horizontal_line(&self.separator_lines.framerate, &line_color); y += 1; } } ExportSetting::Mp3BitRate => { renderer.key_list_corners( &((MP3_BIT_RATES[exporter.mp3_bit_rate.get()] as u16) as u32 * 1000) .to_string(), &self.mp3_bit_rate, setting_focus, ); renderer.horizontal_line(&self.separator_lines.mp3_bit_rate, &line_color); y = self.mp3_bit_rate.y + 2; } ExportSetting::Mp3Quality => renderer.key_list_corners( &exporter.mp3_quality.get().to_string(), &self.quality, setting_focus, ), ExportSetting::OggQuality => { renderer.key_list_corners( &exporter.ogg_quality.get().to_string(), &self.quality, setting_focus, ); renderer.horizontal_line(&self.separator_lines.ogg_quality, &line_color); y = self.quality.y + 2; } ExportSetting::Title => { let key_input = KeyInput::new_from_padding( text.get_ref("EXPORT_SETTINGS_PANEL_TITLE"), &exporter.metadata.title, [x, y], self.width - 2, KV_PADDING, renderer, ); renderer.key_input( &exporter.metadata.title, &key_input, state.input.alphanumeric_input, setting_focus, ); y += 1; // For .wav files, draw a separator here. if export_type == ExportType::Wav { renderer.horizontal_line(&self.separator_lines.title, &line_color); y += 1; } } ExportSetting::Artist => self.draw_optional_input( text.get_ref("EXPORT_SETTINGS_PANEL_ARTIST"), &exporter.metadata.artist, (x, &mut y), renderer, state, setting_focus, ), ExportSetting::Copyright => { self.draw_boolean( text.get("EXPORT_SETTINGS_PANEL_COPYRIGHT"), exporter.copyright, (x, &mut y), renderer, text, setting_focus, ); } ExportSetting::Album => self.draw_optional_input( text.get_ref("EXPORT_SETTINGS_PANEL_ALBUM"), &exporter.metadata.album, (x, &mut y), renderer, state, setting_focus, ), ExportSetting::TrackNumber => { let n = text.get("NONE"); let value_width = n.chars().count() as u32; let value = match &exporter.metadata.track_number { Some(value) => value.to_string(), None => n, }; let key_list = KeyListCorners::new( text.get("EXPORT_SETTINGS_PANEL_TRACK_NUMBER"), [x, y], self.width - 2, value_width, renderer, ); renderer.key_list_corners(&value, &key_list, setting_focus); y += 1; } ExportSetting::Genre => self.draw_optional_input( text.get_ref("EXPORT_SETTINGS_PANEL_GENRE"), &exporter.metadata.genre, (x, &mut y), renderer, state, setting_focus, ), ExportSetting::Comment => { self.draw_optional_input( text.get_ref("EXPORT_SETTINGS_PANEL_COMMENT"), &exporter.metadata.comment, (x, &mut y), renderer, state, setting_focus, ); // This is always the last of the metadata. Draw a line. let separator = Self::get_separator([x, y], self.width, renderer); renderer.horizontal_line(&separator, &line_color); y += 1; } ExportSetting::MultiFile => self.draw_boolean( text.get("EXPORT_SETTINGS_PANEL_MULTI_FILE"), exporter.multi_file, (x, &mut y), renderer, text, setting_focus, ), ExportSetting::MultiFileSuffix => { let value = self .multi_file_suffixes .get(&exporter.multi_file_suffix.get()); let key_list = KeyListCorners::new( text.get("EXPORT_SETTINGS_PANEL_MULTI_FILE_SUFFIX"), [x, y], self.width - 2, self.multi_file_suffixes.max_length, renderer, ); renderer.key_list_corners(value, &key_list, setting_focus); y += 1; } } } } fn get_separator(position: [u32; 2], width: u32, renderer: &Renderer) -> Line { let mut position = renderer.grid_to_pixel(position); // Apply an offset to the y value. position[1] += 0.5 * renderer.cell_size[1]; let x1 = position[0] + (width - 2) as f32 * renderer.cell_size[0]; Line::horizontal(position[0], x1, position[1]) } /// Draw an input with optional text. fn draw_optional_input( &self, key: &str, value: &Option, position: (u32, &mut u32), renderer: &Renderer, state: &State, focus: Focus, ) { let value = match value { Some(value) => value, None => "", }; let key_input = KeyInput::new_from_padding( key, value, [position.0, *position.1], self.width - 2, KV_PADDING, renderer, ); renderer.key_input(value, &key_input, state.input.alphanumeric_input, focus); *position.1 += 1; } /// Draw a boolean field. fn draw_boolean( &self, key: String, value: bool, position: (u32, &mut u32), renderer: &Renderer, text: &Text, focus: Focus, ) { let boolean = BooleanCorners::new( key, [position.0, *position.1], self.width - 2, text, renderer, ); renderer.boolean_corners(value, &boolean, focus); *position.1 += 1; } } impl Drawable for ExportSettingsPanel { fn update(&self, renderer: &Renderer, state: &State, conn: &Conn, text: &Text, _: &PathsState) { // Get the focus. let focus = state.panels[state.focus.get()] == PanelType::ExportSettings; // Draw the panel. let color: ColorKey = if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }; let background = &self.backgrounds[&conn.exporter.export_type.get()]; renderer.rectangle_pixel(&background.background, &ColorKey::Background); renderer.rectangle_lines(&background.border, &color); renderer.rectangle(&self.title_rect, &ColorKey::Background); renderer.text(&self.title, &color); // Draw the fields. match &conn.exporter.export_type.get() { ExportType::Wav => self.update_settings( |e| &e.wav_settings, renderer, state, text, &conn.exporter, focus, ), ExportType::Mid => self.update_settings( |e| &e.mid_settings, renderer, state, text, &conn.exporter, focus, ), ExportType::MP3 => self.update_settings( |e| &e.mp3_settings, renderer, state, text, &conn.exporter, focus, ), ExportType::Ogg => self.update_settings( |e| &e.ogg_settings, renderer, state, text, &conn.exporter, focus, ), ExportType::Flac => self.update_settings( |e| &e.flac_settings, renderer, state, text, &conn.exporter, focus, ), } } } ================================================ FILE: render/src/field_params/boolean.rs ================================================ use super::util::KV_PADDING; use super::Label; use crate::Renderer; use hashbrown::HashMap; use text::Text; /// A key-boolean pair. pub(crate) struct Boolean { /// The key label. pub key: Label, /// The width of the boolean kev-value pair. pub width: u32, /// The value labels. values: HashMap, } impl Boolean { /// The key label is at `position`. The value label is at `position[0] + key.w + KV_PADDING`. /// /// - `key` The key label text. /// - `position` The position of the key label in grid units. /// - `text` The text lookup. pub fn new(key: String, position: [u32; 2], text: &Text, renderer: &Renderer) -> Self { let key_width = key.chars().count() as u32 + KV_PADDING; let width = key_width + text.get_max_boolean_length(); // The key is on the left. let key = Label::new(position, key, renderer); // The value is on the right. let value_position = [position[0] + key_width, position[1]]; let values: HashMap = Self::get_boolean_labels(value_position, text, renderer); Self { key, values, width } } /// The key label is at `position`. /// /// - `key` The key label text. /// - `position` The position of the key label in grid units. /// - `width` The value label is at `position[0] + width - max_boolean_width`. /// - `text` The text lookup. pub fn new_from_width( key: String, position: [u32; 2], width: u32, text: &Text, renderer: &Renderer, ) -> Self { // The key is on the left. let key = Label::new(position, key, renderer); // The value is on the right. let value_position = [ position[0] + width - text.get_max_boolean_length(), position[1], ]; let values: HashMap = Self::get_boolean_labels(value_position, text, renderer); Self { key, values, width } } /// Returns the label corresponding to `value`. pub fn get_boolean_label(&self, value: &bool) -> &Label { &self.values[value] } /// Converts a boolean `value` into a `Label`. fn get_boolean_labels( value_position: [u32; 2], text: &Text, renderer: &Renderer, ) -> HashMap { let mut values = HashMap::new(); values.insert( true, Label::new( value_position, text.get_boolean(&true).to_string(), renderer, ), ); values.insert( false, Label::new( value_position, text.get_boolean(&false).to_string(), renderer, ), ); values } } ================================================ FILE: render/src/field_params/boolean_corners.rs ================================================ use super::{Boolean, RectanglePixel}; use crate::Renderer; use text::Text; /// A boolean and corners all around. pub(crate) struct BooleanCorners { /// The boolean. pub boolean: Boolean, /// The corners rect. pub corners_rect: RectanglePixel, } impl BooleanCorners { /// - `key` The key label text. It's at `[position[0] + 1, position[1]]`. /// - `position` The position of the key label in grid units. /// - `width` The value label is at `position[0] + 1 + (width - 2) - max_boolean_width`. The corners size is `[width, 1]`. /// - `text` The text lookup. pub fn new( key: String, position: [u32; 2], width: u32, text: &Text, renderer: &Renderer, ) -> Self { let boolean_width = match width.checked_sub(2) { Some(w) => w, None => width, }; let boolean = Boolean::new_from_width( key, [position[0] + 1, position[1]], boolean_width, text, renderer, ); let corners_rect = RectanglePixel::new_from_u(position, [width, 1], renderer); Self { boolean, corners_rect, } } } ================================================ FILE: render/src/field_params/key_input.rs ================================================ use super::{KeyWidth, RectanglePixel}; use crate::Renderer; /// A key, a value, a rectangle for corners, and a rectangle for input. pub(crate) struct KeyInput { /// The key and the input. pub key_width: KeyWidth, /// A rectangle that will be used to render corners when focused. pub corners_rect: RectanglePixel, /// A rectangle that will appear under the input text when focused and selected. pub input_rect: RectanglePixel, } impl KeyInput { /// - The `key` is at `position[0] + 1`. /// - The value is at a position that tries to fill `width - 2`. /// - The `corners_rect` is at `position` and is size `[width, 1]`. /// - The `input_rect` is at `position[0] + 1`. /// /// The `key` will be truncated and the `value` will match `value_width`. pub fn new_from_value_width( key: &str, position: [u32; 2], width: u32, value_width: u32, renderer: &Renderer, ) -> Self { let key_width = KeyWidth::new_from_width( key, [position[0] + 1, position[1]], width - 2, value_width, renderer, ); let corners_rect = RectanglePixel::new_from_u(position, [width, 1], renderer); let input_rect = RectanglePixel::new_from_u( key_width.value.position, [key_width.value.width_u32, 1], renderer, ); Self { key_width, corners_rect, input_rect, } } /// - The `key` is at `position[0] + 1`. /// - The value is at `position[0] + 1 + key_width + padding`. /// - The `corners_rect` is at `position` and is size `[width, 1]`. /// /// The `key` won't be truncated. The `value` will be trunacted. pub fn new_from_padding( key: &str, value: &str, position: [u32; 2], width: u32, padding: u32, renderer: &Renderer, ) -> Self { let key_width_x = position[0] + 1; // The input rect can be larger than the value width. let input_x = key_width_x + key.chars().count() as u32 + padding; let input_width = width - 2 - (input_x - key_width_x); let mut value_width = value.chars().count() as u32; // Truncate to the input width. if input_width < value_width { value_width = input_width; } let input_rect = RectanglePixel::new_from_u([input_x, position[1]], [input_width, 1], renderer); let key_width = KeyWidth::new_from_width( key, [position[0] + 1, position[1]], width - 2, value_width, renderer, ); let corners_rect = RectanglePixel::new_from_u(position, [width, 1], renderer); Self { key_width, corners_rect, input_rect, } } } ================================================ FILE: render/src/field_params/key_list.rs ================================================ use super::{Label, List}; use crate::Renderer; /// A key `Label` on the left and a `List` on the right. pub(crate) struct KeyList { /// The key label. pub key: Label, /// The value list. pub value: List, } impl KeyList { /// - The key will be at `position` and won't be truncated. /// - The value will at position [`position[0] + width - value_width - 2]` and truncated to `value_width`. pub fn new( key: String, position: [u32; 2], width: u32, value_width: u32, renderer: &Renderer, ) -> Self { let key = Label::new(position, key, renderer); let mut x = position[0] + width; x = x.checked_sub(value_width).unwrap_or(x); let value_position = [x.checked_sub(2).unwrap_or(x), position[1]]; let value = List::new(value_position, value_width, renderer); Self { key, value } } } ================================================ FILE: render/src/field_params/key_list_corners.rs ================================================ use super::{KeyList, RectanglePixel}; use crate::Renderer; /// A key `Label` on the left, a `List` on the right, and corners all around. pub(crate) struct KeyListCorners { /// The key-list pair. pub key_list: KeyList, /// The corners rect. pub corners_rect: RectanglePixel, /// The y positional coordinate in grid units. pub y: u32, } impl KeyListCorners { /// - The `key` is at `position[0] + 1`. /// - The `value` is at a position that tries to fill `width - 2` truncated to `value_width`. /// - The `corners_rect` is at `position` and is size `[width, 1]`. pub fn new( key: String, position: [u32; 2], width: u32, value_width: u32, renderer: &Renderer, ) -> Self { let key_list = KeyList::new( key, [position[0] + 1, position[1]], width.checked_sub(2).unwrap_or(width), value_width, renderer, ); let corners_rect = RectanglePixel::new_from_u(position, [width, 1], renderer); Self { key_list, corners_rect, y: position[1], } } } ================================================ FILE: render/src/field_params/key_width.rs ================================================ use super::util::KV_PADDING; use super::{Label, LabelRef, Width}; use crate::Renderer; use text::truncate; /// A key label and a value width. pub(crate) struct KeyWidth { /// The position and text of the key. pub key: Label, /// The position and width of the value. pub value: Width, /// The total width. pub width: u32, } impl KeyWidth { /// The `key` will be at `position`. The value will be at `position.x + key_width + KV_PADDING + value_width`. pub fn new(key: String, position: [u32; 2], value_width: u32, renderer: &Renderer) -> Self { let width = key.chars().count() as u32 + KV_PADDING + value_width; // The key is on the left. let key = Label::new(position, key, renderer); // The value is on the right. let value = Self::get_value_width(position, width, value_width); Self { key, value, width } } /// The `key` will be at `position` and the `value` will be at a position that tries to fill `width`. /// `key` will be truncated and `value` will match `value_width`. pub fn new_from_width( key: &str, position: [u32; 2], width: u32, value_width: u32, renderer: &Renderer, ) -> Self { let half_width = Self::get_half_width(width); // The key is on the left. let key = Label::new( position, truncate(key, half_width, false).to_string(), renderer, ); // The value is on the right. let value = Self::get_value_width(position, width, value_width); Self { key, value, width } } /// Truncates a value string to `self.width` and converts it into a `LabelRef`. pub fn get_value<'t>(&self, value: &'t str, renderer: &Renderer) -> LabelRef<'t> { LabelRef::new( self.value.position, truncate(value, self.value.width, true), renderer, ) } /// Returns half of the width, or slightly less than half. /// The half-width is `width / 2` for odd numbers `and `width / 2 - 1)` for even numbers. fn get_half_width(width: u32) -> usize { let mut half_width = width / 2; if width % 2 == 0 && half_width > 0 { half_width -= 1; } half_width as usize } /// Returns the value `Width`. fn get_value_width(position: [u32; 2], width: u32, value_width: u32) -> Width { let x = position[0] + width; let value_position = [x.checked_sub(value_width).unwrap_or(x), position[1]]; Width::new(value_position, value_width as usize) } } ================================================ FILE: render/src/field_params/label.rs ================================================ use crate::Renderer; /// A position and a string. #[derive(Clone)] pub(crate) struct Label { /// The position in grid units. pub position: [f32; 2], /// The text. pub text: String, } impl Label { pub fn new(position: [u32; 2], text: String, renderer: &Renderer) -> Self { Self { position: renderer.get_label_position(position, &text), text, } } } ================================================ FILE: render/src/field_params/label_rectangle.rs ================================================ use super::{Label, RectanglePixel}; use crate::Renderer; /// A label and its rectangle. #[derive(Clone)] pub(crate) struct LabelRectangle { /// The label. pub label: Label, /// The rectangle. pub rect: RectanglePixel, } impl LabelRectangle { pub fn new(position: [u32; 2], text: String, renderer: &Renderer) -> Self { let rect = RectanglePixel::new_from_u(position, [text.chars().count() as u32, 1], renderer); let label = Label::new(position, text, renderer); Self { label, rect } } } ================================================ FILE: render/src/field_params/label_ref.rs ================================================ use crate::Renderer; /// A position and a string. #[derive(Clone)] pub(crate) struct LabelRef<'a> { /// The position in grid units. pub position: [f32; 2], /// The text. pub text: &'a str, } impl<'a> LabelRef<'a> { pub fn new(position: [u32; 2], text: &'a str, renderer: &Renderer) -> Self { Self { position: renderer.get_label_position(position, text), text, } } } ================================================ FILE: render/src/field_params/line.rs ================================================ use crate::Renderer; /// A horizontal or vertical line. pub(crate) struct Line { pub a0: f32, pub a1: f32, pub b: f32, } impl Line { pub(crate) fn vertical(x: f32, y0: f32, y1: f32) -> Self { Self { a0: y0, a1: y1, b: x, } } pub(crate) fn horizontal(x0: f32, x1: f32, y: f32) -> Self { Self { a0: x0, a1: x1, b: y, } } pub(crate) fn vertical_line_separator(position: [u32; 2], renderer: &Renderer) -> Self { let x = position[0] as f32 * renderer.cell_size[0] + 0.5 * renderer.cell_size[0]; let y0 = position[1] as f32 * renderer.cell_size[1] - 0.6 * renderer.cell_size[1]; let y1 = (position[1] + 1) as f32 * renderer.cell_size[1] + 0.4 * renderer.cell_size[1]; Self::vertical(x, y0, y1) } } ================================================ FILE: render/src/field_params/list.rs ================================================ use super::{Label, LabelRef, Width}; use crate::Renderer; use text::truncate; const LEFT_ARROW: &str = "<"; const RIGHT_ARROW: &str = ">"; /// A list has a label and two arrows. pub(crate) struct List { /// The label at the center of the list. There is no stored text. label: Width, /// The left arrow. pub left_arrow: Label, /// The right arrow. pub right_arrow: Label, } impl List { /// Fit the text, with the arrows, within the `width`. pub fn new(position: [u32; 2], width: u32, renderer: &Renderer) -> Self { let label = Width::new([position[0] + 1, position[1]], width as usize); let left_arrow = Label::new(position, LEFT_ARROW.to_string(), renderer); let right_arrow = Label::new( [position[0] + width + 1, position[1]], RIGHT_ARROW.to_string(), renderer, ); Self { label, left_arrow, right_arrow, } } /// Truncates a value string to `self.width` and converts it into a `LabelRef`. pub fn get_value<'t>(&self, value: &'t str, renderer: &Renderer) -> LabelRef<'t> { LabelRef::new( self.label.position, truncate(value, self.label.width, false), renderer, ) } } ================================================ FILE: render/src/field_params/panel_background.rs ================================================ use super::{Rectangle, RectanglePixel}; use crate::Renderer; /// A background rectangle and a border rectangle, both in pixel units. #[derive(Clone)] pub(crate) struct PanelBackground { /// The background. pub background: RectanglePixel, /// The border. pub border: RectanglePixel, /// The rectangle in grid units. pub grid_rect: Rectangle, } impl PanelBackground { pub fn new(position: [u32; 2], size: [u32; 2], renderer: &Renderer) -> Self { let background = RectanglePixel::new( renderer.grid_to_pixel(position), renderer.grid_to_pixel(size), ); let border = renderer.get_border_rect(position, size); let grid_rect = Rectangle::new(position, size); Self { background, border, grid_rect, } } /// Adjust the size by a delta in grid units. pub fn resize_by(&mut self, delta: [u32; 2], renderer: &Renderer) { self.grid_rect.size[0] += delta[0]; self.grid_rect.size[1] += delta[1]; let delta = renderer.grid_to_pixel(delta); self.background.size[0] += delta[0]; self.background.size[1] += delta[1]; self.border.size[0] += delta[0]; self.border.size[1] += delta[1]; } } ================================================ FILE: render/src/field_params/rectangle.rs ================================================ /// A rectangle has a position and a size. #[derive(Clone)] pub(crate) struct Rectangle { /// The position in grid units. pub position: [u32; 2], /// The size in grid units. pub size: [u32; 2], } impl Rectangle { pub fn new(position: [u32; 2], size: [u32; 2]) -> Self { Self { position, size } } } ================================================ FILE: render/src/field_params/rectangle_pixel.rs ================================================ use crate::Renderer; /// A rectangle has a position and a size. #[derive(Clone)] pub(crate) struct RectanglePixel { /// The position in pixel units. pub position: [f32; 2], /// The size in pixel units. pub size: [f32; 2], } impl RectanglePixel { pub fn new_from_u(position: [u32; 2], size: [u32; 2], renderer: &Renderer) -> Self { Self::new( renderer.grid_to_pixel(position), renderer.grid_to_pixel(size), ) } pub fn new(position: [f32; 2], size: [f32; 2]) -> Self { Self { position, size } } } ================================================ FILE: render/src/field_params/util.rs ================================================ pub(crate) const KV_PADDING: u32 = 2; ================================================ FILE: render/src/field_params/width.rs ================================================ use super::LabelRef; use crate::Renderer; use text::truncate; /// Not a label... but the IDEA of a label. /// This holds space for text. pub(crate) struct Width { /// The position in grid coordinates. pub position: [u32; 2], /// The width of the space being held. pub width: usize, /// The width as a u32. pub width_u32: u32, } impl Width { pub fn new(position: [u32; 2], width: usize) -> Self { Self { position, width, width_u32: width as u32, } } /// Converts this `Width` into a `Label` with truncated text. pub fn to_label<'t>(&self, value: &'t str, renderer: &Renderer) -> LabelRef<'t> { LabelRef::new(self.position, truncate(value, self.width, true), renderer) } } ================================================ FILE: render/src/field_params.rs ================================================ mod boolean; mod boolean_corners; mod key_input; mod key_list; mod key_list_corners; mod key_width; mod label; mod label_ref; mod line; mod list; mod panel_background; mod rectangle; mod rectangle_pixel; pub(super) mod util; mod width; pub(crate) use boolean::Boolean; pub(crate) use boolean_corners::BooleanCorners; pub(crate) use key_input::KeyInput; pub(crate) use key_list::KeyList; pub(crate) use key_list_corners::KeyListCorners; pub(crate) use key_width::KeyWidth; pub(crate) use label::Label; pub(crate) use label_rectangle::LabelRectangle; pub(crate) use label_ref::LabelRef; pub(crate) use line::Line; pub(crate) use list::List; pub(crate) use panel_background::PanelBackground; pub(crate) use rectangle::Rectangle; pub(crate) use rectangle_pixel::RectanglePixel; pub(super) use width::Width; mod label_rectangle; ================================================ FILE: render/src/lib.rs ================================================ //! This crate handles all rendering. //! //! The `Panels` struct reads the current state of the program and draws on the window using the `Renderer` struct. //! //! This crate does not *modify* any aspects of the state of the program. For that, see the `io` crate. //! //! The sizes of the panels are derived from functions in `common::sizes`. //! It's in `common` because `State` needs some of that information (for example, to define the initial piano roll viewport). //! //! Unless otherwise specified, positions and sizes are set in *grid units* rather than pixels. //! //! `ColorKey` is used to define colors. To change the colors, change the config file.] //! //! Everything in `field_params/` is used to draw parameters and values. mod color_key; mod drawable; mod export_panel; mod export_settings_panel; mod field_params; mod main_menu; mod music_panel; mod panel; mod panels; mod renderer; mod tracks_panel; use color_key::ColorKey; pub use panels::Panels; pub use renderer::Renderer; mod open_file_panel; mod piano_roll_panel; use text::TTS; mod popup; mod types; pub(crate) use popup::Popup; use types::*; mod page; mod page_position; use audio::Conn; use common::State; pub(crate) use page::Page; pub(crate) use page_position::PagePosition; mod links_panel; mod quit_panel; pub(crate) const TRACK_HEIGHT_SOUNDFONT: u32 = 4; pub(crate) const TRACK_HEIGHT_NO_SOUNDFONT: u32 = 1; /// If subtitles are enabled and Casey is speaking, draw the subtitles. pub fn draw_subtitles(renderer: &Renderer, tts: &TTS) { if let Some(subtitles) = tts.get_subtitles() { renderer.subtitle(subtitles) } } /// Get the height of each track. This is shared by the tracks panel and the multi-track piano roll panel. pub(crate) fn get_track_heights(state: &State, conn: &Conn) -> Vec { // Get a list of track element heights. let mut elements = vec![]; for track in state.music.midi_tracks.iter() { elements.push(match conn.state.programs.get(&track.channel) { Some(_) => TRACK_HEIGHT_SOUNDFONT + 1, None => TRACK_HEIGHT_NO_SOUNDFONT, }); } elements } ================================================ FILE: render/src/links_panel.rs ================================================ use crate::panel::*; use crate::Popup; use input::InputEvent; use text::Tooltips; const NUM_LINKS: usize = 3; const LABEL_COLOR: ColorKey = ColorKey::Value; /// Open a link in a browser. pub(crate) struct LinksPanel { /// The panel background. panel: Panel, /// The labels. labels: [Label; NUM_LINKS], /// The popup. pub popup: Popup, } impl LinksPanel { pub fn new(config: &Ini, renderer: &Renderer, text: &Text, input: &Input) -> Self { // Get each label. The x coordinate is right now at 0. let mut y = MAIN_MENU_HEIGHT + 1; let mut tooltips = Tooltips::default(); let mut website = Self::get_label( &mut tooltips, &mut y, "LINKS_PANEL_WEBSITE", InputEvent::WebsiteUrl, renderer, text, input, ); let mut discord = Self::get_label( &mut tooltips, &mut y, "LINKS_PANEL_DISCORD", InputEvent::DiscordUrl, renderer, text, input, ); let mut github = Self::get_label( &mut tooltips, &mut y, "LINKS_PANEL_GITHUB", InputEvent::GitHubUrl, renderer, text, input, ); // Get the maximum width of the labels. let max_w = [ &website.text, &discord.text, &github.text, text.get_ref("TITLE_LINKS"), ] .iter() .map(|s| s.chars().count() as u32) .max() .unwrap(); // Get the panel width. let w = max_w + 4; // Get the window width. let window_grid_size = get_window_grid_size(config); // Get the panel x and y coordinates and height. let x = window_grid_size[0] / 2 - w / 2; let h = y - MAIN_MENU_HEIGHT; y = MAIN_MENU_HEIGHT; // Set the x coordinates. let label_x = (x + 1) as f32 * renderer.cell_size[0]; website.position[0] = label_x; discord.position[0] = label_x; github.position[0] = label_x; // Define the panel. let panel = Panel::new(PanelType::Links, [x, y], [w, h], renderer, text); let labels = [website, discord, github]; let popup = Popup::new(PanelType::Links); Self { panel, labels, popup, } } /// Returns a label and moves the y coordinate. fn get_label( tooltips: &mut Tooltips, y: &mut u32, key: &str, event: InputEvent, renderer: &Renderer, text: &Text, input: &Input, ) -> Label { let label = Label::new( [0, *y], tooltips.get_tooltip(key, &[event], input, text).seen, renderer, ); *y += 2; label } } impl Drawable for LinksPanel { fn update(&self, renderer: &Renderer, _: &State, _: &Conn, _: &Text, _: &PathsState) { self.popup.update(renderer); self.panel.update(true, renderer); self.labels .iter() .for_each(|label| renderer.text(label, &LABEL_COLOR)); } } ================================================ FILE: render/src/main_menu.rs ================================================ use crate::panel::*; use colorgrad::Color; use colorgrad::CustomGradient; use input::InputEvent; use macroquad::texture::Texture2D; use text::Tooltips; /// The color of the panel and the text. const COLOR: ColorKey = ColorKey::Key; /// Lerp the sample bars by this delta. const LERP_DT: f32 = 0.2; /// Check the power bar sample this many samples. const POWER_BAR_DELTA: u64 = 5; /// Apply a lerp to a sample value. #[derive(Default)] struct Lerp { a: f32, b: f32, up: bool, } impl Lerp { pub(super) fn set(&mut self, b: f32) { self.b = b; self.up = self.b > self.a; } pub(super) fn lerp(&mut self) -> f32 { if self.up && self.a < self.b { self.a += LERP_DT; if self.a > self.b { self.a = self.b; } } else if !self.up && self.a > self.b { self.a -= LERP_DT; if self.a < self.b { self.a = self.b; } } self.a } } /// The main menu panel. This panel is always in ghostly not-quite-focus. pub(crate) struct MainMenu { /// The panel background. panel: Panel, /// The title if there are unsaved changes. title_changes: LabelRectangle, /// The field labels and the version label. labels: [Label; 7], /// The separator lines. separators: [Line; 2], /// The power bar texture. power_bar_texture: Texture2D, /// The rectangles of the power bars per sample. power_bar_rects: [[[f32; 2]; 2]; 2], /// Sample lerp targets per bar. power_bar_lerps: [Lerp; 2], /// The current sample time. This is updated continuously and is used to smooth the power bars. time: u64, } impl MainMenu { pub fn new( config: &Ini, renderer: &Renderer, input: &Input, text: &mut Text, remote_version: Option, ) -> Self { // Get the width of the panel. let width = get_main_menu_width(config); let position = get_main_menu_position(config); // Get the panel. let mut panel = Panel::new( PanelType::MainMenu, position, [width, MAIN_MENU_HEIGHT], renderer, text, ); // Add an update notice to the title. if let Some(remote_version) = remote_version { let update = text.get_with_values("MAIN_MENU_UPDATE", &[&remote_version]); panel.title.label.text.push_str(" "); panel.title.label.text.push_str(&update); panel.title.rect.size[0] += (update.chars().count() + 3) as f32 * renderer.cell_size[0]; } let title_changes = LabelRectangle::new( [position[0] + 2, position[1]], format!("*{}", panel.title.label.text), renderer, ); // Get the fields. let mut x = position[0] + 2; let y = position[1] + 1; let help = Self::label_from_key("MAIN_MENU_HELP", &mut x, y, renderer, text); x += 4; let mut tooltips = Tooltips::default(); let status = Self::tooltip( &mut tooltips, "MAIN_MENU_STATUS", InputEvent::StatusTTS, &mut x, y, renderer, input, text, ); let input_field = Self::tooltip( &mut tooltips, "MAIN_MENU_INPUT", InputEvent::InputTTS, &mut x, y, renderer, input, text, ); let app = Self::tooltip( &mut tooltips, "MAIN_MENU_APP", InputEvent::AppTTS, &mut x, y, renderer, input, text, ); let file = Self::tooltip( &mut tooltips, "MAIN_MENU_FILE", InputEvent::FileTTS, &mut x, y, renderer, input, text, ); let stop = Self::tooltip( &mut tooltips, "MAIN_MENU_STOP", InputEvent::StopTTS, &mut x, y, renderer, input, text, ); x += 1; let separator_help = [x, y]; x += 2; let x0 = x; let links = Self::tooltip( &mut tooltips, "MAIN_MENU_ONLINE", InputEvent::EnableLinksPanel, &mut x, y, renderer, input, text, ); x = x0 + links.text.chars().count() as u32 + 1; let separator_links = [x, y]; let fields = [help, status, input_field, app, file, stop, links]; x += 1; let window_grid_size = get_window_grid_size(config); let w = window_grid_size[0] - x - 2; let (power_bar_texture, power_bar_position_left) = Self::get_power_textures([x, y], [w, 1], renderer); let cell_height = renderer.grid_to_pixel([1, 1])[1]; let mut power_bar_position_right = [ [ power_bar_position_left[0][0], power_bar_position_left[0][1] + cell_height, ], power_bar_position_left[1], ]; power_bar_position_right[0][1] -= power_bar_position_right[1][1]; let power_bar_rects = [power_bar_position_left, power_bar_position_right]; let power_bar_lerps = [Lerp::default(), Lerp::default()]; let separators = [ Line::vertical_line_separator(separator_help, renderer), Line::vertical_line_separator(separator_links, renderer), ]; Self { panel, labels: fields, title_changes, separators, power_bar_texture, power_bar_rects, power_bar_lerps, time: 0, } } fn label(key: String, x: &mut u32, y: u32, renderer: &Renderer) -> Label { let width = key.chars().count() as u32; let position = [*x, y]; *x += width; Label::new(position, key, renderer) } fn label_from_key(key: &str, x: &mut u32, y: u32, renderer: &Renderer, text: &Text) -> Label { Self::label(text.get(key), x, y, renderer) } #[allow(clippy::too_many_arguments)] fn tooltip( tooltips: &mut Tooltips, key: &str, event: InputEvent, x: &mut u32, y: u32, renderer: &Renderer, input: &Input, text: &Text, ) -> Label { let tooltip = tooltips.get_tooltip(key, &[event], input, text).seen; let width = key.chars().count() as u32; let position = [*x, y]; *x += width; Label::new(position, tooltip, renderer) } /// Returns a tuple: The power bar texture and a rectangle around it. fn get_power_textures( position: [u32; 2], size: [u32; 2], renderer: &Renderer, ) -> (Texture2D, [[f32; 2]; 2]) { let position = renderer.grid_to_pixel(position); let mut size = renderer.grid_to_pixel(size); size[1] /= 2.0; // Define the gradient. let color_0 = Self::get_color(&ColorKey::FocusDefault, renderer); let color_1 = Self::get_color(&ColorKey::Value, renderer); let gradiant = CustomGradient::new() .colors(&[color_0, color_1]) .build() .unwrap(); // Define the image. let width_u = size[0] as usize; let width_f = size[0] as f64; let height = size[1] as usize; let mut image: Vec = vec![0; width_u * height * 4]; let mut i = 0; for _ in 0..height { for x in 0..width_u { let rgba = gradiant.at(x as f64 / width_f).to_rgba8(); image[i..i + 4].copy_from_slice(&rgba); i += 4; } } let texture = Texture2D::from_rgba8(size[0] as u16, size[1] as u16, &image); (texture, [position, size]) } /// Converts a ColorKey into a gradiant color. fn get_color(color_key: &ColorKey, renderer: &Renderer) -> Color { let color = renderer.get_color(color_key); Color::new(color.r as f64, color.g as f64, color.b as f64, 1.0) } /// Draw a power bar and its mask. fn draw_sample_power(&mut self, index: usize, renderer: &Renderer) { // Draw the bar. let rect = &self.power_bar_rects[index]; renderer.texture_pixel(&self.power_bar_texture, &rect[0], None); let value = self.power_bar_lerps[index].lerp(); // Get the width of the mask. let w = rect[1][0] * (1.0 - value); // Get the x coordinate of the mask. let x = rect[0][0] + (rect[1][0] - w); let position = [x, rect[0][1]]; let size = [w, rect[1][1]]; // Draw the mask. renderer.rectangle_note(position, size, &ColorKey::Background); } /// Set the lerp target of power bar. fn set_lerp_target(&mut self, index: usize, sample: f32) { self.power_bar_lerps[index].set(sample.abs()); } /// Get a sample, set lerp targets, and draw bars. pub fn late_update(&mut self, renderer: &Renderer, conn: &Conn) { // Set the power bar lerp targets from the sample. if self.time % POWER_BAR_DELTA == 0 { let sample = *conn.sample.lock(); self.set_lerp_target(0, sample.0); self.set_lerp_target(1, sample.1); } self.time += 1; // Draw each bar. self.draw_sample_power(0, renderer); self.draw_sample_power(1, renderer); } } impl Drawable for MainMenu { fn update(&self, renderer: &Renderer, state: &State, _: &Conn, _: &Text, _: &PathsState) { self.panel.update_ex(&COLOR, renderer); if state.unsaved_changes { renderer.rectangle_pixel(&self.title_changes.rect, &ColorKey::Background); renderer.text(&self.title_changes.label, &COLOR); } for label in self.labels.iter() { renderer.text(label, &COLOR) } for separator in self.separators.iter() { renderer.vertical_line(separator, &COLOR) } } } ================================================ FILE: render/src/music_panel.rs ================================================ use crate::panel::*; use common::music_panel_field::*; /// The music panel. pub(crate) struct MusicPanel { /// The panel background. panel: Panel, /// The name field. name: Width, /// The total span of the name field, including where the corners are renderered. name_rect: RectanglePixel, /// The rectangle of the backround of the namefield. name_input_rect: RectanglePixel, /// The BPM field. bpm: KeyInput, /// The gain field. gain: KeyList, /// The rectangle of the background of the name field. gain_rect: RectanglePixel, } impl MusicPanel { pub fn new(config: &Ini, renderer: &Renderer, text: &Text) -> Self { // Get the width of the panel. let mut width = get_tracks_panel_width(config); // Get the panel. let panel = Panel::new( PanelType::Music, MUSIC_PANEL_POSITION, [width, MUSIC_PANEL_HEIGHT], renderer, text, ); // Move the (x, y) coordinates inward by 1. let x = MUSIC_PANEL_POSITION[0] + 1; let mut y = MUSIC_PANEL_POSITION[1] + 1; // Shorten the width for the fields. width -= 2; let w_usize = width as usize; // Set the fields. let name = Width::new([x + 1, y], w_usize - 2); let name_rect = RectanglePixel::new_from_u([x, y], [width, 1], renderer); let name_input_rect = RectanglePixel::new_from_u(name.position, [name.width_u32, 1], renderer); y += 1; let bpm = KeyInput::new_from_value_width(text.get_ref("TITLE_BPM"), [x, y], width, 4, renderer); // Move the position of the value to align it with the gain field. y += 1; let gain = KeyList::new(text.get("TITLE_GAIN"), [x + 1, y], width - 2, 3, renderer); let gain_rect = RectanglePixel::new_from_u([x, y], [width, 1], renderer); // Return. Self { panel, name, name_rect, name_input_rect, bpm, gain, gain_rect, } } } impl Drawable for MusicPanel { fn update(&self, renderer: &Renderer, state: &State, conn: &Conn, _: &Text, _: &PathsState) { // Get the focus, let focus = self.panel.has_focus(state); // Draw the rect. self.panel.update(focus, renderer); // Get the enum value of the focused widget. let focused_field = state.music_panel_field.get(); let key_color = Renderer::get_key_color(focus); // Name. let name_focus = focused_field == MusicPanelField::Name; if name_focus { // Draw corners. renderer.corners(&self.name_rect, focus); // Draw a rectangle for input. if state.input.alphanumeric_input { renderer.rectangle_pixel(&self.name_input_rect, &ColorKey::TextFieldBG); } } // Draw the name. renderer.text_ref( &self.name.to_label(&conn.exporter.metadata.title, renderer), &Renderer::get_value_color([focus, name_focus]), ); // BPM. let bpm_focus = focused_field == MusicPanelField::BPM; if bpm_focus { // Draw corners. renderer.corners(&self.bpm.corners_rect, focus); // Draw a rectangle for input. if state.input.alphanumeric_input { renderer.rectangle_pixel(&self.bpm.input_rect, &ColorKey::TextFieldBG); } } // Draw the BPM. renderer.key_value( &state.time.bpm.to_string(), &self.bpm.key_width, [&key_color, &Renderer::get_value_color([focus, bpm_focus])], ); // Gain. let gain_focus = focused_field == MusicPanelField::Gain; if gain_focus { renderer.corners(&self.gain_rect, focus); } renderer.key_list( &conn.state.gain.to_string(), &self.gain, [focus, gain_focus], ) } } ================================================ FILE: render/src/open_file_panel.rs ================================================ use crate::panel::*; use crate::{Page, PagePosition, Popup}; use common::open_file::*; use hashbrown::HashMap; use text::truncate; const EXTENSION_WIDTH: u32 = 4; /// The open-file dialogue box. pub(crate) struct OpenFilePanel { /// The panel. panel: Panel, /// The titles for each open-file type. titles: HashMap, /// The background of the filename prompt. prompt: PanelBackground, /// The filename extension. extension: Width, /// The filename input. input: Width, /// The filename input background rectangle. input_rect: Rectangle, /// The popup handler. pub popup: Popup, /// Labels for scrolling through pages. scroll_labels: HashMap, } impl OpenFilePanel { pub fn new(config: &Ini, renderer: &Renderer, text: &Text) -> Self { let (position, size) = get_open_file_rect(config); let panel = Panel::new(PanelType::OpenFile, position, size, renderer, text); let prompt_position = [position[0], position[1] + size[1]]; let prompt_size = [size[0], OPEN_FILE_PANEL_PROMPT_HEIGHT]; let prompt = PanelBackground::new(prompt_position, prompt_size, renderer); let extension = Width::new( [ prompt_position[0] + prompt_size[0] - EXTENSION_WIDTH - 1, prompt_position[1] + 1, ], EXTENSION_WIDTH as usize, ); let input = Width::new( [prompt_position[0] + 1, prompt_position[1] + 1], (prompt_size[0] - EXTENSION_WIDTH - 3) as usize, ); let input_rect = Rectangle::new(input.position, [input.width_u32, 1]); let popup = Popup::new(PanelType::OpenFile); // Define the titles. let mut titles = HashMap::new(); let title_position = [position[0] + 2, position[1]]; titles.insert( OpenFileType::SoundFont, LabelRectangle::new( title_position, text.get("OPEN_FILE_PANEL_TITLE_SOUNDFONT"), renderer, ), ); titles.insert( OpenFileType::ReadSave, LabelRectangle::new( title_position, text.get("OPEN_FILE_PANEL_TITLE_READ_SAVE"), renderer, ), ); titles.insert( OpenFileType::WriteSave, LabelRectangle::new( title_position, text.get("OPEN_FILE_PANEL_TITLE_WRITE_SAVE"), renderer, ), ); titles.insert( OpenFileType::Export, LabelRectangle::new( title_position, text.get("OPEN_FILE_PANEL_TITLE_EXPORT"), renderer, ), ); titles.insert( OpenFileType::ImportMidi, LabelRectangle::new( title_position, text.get("OPEN_FILE_PANEL_TITLE_IMPORT_MIDI"), renderer, ), ); // Get the scroll labels. let mut scroll_labels = HashMap::new(); let panel_x = position[0]; let panel_w = size[0]; let label_y = position[1] + size[1] - 2; scroll_labels.insert( PagePosition::First, Self::get_scroll_label( "OPEN_FILE_PANEL_DOWN", text, panel_x, panel_w, label_y, renderer, ), ); scroll_labels.insert( PagePosition::Mid, Self::get_scroll_label( "OPEN_FILE_PANEL_UP_DOWN", text, panel_x, panel_w, label_y, renderer, ), ); scroll_labels.insert( PagePosition::Last, Self::get_scroll_label( "OPEN_FILE_PANEL_UP", text, panel_x, panel_w, label_y, renderer, ), ); Self { panel, prompt, extension, input, input_rect, popup, titles, scroll_labels, } } /// Returns a label for scrolling, e.g. "MORE v". /// /// - `key` The lookup key. /// - `text` The text. /// - `panel_x` The x coordinate of the panel. /// - `panel_w` The width of the panel. /// - `label_y` the y coordinate of the label (the x coordinate varies). /// - `renderer` The renderer. fn get_scroll_label( key: &str, text: &Text, panel_x: u32, panel_w: u32, label_y: u32, renderer: &Renderer, ) -> Label { let string = text.get(key); let x = panel_x + panel_w - (string.chars().count() as u32 + 2); Label::new([x, label_y], string, renderer) } } impl Drawable for OpenFilePanel { fn update( &self, renderer: &Renderer, state: &State, conn: &Conn, _: &Text, paths_state: &PathsState, ) { let focus = self.panel.has_focus(state); self.popup.update(renderer); let focus_color = if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }; // Draw the panel background. renderer.rectangle_pixel(&self.panel.background.background, &ColorKey::Background); renderer.rectangle_lines(&self.panel.background.border, &focus_color); // Draw the title. let title = &self.titles[&paths_state.open_file_type]; renderer.rectangle_pixel(&title.rect, &ColorKey::Background); renderer.text(&title.label, &focus_color); // Draw the working directory. let mut x = self.panel.background.grid_rect.position[0] + 1; let mut y = self.panel.background.grid_rect.position[1] + 1; let mut length = (self.panel.background.grid_rect.size[0] - 2) as usize; // Show the current directory. let path_string = format!("{}/", paths_state.get_directory().stem); let cwd = LabelRef::new([x, y], truncate(&path_string, length, true), renderer); renderer.text_ref(&cwd, &Renderer::get_key_color(focus)); // Prepare to show the children. x += 1; let height: u32 = self.panel.background.grid_rect.size[1] - 4; y += 1; length -= 1; let width = length as u32; // Get a page of elements. let elements: Vec = vec![1; paths_state.children.children.len()]; let page = Page::new(&paths_state.children.selected, &elements, height); // Show the elements. for index in page.visible { let path = &paths_state.children.children[index]; // Get the color of the text. Flip the fg/bg colors for the selected element. let c = if focus { if path.is_file { ColorKey::Value } else { ColorKey::FocusDefault } } else { ColorKey::NoFocus }; let (text_color, bg_color) = if focus { match paths_state.children.selected { Some(selected) => match selected == index { true => (ColorKey::Background, Some(c)), false => (c, None), }, None => (c, None), } } else { (ColorKey::NoFocus, None) }; let position = [x, y]; // Draw the background. if let Some(bg_color) = bg_color { renderer.rectangle(&Rectangle::new(position, [width, 1]), &bg_color); } // Draw the text. let s = if path.path.parent().is_some() { truncate(&path.stem, length, true) } else { path.path.to_str().unwrap() }; let p = LabelRef::new(position, s, renderer); renderer.text_ref(&p, &text_color); y += 1; } // Possibly show the input dialogue. if let Some(filename) = &paths_state.get_filename() { // Draw the background of the prompt. renderer.rectangle_pixel(&self.prompt.background, &ColorKey::Background); renderer.rectangle_lines( &self.prompt.border, &if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }, ); // Draw the extension. let mut extension = String::from("."); let ext = match paths_state.open_file_type { OpenFileType::ReadSave | OpenFileType::WriteSave => Extension::Cac, OpenFileType::SoundFont => Extension::Sf2, OpenFileType::Export => conn.exporter.export_type.get().into(), OpenFileType::ImportMidi => Extension::Mid, }; extension.push_str(ext.to_str(true)); renderer.text_ref( &self.extension.to_label(&extension, renderer), &if focus { ColorKey::Arrow } else { ColorKey::NoFocus }, ); // Draw the input background. if focus { renderer.rectangle(&self.input_rect, &ColorKey::TextFieldBG); } // Draw the input text. renderer.text_ref( &self.input.to_label(filename, renderer), &if focus { ColorKey::Key } else { ColorKey::NoFocus }, ); } // Possible draw a scroll indicator. if page.position != PagePosition::Only { renderer.text(&self.scroll_labels[&page.position], &ColorKey::Value); } } } ================================================ FILE: render/src/page.rs ================================================ use crate::PagePosition; /// A page of elements in a scrollable context. pub(crate) struct Page { /// The indices of the visible elements. pub visible: Vec, /// A description of the position of this page. pub position: PagePosition, } impl Page { pub(crate) fn new(selected: &Option, elements: &[u32], height: u32) -> Self { // Visible elements. let mut visible = vec![]; // The current height of the page. let mut page_h = 0; // If true, we found the page. let mut this_page = false; for (i, element) in elements.iter().enumerate() { // There is room for this element. Add it. if page_h + *element <= height { visible.push(i); // Increment. page_h += *element; } else { // It's this page. Stop here. if this_page { break; } // New page. visible.clear(); visible.push(i); page_h = *element; } // This is the page! if let Some(selected) = selected { if *selected == i { this_page = true; } } else { this_page = true; } } // I guess there is no page?? if !this_page { visible.clear(); } // Are there more elements after the last one? let after = match visible.iter().max() { Some(max) => *max < elements.len() - 1, None => false, }; // What about before? let before = match visible.iter().min() { Some(min) => *min > 0, None => false, }; // Infer the relative position. let position = match (after, before) { (true, true) => PagePosition::Mid, (true, false) => PagePosition::First, (false, true) => PagePosition::Last, (false, false) => PagePosition::Only, }; Self { visible, position } } } ================================================ FILE: render/src/page_position.rs ================================================ /// The position of a page in a scrollable context. #[derive(Eq, PartialEq, Hash, Copy, Clone, Debug)] pub(crate) enum PagePosition { /// This is the only page. Only, /// The first page of n where n > 1. First, /// Somewhere between 1 inclusive and len() exclusive. Mid, /// The last page of n where n > 2. Last, } ================================================ FILE: render/src/panel.rs ================================================ pub(crate) use crate::drawable::*; pub(crate) use crate::field_params::*; pub(crate) use crate::ColorKey; pub(crate) use common::sizes::*; pub(crate) use common::PanelType; use common::VERSION; pub(crate) use ini::Ini; /// A panel has a rectangular backaground and a title label. #[derive(Clone)] pub(crate) struct Panel { /// The type of panel. panel_type: PanelType, /// The panel background. pub background: PanelBackground, /// The title label for the panel. pub title: LabelRectangle, } impl Panel { pub fn new( panel_type: PanelType, position: [u32; 2], size: [u32; 2], renderer: &Renderer, text: &Text, ) -> Self { // Get the title from the panel type. let title = match panel_type { PanelType::MainMenu => format!("{} v{}", text.get("TITLE_MAIN_MENU"), VERSION), PanelType::Music => text.get("TITLE_MUSIC"), PanelType::OpenFile => text.get("TITLE_OPEN_FILE"), PanelType::PianoRoll => text.get("TITLE_PIANO_ROLL"), PanelType::Tracks => text.get("TITLE_TRACKS"), PanelType::ExportState => text.get("TITLE_EXPORT_STATE"), PanelType::ExportSettings => text.get("TITLE_EXPORT_SETTINGS"), PanelType::Quit => text.get("TITLE_QUIT"), PanelType::Links => text.get("TITLE_LINKS"), }; let title_position = [position[0] + 2, position[1]]; let title = LabelRectangle::new(title_position, title, renderer); Self { panel_type, title, background: PanelBackground::new(position, size, renderer), } } /// Draw an empty panel. The color will be defined by the value of `focus`. pub fn update(&self, focus: bool, renderer: &Renderer) { let color: ColorKey = if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }; self.update_ex(&color, renderer); } /// Draw an empty panel. The border and title text will be an explicitly defined color. pub fn update_ex(&self, color: &ColorKey, renderer: &Renderer) { renderer.rectangle_pixel(&self.background.background, &ColorKey::Background); renderer.rectangle_lines(&self.background.border, color); renderer.rectangle_pixel(&self.title.rect, &ColorKey::Background); renderer.text(&self.title.label, color); } /// Returns true if this panel has focus. pub fn has_focus(&self, state: &State) -> bool { self.panel_type == state.panels[state.focus.get()] } } ================================================ FILE: render/src/panels.rs ================================================ use crate::export_panel::ExportPanel; use crate::export_settings_panel::ExportSettingsPanel; use crate::links_panel::LinksPanel; use crate::main_menu::MainMenu; use crate::music_panel::MusicPanel; use crate::open_file_panel::OpenFilePanel; use crate::panel::*; use crate::piano_roll_panel::PianoRollPanel; use crate::quit_panel::QuitPanel; use crate::tracks_panel::TracksPanel; use common::State; /// Every panel. pub struct Panels { /// The music panel. music_panel: MusicPanel, /// The main menu. main_menu: MainMenu, /// The tracks panel. tracks_panel: TracksPanel, /// The open-file panel. open_file_panel: OpenFilePanel, /// The piano roll panel. piano_roll_panel: PianoRollPanel, /// The export panel. export_panel: ExportPanel, /// The export settings panel. export_settings_panel: ExportSettingsPanel, /// The quit panel. quit_panel: QuitPanel, /// The links panel. links_panel: LinksPanel, } impl Panels { pub fn new( config: &Ini, input: &Input, state: &State, conn: &Conn, text: &mut Text, renderer: &Renderer, remote_version: Option, ) -> Self { let music_panel = MusicPanel::new(config, renderer, text); let main_menu = MainMenu::new(config, renderer, input, text, remote_version); let tracks_panel = TracksPanel::new(config, renderer, text); let open_file_panel = OpenFilePanel::new(config, renderer, text); let piano_roll_panel = PianoRollPanel::new(config, renderer, state, text); let export_panel = ExportPanel::new(config, renderer, text); let export_settings_panel = ExportSettingsPanel::new(config, renderer, &conn.exporter, text); let quit_panel = QuitPanel::new(config, renderer, text, input); let links_panel = LinksPanel::new(config, renderer, text, input); Self { music_panel, main_menu, tracks_panel, open_file_panel, piano_roll_panel, export_panel, export_settings_panel, quit_panel, links_panel, } } /// Draw the panels. /// /// - `renderer` The renderer. /// - `state` The state of the app. /// - `conn` The synthesizer-player connection. /// - `text` The text. /// - `paths_state` The state of the file paths. pub fn update( &self, renderer: &Renderer, state: &State, conn: &Conn, text: &Text, paths_state: &PathsState, ) { // Draw the main panel. self.main_menu .update(renderer, state, conn, text, paths_state); for panel_type in &state.panels { // Get the panel. let panel: &dyn Drawable = match panel_type { PanelType::Music => &self.music_panel, PanelType::MainMenu => &self.main_menu, PanelType::Tracks => &self.tracks_panel, PanelType::OpenFile => &self.open_file_panel, PanelType::PianoRoll => &self.piano_roll_panel, PanelType::ExportState => &self.export_panel, PanelType::ExportSettings => &self.export_settings_panel, PanelType::Quit => &self.quit_panel, PanelType::Links => &self.links_panel, }; // Draw the panel. panel.update(renderer, state, conn, text, paths_state); } } /// Do something after input is received from elsewhere. pub fn late_update(&mut self, state: &State, conn: &Conn, renderer: &mut Renderer) { self.open_file_panel.popup.late_update(state, renderer); self.export_panel.popup.late_update(state, renderer); self.quit_panel.popup.late_update(state, renderer); self.links_panel.popup.late_update(state, renderer); self.main_menu.late_update(renderer, conn); self.piano_roll_panel.late_update(state, renderer); } } ================================================ FILE: render/src/piano_roll_panel/multi_track.rs ================================================ use super::viewable_notes::{ViewableNote, ViewableNotes}; use crate::panel::*; use crate::{get_track_heights, Page}; use common::config::parse; use common::{U64orF32, MAX_NOTE, MIN_NOTE}; /// Track colors for when the panel has focus. const TRACK_COLORS_FOCUS: [ColorKey; 6] = [ ColorKey::Track0Focus, ColorKey::Track1Focus, ColorKey::Track2Focus, ColorKey::Track3Focus, ColorKey::Track4Focus, ColorKey::Track5Focus, ]; /// Track colors for when the panel doesn't have focus. const TRACK_COLORS_NO_FOCUS: [ColorKey; 6] = [ ColorKey::Track0NoFocus, ColorKey::Track1NoFocus, ColorKey::Track2NoFocus, ColorKey::Track3NoFocus, ColorKey::Track4NoFocus, ColorKey::Track5NoFocus, ]; /// The min/max note delta as a float. const DN_F: f32 = (MAX_NOTE - MIN_NOTE) as f32; /// The viewable note delta. const DN: [u8; 2] = [MAX_NOTE, MIN_NOTE]; /// View multiple tracks at the same time. pub(crate) struct MultiTrack { /// The rectangle of the entire multi-track sub-panel. rect: Rectangle, /// The (x, y, w, h) of the sub-panel in pixels. rect_f: [f32; 4], /// The height of each note in pixels. note_height: f32, /// The string used for drawing an arrow. arrow: Label, } impl MultiTrack { pub fn new(config: &Ini, renderer: &Renderer) -> Self { let section = config.section(Some("RENDER")).unwrap(); let note_height = parse(section, "multi_track_note_height"); let piano_roll_panel_position = get_piano_roll_panel_position(config); let piano_roll_panel_size = get_piano_roll_panel_size(config); let position = [ piano_roll_panel_position[0] + 1 + PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH, piano_roll_panel_position[1] + PIANO_ROLL_PANEL_TOP_BAR_HEIGHT + 1, ]; let size = [ piano_roll_panel_size[0] - 2 - PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH, piano_roll_panel_size[1], ]; let rect = Rectangle::new(position, size); let position_f = renderer.grid_to_pixel(position); let size_f = renderer.grid_to_pixel(size); let rect_f = [position_f[0], position_f[1], size_f[0], size_f[1]]; let mut arrow_text = String::from("<"); for _ in 0..PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH as usize + 1 { arrow_text.push('='); } let arrow = Label::new( [piano_roll_panel_position[0] - 1, position[1]], arrow_text, renderer, ); Self { rect, rect_f, note_height, arrow, } } pub(crate) fn update( &self, dt: [U64orF32; 2], renderer: &Renderer, state: &State, conn: &Conn, ) { let focus = state.panels[state.focus.get()] == PanelType::PianoRoll; // Get the page. let track_heights = get_track_heights(state, conn); let page = Page::new(&state.music.selected, &track_heights, self.rect.size[1]).visible; let x = self.rect.position[0]; let mut y = self.rect.position[1]; let w = self.rect.size[0]; let mut color_index = 0; // Iterate through the heights and indices. for (height, i) in track_heights.iter().zip(page) { // Draw a rectangle. let rect = Rectangle::new([x, y], [w, *height]); let color = if focus { TRACK_COLORS_FOCUS[color_index] } else { TRACK_COLORS_NO_FOCUS[color_index] }; renderer.rectangle(&rect, &color); // Draw an arrow if this is the selected track. if let Some(selected) = state.music.selected { if selected == i { let mut arrow = self.arrow.clone(); arrow.position[1] = (y + *height / 2) as f32 * renderer.cell_size[1]; renderer.text(&arrow, &color); } } // Increment the color index. color_index += 1; if color_index >= TRACK_COLORS_FOCUS.len() { color_index = 0; } // Get the track. let track = &state.music.midi_tracks[i]; // Get the viewable notes. let notes = ViewableNotes::new_from_track( self.rect_f[0], self.rect_f[2], track, state, conn, focus, dt, DN, ); // Draw the selection background. let selected = notes .notes .iter() .filter(|n| n.selected) .collect::>(); let h = renderer.cell_size[1] * *height as f32; let note_y = renderer.grid_to_pixel([x, y])[1]; let x1 = self.rect_f[0] + self.rect_f[2]; // Get the start and end of the selection. if let Some(select_0) = selected .iter() .min_by(|a, b| a.note.start.cmp(&b.note.start)) { if let Some(select_1) = selected.iter().max_by(|a, b| a.note.end.cmp(&b.note.end)) { let color = if focus { ColorKey::SelectedNotesBackground } else { ColorKey::NoFocus }; // Get the end of the note last note. let x1 = ViewableNotes::get_note_x( select_1.note.end, notes.pulses_per_pixel, self.rect_f[0], &dt, ) .clamp(self.rect_f[0], x1); renderer.rectangle_note([select_0.x, note_y], [x1 - select_0.x, h], &color) } } // Draw some notes. for note in notes.notes.iter() { let note_y = note_y + (1.0 - ((note.note.note - MIN_NOTE) as f32) / DN_F) * h; let mut note_w = notes.get_note_w(note); // Clamp the note. let note_x0 = note.x.clamp(self.rect_f[0], x1); let note_x1 = note.x + note_w; if note_x1 > x1 { note_w = x1 - note_x0; } renderer.rectangle_note( [note_x0, note_y], [note_w, self.note_height], &ColorKey::Background, ) } y += *height; } } } ================================================ FILE: render/src/piano_roll_panel/piano_roll_rows.rs ================================================ use super::viewable_notes::ViewableNotes; use crate::panel::Rectangle; use crate::{ColorKey, Renderer}; use common::view::View; use common::{PanelType, State, U64orF32}; use macroquad::prelude::*; const BACKGROUND_COLOR: ColorKey = ColorKey::Background; pub(crate) struct PianoRollRows { /// A RGBA buffer defining a row. We'll use this to quickly write simple texture data. row: Vec, /// A 1-D RGBA buffer used to fill `row`. sub_row: Vec, /// The width of the piano roll rows in pixels. width: f32, // The position of each row on the screen. positions: Vec<[f32; 2]>, /// The viewport rectangle. rect: Rectangle, /// A copy of the view. This is used to decide whether we need to redraw the rows. view: View, /// As far as this struct knows, this is whether the piano roll panel has focus. focus: bool, /// As far as this struct knows, this is the input beat. beat: U64orF32, /// The row texture. texture: Texture2D, } impl PianoRollRows { pub fn new(rect: Rectangle, state: &State, renderer: &Renderer) -> Self { // Get the pixel width of the viewport. let width = rect.size[0] as f32 * renderer.cell_size[0]; // Define the row buffers. let row_width = width as usize * 4; let mut row = vec![0u8; row_width * renderer.line_width as usize]; let mut sub_row = vec![0u8; row_width]; // Get the half-height of each cell. This will be used to position the lines in the vertical-center of the cell. let half_height = renderer.cell_size[1] / 2.0; // Derive the positions of each row from the dimensions of the viewport. let positions = (0..rect.size[1]) .map(|y| { let p = renderer.grid_to_pixel([rect.position[0], rect.position[1] + y]); [p[0], p[1] + half_height] }) .collect(); let mut texture = Texture2D::from_rgba8(width as u16, renderer.line_width as u16, &row); Self::set_row_texture( &mut texture, &mut sub_row, &mut row, width, false, state, renderer, ); Self { row, sub_row, width, positions, rect, view: state.view.clone(), focus: false, beat: state.input.beat, texture, } } /// Draw the rows. pub fn update(&self, renderer: &Renderer) { // Draw the background. renderer.rectangle(&self.rect, &BACKGROUND_COLOR); // Draw each row. self.positions .iter() .for_each(|p| renderer.texture_pixel(&self.texture, p, None)); } /// Check if we need to re-define the row pattern and, if so, do it. pub fn late_update(&mut self, state: &State, renderer: &Renderer) { let focus = state.panels[state.focus.get()] == PanelType::PianoRoll; // The focus or the view changed. if state.view.single_track && (focus != self.focus || self.beat != state.input.beat || state.view != self.view) { self.focus = focus; self.beat = state.input.beat; self.view = state.view.clone(); Self::set_row_texture( &mut self.texture, &mut self.sub_row, &mut self.row, self.width, focus, state, renderer, ); } } /// Write color data to the row buffer and use it to create a very thin texture. fn set_row_texture( texture: &mut Texture2D, sub_row: &mut [u8], row: &mut [u8], w: f32, focus: bool, state: &State, renderer: &Renderer, ) { let len = sub_row.len(); let dt = [ U64orF32::from(state.view.dt[0]), U64orF32::from(state.view.dt[1]), ]; let t1 = dt[1].get_u() - dt[0].get_u(); let ppp = ViewableNotes::get_pulses_per_pixel(&dt, w); let line_segment_width = ViewableNotes::get_note_x( state.input.beat.get_u(), ppp, 0.0, &[U64orF32::from(0), U64orF32::from(t1)], ) as usize; let color: [u8; 4] = renderer .get_color(if focus { &ColorKey::Separator } else { &ColorKey::NoFocus }) .into(); let clear = [0u8; 4]; // Copy the color into the sub-row. let mut draw_color = false; let lsw = usize::max(line_segment_width * 4, 1); for i in (0..sub_row.len()).step_by(4) { if i % lsw == 0 { draw_color = !draw_color; } sub_row[i..i + 4].copy_from_slice(if draw_color { &color } else { &clear }); } let num_sub_rows = row.len() / sub_row.len(); // Copy the sub-row into the row. for i in 0..num_sub_rows { let ir = i * len; row[ir..ir + len].copy_from_slice(sub_row); } let image = Image { bytes: row.to_vec(), width: w as u16, height: num_sub_rows as u16, }; texture.update(&image); } } ================================================ FILE: render/src/piano_roll_panel/top_bar.rs ================================================ use crate::panel::*; use common::{EditMode, IndexedEditModes, PianoRollMode, SelectMode}; use hashbrown::HashMap; use text::ppq_to_string; /// The padding between input and mode labels. const PADDING: u32 = 4; type ModesMap = HashMap; /// Render the top bar. pub(super) struct TopBar { /// The armed toggle. armed: Boolean, /// The input beat value. beat: KeyWidth, /// The use-volume toggle. use_volume: Boolean, /// The input volume value. volume: KeyWidth, /// The vertical separator line to the right of the inputs. inputs_separator: Line, /// The vertical separator line to the right of the modes. modes_separator: Line, /// The piano roll mode labels. modes: ModesMap, /// The position of the sub-mode label. edit_mode_position: [u32; 2], /// The bar's horizontal line. horizontal_line: Line, } impl TopBar { pub fn new(config: &Ini, renderer: &Renderer, text: &Text) -> Self { let piano_roll_panel_size = get_piano_roll_panel_size(config); let size = [piano_roll_panel_size[0], PIANO_ROLL_PANEL_TOP_BAR_HEIGHT]; let piano_roll_panel_position = get_piano_roll_panel_position(config); let mut x = piano_roll_panel_position[0]; let x0 = x; let y = piano_roll_panel_position[1] + 1; let width = size[0] - 2; // Define the horizontal line. let mut horizontal_line_pos = renderer.grid_to_pixel([x + 1, y + 1]); let horizontal_line_pos_x1 = (x + width + 2) as f32 * renderer.cell_size[0] - 0.45 * renderer.cell_size[0]; horizontal_line_pos[0] -= 0.45 * renderer.cell_size[0]; horizontal_line_pos[1] += 0.6 * renderer.cell_size[1]; let horizontal_line = Line::horizontal( horizontal_line_pos[0], horizontal_line_pos_x1, horizontal_line_pos[1], ); x += 1; // Get the fields. let armed = Boolean::new( text.get("PIANO_ROLL_PANEL_TOP_BAR_ARMED"), [x, y], text, renderer, ); x += armed.width + PADDING; let beat = KeyWidth::new( text.get("PIANO_ROLL_PANEL_TOP_BAR_BEAT"), [x, y], 4, renderer, ); // Only increment by 1 because beat has a long value space. x += beat.width + 1; let use_volume = Boolean::new( text.get("PIANO_ROLL_PANEL_TOP_BAR_USE_VOLUME"), [x, y], text, renderer, ); x += use_volume.width + PADDING; let volume = KeyWidth::new( text.get("PIANO_ROLL_PANEL_TOP_BAR_VOLUME"), [x, y], 3, renderer, ); x += volume.width + PADDING; // Get the separator position. let inputs_separator = Line::vertical_line_separator([x, y], renderer); x += PADDING + 3; // Get the modes. let total_modes_width = (((piano_roll_panel_size[0] - 2) - (x - x0)) as f64 * 0.75) as u32; let dx = total_modes_width / 4; let mut modes = HashMap::new(); TopBar::insert_mode( "PIANO_ROLL_PANEL_TOP_BAR_TIME", PianoRollMode::Time, [x, y], &mut modes, renderer, text, ); x += dx; TopBar::insert_mode( "PIANO_ROLL_PANEL_TOP_BAR_VIEW", PianoRollMode::View, [x, y], &mut modes, renderer, text, ); x += dx; TopBar::insert_mode( "PIANO_ROLL_PANEL_TOP_BAR_SELECT", PianoRollMode::Select, [x, y], &mut modes, renderer, text, ); x += dx; TopBar::insert_mode( "PIANO_ROLL_PANEL_TOP_BAR_EDIT", PianoRollMode::Edit, [x, y], &mut modes, renderer, text, ); x += dx; // Get the separator position. let modes_separator = Line::vertical_line_separator([x, y], renderer); x += 2; let edit_mode_position = [x, y]; Self { armed, beat, use_volume, volume, modes, inputs_separator, modes_separator, edit_mode_position, horizontal_line, } } /// Update the top bar from the app state. pub fn update(&self, state: &State, renderer: &Renderer, text: &Text, focus: bool) { // Draw the fields. renderer.boolean(state.input.armed, &self.armed, focus); let value_color = Renderer::get_value_color([focus, true]); let key_color = Renderer::get_key_color(focus); let colors = [&key_color, &value_color]; renderer.key_value(&ppq_to_string(state.input.beat.get_u()), &self.beat, colors); renderer.boolean(state.input.use_volume, &self.use_volume, focus); renderer.key_value(&state.input.volume.get().to_string(), &self.volume, colors); // Separator. let line_color = if focus { &ColorKey::FocusDefault } else { &ColorKey::NoFocus }; renderer.vertical_line(&self.inputs_separator, line_color); // Draw the modes. for mode in self.modes.iter() { // Reverse the colors. if focus && *mode.0 == state.piano_roll_mode { renderer.rectangle(&mode.1 .1, &ColorKey::FocusDefault); renderer.text(&mode.1 .0, &ColorKey::Background); } else { renderer.text( &mode.1 .0, &(if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }), ); } } // Separator. renderer.vertical_line(&self.modes_separator, line_color); // Edit mode. let edit_mode = match state.piano_roll_mode { PianoRollMode::Edit => Self::get_edit_mode_text(&state.edit_mode, text), PianoRollMode::Select => match state.select_mode { SelectMode::Single(_) => text.get_ref("PIANO_ROLL_PANEL_EDIT_MODE_SINGLE"), SelectMode::Many(_) => text.get_ref("PIANO_ROLL_PANEL_EDIT_MODE_MANY"), }, PianoRollMode::Time => Self::get_edit_mode_text(&state.time.mode, text), PianoRollMode::View => Self::get_edit_mode_text(&state.view.mode, text), }; let edit_mode = LabelRef::new(self.edit_mode_position, edit_mode, renderer); let edit_mode_color = if focus { ColorKey::Key } else { ColorKey::NoFocus }; renderer.text_ref(&edit_mode, &edit_mode_color); // Horizontal line. renderer.horizontal_line(&self.horizontal_line, line_color); } /// Returns the string corresponding to the edit mode. fn get_edit_mode_text<'t>(edit_mode: &IndexedEditModes, text: &'t Text) -> &'t str { let key = match edit_mode.get_ref() { EditMode::Normal => "PIANO_ROLL_PANEL_EDIT_MODE_NORMAL", EditMode::Quick => "PIANO_ROLL_PANEL_EDIT_MODE_QUICK", EditMode::Precise => "PIANO_ROLL_PANEL_EDIT_MODE_PRECISE", }; text.get_ref(key) } /// Insert an edit mode label into a HashMap. fn insert_mode( key: &str, mode: PianoRollMode, position: [u32; 2], modes: &mut ModesMap, renderer: &Renderer, text: &Text, ) { let label = Label::new(position, text.get(key), renderer); let rect = Rectangle::new(position, [label.text.chars().count() as u32, 1]); modes.insert(mode, (label, rect)); } } ================================================ FILE: render/src/piano_roll_panel/viewable_notes.rs ================================================ use crate::panel::*; use audio::play_state::PlayState; use common::*; /// A viewable note. pub(crate) struct ViewableNote<'a> { /// The note. pub note: &'a Note, /// The x pixel coordinate of the note. pub x: f32, /// If true, this note is being played. pub playing: bool, /// If true, this note is selected. pub selected: bool, /// If true, the note is within the viewport's pitch range (`dn`). /// We need this because we want to render note rectangles only for notes in the pitch range, but we also want to render volume lines for notes beyond the pitch range. pub in_pitch_range: bool, /// The color of this note. pub color: ColorKey, } /// Render information for all notes that are in the viewport. /// This information is shared between the piano roll and volume sub-panels. pub(crate) struct ViewableNotes<'a> { /// The notes that are in view. pub notes: Vec>, /// Cached viewport dt in PPQ. dt: [U64orF32; 2], /// The number of pulses in 1 pixel. pub pulses_per_pixel: u64, } impl<'a> ViewableNotes<'a> { /// - `x` The x pixel coordinate of the note's position. /// - `w` The pixel width of the note. /// - `state` The app state. /// - `conn` The audio conn. /// - `focus` If true, the piano roll panel has focus. /// - `dt` The time delta. pub fn new( x: f32, w: f32, state: &'a State, conn: &Conn, focus: bool, dt: [U64orF32; 2], ) -> Self { match state.music.get_selected_track() { Some(track) => Self::new_from_track(x, w, track, state, conn, focus, dt, state.view.dn), None => Self { pulses_per_pixel: Self::get_pulses_per_pixel(&dt, w), notes: vec![], dt, }, } } /// - `x` The x pixel coordinate of the note's position. /// - `w` The pixel width of the note. /// - `xw` The x and w pixel values of the rectangle where notes can be rendered. /// - `track` The track. /// - `state` The app state. /// - `conn` The audio conn. /// - `focus` If true, the piano roll panel has focus. /// - `dt` The time delta. /// - `dn` The range of viewable note pitches. #[allow(clippy::too_many_arguments)] pub fn new_from_track( x: f32, w: f32, track: &'a MidiTrack, state: &'a State, conn: &Conn, focus: bool, dt: [U64orF32; 2], dn: [u8; 2], ) -> Self { let pulses_per_pixel = Self::get_pulses_per_pixel(&dt, w); // Get any notes being played. let playtime = match *conn.play_state.lock() { PlayState::Playing(time) => Some(state.time.samples_to_ppq(time, conn.framerate)), _ => None, }; // Get the selected notes. let selected = state .select_mode .get_notes(&state.music) .unwrap_or_default(); let mut notes = vec![]; for note in track.notes.iter() { // Is the note in view? if note.end <= dt[0].get_u() || note.start >= dt[1].get_u() { continue; } // Get the start time of the note. This could be the start of the viewport. let t = if note.start < dt[0].get_u() { dt[0].get_u() } else { note.start }; // Get the x coordinate of the note. let x_note = Self::get_note_x(t, pulses_per_pixel, x, &dt); // Is this note in the selection? let selected = selected.contains(¬e); // Is this note being played? let playing = match playtime { Some(playtime) => note.start <= playtime && note.end >= playtime, None => false, }; // Get the color of the note. let color = if focus { if playing { ColorKey::NotePlaying } else if selected { ColorKey::NoteSelected } else { ColorKey::Note } } else { ColorKey::NoFocus }; let in_pitch_range = note.note <= dn[0] && note.note > dn[1]; // Add the note. notes.push(ViewableNote { note, x: x_note, color, selected, playing, in_pitch_range, }); } Self { notes, dt, pulses_per_pixel, } } /// Returns the width of a note. pub fn get_note_w(&self, note: &ViewableNote) -> f32 { let t0 = if note.note.start < self.dt[0].get_u() { self.dt[0].get_u() } else { note.note.start }; let t1 = if note.note.end > self.dt[1].get_u() { self.dt[1].get_u() } else { note.note.end }; ((t1 - t0) / self.pulses_per_pixel) as f32 } /// Returns the x pixel coordinate corresonding with time `t` within the viewport defined by `x`, `w` and `dt`. /// /// - `t` The time in PPQ. /// - `ppp` The number of pulses in 1 pixel. pub fn get_note_x(t: u64, ppp: u64, x: f32, dt: &[U64orF32; 2]) -> f32 { if t >= dt[0].get_u() { x + ((t - dt[0].get_u()) / ppp) as f32 } else { x + ((dt[0].get_u() - t) / ppp) as f32 } } /// Returns the number of pulses in 1 pixel. /// /// - `dt` The start and end time in PPQ. /// - `w` The width of the view in pixels. pub fn get_pulses_per_pixel(dt: &[U64orF32; 2], w: f32) -> u64 { (((dt[1].get_f() - dt[0].get_f()) / w) as u64).clamp(1, u64::MAX) } } ================================================ FILE: render/src/piano_roll_panel/volume.rs ================================================ use super::viewable_notes::*; use crate::panel::*; use common::MAX_VOLUME; const MAX_VOLUME_F: f32 = MAX_VOLUME as f32; /// The piano roll volume sub-panel. pub(crate) struct Volume { /// The position and size of the panel in grid units. pub(super) rect: Rectangle, /// The title label for the panel. title: Label, /// The position and size of the title in grid units. title_rect: RectanglePixel, /// The top, bottom, and height of the line extents. line_extents: [f32; 3], } impl Volume { pub fn new(config: &Ini, text: &Text, renderer: &Renderer) -> Self { let piano_roll_panel_position = get_piano_roll_panel_position(config); let piano_roll_panel_size = get_piano_roll_panel_size(config); let position = [ piano_roll_panel_position[0], piano_roll_panel_position[1] + piano_roll_panel_size[1], ]; let size = [piano_roll_panel_size[0], PIANO_ROLL_PANEL_VOLUME_HEIGHT]; let rect = Rectangle::new(position, size); let title_position = [position[0] + 2, position[1]]; let title_text = text.get("PIANO_ROLL_PANEL_VOLUME_TITLE"); let title_width = title_text.chars().count() as u32; let title = Label::new(title_position, title_text, renderer); let title_rect = RectanglePixel::new_from_u(title_position, [title_width, 1], renderer); let position_f = renderer.grid_to_pixel([ position[0] + 1 + PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH, position[1] + 1, ]); let size_f = renderer.grid_to_pixel([size[0] - 2 - PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH, size[1] - 2]); let line_y1 = position_f[1] + size_f[1]; let line_y0 = position_f[1] + size_f[1] * 2.0; let line_extents = [line_y1, line_y0, line_y0 - line_y1]; Self { rect, title, title_rect, line_extents, } } /// Render the volume. /// /// - `notes` All viewable notes. /// - `renderer` The renderer. /// - `state` The app state. pub fn update(&self, notes: &ViewableNotes, renderer: &Renderer, state: &State) { // Get focus. let focus = state.panels[state.focus.get()] == PanelType::PianoRoll; // Draw the panel background. let bg_color = if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }; renderer.rectangle(&self.rect, &ColorKey::Background); renderer.border(&self.rect, &bg_color); renderer.rectangle_pixel(&self.title_rect, &ColorKey::Background); renderer.text(&self.title, &bg_color); // Render the lines in layers. // This forces selected notes and playing notes to render on top. for note in notes.notes.iter().filter(|n| !n.playing && !n.selected) { self.render_note(note, renderer); } for note in notes.notes.iter().filter(|n| !n.playing && n.selected) { self.render_note(note, renderer); } for note in notes.notes.iter().filter(|n| n.playing) { self.render_note(note, renderer); } } fn render_note(&self, note: &ViewableNote, renderer: &Renderer) { let h = self.line_extents[2] * (note.note.velocity as f32 / MAX_VOLUME_F); let bottom = self.line_extents[0]; let top = bottom - h; renderer.vertical_line_pixel(note.x, bottom, top, ¬e.color) } } ================================================ FILE: render/src/piano_roll_panel.rs ================================================ use crate::panel::*; use audio::play_state::PlayState; use audio::SharedPlayState; mod piano_roll_rows; use piano_roll_rows::PianoRollRows; mod multi_track; mod top_bar; mod viewable_notes; mod volume; use common::{SelectMode, State, U64orF32, NOTE_NAMES, PPQ_U}; use hashbrown::HashSet; use multi_track::MultiTrack; use text::ppq_to_string; use top_bar::TopBar; use viewable_notes::{ViewableNote, ViewableNotes}; use volume::Volume; const TIME_PADDING: u32 = 3; /// Draw the piano roll panel. pub struct PianoRollPanel { /// The panel used in single-track mode. panel_single_track: Panel, /// The panel used in multi-track mode. panel_multi_track: Panel, /// Data for the top bar sub-panel. top_bar: TopBar, /// The volume sub-panel. volume: Volume, /// The multi-track sub-panel. multi_track: MultiTrack, /// The position of the note names. note_name_positions: Vec<[u32; 2]>, /// The piano roll rows textures. piano_roll_rows: PianoRollRows, /// The (x, y, w, h) values of the piano row rolls rect. piano_roll_rows_rect: [f32; 4], /// The size of a cell. cell_size: [f32; 2], /// The y coordinate of the time labels in grid units. time_y: u32, /// The time horizontal line y pixel coordinate. time_horizontal_line_y: f32, /// The bottom y coordinates for time lines in single- and multi- track modes. time_line_bottoms: [f32; 2], } impl PianoRollPanel { pub fn new(config: &Ini, renderer: &Renderer, state: &State, text: &Text) -> Self { let piano_roll_panel_position = get_piano_roll_panel_position(config); let piano_roll_panel_size = get_piano_roll_panel_size(config); let panel_single_track = Panel::new( PanelType::PianoRoll, piano_roll_panel_position, piano_roll_panel_size, renderer, text, ); let top_bar = TopBar::new(config, renderer, text); let note_names_position = [ piano_roll_panel_position[0] + 1, piano_roll_panel_position[1] + PIANO_ROLL_PANEL_TOP_BAR_HEIGHT + 1, ]; let piano_roll_rows_size = get_viewport_size(config); let note_name_positions: Vec<[u32; 2]> = (note_names_position[1] ..=note_names_position[1] + piano_roll_rows_size[1]) .map(|y| [note_names_position[0], y]) .collect(); let piano_roll_rows_position = [ note_names_position[0] + PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH, note_names_position[1], ]; let piano_roll_rows_rect = Rectangle::new(piano_roll_rows_position, piano_roll_rows_size); let piano_roll_rows = PianoRollRows::new(piano_roll_rows_rect, state, renderer); let piano_roll_rows_position_f = renderer.grid_to_pixel(piano_roll_rows_position); let viewport_size = renderer.grid_to_pixel(piano_roll_rows_size); let piano_roll_rows_rect = [ piano_roll_rows_position_f[0], piano_roll_rows_position_f[1], viewport_size[0], viewport_size[1], ]; let cell_size = get_cell_size(config); let time_y = note_names_position[1] - 1; let time_horizontal_line_y = cell_size[1] * (time_y + 1) as f32; let volume = Volume::new(config, text, renderer); let multi_track = MultiTrack::new(config, renderer); let mut panel_multi_track = panel_single_track.clone(); panel_multi_track .background .resize_by([0, volume.rect.size[1]], renderer); let volume_size_f = renderer.grid_to_pixel(volume.rect.size); let time_line_bottom_single_track = piano_roll_rows_rect[1] + piano_roll_rows_rect[3]; let time_line_bottoms = [ time_line_bottom_single_track, time_line_bottom_single_track + volume_size_f[1], ]; Self { panel_single_track, panel_multi_track, top_bar, note_name_positions, piano_roll_rows, piano_roll_rows_rect, cell_size, time_y, time_horizontal_line_y, volume, multi_track, time_line_bottoms, } } pub fn late_update(&mut self, state: &State, renderer: &Renderer) { self.piano_roll_rows.late_update(state, renderer); } /// Draw a horizontal line from a time label and optionally a vertical line down the rows. fn draw_time_lines( &self, x: u32, time: u64, color: &ColorKey, single_track: bool, renderer: &Renderer, dt: &[U64orF32; 2], ) { // Get the pixel position of the start coordinate. let x0 = x as f32 * self.cell_size[0]; // The time is before the start time. let (x1, vertical) = if time < dt[0].get_u() { (self.piano_roll_rows_rect[0], false) } // The time is after the end time. else if time > dt[1].get_u() { ( self.piano_roll_rows_rect[0] + self.piano_roll_rows_rect[2], false, ) } // The time is within the viewport. else { ( ViewableNotes::get_note_x( time, ViewableNotes::get_pulses_per_pixel(dt, self.piano_roll_rows_rect[2]), self.piano_roll_rows_rect[0], dt, ), true, ) }; // Draw a horizontal line. renderer.horizontal_line_pixel(x0, x1, self.time_horizontal_line_y, color); // Draw a vertical line. if vertical { renderer.vertical_line_pixel( x1, self.piano_roll_rows_rect[1], if single_track { self.time_line_bottoms[0] } else { self.time_line_bottoms[1] }, color, ); } } /// If music isn't playing, this returns `state.view.dt`. /// Otherwise, this returns a view delta that has been moved to include the current playback time. fn get_view_dt(state: &State, conn: &Conn) -> [u64; 2] { let dt = [state.view.dt[0], state.view.dt[1]]; let play_state = Self::get_play_state(&conn.play_state); match play_state { // We are playing music. PlayState::Playing(samples) => { let time_ppq = state.time.samples_to_ppq(samples, conn.framerate); // The time is in range if time_ppq >= dt[0] && time_ppq <= dt[1] { dt } else { let delta = dt[1] - dt[0]; // This is maybe not the best way to round, but it gets the job done! let t0 = (time_ppq / delta) * delta; let t1 = t0 + delta; [t0, t1] } } // If there is no music playing, just use the "actual" view. _ => dt, } } fn get_play_state(play_state: &SharedPlayState) -> PlayState { *play_state.lock() } } impl Drawable for PianoRollPanel { fn update(&self, renderer: &Renderer, state: &State, conn: &Conn, text: &Text, _: &PathsState) { let panel = if state.view.single_track { &self.panel_single_track } else { &self.panel_multi_track }; let focus = panel.has_focus(state); // Panel background. panel.update(focus, renderer); let program_exists = match state.music.get_selected_track() { Some(track) => conn.state.programs.contains_key(&track.channel), None => false, }; let focus = focus && program_exists; // Top bar. self.top_bar.update(state, renderer, text, focus); let dt = Self::get_view_dt(state, conn).map(U64orF32::from); if state.view.single_track { // Piano roll rows. self.piano_roll_rows.update(renderer); // Get the viewable notes. let notes = ViewableNotes::new( self.piano_roll_rows_rect[0], self.piano_roll_rows_rect[2], state, conn, focus, dt, ); // Draw the selection background. let selected = notes .notes .iter() .filter(|n| n.selected && n.in_pitch_range) .collect::>(); // Get the start and end of the selection. if let Some(select_0) = selected .iter() .min_by(|a, b| a.note.start.cmp(&b.note.start)) { if let Some(select_1) = selected.iter().max_by(|a, b| a.note.end.cmp(&b.note.end)) { let color = if focus { ColorKey::SelectedNotesBackground } else { ColorKey::NoFocus }; let x1 = ViewableNotes::get_note_x( select_1.note.end, notes.pulses_per_pixel, self.piano_roll_rows_rect[0], &dt, ); renderer.rectangle_note( [select_0.x, self.piano_roll_rows_rect[1]], [x1 - select_0.x, self.piano_roll_rows_rect[3]], &color, ) } } let in_pitch_range: Vec<&ViewableNote> = notes.notes.iter().filter(|n| n.in_pitch_range).collect(); let selected_pitches: Vec = selected .iter() .map(|n| n.note.note) .collect::>() .into_iter() .collect(); // Draw the notes. for note in in_pitch_range.iter() { let w = notes.get_note_w(note); // Get the y value from the pitch. let y = self.piano_roll_rows_rect[1] + ((state.view.dn[0] - note.note.note) as f32) * self.cell_size[1]; renderer.rectangle_note([note.x, y], [w, self.cell_size[1]], ¬e.color) } // Volume. self.volume.update(¬es, renderer, state); // Note names. let note_name_color = if focus { &ColorKey::Separator } else { &ColorKey::NoFocus }; for (position, pitch) in self .note_name_positions .iter() .zip((state.view.dn[1] + 1..state.view.dn[0] + 1).rev()) { let note_name = LabelRef::new(*position, NOTE_NAMES[127 - pitch as usize], renderer); let note_name_color = if selected_pitches.contains(&pitch) { &ColorKey::NoteSelected } else { note_name_color }; renderer.text_ref(¬e_name, note_name_color); } } // Cursor label. let cursor_color = if focus { ColorKey::TimeCursor } else { ColorKey::NoFocus }; let cursor_x = panel.background.grid_rect.position[0] + PIANO_ROLL_PANEL_NOTE_NAMES_WIDTH + 1; let cursor_string = text.get_with_values( "PIANO_ROLL_PANEL_CURSOR_TIME", &[&ppq_to_string(state.time.cursor)], ); let cursor_string_width = cursor_string.chars().count() as u32; let playback_x = cursor_x + cursor_string_width + TIME_PADDING; let cursor_label = Label::new([cursor_x, self.time_y], cursor_string, renderer); renderer.text(&cursor_label, &cursor_color); // Cursor horizontal line. let cursor_line_x0 = cursor_x + cursor_string_width / 2; // Playback label. let playback_color = if focus { ColorKey::TimePlayback } else { ColorKey::NoFocus }; let playback_string = text.get_with_values( "PIANO_ROLL_PANEL_PLAYBACK_TIME", &[&ppq_to_string(state.time.playback)], ); let playback_string_width = playback_string.chars().count() as u32; let playback_line_x0 = playback_x + playback_string_width / 2; let selection_x = playback_x + playback_string_width + TIME_PADDING; let (selection_string, selected) = match &state.select_mode { SelectMode::Single(index) => match index { Some(index) => { let note = &state.music.get_selected_track().unwrap().notes[*index]; ( text.get_with_values( "PIANO_ROLL_PANEL_SELECTED_SINGLE", &[note.get_name(), &(note.start / PPQ_U).to_string()], ), true, ) } None => (text.get("PIANO_ROLL_PANEL_SELECTED_NONE"), false), }, SelectMode::Many(indices) => match indices { Some(_) => { let mut notes = state.select_mode.get_notes(&state.music).unwrap(); notes.sort(); let min = notes[0].start / PPQ_U; let max = notes.last().unwrap().end / PPQ_U; ( text.get_with_values( "PIANO_ROLL_PANEL_SELECTED_MANY", &[&min.to_string(), &max.to_string()], ), true, ) } None => (text.get("PIANO_ROLL_PANEL_SELECTED_NONE"), false), }, }; let playback_label = Label::new([playback_x, self.time_y], playback_string, renderer); renderer.text(&playback_label, &playback_color); let selection_label = Label::new([selection_x, self.time_y], selection_string, renderer); // Current playback time. let play_state = Self::get_play_state(&conn.play_state); if let PlayState::Playing(samples) = play_state { let music_time_string = ppq_to_string(state.time.samples_to_ppq(samples, conn.framerate)); let music_time_x = selection_x + selection_label.text.chars().count() as u32 + TIME_PADDING; let music_time_label = Label::new([music_time_x, self.time_y], music_time_string, renderer); renderer.text(&music_time_label, &Renderer::get_key_color(focus)); } renderer.text( &selection_label, &if focus { if selected { ColorKey::NoteSelected } else { ColorKey::SelectedNotesBackground } } else { ColorKey::NoFocus }, ); // Time delta label. let dt_string = text.get_with_values( "PIANO_ROLL_PANEL_VIEW_DT", &[&ppq_to_string(dt[0].get_u()), &ppq_to_string(dt[1].get_u())], ); let dt_x = panel.background.grid_rect.position[0] + panel.background.grid_rect.size[0] - dt_string.chars().count() as u32 - 1; let dt_label = Label::new([dt_x, self.time_y], dt_string, renderer); renderer.text(&dt_label, &Renderer::get_key_color(focus)); if !state.view.single_track { self.multi_track.update(dt, renderer, state, conn); } // Draw time lines. self.draw_time_lines( cursor_line_x0, state.time.cursor, &cursor_color, state.view.single_track, renderer, &dt, ); self.draw_time_lines( playback_line_x0, state.time.playback, &playback_color, state.view.single_track, renderer, &dt, ); // Show where we are in the music. if let PlayState::Playing(samples) = play_state { let music_time = state.time.samples_to_ppq(samples, conn.framerate); if music_time >= dt[0].get_u() && music_time <= dt[1].get_u() { let x = ViewableNotes::get_note_x( music_time, ViewableNotes::get_pulses_per_pixel(&dt, self.piano_roll_rows_rect[2]), self.piano_roll_rows_rect[0], &dt, ); let music_color = if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }; renderer.vertical_line_pixel( x, self.piano_roll_rows_rect[1], if state.view.single_track { self.time_line_bottoms[0] } else { self.time_line_bottoms[1] }, &music_color, ); } } } } ================================================ FILE: render/src/popup.rs ================================================ use crate::Renderer; use common::{PanelType, State}; /// A popup tries to capture the backround texture when its panel is first enabled. pub(crate) struct Popup { /// My panel type. panel_type: PanelType, /// If true, capture the screen. captured_screen: bool, } impl Popup { pub(crate) fn new(panel_type: PanelType) -> Self { Self { panel_type, captured_screen: false, } } /// Update and draw. pub(crate) fn update(&self, renderer: &Renderer) { renderer.background(); } /// Update the popup. Maybe request a screen capture. /// /// - If the corresponding panel was enabled on this frame, set the background texture. /// - If the corresponding panel was disabled on thie frame, un-set the background texture. pub(crate) fn late_update(&mut self, state: &State, renderer: &mut Renderer) { // This panel is active. if state.panels.contains(&self.panel_type) { if !self.captured_screen { self.captured_screen = true; // I don't have a background and I need one. renderer.screen_capture(); } } else { self.captured_screen = false; } } } ================================================ FILE: render/src/quit_panel.rs ================================================ use crate::{panel::*, popup::Popup}; use input::InputEvent; use text::Tooltips; const LABEL_PADDING: u32 = 8; const LABEL_COLOR: ColorKey = ColorKey::Value; /// Do you really want to quit? pub(crate) struct QuitPanel { /// The panel. panel: Panel, /// Yes and no. labels: [Label; 2], /// The popup. pub popup: Popup, } impl QuitPanel { pub fn new(config: &Ini, renderer: &Renderer, text: &Text, input: &Input) -> Self { let mut tooltips = Tooltips::default(); // Get the width of the panel. let yes = tooltips .get_tooltip("QUIT_PANEL_YES", &[InputEvent::QuitPanelYes], input, text) .seen; let no = tooltips .get_tooltip("QUIT_PANEL_NO", &[InputEvent::QuitPanelNo], input, text) .seen; let yes_w = yes.chars().count() as u32 + LABEL_PADDING; let w = yes_w + no.chars().count() as u32 + 4; let h = 3; // Get the position of the panel. let window_grid_size = get_window_grid_size(config); let x = window_grid_size[0] / 2 - w / 2; let y = window_grid_size[1] / 2 - h / 2; // Define the panel. let panel = Panel::new(PanelType::Quit, [x, y], [w, h], renderer, text); // Define the labels. let yes_x = x + 2; let yes_y = y + 1; let yes = Label::new([yes_x, yes_y], yes, renderer); let no = Label::new([yes_x + yes_w, yes_y], no, renderer); let labels = [yes, no]; let popup = Popup::new(PanelType::Quit); Self { panel, labels, popup, } } } impl Drawable for QuitPanel { fn update(&self, renderer: &Renderer, _: &State, _: &Conn, _: &Text, _: &PathsState) { self.popup.update(renderer); self.panel.update(true, renderer); renderer.text(&self.labels[0], &LABEL_COLOR); renderer.text(&self.labels[1], &LABEL_COLOR); } } ================================================ FILE: render/src/renderer.rs ================================================ use crate::field_params::*; use crate::{ColorKey, Focus}; use common::config::parse_bool; use common::font::{get_font, get_subtitle_font}; use common::sizes::*; use hashbrown::HashMap; use ini::Ini; use macroquad::prelude::*; const TEXTURE_COLOR: Color = macroquad::color::colors::WHITE; /// Draw shapes and text. This also stores colors, fonts, etc. pub struct Renderer { /// Color key - Macroquad color map. colors: HashMap, /// The font for everything except subtitltes. font: Font, /// The font used for subtitles. subtitle_font: Font, /// The size of a single cell. pub(crate) cell_size: [f32; 2], /// The font size. font_size: u16, /// The top-left position of the subtitle text. subtitle_position: [u32; 2], /// The maximum width of a line of subtitles. max_subtitle_width: u32, /// The width of all lines. pub(crate) line_width: f32, /// Half-width line. half_line_width: f32, /// The offsets used when drawing a border. border_offsets: [f32; 4], /// The length of each line when drawing corners. corner_line_length: f32, /// This is used to flip captured textures. flip_y: bool, /// This is used to resize captured textures. pub(crate) window_pixel_size: [f32; 2], /// An optional background texture. This is used for popups. background: Option, /// Parameters for drawing the texture. background_params: Option, } impl Renderer { pub fn new(config: &Ini) -> Self { // Get the color aliases. let aliases_section = config.section(Some("COLOR_ALIASES")).unwrap(); let mut aliases = HashMap::new(); for kv in aliases_section.iter() { aliases.insert(kv.0.to_string(), Renderer::parse_color(kv.1)); } // Get the colors. let colors_section = config.section(Some("COLORS")).unwrap(); let mut colors = HashMap::new(); for kv in colors_section.iter() { match kv.0.parse::() { Ok(key) => { let color = match aliases.get(kv.1) { Some(color) => *color, None => Renderer::parse_color(kv.1), }; colors.insert(key, color); } Err(error) => panic!("Invalid color key: {:?} {}", kv, error), } } // Fonts. let font = get_font(config); let subtitle_font = get_subtitle_font(config); let font_size = get_font_size(config); // Sizes. let cell_size = get_cell_size(config); let main_menu_position = get_main_menu_position(config); let subtitle_position = [(main_menu_position[0] + 1), (main_menu_position[1] + 1)]; let border_offsets: [f32; 4] = [ cell_size[0] / 2.0, cell_size[1] / 3.0, -cell_size[0], -cell_size[1] * (2.0 / 3.0), ]; let corner_line_length = cell_size[0] / 2.0; let max_subtitle_width = get_main_menu_width(config) - 2; // Render settings. let render_section = config.section(Some("RENDER")).unwrap(); let line_width = get_line_width(config); let half_line_width = line_width / 2.0; let flip_y = parse_bool(render_section, "flip_y"); let window_pixel_size = get_window_pixel_size(config); Self { colors, font, subtitle_font, font_size, cell_size, line_width, half_line_width, corner_line_length, border_offsets, flip_y, subtitle_position, max_subtitle_width, window_pixel_size, background: None, background_params: None, } } /// Draw a rectangle. /// /// - `rectangle` The position and size of the bordered area. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn rectangle(&self, rect: &Rectangle, color: &ColorKey) { let xy = self.grid_to_pixel(rect.position); let wh = self.grid_to_pixel(rect.size); let color = self.colors[color]; draw_rectangle(xy[0], xy[1], wh[0], wh[1], color); } /// Draw a rectangle using pixel coordinates instead of grid coordinates. /// /// - `rect` The rectangle. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn rectangle_pixel(&self, rect: &RectanglePixel, color: &ColorKey) { draw_rectangle( rect.position[0], rect.position[1], rect.size[0], rect.size[1], self.colors[color], ) } /// Draw a rectangle using pixel coordinates instead of grid coordinates. /// This is used to draw notes. /// /// - `position` The top-left position in pixel coordinates. /// - `size` The width-height in pixel coordinates. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn rectangle_note(&self, position: [f32; 2], size: [f32; 2], color: &ColorKey) { draw_rectangle( position[0], position[1], size[0], size[1], self.colors[color], ) } /// Draw a border that is slightly offset from the edges of the cells. /// /// - `rectangle` The position and size of the bordered area. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn border(&self, rect: &Rectangle, color: &ColorKey) { self.rectangle_lines(&self.get_border_rect(rect.position, rect.size), color); } /// Draw a border that is slightly offset from the edges of the cells. /// /// - `rectangle` The position and size of the bordered area. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn rectangle_lines(&self, rect: &RectanglePixel, color: &ColorKey) { draw_rectangle_lines( rect.position[0], rect.position[1], rect.size[0], rect.size[1], self.line_width, self.colors[color], ); } /// Draw text. /// /// - `label` Parameters for drawing text. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn text(&self, label: &Label, text_color: &ColorKey) { self.text_ex( label.position, &label.text, text_color, &self.font, self.font_size, ); } /// Draw text. /// /// - `label` Parameters for drawing text. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn text_ref(&self, label: &LabelRef, text_color: &ColorKey) { self.text_ex( label.position, label.text, text_color, &self.font, self.font_size, ); } /// Draw corner borders around a rectangle. /// /// - `rectangle` The position and size of the bordered area. /// - `focus` If true, the panel has focus. This determines the color of the corners. pub(crate) fn corners(&self, rect: &RectanglePixel, focus: bool) { // Get the color. let color = self.colors[&if focus { ColorKey::FocusDefault } else { ColorKey::NoFocus }]; // Top-left. draw_line( rect.position[0] - self.half_line_width, rect.position[1], rect.position[0] + self.corner_line_length, rect.position[1], self.line_width, color, ); draw_line( rect.position[0], rect.position[1], rect.position[0], rect.position[1] + self.corner_line_length, self.line_width, color, ); // Top-right. let position = [rect.position[0] + rect.size[0], rect.position[1]]; draw_line( position[0] - self.corner_line_length, position[1], position[0] + self.half_line_width, position[1], self.line_width, color, ); draw_line( position[0], position[1], position[0], position[1] + self.corner_line_length, self.line_width, color, ); // Bottom-right. let position = [ rect.position[0] + rect.size[0], rect.position[1] + rect.size[1], ]; draw_line( position[0] - self.corner_line_length, position[1], position[0] + self.half_line_width, position[1], self.line_width, color, ); draw_line( position[0], position[1] - self.corner_line_length, position[0], position[1], self.line_width, color, ); // Bottom-left. let position = [rect.position[0], rect.position[1] + rect.size[1]]; draw_line( position[0] - self.half_line_width, position[1], position[0] + self.corner_line_length, position[1], self.line_width, color, ); draw_line( position[0], position[1] - self.corner_line_length, position[0], position[1], self.line_width, color, ); } /// Draw an arbitrary texture at a pixel position. /// /// - `texture` The texture. /// - `position` The top-left position in pixel coordinates. /// - `rect` The area of the texture to draw. pub(crate) fn texture_pixel( &self, texture: &Texture2D, position: &[f32; 2], rect: Option, ) { match rect { Some(rect) => { let params = DrawTextureParams { source: Some(rect), ..Default::default() }; draw_texture_ex(texture, position[0], position[1], TEXTURE_COLOR, params); } None => draw_texture(texture, position[0], position[1], TEXTURE_COLOR), } } /// Draw the background texture. This is used by popups. pub(crate) fn background(&self) { if let Some(texture) = &self.background { if let Some(params) = &self.background_params { draw_texture_ex(texture, 0.0, 0.0, TEXTURE_COLOR, params.clone()); } } } /// Draw a line from top to bottom in pixel coordinates. /// /// - `x` The x pixel coordinate. /// - `top` The top y pixel coordinate. /// - `bottom` The bottom y pixel coordinate. /// - `color` A `ColorKey` for the line. pub(crate) fn vertical_line_pixel(&self, x: f32, top: f32, bottom: f32, color: &ColorKey) { draw_line(x, top, x, bottom, self.line_width, self.colors[color]); } /// Draw a line from top to bottom. /// /// - `line` The vertical line. /// - `color` A `ColorKey` for the line. pub(crate) fn vertical_line(&self, line: &Line, color: &ColorKey) { draw_line( line.b, line.a0, line.b, line.a1, self.half_line_width, self.colors[color], ); } /// Draw a line from left to right. /// /// - `line` The vertical line. /// - `color` A `ColorKey` for the line. pub(crate) fn horizontal_line(&self, line: &Line, color: &ColorKey) { draw_line( line.a0, line.b, line.a1, line.b, self.half_line_width, self.colors[color], ); } /// Draw a line from left to right. /// /// - `left` The left grid coordinate. /// - `right` The right grid coordinate. /// - `x_offsets` Two floats between 0.0 and 1.0 to offset `left` and `right` in pixel coordinates. 0.5 will put the y coordinate at the mid-point of the grid cell. /// - `y` The y grid coordinate. /// - `y_offset` A float between 0.0 and 1.0 to offset `y` in pixel coordinates. 0.5 will put the y coordinate at the mid-point of the grid cell. /// - `color` A `ColorKey` for the rectangle. pub(crate) fn horizontal_line_grid( &self, left: u32, right: u32, x_offsets: [f32; 2], y: u32, y_offset: f32, color: &ColorKey, ) { let left = left as f32 * self.cell_size[0] + x_offsets[0] * self.cell_size[0]; let right = right as f32 * self.cell_size[0] + x_offsets[1] * self.cell_size[0]; let y = y as f32 * self.cell_size[1] + y_offset * self.cell_size[1]; draw_line(left, y, right, y, self.half_line_width, self.colors[color]); } /// Draw a line from left to right using pixel coordinates. /// /// - `left` The left pixel coordinate. /// - `right` The right pixel coordinate. /// - `y` The y pixel coordinate. /// - `color` A `ColorKey` for the line. pub(crate) fn horizontal_line_pixel(&self, left: f32, right: f32, y: f32, color: &ColorKey) { draw_line(left, y, right, y, self.half_line_width, self.colors[color]); } /// Draw subtitles. /// /// - `text` The text. pub(crate) fn subtitle(&self, text: &str) { let width = text.chars().count() as u32; // One row. if width <= self.max_subtitle_width { self.rectangle( &Rectangle::new(self.subtitle_position, [width, 1]), &ColorKey::SubtitleBackground, ); self.text_sub(&Label::new(self.subtitle_position, text.to_string(), self)); } // Multi-row. else { let mut rows = vec![]; let mut words = text.split(' ').collect::>(); let mut row = String::new(); while !words.is_empty() { let mut row1 = row.clone(); row1.push(' '); row1.push_str(words[0]); let width = row1.chars().count() as u32; // The row doesn't fit. if width > self.max_subtitle_width { // Add the row. rows.push(row.trim().to_string()); row = words[0].to_string(); words.remove(0); } // Append the row. else { row.push(' '); row.push_str(words[0]); words.remove(0); } } // Last row. if row.chars().count() > 0 { rows.push(row.trim().to_string()); } let mut y = self.subtitle_position[1]; for row in rows { self.rectangle( &Rectangle::new( [self.subtitle_position[0], y], [row.chars().count() as u32, 1], ), &ColorKey::SubtitleBackground, ); self.text_sub(&Label::new([self.subtitle_position[0], y], row, self)); y += 1; } } } /// Capture the screen and update the cached background texture. pub(crate) fn screen_capture(&mut self) { let screen_data = get_screen_data(); match self.background.as_mut() { Some(background) => { background.update(&screen_data); } None => { self.background = Some(Texture2D::from_image(&screen_data)); let dest_size = Some(Vec2::new( self.window_pixel_size[0], self.window_pixel_size[1], )); self.background_params = Some(DrawTextureParams { flip_y: self.flip_y, dest_size, ..Default::default() }); } } } /// Draw a value with left and right arrows with a key. /// /// - `text` The value text. /// - `key_list` The key-list parameters pair. /// - `focus` A two-element array. Element 0: Panel focus. Element 1: widget focus. pub(crate) fn key_list(&self, text: &str, key_list: &KeyList, focus: Focus) { // Draw the key. self.text(&key_list.key, &Renderer::get_key_color(focus[0])); // Draw the value. self.list(text, &key_list.value, focus); } /// Draw a value with left and right arrows with a key, and possible corners. /// /// - `text` The value text. /// - `key_list` The key-list parameters pair. /// - `focus` A two-element array. Element 0: Panel focus. Element 1: widget focus. pub(crate) fn key_list_corners(&self, text: &str, key_list: &KeyListCorners, focus: Focus) { // Draw corners. if focus[1] { self.corners(&key_list.corners_rect, focus[0]); } // Draw the key. self.text(&key_list.key_list.key, &Renderer::get_key_color(focus[0])); // Draw the value. self.list(text, &key_list.key_list.value, focus); } /// Draw a value with left and right arrows. /// /// - `text` The text in the label. /// - `list` The `List` draw parameters. /// - `focus` A two-element array. Element 0: Panel focus. Element 1: widget focus. pub(crate) fn list(&self, text: &str, list: &List, focus: Focus) { // Draw the arrows. if focus[1] { let arrow_color = if focus[0] { ColorKey::Arrow } else { ColorKey::NoFocus }; self.text(&list.left_arrow, &arrow_color); self.text(&list.right_arrow, &arrow_color); } // Get the label. let value = list.get_value(text, self); // Draw the value. self.text_ref(&value, &Self::get_value_color(focus)); } /// Draw a key-value pair. /// /// - `text` The value text. /// - `kv` Draw parameters for the key-value pair. /// - `colors` The key and value colors. pub(crate) fn key_value(&self, text: &str, kv: &KeyWidth, colors: [&ColorKey; 2]) { self.text(&kv.key, colors[0]); self.text_ref(&kv.get_value(text, self), colors[1]); } /// Draw a key-input pair. /// /// - `text` The text in the label. /// - `ki` The `KeyInput` draw parameters. /// - `alphanumeric_input` If true, alphanumeric input is enabled. /// - `focus` A two-element array. Element 0: Panel focus. Element 1: Widget focus. pub(crate) fn key_input( &self, value: &str, ki: &KeyInput, alphanumeric_input: bool, focus: Focus, ) { if focus[1] { // Draw corners. self.corners(&ki.corners_rect, focus[0]); // Draw a rectangle for input. if alphanumeric_input { self.rectangle_pixel(&ki.input_rect, &ColorKey::TextFieldBG); } } let key_color = &Self::get_key_color(focus[0]); if value.is_empty() { self.text(&ki.key_width.key, key_color); } else { // Draw the key-value pair. self.key_value( value, &ki.key_width, [ &Self::get_key_color(focus[0]), &Self::get_value_color(focus), ], ); } } /// Draw a horizontally-aligned key-value boolean pair. /// /// - `value` The value of the boolean. /// - `boolean` Parameters for drawing a key-value string-bool pair. /// - `focus` If true, the panel has focus. pub(crate) fn boolean(&self, value: bool, boolean: &Boolean, focus: bool) { self.text(&boolean.key, &Renderer::get_key_color(focus)); self.text( boolean.get_boolean_label(&value), &Renderer::get_boolean_color(focus, value), ); } /// Draw a horizontally-aligned key-value boolean pair with corners. /// /// - `value` The value of the boolean. /// - `boolean` Parameters for drawing a key-value string-bool pair. /// - `focus` If true, the panel has focus. pub(crate) fn boolean_corners(&self, value: bool, boolean: &BooleanCorners, focus: Focus) { if focus[1] { // Draw corners. self.corners(&boolean.corners_rect, focus[0]); } self.boolean(value, &boolean.boolean, focus[0]); } /// Returns the color of the value text. /// /// - `focus` A two-element array. Element 0: Panel focus. Element 1: widget focus. pub(crate) fn get_value_color(focus: Focus) -> ColorKey { match (focus[0], focus[1]) { (true, true) => ColorKey::Value, (true, false) => ColorKey::Key, (false, true) => ColorKey::NoFocus, (false, false) => ColorKey::NoFocus, } } /// Returns the color of the key text. /// /// - `focus` If true, the panel has focus. pub(crate) fn get_key_color(focus: bool) -> ColorKey { if focus { ColorKey::Key } else { ColorKey::NoFocus } } /// Returns the color of a boolean value. pub(crate) fn get_boolean_color(focus: bool, value: bool) -> ColorKey { if !focus { ColorKey::NoFocus } else if value { ColorKey::True } else { ColorKey::False } } /// Returns a color. pub(crate) fn get_color(&self, color_key: &ColorKey) -> Color { self.colors[color_key] } /// Converts a grid point to a pixel point. /// /// - `point` The point in grid coordinates. pub(crate) fn grid_to_pixel(&self, point: [u32; 2]) -> [f32; 2] { [ point[0] as f32 * self.cell_size[0], point[1] as f32 * self.cell_size[1], ] } /// Returns the position of the size of a border rectangle in pixels. pub(crate) fn get_border_rect(&self, position: [u32; 2], size: [u32; 2]) -> RectanglePixel { let mut position = self.grid_to_pixel(position); position[0] += self.border_offsets[0]; position[1] += self.border_offsets[1]; let mut size = self.grid_to_pixel(size); size[0] += self.border_offsets[2]; size[1] += self.border_offsets[3]; RectanglePixel::new(position, size) } pub(crate) fn get_label_position(&self, position: [u32; 2], text: &str) -> [f32; 2] { self.get_text_position(position, text, &self.font) } /// Draw text. /// /// - `position` The position of the text in pixels. /// - `text` The text. /// - `color` A `ColorKey` for the rectangle. /// - `font` The font. /// - `font_size` The font size. fn get_text_position(&self, position: [u32; 2], text: &str, font: &Font) -> [f32; 2] { let font = Some(font); let dim = measure_text(text, font, self.font_size, 1.0); let mut position = self.grid_to_pixel(position); position[1] = position[1] + self.cell_size[1] - dim.offset_y / 3.0; position } /// Parse a serialized 3-element array as an RGBA color. fn parse_color(value: &str) -> Color { let c: Result<[u8; 3], serde_json::Error> = serde_json::from_str(value); match c { Ok(c) => color_u8!(c[0], c[1], c[2], 255), Err(error) => panic!("Invalid color alias: {} {}", value, error), } } /// Draw subtitle text. /// /// - `label` Parameters for drawing text. fn text_sub(&self, label: &Label) { self.text_ex( label.position, &label.text, &ColorKey::Subtitle, &self.subtitle_font, self.font_size, ); } /// Draw text. /// /// - `position` The position of the text in pixels. /// - `text` The text. /// - `color` A `ColorKey` for the rectangle. /// - `font` The font. /// - `font_size` The font size. fn text_ex( &self, position: [f32; 2], text: &str, text_color: &ColorKey, font: &Font, font_size: u16, ) { let font = Some(font); let color = self.colors[text_color]; let text_params = TextParams { font, font_size, font_scale: 1.0, font_scale_aspect: 1.0, rotation: 0.0, color, }; draw_text_ex(text, position[0], position[1], text_params); } } ================================================ FILE: render/src/tracks_panel.rs ================================================ use crate::panel::*; use crate::{get_track_heights, Page, TRACK_HEIGHT_NO_SOUNDFONT, TRACK_HEIGHT_SOUNDFONT}; use text::{get_file_name, truncate}; const MUTE_OFFSET: u32 = 6; /// The list of tracks. pub struct TracksPanel { /// The panel. panel: Panel, /// The size of a track with a SoundFont. track_size_sf: [u32; 2], /// The size of a track with no SoundFont. track_size_no_sf: [u32; 2], /// The bank key string. bank_key: String, /// The gain key string. gain_key: String, /// The mute string. mute_text: String, /// The solo string. solo_text: String, /// The maximum height of a page of tracks. page_height: u32, /// The width of each field. field_width: u32, } impl TracksPanel { pub fn new(config: &Ini, renderer: &Renderer, text: &Text) -> Self { // Get the panel. let width = get_tracks_panel_width(config); let grid_size = get_window_grid_size(config); let height = grid_size[1] - MUSIC_PANEL_HEIGHT; let x = MUSIC_PANEL_POSITION[0]; let y = MUSIC_PANEL_POSITION[1] + MUSIC_PANEL_HEIGHT; let panel_position = [x, y]; let panel = Panel::new( PanelType::Tracks, panel_position, [width, height], renderer, text, ); // Get the sizes. let track_width = width - 2; let track_size_sf = [track_width, TRACK_HEIGHT_SOUNDFONT]; let track_size_no_sf = [track_width, TRACK_HEIGHT_NO_SOUNDFONT]; let field_width = width - 4; let bank_key = text.get("TRACKS_PANEL_BANK"); let gain_key = text.get("TRACKS_PANEL_GAIN"); let mute_text = text.get("TRACKS_PANEL_MUTE"); let solo_text = text.get("TRACKS_PANEL_SOLO"); let page_height = height - 2; // Return. Self { panel, track_size_sf, track_size_no_sf, bank_key, gain_key, mute_text, solo_text, page_height, field_width, } } } impl Drawable for TracksPanel { fn update(&self, renderer: &Renderer, state: &State, conn: &Conn, text: &Text, _: &PathsState) { // Get the focus, let focus = self.panel.has_focus(state); // Draw the panel. self.panel.update(focus, renderer); // Get a list of track element heights. let track_page = Page::new( &state.music.selected, &get_track_heights(state, conn), self.page_height, ) .visible; // Get the color of the separator. let separator_color = if focus { ColorKey::Separator } else { ColorKey::NoFocus }; // Draw the tracks. let x = self.panel.background.grid_rect.position[0] + 1; let mut y = self.panel.background.grid_rect.position[1] + 1; for i in track_page { let track = &state.music.midi_tracks[i]; let channel = track.channel; let mut track_focus = false; // There is a selected track. if let Some(selected) = state.music.selected { // *This* is the selected track. if selected == i { // Get the size of the track. let track_size = match conn.state.programs.get(&channel) { Some(_) => self.track_size_sf, None => self.track_size_no_sf, }; let rect = RectanglePixel::new_from_u([x, y], track_size, renderer); // Draw corners. renderer.corners(&rect, focus); // This widget has focus. track_focus = true; } } // Draw the track. match conn.state.programs.get(&channel) { // No program. No SoundFont. None => { let label = Label::new( [x + 1, y], text.get_with_values("TRACKS_PANEL_TRACK_TITLE", &[&channel.to_string()]), renderer, ); renderer.text(&label, &Renderer::get_key_color(focus)); y += 1; } // There is a program. Draw the properties. Some(program) => { let f = [focus, track_focus]; let list = List::new([x, y], self.field_width - 1, renderer); // Draw the preset. renderer.list(&program.preset_name, &list, f); y += 1; // Draw the bank. let bank = KeyList::new( self.bank_key.clone(), [x + 1, y], self.field_width, 3, renderer, ); renderer.key_list(&program.bank.to_string(), &bank, f); y += 1; // Draw the gain. let gain = KeyList::new( self.gain_key.clone(), [x + 1, y], self.field_width, 3, renderer, ); renderer.key_list(&track.gain.to_string(), &gain, f); // Mute. if track.mute { let mute_position = [x + self.field_width - MUTE_OFFSET, y]; let label = Label::new(mute_position, self.mute_text.clone(), renderer); renderer.text( &label, &Renderer::get_boolean_color(track_focus && focus, track.mute), ); } // Solo. if track.solo { let solo_position = [ x + self.field_width - MUTE_OFFSET - self.solo_text.chars().count() as u32 - 1, y, ]; let label = Label::new(solo_position, self.solo_text.clone(), renderer); renderer.text( &label, &Renderer::get_boolean_color(track_focus && focus, track.solo), ); } y += 1; // Draw the file. let file_text = truncate( get_file_name(&program.path), self.field_width as usize, false, ); let file_color = match (focus, track_focus) { (true, true) => ColorKey::Arrow, (true, false) => ColorKey::Key, _ => ColorKey::NoFocus, }; let file_label = LabelRef::new([x + 1, y], file_text, renderer); renderer.text_ref(&file_label, &file_color); y += 1; // Draw a line separator. renderer.horizontal_line_grid( x, x + self.field_width, [0.0, 0.0], y, 0.5, &separator_color, ); y += 1; } } } } } ================================================ FILE: render/src/types.rs ================================================ pub(crate) type Focus = [bool; 2]; ================================================ FILE: src/main.rs ================================================ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use audio::Conn; use clap::Parser; use common::args::Args; use common::config::{load, parse_bool}; use common::sizes::get_window_pixel_size; use common::{get_bytes, Paths, PathsState, State, VERSION}; use ini::Ini; use input::Input; use io::IO; use macroquad::prelude::*; use regex::Regex; use render::{draw_subtitles, Panels, Renderer}; use text::{Text, TTS}; use ureq::get; const CLEAR_COLOR: macroquad::color::Color = macroquad::color::BLACK; #[macroquad::main(window_conf)] async fn main() { // Parse and load the command line arguments. let args = Args::parse(); // Get the paths, initialized in loading the window configuration. let paths = Paths::get(); // Load the splash image. let splash = load_texture(paths.splash_path.as_os_str().to_str().unwrap()) .await .unwrap(); // Linux X11 can mess this up the initial window size. let splash_width = splash.width(); let splash_height = splash.height(); let screen_width = screen_width(); let screen_height = screen_height(); // Oops something went wrong. let dest_size = if splash_width != screen_width || splash_height != screen_height { Some(Vec2::new(screen_width, screen_height)) } else { None }; let draw_texture_params = DrawTextureParams { dest_size, ..Default::default() }; draw_texture_ex(&splash, 0.0, 0.0, WHITE, draw_texture_params); next_frame().await; // Load the config file. let config = load(); // Check if a new version is available. let remote_version = get_remote_version(&config); // Create the text. let mut text = Text::new(&config, paths); // Try to load the text-to-speech engine. let mut tts = TTS::new(&config); // Get the input object. let mut input = Input::new(&config, &args); // Create the audio connection. let mut conn = Conn::default(); // Create the state. let mut state = State::new(&config); // Create the paths state. let mut paths_state = PathsState::new(paths); // Get the IO state. let mut io = IO::new(&config, &input, &state.input, &mut text); // Load the renderer. let mut renderer = Renderer::new(&config); // Load the panels. let mut panels = Panels::new( &config, &input, &state, &conn, &mut text, &renderer, remote_version, ); // Resize the screen. let window_size = get_window_pixel_size(&config); request_new_screen_size(window_size[0], window_size[1]); // Fullscreen. let fullscreen = if args.fullscreen { // Use the CLI or env argument first if set true } else { let render_section = config.section(Some("RENDER")).unwrap(); parse_bool(render_section, "fullscreen") }; if fullscreen { set_fullscreen(fullscreen); } // Open the initial save file if set. if let Some(save_path) = args.file { io.load_save(&save_path, &mut state, &mut conn, &mut paths_state); } // Begin. let mut done: bool = false; while !done { // Clear. clear_background(CLEAR_COLOR); // Draw. panels.update(&renderer, &state, &conn, &text, &paths_state); // Draw subtitles. draw_subtitles(&renderer, &tts); // If we're exporting audio, don't allow input. if !conn.exporting() { // Update the user input state input.update(&state); // Modify the state. done = io.update( &mut state, &mut conn, &input, &mut tts, &mut text, &mut paths_state, ); } if !done { // Update the subtitles. tts.update(); // Late update to do stuff like screen capture. panels.late_update(&state, &conn, &mut renderer); // Wait. next_frame().await; } } } /// Configure the window. fn window_conf() -> Conf { // Parse and load the command line arguments. let args = Args::parse(); // Initialize the paths. Paths::init(&args.data_directory); let icon = if cfg!(windows) { let icon_bytes = get_bytes(&Paths::get().data_directory.join("icon")); let big: [u8; 16384] = icon_bytes[0..16384].try_into().unwrap(); let medium: [u8; 4096] = icon_bytes[16384..20480].try_into().unwrap(); let small: [u8; 1024] = icon_bytes[20480..21504].try_into().unwrap(); Some(miniquad::conf::Icon { big, medium, small }) } else { None }; let window_resizable = cfg!(target_os = "linux"); Conf { window_title: "Cacophony".to_string(), window_width: 926, window_height: 240, high_dpi: false, window_resizable, icon, ..Default::default() } } /// Returns a string of the latest version if an update is available. fn get_remote_version(config: &Ini) -> Option { // Check the config file to decide if we should to an HTTP request. if parse_bool(config.section(Some("UPDATE")).unwrap(), "check_for_updates") { // HTTP request. match get("https://raw.githubusercontent.com/subalterngames/cacophony/main/Cargo.toml") .call() { // We got a request. Ok(resp) => match resp.into_string() { // We got text. Ok(text) => { // Get the version from the Cargo.toml. let regex = Regex::new("version = \"(.*?)\"").unwrap(); match regex.captures(&text) { Some(captures) => { // If the remote version is the same as the local version, return None. // Why? Because we only need this to show a UI message. // If the versions are the same, we don't need to show the message. if &captures[1] == VERSION { None } else { Some(captures[1].to_string()) } } None => None, } } Err(_) => None, }, Err(_) => None, } } else { None } } ================================================ FILE: test_files/child_paths/test_0.cac ================================================ {"state":{"music":{"midi_tracks":[{"channel":0,"gain":127,"notes":[[60,127,0,192],[62,127,192,384],[64,127,384,576]],"mute":false,"solo":false}],"selected":0},"view":{"dt":[0,25536],"dn":[75,45],"mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}},"single_track":true,"zoom_levels":[404,462,528,604,691,790,903,1033,1181,1350,1543,1764,2016,2304,2634,3011,3442,3934,4497,5140,5875,6715,7675,8772,10026,11459,13097,14968,17107,19551,22344,25536,29184,33353,38117,43562,49785,56897,65025,74314,84930,97062,110928,126774,144884,165581,189235,216268,247163,282472,322825,368942,421648,481883,550723,629397,719310,822068,939506,1073721,1227109,1402410,1602754,1831718],"zoom_index":{"index":31,"length":64},"zoom_increments":{"Quick":4,"Normal":2,"Precise":1},"initial_zoom_index":31},"time":{"cursor":576,"playback":0,"bpm":120,"mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}}},"input":{"armed":true,"alphanumeric_input":false,"volume":{"index":127,"length":128},"use_volume":true,"beat":192},"panels":["Music","Tracks","PianoRoll"],"focus":{"index":2,"length":3},"music_panel_field":{"values":["Name","BPM","Gain"],"index":{"index":0,"length":3}},"piano_roll_mode":"Time","edit_mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}},"select_mode":{"Single":null}},"synth_state":{"programs":{"0":{"path":"/home/esther/cacophony/data/CT1MBGMRSV1.06.sf2","num_banks":2,"bank_index":0,"bank":0,"num_presets":128,"preset":0,"preset_index":0,"preset_name":"Piano 1"}},"gain":127},"paths_state":{"soundfonts":{"directory":{"path":"/home/esther/Documents/cacophony/soundfonts","is_file":false,"stem":"soundfonts"},"filename":null},"saves":{"directory":{"path":"/home/esther/Documents/cacophony/saves","is_file":false,"stem":"saves"},"filename":"uh"},"exports":{"directory":{"path":"/home/esther/Documents/cacophony/exports","is_file":false,"stem":"exports"},"filename":null},"midis":{"directory":{"path":"/home/esther/Documents/cacophony","is_file":false,"stem":"cacophony"},"filename":null}},"exporter":{"framerate":44100,"metadata":{"title":"My Music","artist":null,"album":null,"track_number":null,"genre":null,"comment":null},"copyright":false,"mp3_bit_rate":{"index":12,"length":16},"mp3_quality":{"index":9,"length":10},"multi_file":false,"multi_file_suffix":{"values":["ChannelAndPreset","Preset","Channel"],"index":{"index":0,"length":3}},"ogg_quality":{"index":9,"length":10},"export_type":{"values":["Wav","Mid","MP3","Ogg","Flac"],"index":{"index":0,"length":5}},"mid_settings":{"values":["Title","Artist","Copyright"],"index":{"index":0,"length":3}},"wav_settings":{"values":["Framerate","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":3}},"mp3_settings":{"values":["Framerate","Mp3Quality","Mp3BitRate","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":12}},"ogg_settings":{"values":["Framerate","OggQuality","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":11}},"flac_settings":{"values":["Framerate","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":10}}},"version":"0.2.4"} ================================================ FILE: test_files/child_paths/test_1.cac ================================================ {"state":{"music":{"midi_tracks":[{"channel":0,"gain":127,"notes":[[60,127,0,192],[62,127,192,384],[64,127,384,576]],"mute":false,"solo":false}],"selected":0},"view":{"dt":[0,25536],"dn":[75,45],"mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}},"single_track":true,"zoom_levels":[404,462,528,604,691,790,903,1033,1181,1350,1543,1764,2016,2304,2634,3011,3442,3934,4497,5140,5875,6715,7675,8772,10026,11459,13097,14968,17107,19551,22344,25536,29184,33353,38117,43562,49785,56897,65025,74314,84930,97062,110928,126774,144884,165581,189235,216268,247163,282472,322825,368942,421648,481883,550723,629397,719310,822068,939506,1073721,1227109,1402410,1602754,1831718],"zoom_index":{"index":31,"length":64},"zoom_increments":{"Quick":4,"Normal":2,"Precise":1},"initial_zoom_index":31},"time":{"cursor":576,"playback":0,"bpm":120,"mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}}},"input":{"armed":true,"alphanumeric_input":false,"volume":{"index":127,"length":128},"use_volume":true,"beat":192},"panels":["Music","Tracks","PianoRoll"],"focus":{"index":2,"length":3},"music_panel_field":{"values":["Name","BPM","Gain"],"index":{"index":0,"length":3}},"piano_roll_mode":"Time","edit_mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}},"select_mode":{"Single":null}},"synth_state":{"programs":{"0":{"path":"/home/esther/cacophony/data/CT1MBGMRSV1.06.sf2","num_banks":2,"bank_index":0,"bank":0,"num_presets":128,"preset":0,"preset_index":0,"preset_name":"Piano 1"}},"gain":127},"paths_state":{"soundfonts":{"directory":{"path":"/home/esther/Documents/cacophony/soundfonts","is_file":false,"stem":"soundfonts"},"filename":null},"saves":{"directory":{"path":"/home/esther/Documents/cacophony/saves","is_file":false,"stem":"saves"},"filename":"uh"},"exports":{"directory":{"path":"/home/esther/Documents/cacophony/exports","is_file":false,"stem":"exports"},"filename":null},"midis":{"directory":{"path":"/home/esther/Documents/cacophony","is_file":false,"stem":"cacophony"},"filename":null}},"exporter":{"framerate":44100,"metadata":{"title":"My Music","artist":null,"album":null,"track_number":null,"genre":null,"comment":null},"copyright":false,"mp3_bit_rate":{"index":12,"length":16},"mp3_quality":{"index":9,"length":10},"multi_file":false,"multi_file_suffix":{"values":["ChannelAndPreset","Preset","Channel"],"index":{"index":0,"length":3}},"ogg_quality":{"index":9,"length":10},"export_type":{"values":["Wav","Mid","MP3","Ogg","Flac"],"index":{"index":0,"length":5}},"mid_settings":{"values":["Title","Artist","Copyright"],"index":{"index":0,"length":3}},"wav_settings":{"values":["Framerate","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":3}},"mp3_settings":{"values":["Framerate","Mp3Quality","Mp3BitRate","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":12}},"ogg_settings":{"values":["Framerate","OggQuality","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":11}},"flac_settings":{"values":["Framerate","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":10}}},"version":"0.2.4"} ================================================ FILE: test_files/child_paths/test_2.CAC ================================================ {"state":{"music":{"midi_tracks":[{"channel":0,"gain":127,"notes":[[60,127,0,192],[62,127,192,384],[64,127,384,576]],"mute":false,"solo":false}],"selected":0},"view":{"dt":[0,25536],"dn":[75,45],"mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}},"single_track":true,"zoom_levels":[404,462,528,604,691,790,903,1033,1181,1350,1543,1764,2016,2304,2634,3011,3442,3934,4497,5140,5875,6715,7675,8772,10026,11459,13097,14968,17107,19551,22344,25536,29184,33353,38117,43562,49785,56897,65025,74314,84930,97062,110928,126774,144884,165581,189235,216268,247163,282472,322825,368942,421648,481883,550723,629397,719310,822068,939506,1073721,1227109,1402410,1602754,1831718],"zoom_index":{"index":31,"length":64},"zoom_increments":{"Quick":4,"Normal":2,"Precise":1},"initial_zoom_index":31},"time":{"cursor":576,"playback":0,"bpm":120,"mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}}},"input":{"armed":true,"alphanumeric_input":false,"volume":{"index":127,"length":128},"use_volume":true,"beat":192},"panels":["Music","Tracks","PianoRoll"],"focus":{"index":2,"length":3},"music_panel_field":{"values":["Name","BPM","Gain"],"index":{"index":0,"length":3}},"piano_roll_mode":"Time","edit_mode":{"values":["Normal","Quick","Precise"],"index":{"index":0,"length":3}},"select_mode":{"Single":null}},"synth_state":{"programs":{"0":{"path":"/home/esther/cacophony/data/CT1MBGMRSV1.06.sf2","num_banks":2,"bank_index":0,"bank":0,"num_presets":128,"preset":0,"preset_index":0,"preset_name":"Piano 1"}},"gain":127},"paths_state":{"soundfonts":{"directory":{"path":"/home/esther/Documents/cacophony/soundfonts","is_file":false,"stem":"soundfonts"},"filename":null},"saves":{"directory":{"path":"/home/esther/Documents/cacophony/saves","is_file":false,"stem":"saves"},"filename":"uh"},"exports":{"directory":{"path":"/home/esther/Documents/cacophony/exports","is_file":false,"stem":"exports"},"filename":null},"midis":{"directory":{"path":"/home/esther/Documents/cacophony","is_file":false,"stem":"cacophony"},"filename":null}},"exporter":{"framerate":44100,"metadata":{"title":"My Music","artist":null,"album":null,"track_number":null,"genre":null,"comment":null},"copyright":false,"mp3_bit_rate":{"index":12,"length":16},"mp3_quality":{"index":9,"length":10},"multi_file":false,"multi_file_suffix":{"values":["ChannelAndPreset","Preset","Channel"],"index":{"index":0,"length":3}},"ogg_quality":{"index":9,"length":10},"export_type":{"values":["Wav","Mid","MP3","Ogg","Flac"],"index":{"index":0,"length":5}},"mid_settings":{"values":["Title","Artist","Copyright"],"index":{"index":0,"length":3}},"wav_settings":{"values":["Framerate","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":3}},"mp3_settings":{"values":["Framerate","Mp3Quality","Mp3BitRate","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":12}},"ogg_settings":{"values":["Framerate","OggQuality","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":11}},"flac_settings":{"values":["Framerate","Title","Artist","Copyright","Album","TrackNumber","Genre","Comment","MultiFile","MultiFileSuffix"],"index":{"index":0,"length":10}}},"version":"0.2.4"} ================================================ FILE: test_files/ubuntu20.04/speechd.conf ================================================ # Global configuration for Speech Dispatcher # ========================================== # -----SYSTEM OPTIONS----- # CommunicationMethod specifies the method to be used by Speech Dispatcher to communicate with # its clients. Two basic methods are "unix_socket" and "inet_socket". # # unix_socket -- communication over Unix sockets represented by a file in the # filesystem (see SocketPath below). This method works only locally, but is # prefered for standard session setup, where every user runs his own instance of Speech # Dispatcher to get voice feedback on his own computer. # # inet_socket -- alternatively, you can start Speech Dispatcher on # a TCP port and connect to it via hostname/port. This allows for a more # flexible setup, where you can use Speech Dispatcher over network # from different machines. See also the Port and LocalhostAccessOnly # configuration variables. # # CommunicationMethod "unix_socket" # SocketPath is either "default" or a full path to the filesystem # where the driving Unix socket file should be created in case the # CommunicationMethod is set to "unix_socket". The default is # $XDG_RUNTIME_DIR/speech-dispatcher/speechd.sock where $XDG_RUNTIME_DIR # is the directory specified by the XDG Base Directory Specification. # Do not change this unless you have a reason and know what you are doing. # SocketPath "default" # The Port on which Speech Dispatcher should be available to clients if the "inet_socket" # communication method is used. # Port 6560 # By default, if "inet_socket" communication method is used, the specified port is opened only # for connections coming from localhost. If LocalhostAccessOnly is set to 0 it disables this # access control. It means that the port will be accessible from all computers on the # network. If you turn off this option, please make sure you set up some system rules on what # computers are and are not allowed to access the Speech Dispatcher port. # LocalhostAccessOnly 1 # By default, Speech Dispatcher is configured to shut itself down after a period of # time if no clients are connected. The timeout value is in seconds, and is started when # the last client disconnects. A value of 0 disables the timeout. # Timeout 5 # -----LOGGING CONFIGURATION----- # The LogLevel is a number between 0 and 5 specifying the # verbosity of information to the logfile or screen # 0 means nothing, 5 means everything (not recommended). LogLevel 3 # The LogDir specifies where the Speech Dispatcher logs reside # Specify "stdout" for standard console output # or a custom log directory path. 'default' means # the logs are written to the default destination (e.g. a preconfigured # system directory or the home directory if .speech-dispatcher is present) # DO NOT COMMENT OUT THIS OPTION, leave as "default" for standard logging LogDir "default" #LogDir "/var/log/speech-dispatcher/" #LogDir "stdout" # The CustomLogFile allows logging all messages # regardless of # priority, to the given destination. #CustomLogFile "protocol" "/var/log/speech-dispatcher/speech-dispatcher-protocol.log" # ----- VOICE PARAMETERS ----- # The DefaultRate controls how fast the synthesizer is going to speak. # The value must be between -100 (slowest) and +100 (fastest), default # is 0. # DefaultRate 0 # The DefaultPitch controls the pitch of the synthesized voice. The # value must be between -100 (lowest) and +100 (highest), default is # 0. # DefaultPitch 0 # The DefaultPitchRange controls the pitch range of the synthesized voice. The # value must be between -100 (lowest) and +100 (highest), default is # 0. # DefaultPitchRange 0 # The DefaultVolume controls the default volume of the voice. It is # a value between -100 (softly) and +100 (loudly). Currently, +100 # maps to the default volume of the synthesizer. DefaultVolume 100 # The DefaultVoiceType controls which voice type should be used by # default. Voice types are symbolic names which map to particular # voices provided by the synthesizer according to the output module # configuration. Please see the synthesizer-specific configuration # in etc/speech-dispatcher/modules/ to see which voices are assigned to # different symbolic names. The following symbolic names are # currently supported: MALE1, MALE2, MALE3, FEMALE1, FEMALE2, FEMALE3, # CHILD_MALE, CHILD_FEMALE # DefaultVoiceType "MALE1" # The Default language with which to speak # DefaultLanguage "en" # ----- MESSAGE DISPATCHING CONTROL ----- # The DefaultClientName specifies the name of a client who didn't # introduce himself at the beginning of an SSIP session. # DefaultClientName "unknown:unknown:unknown" # The Default Priority. Use with caution, normally this shouldn't be # changed globally (at this place) # DefaultPriority "text" # The DefaultPauseContext specifies by how many index marks a speech # cursor should return when resuming after a pause. This is roughly # equivalent to the number of sentences before the place of the # execution of pause that will be repeated. # DefaultPauseContext 0 # -----SPELLING/PUNCTUATION/CAPITAL LETTERS CONFIGURATION----- # The DefaultPunctuationMode sets the way dots, comas, exclamation # marks, question marks etc. are interpreted. none: they are ignored # some: some of them are sent to synthesis (see # DefaultPunctuationSome) all: all punctuation marks are sent to # synthesis # DefaultPunctuationMode "none" # Whether to use server-side symbols pre-processing by default. # This controls whether the server should pre-process the messages to insert # the appropriate words or if the output module is responsible for speaking # symbols and punctuation. # DefaultSymbolsPreprocessing 0 # The DefaultCapLetRecognition: if set to "spell", capital letters # should be spelled (e.g. "capital b"), if set to "icon", # capital letters are indicated by inserting a special sound # before them but they should be read normally, it set to "none" # capital letters are not recognized (by default) # DefaultCapLetRecognition "none" # The DefaultSpelling: if set to On, all messages will be spelt # unless set otherwise (this is usually not something you want to do.) # DefaultSpelling Off # ----- AUDIO CONFIGURATION ----------- # -- AUDIO OUTPUT -- # Chooses between the possible sound output systems: # "pulse" - PulseAudio # "alsa" - Advanced Linux Sound System # "oss" - Open Sound System # "nas" - Network Audio System # "libao" - A cross platform audio library # Pulse audio is the default and recommended sound server. OSS and ALSA # are only provided for compatibility with architectures that do not # include Pulse Audio. NAS provides network transparency, but is not # very well tested. libao is a cross platform library with plugins for # different sound systems and provides alternative output for Pulse Audio # and ALSA as well as for other backends. # AudioOutputMethod "pulse" # -- Pulse Audio parameters -- # Pulse audio server name or "default" for the default pulse server #AudioPulseServer "default" #AudioPulseMinLength 1764 # -- ALSA parameters -- # Audio device for ALSA output #AudioALSADevice "default" # -- OSS parameters -- # Audio device for OSS output #AudioOSSDevice "/dev/dsp" # -- NAS parameters -- # Route to the Network Audio System server when NAS # is chosen for the audio output. Note that NAS # server doesn't need to run on your machine, # you can use it also over network (for instance # when working on remote machines). #AudioNASServer "tcp/localhost:5450" # -----OUTPUT MODULES CONFIGURATION----- # Each AddModule line loads an output module. # Syntax: AddModule "name" "binary" "configuration" "logfile" # - name is the name under which you can access this module # - binary is the path to the binary executable of this module, # either relative (to lib/speech-dispatcher-modules/) or absolute # - configuration is the path to the config file of this module, # either relative (to etc/speech-dispatcher/modules/) or absolute #AddModule "espeak" "sd_espeak" "espeak.conf" #AddModule "espeak-ng" "sd_espeak-ng" "espeak-ng.conf" #AddModule "festival" "sd_festival" "festival.conf" #AddModule "flite" "sd_flite" "flite.conf" #AddModule "ivona" "sd_ivona" "ivona.conf" #AddModule "pico" "sd_pico" "pico.conf" #AddModule "espeak-generic" "sd_generic" "espeak-generic.conf" #AddModule "espeak-ng-mbrola-generic" "sd_generic" "espeak-ng-mbrola-generic.conf" #AddModule "espeak-mbrola-generic" "sd_generic" "espeak-mbrola-generic.conf" #AddModule "swift-generic" "sd_generic" "swift-generic.conf" #AddModule "epos-generic" "sd_generic" "epos-generic.conf" #AddModule "dtk-generic" "sd_generic" "dtk-generic.conf" #AddModule "pico-generic" "sd_generic" "pico-generic.conf" #AddModule "ibmtts" "sd_ibmtts" "ibmtts.conf" #AddModule "cicero" "sd_cicero" "cicero.conf" #AddModule "kali" "sd_kali" "kali.conf" #AddModule "mary-generic" "sd_generic" "mary-generic.conf" #AddModule "baratinoo" "sd_baratinoo" "baratinoo.conf" #AddModule "rhvoice" "sd_rhvoice" "rhvoice.conf" #AddModule "voxin" "sd_voxin" "voxin.conf" # DO NOT REMOVE the following line unless you have # a specific reason -- this is the fallback output module # that is only used when no other modules are in use #AddModule "dummy" "sd_dummy" "" # The output module testing doesn't actually connect to anything. It # outputs the requested commands to standard output and reads # responses from stdandard input. This way, Speech Dispatcher's # communication with output modules can be tested easily. AddModule "testing" # The DefaultModule selects which output module is the default. You # must use one of the names of the modules loaded with AddModule. DefaultModule testing # The LanguageDefaultModule selects which output modules are prefered # for specified languages. #LanguageDefaultModule "en" "espeak" #LanguageDefaultModule "cs" "festival" #LanguageDefaultModule "es" "festival" # -----CLIENT SPECIFIC CONFIGURATION----- # Here you can include the files with client-specific configuration # for different types of clients. They must contain one or more sections with # this structure: # BeginClient "emacs:*" # DefaultPunctuationMode "some" # ...and/or some other settings # EndClient # The parameter of BeginClient tells Speech Dispatcher which clients # it should apply the settings to (it does glob-style matching, you can use # * to match any number of characters and ? to match one character) # There are some sample client settings Include "clients/*.conf" # The DisableAutoSpawn option will disable the autospawn mechanism. # Thus the server will not start automatically on requests from the clients # DisableAutoSpawn # Copyright (C) 2001-2009 Brailcom, o.p.s # Copyright (C) 2009 Rui Batista # Copyright (C) 2010 Andrei Kholodnyi # Copyright (C) 2010 William Hubbs # Copyright (C) 2010 Trevor Saunders # Copyright (C) 2012 William Jon McCann # Copyright (C) 2014 Rob Whyte # Copyright (C) 2014-2016 Luke Yelavich # Copyright (C) 2014 Hussain Jasim # Copyright (C) 2017 Colomban Wendling # Copyright (C) 2018 Raphaël POITEVIN # Copyright (C) 2018 Florian Steinhardt # Copyright (C) 2018 Samuel Thibault # # This program is free software; you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation; either version 2 of the License, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A # PARTICULAR PURPOSE. See the GNU General Public License for more details (file # COPYING in the root directory). # # You should have received a copy of the GNU General Public License # along with this program. If not, see . ================================================ FILE: text/Cargo.toml ================================================ [package] name = "text" version.workspace = true authors.workspace = true description.workspace = true documentation.workspace = true edition.workspace = true [dependencies] rust-ini = { workspace = true } macroquad = { workspace = true } csv = { workspace = true } hashbrown = { workspace = true } regex = { workspace = true } tts = { workspace = true } [dependencies.common] path = "../common" [dependencies.input] path = "../input" [features] speech_dispatcher_0_11 = ["tts/speech_dispatcher_0_11"] speech_dispatcher_0_9 = ["tts/speech_dispatcher_0_9"] ================================================ FILE: text/src/lib.rs ================================================ //! This crate handles three related, but separate, tasks: //! //! 1. `Text` stores localized text. Throughout Cacophony, all strings that will be spoken or drawn are referenced via lookup keys. The text data is in `data/text.csv`. //! 2. `TTS` converts text-to-speech strings into spoken audio. //! 3. This crate also contains language-agnostic string manipulation functions e.g. `truncate`. mod tooltips; mod tts; mod value_map; pub use self::tts::{Enqueable, TTS}; use std::path::Path; pub use value_map::ValueMap; mod tts_string; use common::config::parse; use common::{EditMode, Paths, PianoRollMode, Time, MIN_NOTE, PPQ_F, PPQ_U}; use csv::Reader; use hashbrown::HashMap; use ini::Ini; use input::KEYS; use macroquad::input::KeyCode; pub use tooltips::Tooltips; pub use tts_string::TtsString; /// All possible languages. const LANGUAGES: [&str; 1] = ["en"]; /// Keycode lookup string prefixes. const KEYCODE_LOOKUPS: [&str; 121] = [ "Space", "Apostrophe", "Comma", "Minus", "Period", "Slash", "Key0", "Key1", "Key2", "Key3", "Key4", "Key5", "Key6", "Key7", "Key8", "Key9", "Semicolon", "Equal", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "LeftBracket", "Backslash", "RightBracket", "GraveAccent", "World1", "World2", "Escape", "Enter", "Tab", "Backspace", "Insert", "Delete", "Right", "Left", "Down", "Up", "PageUp", "PageDown", "Home", "End", "CapsLock", "ScrollLock", "NumLock", "PrintScreen", "Pause", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20", "F21", "F22", "F23", "F24", "F25", "Kp0", "Kp1", "Kp2", "Kp3", "Kp4", "Kp5", "Kp6", "Kp7", "Kp8", "Kp9", "KpDecimal", "KpDivide", "KpMultiply", "KpSubtract", "KpAdd", "KpEnter", "KpEqual", "LeftShift", "LeftControl", "LeftAlt", "LeftSuper", "RightShift", "RightControl", "RightAlt", "RightSuper", "Menu", "Unknown", ]; /// Localized text lookup. pub struct Text { /// The text key-value map. text: HashMap, /// A map of key codes to spoken text. keycodes_spoken: HashMap, /// A map of key codes to seen text. keycodes_seen: HashMap, /// The text for each edit mode. edit_modes: HashMap, /// The text for each piano roll mode. piano_roll_modes: HashMap, /// The name of each MIDI note. note_names: Vec, /// Boolean dislay booleans: ValueMap, } impl Text { pub fn new(config: &Ini, paths: &Paths) -> Self { // Get the text language. let language: String = parse(config.section(Some("TEXT")).unwrap(), "language"); // Find the column with the language. let column = LANGUAGES.iter().position(|&lang| lang == language).unwrap() + 1; // Get the text. let mut text = HashMap::new(); // Read the .csv file. let mut reader = Reader::from_path(&paths.text_path).unwrap(); for record in reader.records().filter(|r| r.is_ok()).flatten() { let key = record.get(0).unwrap().to_string(); let value = record.get(column).unwrap().to_string(); text.insert(key, value); } let note_names: Vec = text .remove("NOTE_NAMES") .unwrap() .split(", ") .map(|s| s.to_string()) .collect(); let keycodes_spoken = Text::get_keycode_map(&text, true); let keycodes_seen = Text::get_keycode_map(&text, false); let edit_modes = Text::get_edit_mode_map(&text); let piano_roll_modes = Text::get_piano_roll_mode_map(&text); let mut booleans = HashMap::new(); booleans.insert(true, text["TRUE"].clone()); booleans.insert(false, text["FALSE"].clone()); let booleans = ValueMap::new_from_strings( [true, false], [text["TRUE"].clone(), text["FALSE"].clone()], ); Self { text, keycodes_spoken, keycodes_seen, edit_modes, piano_roll_modes, note_names, booleans, } } /// Returns the text. pub fn get(&self, key: &str) -> String { match self.text.get(key) { Some(t) => t.clone(), None => panic!("Invalid text key {}", key), } } /// Returns the text. pub fn get_ref(&self, key: &str) -> &str { match self.text.get(key) { Some(t) => t, None => panic!("Invalid text key {}", key), } } /// Returns the text. Fills in the values. pub fn get_with_values(&self, key: &str, values: &[&str]) -> String { match self.text.get(key) { Some(t) => { let mut text = t.clone(); for (i, v) in values.iter().enumerate() { let mut k: String = String::from("\\"); k.push_str(i.to_string().as_str()); let vv = v.to_string(); text = text.replace(&k, vv.as_str()); } if text.contains('\\') { println!("WARNING! Bad TTS text. {} {} {:?}", text, key, values); } text.replace(" ", " ") } None => panic!("Invalid text key {}", key), } } /// Returns the string version of a key code. pub fn get_keycode(&self, key: &KeyCode, spoken: bool) -> &str { match (if spoken { &self.keycodes_spoken } else { &self.keycodes_seen }) .get(key) { Some(t) => t, None => panic!("Invalid key code {:?}", key), } } /// Returns the string version of a piano roll mode. pub fn get_piano_roll_mode(&self, mode: &PianoRollMode) -> &str { match self.piano_roll_modes.get(mode) { Some(t) => t, None => panic!("Invalid piano roll mode {:?}", mode), } } /// Returns the string version of an edit mode. pub fn get_edit_mode(&self, mode: &EditMode) -> &str { match self.edit_modes.get(mode) { Some(t) => t, None => panic!("Invalid edit mode {:?}", mode), } } /// Returns boolean text. pub fn get_boolean(&self, value: &bool) -> &str { self.booleans.get(value) } /// Returns the maximum character width of the boolean values. pub fn get_max_boolean_length(&self) -> u32 { self.booleans.max_length } /// Converts a beat PPQ value into a time string. pub fn get_time(&self, ppq: u64, time: &Time) -> String { let duration = time.ppq_to_duration(ppq); let whole_seconds = duration.as_secs(); let hours = whole_seconds / 3600; let minutes = whole_seconds / 60 - (hours * 60); let seconds = whole_seconds - (minutes * 60); // Include hours? if hours > 0 { self.get_with_values( "TIME_TTS_HOURS", &[ hours.to_string().as_str(), minutes.to_string().as_str(), seconds.to_string().as_str(), ], ) } else { self.get_with_values( "TIME_TTS", &[minutes.to_string().as_str(), seconds.to_string().as_str()], ) } } /// Returns a text-to-speech string of the `ppq` value. pub fn get_ppq_tts(&self, ppq: &u64) -> String { // This is a whole note. if ppq % PPQ_U == 0 { (ppq / PPQ_U).to_string() } else { match ppq { 288 => self.get("FRACTION_TTS_ONE_AND_A_HALF"), 96 => self.get("FRACTION_TTS_ONE_HALF"), 64 => self.get("FRACTION_TTS_ONE_THIRD"), 48 => self.get("FRACTION_TTS_ONE_FOURTH"), 32 => self.get("FRACTION_TTS_ONE_SIXTH"), 24 => self.get("FRACTION_TTS_ONE_EIGHTH"), 12 => self.get("FRACTION_TTS_ONE_SIXTEENTH"), 6 => self.get("FRACTION_TTS_ONE_THIRTY_SECOND"), other => format!("{:.2}", (*other as f32 / PPQ_F)), } } } /// Returns an error text-to-speech string. pub fn get_error(&self, error: &str) -> String { self.get_with_values("ERROR", &[error]) } /// Returns the name of the note. pub fn get_note_name(&self, note: u8) -> &str { &self.note_names[(note - MIN_NOTE) as usize] } /// Returns a map of keycodes to displayable/sayable text (NOT string keys). fn get_keycode_map(text: &HashMap, spoken: bool) -> HashMap { let suffix = if spoken { "_SPOKEN" } else { "_SEEN" }; let mut keycodes = HashMap::new(); for (key, lookup) in KEYS.iter().zip(KEYCODE_LOOKUPS) { let mut lookup_key = lookup.to_string(); lookup_key.push_str(suffix); keycodes.insert(*key, text[&lookup_key].clone()); } keycodes } /// Returns a HashMap of the edit modes. fn get_edit_mode_map(text: &HashMap) -> HashMap { let mut edit_modes = HashMap::new(); edit_modes.insert(EditMode::Normal, text["EDIT_MODE_NORMAL"].clone()); edit_modes.insert(EditMode::Quick, text["EDIT_MODE_QUICK"].clone()); edit_modes.insert(EditMode::Precise, text["EDIT_MODE_PRECISE"].clone()); edit_modes } /// Returns a HashMap of the piano roll modes. fn get_piano_roll_mode_map(text: &HashMap) -> HashMap { let mut piano_roll_modes = HashMap::new(); piano_roll_modes.insert(PianoRollMode::Edit, text["PIANO_ROLL_MODE_EDIT"].clone()); piano_roll_modes.insert( PianoRollMode::Select, text["PIANO_ROLL_MODE_SELECT"].clone(), ); piano_roll_modes.insert(PianoRollMode::Time, text["PIANO_ROLL_MODE_TIME"].clone()); piano_roll_modes.insert(PianoRollMode::View, text["PIANO_ROLL_MODE_VIEW"].clone()); piano_roll_modes } } /// Converts a PPQ value into a string beat value. pub fn ppq_to_string(ppq: u64) -> String { // This is a whole note. if ppq % PPQ_U == 0 { (ppq / PPQ_U).to_string() } else { match ppq { 288 => "3/2".to_string(), 96 => "1/2".to_string(), 64 => "1/3".to_string(), 48 => "1/4".to_string(), 32 => "1/6".to_string(), 24 => "1/8".to_string(), 12 => "1/16".to_string(), 6 => "1/32".to_string(), other => format!("{:.2}", (other as f32 / PPQ_F)), } } } /// Truncate a string to fit a specified length. /// /// - `string` The string. /// - `length` The maximum length of the string. /// - `left` If true, remove characters from the left. Example: `"ABCDEFG" -> `"DEFG"`. If false, remove characters from the right. Example: `"ABCDEFG" -> `"ABCD"`. pub fn truncate(string: &str, length: usize, left: bool) -> &str { let len = string.chars().count(); if len <= length { string } // Remove characters on the left. else if left { &string[len - length..len] } // Remove characters on the right. else { &string[0..length] } } /// Returns the file name of a path. pub fn get_file_name(path: &Path) -> &str { match path.file_name() { Some(filename) => match filename.to_str() { Some(s) => s, None => panic!("Invalid filename: {:?}", filename), }, None => panic!("Not a file: {:?}", path), } } /// Returns the file name of a path without the extension. pub fn get_file_name_no_ex(path: &Path) -> &str { match path.file_stem() { Some(filename) => match filename.to_str() { Some(s) => s, None => panic!("Invalid filename: {:?}", filename), }, None => panic!("Not a file: {:?}", path), } } #[cfg(test)] mod tests { use crate::{get_file_name, get_file_name_no_ex, ppq_to_string, truncate}; use std::path::PathBuf; #[test] fn file_name() { const FILE_PATH: &str = "/home/users/username/Documents/cacophony/music.cac"; let path = PathBuf::from(FILE_PATH); assert_eq!(get_file_name(&path), "music.cac"); assert_eq!(get_file_name_no_ex(&path), "music"); } #[test] fn truncate_test() { const STRING: &str = "This is a moderately long string!"; assert_eq!(truncate(STRING, 4, false), "This"); assert_eq!(truncate(STRING, 4, true), "ing!"); assert_eq!(truncate(STRING, STRING.len(), true), STRING); assert_eq!(truncate(STRING, 0, true), ""); assert_eq!(truncate(STRING, 0, false), ""); assert_eq!(truncate(STRING, STRING.len() + 1, false), STRING); assert_eq!(truncate(STRING, STRING.len() + 1, true), STRING); } #[test] fn ppq_fraction() { assert_eq!(ppq_to_string(192), "1"); assert_eq!(ppq_to_string(288), "3/2"); assert_eq!(ppq_to_string(3), "0.02"); assert_eq!(ppq_to_string(0), "0"); } } ================================================ FILE: text/src/tooltips.rs ================================================ use crate::{Text, TtsString}; use hashbrown::hash_map::Entry; use hashbrown::HashMap; use input::{Input, InputEvent, QwertyBinding}; use regex::Regex; const NUM_REGEXES: usize = 16; type Regexes = [Regex; NUM_REGEXES]; /// A map of tooltips and the regex bindings used to find them. /// This isn't handled globally because we want `Text` to be immutable. pub struct Tooltips { /// The map of keys and tooltips. tooltips: HashMap, /// The regex used to find bindings. re_bindings: Regexes, /// The regex used to find wildcard values. re_values: Regexes, } impl Default for Tooltips { fn default() -> Self { let tooltips = HashMap::new(); let re_bindings = Self::get_regexes("\\\\"); let re_values = Self::get_regexes("%"); Self { tooltips, re_bindings, re_values, } } } impl Tooltips { /// Build a tooltip from a text lookup key and a list of events. /// /// - `key` The text lookup key, for example "TITLE_MAIN_MENU". /// - `events` An ordered list of input events. These will be inserted in the order that the binding wildcards are found. /// - `input` The input manager. /// - `text` The text manager. /// /// Returns a tooltip `TtsString`. pub fn get_tooltip( &mut self, key: &str, events: &[InputEvent], input: &Input, text: &Text, ) -> TtsString { match self.tooltips.entry(key.to_string()) { Entry::Occupied(o) => o.get().clone(), Entry::Vacant(v) => { let mut seen = text.get(key); let mut spoken = seen.clone(); for (i, event) in events.iter().enumerate() { // Get the key bindings. Self::event_to_text( &self.re_bindings[i], event, input, text, &mut spoken, &mut seen, ); } v.insert(TtsString { spoken, seen }).clone() } } } /// Build a tooltip from a text lookup key and a list of events and another list of values. /// /// - `key` The text lookup key, for example "TITLE_MAIN_MENU". /// - `events` An ordered list of input events. The index is used to find the wildcard in the text, e.g. if the index is 0 then the wildcard is "\0". /// - `values` An ordered list of string values. The index is used to find the wildcard in the text, e.g. if the index is 0 then the wildcard is "%0". /// - `input` The input manager. /// - `text` The text manager. /// /// Returns a list of text-to-speech strings. pub fn get_tooltip_with_values( &self, key: &str, events: &[InputEvent], values: &[&str], input: &Input, text: &Text, ) -> TtsString { // Get the string with the wildcards. let raw_string = text.get(key); let mut spoken = raw_string.clone(); let mut seen = raw_string; let mut regexes = HashMap::new(); // Iterate through each event. for (i, event) in events.iter().enumerate() { let regex = &self.re_bindings[i]; regexes.insert(i, regex.clone()); Self::event_to_text(regex, event, input, text, &mut spoken, &mut seen); } // Iterate through each value. let mut regexes = HashMap::new(); for (i, value) in values.iter().enumerate() { // Get the value regex. let regex = &self.re_values[i]; regexes.insert(i, regex.clone()); // Replace the value wildcard. spoken = regex.replace(&spoken, *value).to_string(); seen = regex.replace(&seen, *value).to_string(); } TtsString { spoken, seen } } /// Returns an array of regexes with search patterns that begin with `prefix`. fn get_regexes(prefix: &str) -> Regexes { let iv = (0..NUM_REGEXES).collect::>(); let mut ia = [0usize; NUM_REGEXES]; ia.copy_from_slice(&iv[0..NUM_REGEXES]); ia.map(|i| Regex::new(&format!("{}{}", prefix, i)).unwrap()) } /// Converts an input event to spoken and seen strings. fn event_to_text( regex: &Regex, event: &InputEvent, input: &Input, text: &Text, spoken: &mut String, seen: &mut String, ) { // Get the key bindings. let bindings = input.get_bindings(event); // The replacement string. let mut spoken_replacement = vec![]; let mut seen_replacement = vec![]; let mut has_qwerty = false; // Get the qwerty binding. if let Some(qwerty) = bindings.0 { has_qwerty = true; // Add spoken mods. for m in Self::get_mods(qwerty, true, text) { spoken_replacement.push(m.to_string()); } // Add seen mod tokens. for m in Self::get_mods(qwerty, false, text) { seen_replacement.push(m.to_string()); } // Add spoken keys. for k in Self::get_keys(qwerty, true, text) { spoken_replacement.push(k.to_string()); } // Add seen key tokens. for k in Self::get_keys(qwerty, false, text) { seen_replacement.push(k.to_string()); } } // Get the MIDI binding. if let Some(midi) = bindings.1 { if has_qwerty { // Or... let or_str = text.get("OR").trim().to_string(); spoken_replacement.push(or_str.clone()); seen_replacement.push(or_str.clone()); // Get the MIDI binding. let midi = match &midi.alias { Some(alias) => alias.clone(), None => text.get_with_values( "MIDI_CONTROL", &[&midi.bytes[0].to_string(), &midi.bytes[1].to_string()], ), }; spoken_replacement.push(midi.clone()); seen_replacement.push(midi); } } // Replace. *spoken = regex .replace(spoken, &spoken_replacement.join(" ")) .to_string(); *seen = regex.replace(seen, &seen_replacement.join(" ")).to_string(); } /// Returns a qwerty binding's mods as strings. /// /// The strings may be different depending on the value of `spoken` i.e. whether this is meant to be spoken or seen. fn get_mods<'a>(qwerty: &QwertyBinding, spoken: bool, text: &'a Text) -> Vec<&'a str> { qwerty .mods .iter() .map(|k| text.get_keycode(k, spoken)) .collect::>() } /// Returns a qwerty binding's keys as strings. /// /// The strings may be different depending on the value of `spoken` i.e. whether this is meant to be spoken or seen. fn get_keys<'a>(qwerty: &QwertyBinding, spoken: bool, text: &'a Text) -> Vec<&'a str> { qwerty .keys .iter() .map(|k| text.get_keycode(k, spoken)) .collect::>() } } ================================================ FILE: text/src/tts.rs ================================================ use crate::TtsString; use common::config::{parse, parse_bool}; use ini::Ini; use tts::{Gender, Tts, Voice}; /// Text-to-speech. pub struct TTS { /// The text-to-speech engine. tts: Option, /// A queue of text-to-speech strings. Casey is saying the first element, if any. speech: Vec, /// If true, return subtitle text. pub show_subtitles: bool, /// If true, Casey is speaking. pub speaking: bool, } impl TTS { pub fn new(config: &Ini) -> Self { let section = config.section(Some("TEXT_TO_SPEECH")).unwrap(); // Get subtitles. let show_subtitles = parse_bool(section, "subtitles"); // Try to load the text-to-speech engine. let tts = match Tts::default() { Ok(mut tts) => { // Try to set the voice. if let Ok(voices) = tts.voices() { // Try to parse the voice ID as an index. let voice_id = section.get("voice_id").unwrap(); match voice_id.parse::() { Ok(index) => if tts.set_voice(&voices[index]).is_ok() {}, // Try to parse the voice ID as a language. Err(_) => { let language = if cfg!(target_os = "linux") { match voice_id.split('-').next() { Some(language) => language, None => voice_id, } } else { voice_id }; // Get all voices in this language. let voices_lang: Vec<&Voice> = voices.iter().filter(|v| v.language() == language).collect(); if voices_lang.is_empty() { println!( "No voices found with language {}. Using the default instead.", voice_id ); if tts.set_voice(&voices[0]).is_ok() {} } else { // Try to get the gender. match section.get("gender") { Some(gender) => { // Convert the gender to an enum value. let gender = match gender { "f" => Some(Gender::Female), "m" => Some(Gender::Male), _ => None, }; // Try to get the first voice. match voices_lang.iter().find(|v| v.gender() == gender) { // Set the first voice in this language with this gender. Some(voice) => if tts.set_voice(voice).is_ok() {}, // Set the first voice in this language. None => if tts.set_voice(voices_lang[0]).is_ok() {}, } } // Set the first voice in this language. None => if tts.set_voice(voices_lang[0]).is_ok() {}, } } } } } // Try to set the rate. let rate_key = if cfg!(windows) { "rate_windows" } else if cfg!(target_os = "macos") { "rate_macos" } else { "rate_linux" }; let _ = tts.set_rate(parse(section, rate_key)); Some(tts) } Err(error) => { println!("{}", error); None } }; Self { show_subtitles, tts, speech: vec![], speaking: false, } } /// Stop speaking. pub fn stop(&mut self) { if self.speaking { if let Some(tts) = &mut self.tts { let _ = tts.stop(); self.speaking = false; } self.speech.clear(); } } /// Update the subtitle state. pub fn update(&mut self) { // We're done speaking but we have more to say. if !self.speech.is_empty() && !self.speaking { // Remove the first element. self.speech.remove(0); // Start speaking the next element. if !self.speech.is_empty() { self.say(&self.speech[0].spoken.clone()); } } } /// Returns the subtitles string of the current TTS string. pub fn get_subtitles(&self) -> Option<&str> { if self.speech.is_empty() { None } else { Some(&self.speech[0].seen) } } /// Say something and show subtitles. fn say(&mut self, text: &str) { if let Some(tts) = &mut self.tts { if tts.speak(text, true).is_ok() { self.speaking = true; } } } } impl Enqueable for TTS { fn enqueue(&mut self, text: TtsString) { // Start speaking the first element. if !self.speaking { self.say(&text.spoken) } // Push this element. We need it for subtitles. self.speech.push(text); } } impl Enqueable<&TtsString> for TTS { fn enqueue(&mut self, text: &TtsString) { // Start speaking the first element. if !self.speaking { self.say(&text.spoken) } // Push this element. We need it for subtitles. self.speech.push(text.clone()); } } impl Enqueable for TTS { fn enqueue(&mut self, text: String) { self.enqueue(TtsString::from(text)); } } impl Enqueable<&str> for TTS { fn enqueue(&mut self, text: &str) { self.enqueue(TtsString::from(text)); } } impl Enqueable> for TTS { fn enqueue(&mut self, text: Vec) { if text.is_empty() { return; } // Start speaking the first element. if !self.speaking { self.say(&text[0].spoken) } self.speech.extend(text); } } impl Enqueable> for TTS { fn enqueue(&mut self, text: Vec<&TtsString>) { if text.is_empty() { return; } // Start speaking the first element. if !self.speaking { self.say(&text[0].spoken) } self.speech .extend(text.iter().map(|&t| t.clone()).collect::>()); } } /// This is something that can be enqueued into a vec of TTS strings. pub trait Enqueable { /// Enqueue something to the text-to-speech strings. fn enqueue(&mut self, text: T); } #[cfg(test)] mod tests { use crate::Enqueable; use common::get_test_config; use super::TTS; #[test] fn test_tts() { const TTS_STRING: &str = "Hello world!"; let config = get_test_config(); let mut tts = TTS::new(&config); assert!(tts.tts.is_some()); tts.enqueue(TTS_STRING); assert_eq!(tts.speech.len(), 1); assert!(tts.show_subtitles); assert_eq!(tts.get_subtitles().unwrap(), TTS_STRING); tts.update(); assert!(tts.speaking); tts.stop(); tts.update(); assert!(!tts.speaking); } } ================================================ FILE: text/src/tts_string.rs ================================================ /// A text-to-speech string is spoken via a TTS engine and displayed as subtitles. #[derive(Default, Clone)] pub struct TtsString { /// This string will be spoken by the TTS engine. pub spoken: String, /// This string will be displayed on the screen. pub seen: String, } impl From for TtsString { fn from(value: String) -> Self { Self { spoken: value.clone(), seen: value, } } } impl From<&str> for TtsString { fn from(value: &str) -> Self { Self { spoken: value.to_string(), seen: value.to_string(), } } } ================================================ FILE: text/src/value_map.rs ================================================ use crate::Text; use hashbrown::HashMap; use std::hash::Hash; /// A map of keys of type T to value strings, and a map of the same keys to the lengths of the strings. pub struct ValueMap where T: Eq + Hash + Copy, { /// A map of value strings corresponding with keys of type T. values: HashMap, /// The maximum length of the values strings. pub max_length: u32, } impl ValueMap where T: Eq + Hash + Copy, { /// - `keys` the lookup keys of the underlying HashMap. /// - `values` The string look-up keys that will be used in `text.get(v)`. /// - `text` The text. pub fn new(keys: [T; N], values: [&str; N], text: &Text) -> Self { const EMPTY_STRING: String = String::new(); let mut strings: [String; N] = [EMPTY_STRING; N]; for (i, v) in values.iter().enumerate() { strings[i] = text.get(v); } Self::new_from_strings(keys, strings) } /// - `keys` the lookup keys of the underlying HashMap. /// - `values` The string values. pub fn new_from_strings(keys: [T; N], values: [String; N]) -> Self { let mut vs = HashMap::new(); let mut lengths = Vec::new(); for (k, v) in keys.iter().zip(values) { let length = v.chars().count() as u32; vs.insert(*k, v); lengths.push(length); } let max_length = *lengths.iter().max().unwrap(); Self { values: vs, max_length, } } pub fn get(&self, key: &T) -> &str { &self.values[key] } }