[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: agourlay\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [ '*' ]\n  pull_request:\n    branches: [ '*' ]\n\nenv:\n  CARGO_TERM_COLOR: always\n\njobs:\n  linux-build:\n    name: Linux CI\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Update apt\n        run: sudo apt update\n      - name: Install alsa\n        run: sudo apt-get install libasound2-dev\n      - name: Install stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          components: rustfmt, clippy\n      - name: Check code formatting\n        run: cargo fmt --all -- --check\n      - name: Build\n        run: cargo build --locked --verbose\n      - name: Run tests\n        run: cargo test --locked --verbose\n      - name: Check cargo clippy warnings\n        run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings\n\n  macos-build:\n    name: macOS CI\n    runs-on: macOS-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Install llvm and clang\n        run: brew install llvm\n      - name: Install stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          components: rustfmt, clippy\n      - name: Build\n        run: cargo build --locked --verbose\n      - name: Run tests\n        run: cargo test --locked --verbose\n      - name: Check cargo clippy warnings\n        run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings\n\n  windows-build:\n    name: windows CI\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Install ASIO SDK\n        env:\n          LINK: https://www.steinberg.net/asiosdk\n        run: |\n          curl -L -o asio.zip $env:LINK\n          7z x -oasio asio.zip\n          move asio\\*\\* asio\\\n      - name: Install ASIO4ALL\n        run: choco install asio4all\n      - name: Install llvm and clang\n        run: choco install llvm\n      - name: Install stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          target: x86_64-pc-windows-msvc\n          components: clippy\n      - name: Build\n        run: cargo build --locked --verbose\n      - name: Run tests\n        run: cargo test --locked --verbose\n      - name: Check cargo clippy warnings\n        run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: release binaries\n\non:\n  release:\n    types: [created]\n\npermissions:\n  contents: write\n\njobs:\n  upload-bins:\n    name: \"Upload release binaries\"\n    strategy:\n      matrix:\n        include:\n          - target: x86_64-unknown-linux-gnu\n            os: ubuntu-latest\n          - target: x86_64-pc-windows-msvc\n            os: windows-latest\n          - target: aarch64-pc-windows-msvc\n            os: windows-latest\n          - target: x86_64-apple-darwin\n            os: macos-latest\n          - target: aarch64-apple-darwin\n            os: macos-latest\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@v6\n      # Install dependencies per OS\n      # copy the dependencies step from the ci.yml file\n      - if: matrix.os == 'ubuntu-latest'\n        name: Install dependencies (ubuntu-latest)\n        run: |\n          sudo apt update\n          sudo apt-get install libasound2-dev\n\n      - if: matrix.os == 'macOS-latest'\n        name: Install dependencies (macOS-latest)\n        run: |\n          brew install llvm\n\n      - if: matrix.os == 'windows-latest'\n        name: Install ASIO SDK\n        env:\n          LINK: https://www.steinberg.net/asiosdk\n        run: |\n          curl -L -o asio.zip $env:LINK\n          7z x -oasio asio.zip\n          move asio\\*\\* asio\\\n          choco install asio4all\n          choco install llvm\n\n      - if: matrix.os == 'windows-latest'\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          target: x86_64-pc-windows-msvc\n\n      - if: matrix.os != 'windows-latest'\n        uses: dtolnay/rust-toolchain@stable\n\n      # All\n      - uses: taiki-e/upload-rust-binary-action@v1\n        with:\n          target: ${{ matrix.target }}\n          bin: ruxguitar\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  publish-crate:\n    name: \"Publish on crates.io\"\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v6\n      # Install dependencies per OS\n      # copy the dependencies step from the ci.yml file\n      - name: Install dependencies (ubuntu-latest)\n        run: |\n          sudo apt update\n          sudo apt-get install libasound2-dev\n      - name: Publish\n        uses: actions-rs/cargo@v1\n        with:\n          command: publish\n          args: --token ${{ secrets.CARGO_TOKEN }}"
  },
  {
    "path": ".gitignore",
    "content": "/target\n.idea\n/test-files/"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"ruxguitar\"\nversion = \"0.8.1\"\nedition = \"2024\"\nauthors = [\"Arnaud Gourlay <arnaud.gourlay@gmail.com>\"]\ndescription = \"Guitar pro tablature player\"\nrepository = \"https://github.com/agourlay/ruxguitar\"\nlicense = \"Apache-2.0\"\nreadme = \"README.md\"\ncategories = [\"multimedia\"]\nkeywords = [\"guitar\", \"tablature\", \"music\"]\n\n[lints.clippy]\ncast_lossless = \"warn\"\ndoc_link_with_quotes = \"warn\"\nenum_glob_use = \"warn\"\nexplicit_into_iter_loop = \"warn\"\nfilter_map_next = \"warn\"\nflat_map_option = \"warn\"\nfrom_iter_instead_of_collect = \"warn\"\nimplicit_clone = \"warn\"\ninconsistent_struct_constructor = \"warn\"\ninefficient_to_string = \"warn\"\nmanual_is_variant_and = \"warn\"\nmanual_let_else = \"warn\"\nneedless_continue = \"warn\"\nneedless_raw_string_hashes = \"warn\"\nptr_as_ptr = \"warn\"\nref_option_ref = \"warn\"\nuninlined_format_args = \"warn\"\nunnecessary_wraps = \"warn\"\nunused_self = \"warn\"\nused_underscore_binding = \"warn\"\nmatch_wildcard_for_single_variants = \"warn\"\nneedless_pass_by_ref_mut = \"warn\"\nmissing_const_for_fn = \"warn\"\nredundant_closure_for_method_calls = \"warn\"\nsemicolon_if_nothing_returned = \"warn\"\nunreadable_literal = \"warn\"\nunused_async = \"warn\"\n\n[dependencies]\nnom = \"8.0.0\"\nencoding_rs = \"0.8.35\"\niced = { version = \"0.14.0\", features = [\n    \"advanced\",\n    \"canvas\",\n    \"tokio\",\n    \"selector\",\n] }\ntokio = { version = \"1.52.1\", features = [\"fs\", \"sync\"] }\nrfd = \"0.17.2\"\nlog = \"0.4.29\"\nenv_logger = \"0.11.10\"\nrustysynth = \"1.3.6\"\ncpal = \"0.17.3\"\nthiserror = \"2.0.18\"\nclap = { version = \"4.6.1\", features = [\"derive\", \"cargo\"] }\nserde = { version = \"1.0.228\", features = [\"derive\"] }\nserde_json = \"1.0.149\"\n\n[profile.release]\nlto = \"fat\"\ncodegen-units = 1"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# ruxguitar\n\n[![Build status](https://github.com/agourlay/ruxguitar/actions/workflows/ci.yml/badge.svg)](https://github.com/agourlay/ruxguitar/actions/workflows/ci.yml)\n[![Crates.io](https://img.shields.io/crates/v/ruxguitar.svg)](https://crates.io/crates/ruxguitar)\n\nA guitar pro tablature player.\n\nThe design of the application is described in details in the blog article \"[Playing guitar tablatures in Rust](https://agourlay.github.io/ruxguitar-tablature-player/)\".\n\n![capture](ruxguitar.gif)\n\n## Features\n\n- GP4 and GP5 file support (drag-and-drop supported)\n- MIDI playback with embedded soundfont (or custom soundfont)\n- Repeat sections with alternative endings\n- Tempo control (25% to 200%)\n- Solo mode (isolate single track)\n- Track selection\n- Keyboard shortcuts:\n    - `Space` play/pause\n    - `Ctrl+Up` / `Ctrl+Down` tempo up/down\n    - `Left` / `Right` previous/next measure\n    - `S` toggle solo\n    - `F11` toggle fullscreen\n\n## Limitations\n\n- no editing capabilities (read-only player)\n- no score notation (tablature only)\n- supports only GP5 and GP4 files\n\n## Usage\n\n```bash\n./ruxguitar --help\nGuitar pro tablature player\n\nUsage: ruxguitar [OPTIONS]\n\nOptions:\n      --sound-font-file <SOUND_FONT_FILE>  Optional path to a sound font file\n      --tab-file-path <TAB_FILE_PATH>      Optional path to tab file to by-pass the file picker\n      --no-antialiasing                    Disable antialiasing\n  -h, --help                               Print help\n  -V, --version                            Print version\n```\n\nA basic soundfont is embedded in the binary for a plug and play experience, however it is possible to provide a larger soundfont file to get better sound quality.\n\nFor instance I like to use `FluidR3_GM.sf2` which is present on most systems and easy to find online ([here](https://musical-artifacts.com/artifacts/738) or [there](https://member.keymusician.com/Member/FluidR3_GM/index.html)).\n\n```bash\n./ruxguitar --sound-font-file /usr/share/sounds/sf2/FluidR3_GM.sf2\n```\n\n## FAQ\n\n- **Where can I find guitar pro files?**\n  - You can find a lot of guitar pro files on the internet. For instance on [Ultimate Guitar](https://www.ultimate-guitar.com/).\n\n- **Why is the sound quality so bad?**\n  - The default soundfont is very basic. You can provide a better soundfont file using the `--sound-font-file` option.\n\n- **Which dependencies are needed to run the application?**\n  - Check the necessary dependencies for your system from the [CI configuration](https://github.com/agourlay/ruxguitar/blob/master/.github/workflows/ci.yml).\n\n- **Why is the file picker not opening on Linux?**\n  - Install the `XDG Destop Portal` package for your [desktop environment](https://wiki.archlinux.org/title/XDG_Desktop_Portal#List_of_backends_and_interfaces).\n\n- **Why are the strings not rendered on the tablature?**\n  - You might need to disable antialiasing using the `--no-antialiasing` option.\n\n- **Does it run on Windows 7 or Windows 8?**\n  - The last compatible release with those versions of Windows is [v0.6.3](https://github.com/agourlay/ruxguitar/releases/tag/v0.6.3).\n\n- **Why is the sound not working on Linux?**\n  - Getting the error `The requested device is no longer available. For example, it has been unplugged`.\n  - You are most likely using `PulseAudio` or `Pipewire` which are not supported.\n  - Install compatibility packages `pulseaudio-alsa` or `pipewire-alsa` (requires a restart of the audio service).\n\n## Installation\n\n### Releases\n\nUsing the provided binaries in https://github.com/agourlay/ruxguitar/releases\n\n### Crates.io\n\nUsing Cargo via [crates.io](https://crates.io/crates/ruxguitar).\n\n```bash\ncargo install ruxguitar\n```\n\n### Build\n\nMake sure to check the necessary dependencies for your system from the [CI configuration](https://github.com/agourlay/ruxguitar/blob/master/.github/workflows/ci.yml).\n\n## Acknowledgements\n\nThis project is heavily inspired by the great [TuxGuitar](https://github.com/helge17/tuxguitar) project."
  },
  {
    "path": "src/audio/midi_builder.rs",
    "content": "/// Thanks to `TuxGuitar` for the reference implementation in `MidiSequenceParser.java`\nuse crate::audio::FIRST_TICK;\nuse crate::audio::midi_event::MidiEvent;\nuse crate::parser::song_parser::{\n    Beat, BeatStrokeDirection, BendEffect, BendPoint, HarmonicType, MIN_VELOCITY, Measure,\n    MeasureHeader, MidiChannel, Note, NoteType, QUARTER_TIME, SEMITONE_LENGTH, Song, Track,\n    TremoloBarEffect, TripletFeel, VELOCITY_INCREMENT,\n};\nuse std::rc::Rc;\n\n#[cfg(test)]\nuse crate::audio::playback_order::compute_playback_order;\n\nconst DEFAULT_DURATION_DEAD: u32 = 30;\nconst DEFAULT_DURATION_PM: u32 = 60;\nconst DEFAULT_BEND: f32 = 64.0;\nconst DEFAULT_BEND_SEMI_TONE: f32 = 2.75;\n\npub const NATURAL_FREQUENCIES: [(i32, i32); 6] = [\n    (12, 12), //AH12 (+12 frets)\n    (9, 28),  //AH9 (+28 frets)\n    (5, 24),  //AH5 (+24 frets)\n    (7, 19),  //AH7 (+19 frets)\n    (4, 28),  //AH4 (+28 frets)\n    (3, 31),  //AH3 (+31 frets)\n];\n\npub struct MidiBuilder {\n    events: Vec<MidiEvent>, // events accumulated during build\n}\n\nimpl MidiBuilder {\n    pub const fn new() -> Self {\n        Self { events: Vec::new() }\n    }\n\n    /// Parse song and record events, computing playback order internally.\n    #[cfg(test)]\n    pub fn build_for_song(self, song: &Rc<Song>) -> Vec<MidiEvent> {\n        let playback_order = compute_playback_order(&song.measure_headers);\n        self.build_for_song_with_order(song, &playback_order)\n    }\n\n    /// Parse song and record events using a pre-computed playback order.\n    pub fn build_for_song_with_order(\n        mut self,\n        song: &Rc<Song>,\n        playback_order: &[(usize, i64)],\n    ) -> Vec<MidiEvent> {\n        for (track_id, track) in song.tracks.iter().enumerate() {\n            log::debug!(\"building events for track {track_id}\");\n            let midi_channel = song\n                .midi_channels\n                .iter()\n                .find(|c| c.channel_id == track.channel_id)\n                .unwrap_or_else(|| {\n                    panic!(\n                        \"midi channel {} not found for track {}\",\n                        track.channel_id, track_id\n                    )\n                });\n            self.add_track_events(\n                song.tempo.value,\n                track_id,\n                track,\n                &song.measure_headers,\n                playback_order,\n                midi_channel,\n            );\n        }\n        // Sort events by tick\n        self.events.sort_by_key(|event| event.tick);\n        self.events\n    }\n\n    fn add_track_events(\n        &mut self,\n        song_tempo: u32,\n        track_id: usize,\n        track: &Track,\n        measure_headers: &[MeasureHeader],\n        playback_order: &[(usize, i64)],\n        midi_channel: &MidiChannel,\n    ) {\n        // add MIDI control events for the track channel\n        self.add_track_channel_midi_control(track_id, midi_channel);\n\n        let strings = &track.strings;\n        let mut prev_tempo = song_tempo;\n        assert_eq!(track.measures.len(), measure_headers.len());\n        for (measure_index, tick_offset) in playback_order {\n            let measure = &track.measures[*measure_index];\n            let measure_header = &measure_headers[*measure_index];\n\n            // add song info events once for all tracks\n            if track_id == 0 {\n                // change tempo if necessary\n                let measure_tempo = measure_header.tempo.value;\n                if measure_tempo != prev_tempo {\n                    let tick = (i64::from(measure_header.start) + tick_offset) as u32;\n                    self.add_tempo_change(tick, measure_tempo);\n                    prev_tempo = measure_tempo;\n                }\n            }\n\n            // record event count to shift new events by tick_offset\n            let event_start = self.events.len();\n            self.add_beat_events(\n                track_id,\n                track,\n                measure,\n                measure_header,\n                midi_channel,\n                strings,\n            );\n            // shift events generated for this measure by tick_offset\n            if *tick_offset != 0 {\n                for event in &mut self.events[event_start..] {\n                    event.tick = (i64::from(event.tick) + tick_offset) as u32;\n                }\n            }\n        }\n    }\n\n    fn add_beat_events(\n        &mut self,\n        track_id: usize,\n        track: &Track,\n        measure: &Measure,\n        measure_header: &MeasureHeader,\n        midi_channel: &MidiChannel,\n        strings: &[(i32, i32)],\n    ) {\n        let measure_id = measure.voices[0].measure_index as usize;\n        for voice in &measure.voices {\n            let beats = &voice.beats;\n            for (beat_id, beat) in beats.iter().enumerate() {\n                if beat.empty || beat.notes.is_empty() {\n                    continue;\n                }\n                // extract surrounding beats\n                let previous_beat = if beat_id == 0 {\n                    None\n                } else {\n                    beats.get(beat_id - 1)\n                };\n                let next_beat = beats.get(beat_id + 1).or_else(|| {\n                    // check next measure if it was the last beat\n                    track\n                        .measures\n                        .get(voice.measure_index as usize + 1)\n                        .and_then(|next_measure| next_measure.voices[0].beats.first())\n                });\n                // apply triplet feel adjustment to beat timing\n                let triplet_adj =\n                    apply_triplet_feel(beat, previous_beat, next_beat, measure_header.triplet_feel);\n                self.add_notes(\n                    track_id,\n                    track,\n                    measure_id,\n                    measure_header,\n                    midi_channel,\n                    previous_beat,\n                    beat_id,\n                    beat,\n                    next_beat,\n                    strings,\n                    triplet_adj,\n                );\n            }\n        }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn add_notes(\n        &mut self,\n        track_id: usize,\n        track: &Track,\n        measure_id: usize,\n        measure_header: &MeasureHeader,\n        midi_channel: &MidiChannel,\n        previous_beat: Option<&Beat>,\n        beat_id: usize,\n        beat: &Beat,\n        next_beat: Option<&Beat>,\n        strings: &[(i32, i32)],\n        triplet_adj: TripletAdjustment,\n    ) {\n        let channel_id = midi_channel.channel_id;\n        let tempo = measure_header.tempo.value;\n        // GP files define an effect channel per track, but TuxGuitar doesn't use it for playback.\n        assert!(channel_id < 16);\n        let track_offset = track.offset;\n        let beat_duration = triplet_adj.duration;\n        let stroke = &beat.effect.stroke;\n        let stroke_increment = stroke.increment_for_duration(beat_duration);\n        // pre-compute per-string stroke offsets (only for strings with non-tied notes)\n        let stroke_offsets = compute_stroke_offsets(beat, stroke_increment, strings.len());\n        for note in &beat.notes {\n            if note.kind != NoteType::Tie {\n                let (string_id, string_tuning) = strings[note.string as usize - 1];\n                assert_eq!(string_id, i32::from(note.string));\n\n                // note starts on beat (adjusted for triplet feel)\n                let mut note_start = triplet_adj.start;\n\n                // apply effects on duration\n                let mut duration = apply_duration_effect(\n                    track,\n                    measure_id,\n                    beat_id,\n                    note,\n                    next_beat,\n                    tempo,\n                    beat_duration,\n                );\n                assert_ne!(duration, 0);\n\n                // apply stroke effect: stagger note start times across strings\n                let stroke_offset = stroke_offsets[note.string as usize - 1];\n                if stroke_offset > 0 {\n                    note_start += stroke_offset;\n                    duration = duration.saturating_sub(stroke_offset);\n                }\n\n                // surrounding notes on the same string on the previous & next beat\n                let previous_note =\n                    previous_beat.and_then(|b| b.notes.iter().find(|n| n.string == note.string));\n                let next_note =\n                    next_beat.and_then(|b| b.notes.iter().find(|n| n.string == note.string));\n\n                // pack with beat to propagate duration\n                let next_note = next_beat.zip(next_note);\n\n                // apply effects on velocity\n                let velocity = apply_velocity_effect(note, previous_note, midi_channel);\n\n                // apply effects on key\n                if let Some(key) = self.add_key_effect(\n                    track_id,\n                    track_offset,\n                    string_tuning,\n                    &mut note_start,\n                    &mut duration,\n                    tempo,\n                    note,\n                    next_note,\n                    velocity,\n                    midi_channel,\n                ) {\n                    self.add_note(\n                        track_id,\n                        key,\n                        note_start,\n                        duration,\n                        velocity,\n                        i32::from(channel_id),\n                    );\n                }\n            }\n        }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn add_key_effect(\n        &mut self,\n        track_id: usize,\n        track_offset: i32,\n        string_tuning: i32,\n        note_start: &mut u32,\n        duration: &mut u32,\n        tempo: u32,\n        note: &Note,\n        next_note_beat: Option<(&Beat, &Note)>,\n        velocity: i16,\n        midi_channel: &MidiChannel,\n    ) -> Option<i32> {\n        let channel_id = i32::from(midi_channel.channel_id);\n        let is_percussion = midi_channel.is_percussion();\n\n        // compute key without effect\n        let initial_key = track_offset + i32::from(note.value) + string_tuning;\n\n        // key with effect\n        let mut key = initial_key;\n\n        // fade in\n        if note.effect.fade_in {\n            let mut expression = 31;\n            let expression_increment = 1;\n            let mut tick = *note_start;\n            let tick_increment = *duration / ((127 - expression) / expression_increment);\n            while tick < (*note_start + *duration) && expression < 127 {\n                self.add_expression(tick, track_id, channel_id, expression as i32);\n                tick += tick_increment;\n                expression += expression_increment;\n            }\n            // normalize the expression\n            self.add_expression(*note_start + *duration, track_id, channel_id, 127);\n        }\n\n        // grace note\n        if let Some(grace) = &note.effect.grace {\n            let grace_key = track_offset + i32::from(grace.fret) + string_tuning;\n            let grace_length = grace.duration_time() as u32;\n            let grace_velocity = grace.velocity;\n            let grace_duration = if grace.is_dead {\n                apply_static_duration(tempo, DEFAULT_DURATION_DEAD, grace_length)\n            } else {\n                grace_length\n            };\n            let on_beat_duration = *note_start - grace_length;\n            if grace.is_on_beat || on_beat_duration < QUARTER_TIME {\n                *note_start = note_start.saturating_add(grace_length);\n                *duration = duration.saturating_sub(grace_length);\n            }\n            self.add_note(\n                track_id,\n                grace_key,\n                *note_start - grace_length,\n                grace_duration,\n                grace_velocity,\n                channel_id,\n            );\n        }\n\n        // trill\n        if let Some(trill) = &note.effect.trill\n            && !is_percussion\n        {\n            let trill_key = track_offset + i32::from(trill.fret) + string_tuning;\n            let mut trill_length = trill.duration.time();\n\n            let trill_tick_limit = *note_start + *duration;\n            let mut real_key = false;\n            let mut tick = *note_start;\n\n            let mut counter = 0;\n            while tick + 10 < trill_tick_limit {\n                if tick + trill_length >= trill_tick_limit {\n                    trill_length = trill_tick_limit - tick - 1;\n                }\n                let iter_key = if real_key { initial_key } else { trill_key };\n                self.add_note(track_id, iter_key, tick, trill_length, velocity, channel_id);\n                real_key = !real_key;\n                tick += trill_length;\n                counter += 1;\n            }\n            assert!(\n                counter > 0,\n                \"No trill notes published! trill_length: {trill_length}, tick: {tick}, trill_tick_limit: {trill_tick_limit}\"\n            );\n\n            // all notes published - the caller does not need to publish the note\n            return None;\n        }\n\n        // tremolo picking\n        if let Some(tremolo_picking) = &note.effect.tremolo_picking {\n            let mut tp_length = tremolo_picking.duration.time();\n            let mut tick = *note_start;\n            let tp_tick_limit = *note_start + *duration;\n            let mut counter = 0;\n            while tick + 10 < tp_tick_limit {\n                if tick + tp_length >= tp_tick_limit {\n                    tp_length = tp_tick_limit - tick - 1;\n                }\n                self.add_note(track_id, initial_key, tick, tp_length, velocity, channel_id);\n                tick += tp_length;\n                counter += 1;\n            }\n            assert!(\n                counter > 0,\n                \"No tremolo notes published! tp_length: {tp_length}, tick: {tick}, tp_tick_limit: {tp_tick_limit}\"\n            );\n            // all notes published - the caller does not need to publish the note\n            return None;\n        }\n\n        // bend\n        if let Some(bend_effect) = &note.effect.bend\n            && !is_percussion\n        {\n            self.add_bend(track_id, *note_start, *duration, channel_id, bend_effect);\n        }\n\n        // tremolo bar\n        if let Some(tremolo_bar) = &note.effect.tremolo_bar\n            && !is_percussion\n        {\n            self.add_tremolo_bar(track_id, *note_start, *duration, channel_id, tremolo_bar);\n        }\n\n        // slide\n        if let Some(_slide) = &note.effect.slide\n            && !is_percussion\n            && let Some((next_beat, next_note)) = next_note_beat\n        {\n            let value_1 = i32::from(note.value);\n            let value_2 = i32::from(next_note.value);\n\n            let tick1 = *note_start;\n            let tick2 = next_beat.start;\n\n            // make slide\n            let distance: i32 = value_2 - value_1;\n            let length: i32 = (tick2 - tick1) as i32;\n            let points = length / (QUARTER_TIME / 8) as i32;\n            for p_offset in 1..=points {\n                let tone = ((length / points) * p_offset) * distance / length;\n                let bend = DEFAULT_BEND + (tone as f32 * DEFAULT_BEND_SEMI_TONE * 2.0);\n                let bend_tick = tick1 as i32 + (length / points) * p_offset;\n                self.add_pitch_bend(bend_tick as u32, track_id, channel_id, bend as i32);\n            }\n\n            // normalise the bend\n            self.add_pitch_bend(tick2, track_id, channel_id, DEFAULT_BEND as i32);\n        }\n\n        // vibrato\n        if note.effect.vibrato && !is_percussion {\n            self.add_vibrato(track_id, *note_start, *duration, channel_id);\n        }\n\n        // harmonic\n        if let Some(harmonic) = &note.effect.harmonic\n            && !is_percussion\n        {\n            match harmonic.kind {\n                HarmonicType::Natural => {\n                    for (harmonic_value, harmonic_frequency) in NATURAL_FREQUENCIES {\n                        if note.value % 12 == (harmonic_value % 12) as i16 {\n                            key = (initial_key + harmonic_frequency) - i32::from(note.value);\n                            break;\n                        }\n                    }\n                }\n                HarmonicType::Semi => {\n                    let velocity = MIN_VELOCITY.max(velocity - VELOCITY_INCREMENT * 3);\n                    self.add_note(\n                        track_id,\n                        initial_key,\n                        *note_start,\n                        *duration,\n                        velocity,\n                        channel_id,\n                    );\n                    key = initial_key + NATURAL_FREQUENCIES[0].1;\n                }\n                HarmonicType::Artificial | HarmonicType::Pinch => {\n                    key = initial_key + NATURAL_FREQUENCIES[0].1;\n                }\n                HarmonicType::Tapped => {\n                    if let Some(right_hand_fret) = harmonic.right_hand_fret {\n                        for (harmonic_value, harmonic_frequency) in NATURAL_FREQUENCIES {\n                            if i16::from(right_hand_fret) - note.value == harmonic_value as i16 {\n                                key = initial_key + harmonic_frequency;\n                                break;\n                            }\n                        }\n                    }\n                }\n            }\n            if key - 12 > 0 {\n                let velocity = MIN_VELOCITY.max(velocity - VELOCITY_INCREMENT * 4);\n                self.add_note(\n                    track_id,\n                    key - 12,\n                    *note_start,\n                    *duration,\n                    velocity,\n                    channel_id,\n                );\n            }\n        }\n\n        Some(key)\n    }\n\n    fn add_vibrato(&mut self, track_id: usize, start: u32, duration: u32, channel_id: i32) {\n        let end = start + duration;\n        let mut next_start = start;\n        while next_start < end {\n            next_start = if next_start + 160 > end {\n                end\n            } else {\n                next_start + 160\n            };\n            self.add_pitch_bend(next_start, track_id, channel_id, DEFAULT_BEND as i32);\n\n            next_start = if next_start + 160 > end {\n                end\n            } else {\n                next_start + 160\n            };\n            let value = DEFAULT_BEND + DEFAULT_BEND_SEMI_TONE / 2.0;\n            self.add_pitch_bend(next_start, track_id, channel_id, value as i32);\n        }\n        self.add_pitch_bend(next_start, track_id, channel_id, DEFAULT_BEND as i32);\n    }\n\n    fn add_bend(\n        &mut self,\n        track_id: usize,\n        start: u32,\n        duration: u32,\n        channel_id: i32,\n        bend: &BendEffect,\n    ) {\n        for (point_id, point) in bend.points.iter().enumerate() {\n            let value =\n                DEFAULT_BEND + (f32::from(point.value) * DEFAULT_BEND_SEMI_TONE / SEMITONE_LENGTH);\n            let value = value.clamp(0.0, 127.0) as i32;\n            let bend_start = start + point.get_time(duration);\n            self.add_pitch_bend(bend_start, track_id, channel_id, value);\n\n            // look ahead to next bend point\n            if let Some(next_point) = bend.points.get(point_id + 1) {\n                let next_value = DEFAULT_BEND\n                    + (f32::from(next_point.value) * DEFAULT_BEND_SEMI_TONE / SEMITONE_LENGTH);\n                self.process_next_bend_values(\n                    track_id,\n                    channel_id,\n                    value,\n                    next_value as i32,\n                    bend_start,\n                    start,\n                    next_point,\n                    duration,\n                );\n            }\n        }\n        self.add_pitch_bend(start + duration, track_id, channel_id, DEFAULT_BEND as i32);\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn process_next_bend_values(\n        &mut self,\n        track_id: usize,\n        channel_id: i32,\n        mut value: i32,\n        next_value: i32,\n        mut bend_start: u32,\n        start: u32,\n        next_point: &BendPoint,\n        duration: u32,\n    ) {\n        if value != next_value {\n            let next_bend_start = start + next_point.get_time(duration);\n            let width = (next_bend_start - bend_start) as f32 / (next_value - value).abs() as f32;\n            let width = width as u32;\n            // ascending\n            if value < next_value {\n                while value < next_value {\n                    value += 1;\n                    bend_start += width;\n                    // clamp to 127\n                    let value = value.min(127);\n                    self.add_pitch_bend(bend_start, track_id, channel_id, value);\n                }\n            }\n            // descending\n            if value > next_value {\n                while value > next_value {\n                    value -= 1;\n                    bend_start += width;\n                    // clamp to 0\n                    let value = value.max(0);\n                    self.add_pitch_bend(bend_start, track_id, channel_id, value);\n                }\n            }\n        }\n    }\n\n    fn add_tremolo_bar(\n        &mut self,\n        track_id: usize,\n        start: u32,\n        duration: u32,\n        channel_id: i32,\n        tremolo_bar: &TremoloBarEffect,\n    ) {\n        for (point_id, point) in tremolo_bar.points.iter().enumerate() {\n            let value = DEFAULT_BEND + (f32::from(point.value) * DEFAULT_BEND_SEMI_TONE * 2.0);\n            let value = value.clamp(0.0, 127.0) as i32;\n            let bend_start = start + point.get_time(duration);\n            self.add_pitch_bend(bend_start, track_id, channel_id, value);\n\n            // look ahead to next bend point\n            if let Some(next_point) = tremolo_bar.points.get(point_id + 1) {\n                let next_value =\n                    DEFAULT_BEND + (f32::from(next_point.value) * DEFAULT_BEND_SEMI_TONE * 2.0);\n                self.process_next_bend_values(\n                    track_id,\n                    channel_id,\n                    value,\n                    next_value as i32,\n                    bend_start,\n                    start,\n                    next_point,\n                    duration,\n                );\n            }\n        }\n        self.add_pitch_bend(start + duration, track_id, channel_id, DEFAULT_BEND as i32);\n    }\n\n    fn add_note(\n        &mut self,\n        track_id: usize,\n        key: i32,\n        start: u32,\n        duration: u32,\n        velocity: i16,\n        channel: i32,\n    ) {\n        let note_on = MidiEvent::new_note_on(start, track_id, key, velocity, channel);\n        self.add_event(note_on);\n        if duration > 0 {\n            let tick = start + duration;\n            let note_off = MidiEvent::new_note_off(tick, track_id, key, channel);\n            self.add_event(note_off);\n        }\n    }\n\n    fn add_tempo_change(&mut self, tick: u32, tempo: u32) {\n        let event = MidiEvent::new_tempo_change(tick, tempo);\n        self.add_event(event);\n    }\n\n    fn add_bank_selection(&mut self, tick: u32, track_id: usize, channel: i32, bank: i32) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x00, bank);\n        self.add_event(event);\n    }\n\n    fn add_volume_selection(&mut self, tick: u32, track_id: usize, channel: i32, volume: i32) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x27, volume);\n        self.add_event(event);\n    }\n\n    fn add_expression_selection(\n        &mut self,\n        tick: u32,\n        track_id: usize,\n        channel: i32,\n        expression: i32,\n    ) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x2B, expression);\n        self.add_event(event);\n    }\n\n    fn add_chorus_selection(&mut self, tick: u32, track_id: usize, channel: i32, chorus: i32) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x5D, chorus);\n        self.add_event(event);\n    }\n\n    fn add_reverb_selection(&mut self, tick: u32, track_id: usize, channel: i32, reverb: i32) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x5B, reverb);\n        self.add_event(event);\n    }\n\n    fn add_pitch_bend(&mut self, tick: u32, track_id: usize, channel: i32, value: i32) {\n        // GP uses a value between 0 and 128\n        // MIDI uses a value between 0 and 16383 (128 * 128)\n        let midi_value = value * 128;\n\n        // the bend value must be split into two bytes and sent to the synthesizer.\n        let data1 = midi_value & 0x7F;\n        let data2 = midi_value >> 7;\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xE0, data1, data2);\n        self.add_event(event);\n    }\n\n    fn add_expression(&mut self, tick: u32, track_id: usize, channel: i32, expression: i32) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x0B, expression);\n        self.add_event(event);\n    }\n\n    fn add_program_selection(&mut self, tick: u32, track_id: usize, channel: i32, program: i32) {\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xC0, program, 0);\n        self.add_event(event);\n    }\n\n    fn add_pitch_bend_range(&mut self, tick: u32, track_id: usize, channel: i32) {\n        // RPN MSB: Select RPN group (usually 0)\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x65, 0);\n        self.add_event(event);\n\n        // RPN LSB: Select RPN 0/0 (Pitch Bend Sensitivity)\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x64, 0);\n        self.add_event(event);\n\n        // Data Entry MSB: Set the value (Pitch Bend Range)\n        // 12 semitones for the guitar\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x06, 12);\n        self.add_event(event);\n\n        // Data Entry LSB: Cents (usually 0)\n        let event = MidiEvent::new_midi_message(tick, track_id, channel, 0xB0, 0x26, 0);\n        self.add_event(event);\n    }\n\n    fn add_track_channel_midi_control(&mut self, track_id: usize, midi_channel: &MidiChannel) {\n        let channel_id = midi_channel.channel_id;\n        // publish MIDI control messages for the track channel at the start\n        let info_tick = FIRST_TICK;\n        self.add_volume_selection(\n            info_tick,\n            track_id,\n            i32::from(channel_id),\n            i32::from(midi_channel.volume),\n        );\n        self.add_expression_selection(info_tick, track_id, i32::from(channel_id), 127);\n        self.add_chorus_selection(\n            info_tick,\n            track_id,\n            i32::from(channel_id),\n            i32::from(midi_channel.chorus),\n        );\n        self.add_reverb_selection(\n            info_tick,\n            track_id,\n            i32::from(channel_id),\n            i32::from(midi_channel.reverb),\n        );\n        self.add_bank_selection(\n            info_tick,\n            track_id,\n            i32::from(channel_id),\n            i32::from(midi_channel.bank),\n        );\n        self.add_program_selection(\n            info_tick,\n            track_id,\n            i32::from(channel_id),\n            midi_channel.instrument,\n        );\n        self.add_pitch_bend_range(info_tick, track_id, i32::from(channel_id));\n    }\n\n    fn add_event(&mut self, event: MidiEvent) {\n        self.events.push(event);\n    }\n}\n\nfn apply_velocity_effect(\n    note: &Note,\n    previous_note: Option<&Note>,\n    midi_channel: &MidiChannel,\n) -> i16 {\n    let effect = &note.effect;\n    let mut velocity = note.velocity;\n\n    if !midi_channel.is_percussion() && previous_note.is_some_and(|n| n.effect.hammer) {\n        velocity = MIN_VELOCITY.max(velocity - 25);\n    }\n\n    if effect.ghost_note {\n        velocity = MIN_VELOCITY.max(velocity - VELOCITY_INCREMENT);\n    } else if effect.accentuated_note {\n        velocity = MIN_VELOCITY.max(velocity + VELOCITY_INCREMENT);\n    } else if effect.heavy_accentuated_note {\n        velocity = MIN_VELOCITY.max(velocity + VELOCITY_INCREMENT * 2);\n    }\n    velocity.min(127)\n}\n\nfn apply_duration_effect(\n    track: &Track,\n    measure_id: usize,\n    beat_id: usize,\n    note: &Note,\n    first_next_beat: Option<&Beat>,\n    tempo: u32,\n    mut duration: u32,\n) -> u32 {\n    let note_type = &note.kind;\n    let next_beats_in_next_measures = track.measures[measure_id..]\n        .iter()\n        .flat_map(|m| m.voices[0].beats.iter())\n        .skip(beat_id + 1); // skip current and previous beats\n\n    // handle chains of tie notes\n    for next_beat in next_beats_in_next_measures {\n        // filter for only next notes on matching string\n        if let Some(next_note) = next_beat.notes.iter().find(|n| n.string == note.string) {\n            if next_note.kind == NoteType::Tie {\n                duration += next_beat.duration.time();\n            } else {\n                // stop chain\n                break;\n            }\n        } else {\n            // break chain of tie notes\n            break;\n        }\n    }\n    // hande let-ring\n    if let Some(first_next_beat) = first_next_beat\n        && note.effect.let_ring\n    {\n        duration += first_next_beat.duration.time();\n    }\n    if note_type == &NoteType::Dead {\n        return apply_static_duration(tempo, DEFAULT_DURATION_DEAD, duration);\n    }\n    if note.effect.palm_mute {\n        return apply_static_duration(tempo, DEFAULT_DURATION_PM, duration);\n    }\n    if note.effect.staccato {\n        return (duration as f32 * 50.0 / 100.00) as u32;\n    }\n    duration\n}\n\nfn apply_static_duration(tempo: u32, duration: u32, maximum: u32) -> u32 {\n    let value = tempo * duration / 60;\n    value.min(maximum)\n}\n\n/// Triplet feel adjustment for a beat's start and duration.\nstruct TripletAdjustment {\n    start: u32,\n    duration: u32,\n}\n\n/// Apply triplet feel (swing) to a beat's timing.\n/// Pairs of equal-duration notes are converted to a long-short triplet pattern.\nfn apply_triplet_feel(\n    beat: &Beat,\n    previous_beat: Option<&Beat>,\n    next_beat: Option<&Beat>,\n    triplet_feel: TripletFeel,\n) -> TripletAdjustment {\n    let beat_start = beat.start;\n    let beat_duration = beat.duration.time();\n\n    match triplet_feel {\n        TripletFeel::None => TripletAdjustment {\n            start: beat_start,\n            duration: beat_duration,\n        },\n        TripletFeel::Eighth => apply_triplet_feel_for_duration(\n            beat_start,\n            beat_duration,\n            previous_beat,\n            next_beat,\n            QUARTER_TIME / 2,\n            QUARTER_TIME,\n        ),\n        TripletFeel::Sixteenth => apply_triplet_feel_for_duration(\n            beat_start,\n            beat_duration,\n            previous_beat,\n            next_beat,\n            QUARTER_TIME / 4,\n            QUARTER_TIME / 2,\n        ),\n    }\n}\n\n/// Apply triplet feel for a specific note duration level.\n/// `target_duration` is the straight note duration to match (e.g., 480 for eighth, 240 for sixteenth).\n/// `boundary` is the rhythmic boundary for pairing (e.g., 960 for eighth pairs, 480 for sixteenth pairs).\nfn apply_triplet_feel_for_duration(\n    beat_start: u32,\n    beat_duration: u32,\n    previous_beat: Option<&Beat>,\n    next_beat: Option<&Beat>,\n    target_duration: u32,\n    boundary: u32,\n) -> TripletAdjustment {\n    if beat_duration != target_duration {\n        return TripletAdjustment {\n            start: beat_start,\n            duration: beat_duration,\n        };\n    }\n\n    // triplet duration = target_duration * 2 / 3\n    let triplet_duration = target_duration * 2 / 3;\n\n    // first beat of pair: on the boundary\n    if beat_start.is_multiple_of(boundary) {\n        // check that next beat is also the same duration (forming a pair)\n        let next_qualifies = next_beat.is_none_or(|nb| {\n            nb.start > beat_start + beat_duration || nb.duration.time() == target_duration\n        });\n        if next_qualifies {\n            return TripletAdjustment {\n                start: beat_start,\n                duration: triplet_duration * 2, // long note\n            };\n        }\n    }\n    // second beat of pair: on the half-boundary\n    else if beat_start.is_multiple_of(boundary / 2) {\n        // check that previous beat is also the same duration\n        let prev_qualifies = previous_beat.is_none_or(|pb| {\n            pb.start < beat_start - beat_duration || pb.duration.time() == target_duration\n        });\n        if prev_qualifies {\n            let adjusted_start = (beat_start - beat_duration) + triplet_duration * 2;\n            return TripletAdjustment {\n                start: adjusted_start,\n                duration: triplet_duration, // short note\n            };\n        }\n    }\n\n    TripletAdjustment {\n        start: beat_start,\n        duration: beat_duration,\n    }\n}\n\n/// Compute per-string stroke offsets for a beat, following TuxGuitar's approach:\n/// only strings with non-tied notes receive incremental offsets.\nfn compute_stroke_offsets(beat: &Beat, stroke_increment: u32, string_count: usize) -> Vec<u32> {\n    let mut offsets = vec![0_u32; string_count];\n    if stroke_increment == 0 || beat.effect.stroke.direction == BeatStrokeDirection::None {\n        return offsets;\n    }\n\n    // build bitmask of strings that have non-tied notes\n    let mut strings_used: u32 = 0;\n    for note in &beat.notes {\n        if note.kind != NoteType::Tie {\n            strings_used |= 1 << (note.string as u32 - 1);\n        }\n    }\n\n    // assign cumulative offsets in stroke direction order\n    let mut stroke_move: u32 = 0;\n    for i in 0..string_count {\n        let index = match beat.effect.stroke.direction {\n            BeatStrokeDirection::Down => (string_count - 1) - i,\n            BeatStrokeDirection::Up => i,\n            BeatStrokeDirection::None => unreachable!(),\n        };\n        if strings_used & (1 << index) != 0 {\n            offsets[index] = stroke_move;\n            stroke_move += stroke_increment;\n        }\n    }\n\n    offsets\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::audio::midi_event::MidiEventType;\n    use crate::parser::song_parser::{DURATION_EIGHTH, DURATION_SIXTEENTH, NoteEffect, NoteType};\n    use crate::parser::song_parser_tests::parse_gp_file;\n    use std::collections::HashSet;\n    use std::io::Write;\n    use std::path::{Path, PathBuf};\n\n    #[test]\n    fn test_midi_events_for_all_files() {\n        let test_dir = Path::new(\"test-files\");\n        let gold_dir = Path::new(\"test-files/gold-generated-midi\");\n        for entry in std::fs::read_dir(test_dir).unwrap() {\n            let entry = entry.unwrap();\n            let path = entry.path();\n            if path.is_dir() {\n                continue;\n            }\n            let extension = path.extension().unwrap();\n            if extension != \"gp5\" && extension != \"gp4\" {\n                continue;\n            }\n            let file_name = path.file_name().unwrap().to_str().unwrap();\n            eprintln!(\"Parsing file: {file_name}\");\n            let file_path = path.to_str().unwrap();\n            let song = parse_gp_file(file_path)\n                .unwrap_or_else(|err| panic!(\"Failed to parse file: {file_name}\\n{err}\"));\n            let song = Rc::new(song);\n            let builder = MidiBuilder::new();\n            let events = builder.build_for_song(&song);\n            assert!(!events.is_empty(), \"No events found for {file_name}\");\n\n            // assert sorted by tick\n            assert!(events.windows(2).all(|w| w[0].tick <= w[1].tick));\n            assert_eq!(events[0].tick, 1);\n\n            // check against golden file\n            let gold_file_path = gold_dir.join(format!(\"{file_name}.txt\"));\n            if !gold_file_path.exists() {\n                // create gold file\n                let mut file = std::fs::File::create(&gold_file_path).unwrap();\n                for event in &events {\n                    writeln!(file, \"{}\", print_event(event)).unwrap();\n                }\n            }\n\n            // verify against gold file\n            validate_gold_rendered_result(&events, gold_file_path);\n        }\n    }\n\n    fn print_event(event: &MidiEvent) -> String {\n        format!(\"{:?} {:?} {:?}\", event.tick, event.event, event.track)\n    }\n\n    fn validate_gold_rendered_result(events: &[MidiEvent], gold_path: PathBuf) {\n        let gold = std::fs::read_to_string(&gold_path).expect(\"gold file not found!\");\n        let mut expected_lines = events.iter().map(print_event);\n        for (i1, l1) in gold.lines().enumerate() {\n            let l2 = expected_lines.next().unwrap();\n            if l1.trim_end() != l2.trim_end() {\n                println!(\"## GOLD line {} ##\", i1 + 1);\n                println!(\"{}\", l1.trim_end());\n                println!(\"## ACTUAL ##\");\n                println!(\"{}\", l2.trim_end());\n                println!(\"#####\");\n                assert_eq!(l1, l2, \"line {i1} failed for {gold_path:?}\");\n            }\n        }\n    }\n\n    #[test]\n    fn test_midi_events_for_demo_song() {\n        const FILE_PATH: &str = \"test-files/Demo v5.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        let song = Rc::new(song);\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song(&song);\n\n        assert_eq!(events.len(), 4693);\n        assert_eq!(events[0].tick, 1);\n\n        // assert number of tracks\n        let track_count = song.tracks.len();\n        let unique_tracks: HashSet<_> = events.iter().map(|event| event.track).collect();\n        assert_eq!(unique_tracks.len(), track_count + 1); // plus None for info events\n\n        // skip MIDI program messages\n        let rhythm_track_events: Vec<_> = events\n            .iter()\n            .filter(|e| e.track == Some(0))\n            .skip(10)\n            .collect();\n\n        // print 20 first for debugging\n        // for (i, event) in rhythm_track_events.iter().enumerate().take(20) {\n        //     eprintln!(\"{} {:?}\", i, event);\n        // }\n\n        // C5 ON\n        let event = &rhythm_track_events[0];\n        assert_eq!(event.tick, 960);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 60, 95)));\n\n        let event = &rhythm_track_events[1];\n        assert_eq!(event.tick, 960);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 55, 95)));\n\n        let event = &rhythm_track_events[2];\n        assert_eq!(event.tick, 960);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 48, 127)));\n\n        // C5 OFF\n        let event = &rhythm_track_events[3];\n        assert_eq!(event.tick, 1440);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 60)));\n\n        let event = &rhythm_track_events[4];\n        assert_eq!(event.tick, 1440);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 55)));\n\n        let event = &rhythm_track_events[5];\n        assert_eq!(event.tick, 1440);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 48)));\n\n        // single note `3` on string `1` (E2)\n        let event = &rhythm_track_events[6];\n        assert_eq!(event.tick, 1440);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 48, 95)));\n\n        // single note OFF (palm mute)\n        let event = &rhythm_track_events[7];\n        assert_eq!(event.tick, 1605);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 48)));\n\n        // single note `3` on string `1` (E2)\n        let event = &rhythm_track_events[8];\n        assert_eq!(event.tick, 1920);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 48, 95)));\n\n        // single note OFF (palm mute)\n        let event = &rhythm_track_events[9];\n        assert_eq!(event.tick, 2085);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 48)));\n\n        // C5 ON\n        let event = &rhythm_track_events[10];\n        assert_eq!(event.tick, 2400);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 60, 95)));\n\n        let event = &rhythm_track_events[11];\n        assert_eq!(event.tick, 2400);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 55, 95)));\n\n        let event = &rhythm_track_events[12];\n        assert_eq!(event.tick, 2400);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 48, 127)));\n\n        // skip MIDI program messages\n        let solo_track_events: Vec<_> = events\n            .iter()\n            .filter(|e| e.track == Some(1))\n            .skip(10)\n            .collect();\n\n        //print 100 first for debugging\n        for (i, event) in solo_track_events.iter().enumerate().take(100) {\n            eprintln!(\"{i} {event:?}\");\n        }\n\n        // trill ON\n        let event = &solo_track_events[0];\n        assert_eq!(event.tick, 12480);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 72, 95)));\n\n        // trill OFF\n        let event = &solo_track_events[1];\n        assert_eq!(event.tick, 12720);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 72)));\n\n        // trill ON\n        let event = &solo_track_events[2];\n        assert_eq!(event.tick, 12720);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 69, 95)));\n\n        // trill OFF\n        let event = &solo_track_events[3];\n        assert_eq!(event.tick, 12960);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 69)));\n\n        // trill ON\n        let event = &solo_track_events[4];\n        assert_eq!(event.tick, 12960);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 72, 95)));\n\n        // trill OFF\n        let event = &solo_track_events[5];\n        assert_eq!(event.tick, 13200);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 72)));\n\n        // pass some trill notes...\n\n        // trill ON\n        let event = &solo_track_events[30];\n        assert_eq!(event.tick, 16080);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 69, 95)));\n\n        // trill OFF\n        let event = &solo_track_events[31];\n        assert_eq!(event.tick, 16319);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 69)));\n\n        // tremolo ON (repeated section)\n        let event = &solo_track_events[32];\n        assert_eq!(event.tick, 27840);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 60, 95)));\n\n        // tremolo OFF\n        let event = &solo_track_events[33];\n        assert_eq!(event.tick, 27960);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 60)));\n\n        // note ON (after all tremolo and repeated sections)\n        let event = &solo_track_events[64];\n        assert_eq!(event.tick, 77760);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 63, 95)));\n\n        // note OFF\n        let event = &solo_track_events[65];\n        assert_eq!(event.tick, 78240);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 63)));\n\n        // note ON hammer\n        let event = &solo_track_events[66];\n        assert_eq!(event.tick, 78240);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOn(2, 65, 70)));\n\n        // note OFF hammer\n        let event = &solo_track_events[67];\n        assert_eq!(event.tick, 78720);\n        assert_eq!(event.track, Some(1));\n        assert!(matches!(event.event, MidiEventType::NoteOff(2, 65)));\n    }\n\n    #[test]\n    fn test_midi_events_for_bleed() {\n        const FILE_PATH: &str = \"test-files/Meshuggah - Bleed.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        let song = Rc::new(song);\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song(&song);\n\n        assert_eq!(events.len(), 44442);\n        assert_eq!(events[0].tick, 1);\n\n        // assert number of tracks\n        let track_count = song.tracks.len();\n        let unique_tracks: HashSet<_> = events.iter().map(|event| event.track).collect();\n        assert_eq!(unique_tracks.len(), track_count);\n\n        // skip MIDI program messages\n        let rhythm_track_events: Vec<_> = events\n            .iter()\n            .filter(|e| e.track == Some(0))\n            .skip(10)\n            .collect();\n\n        // print 60 first for debugging\n        // for (i, event) in rhythm_track_events.iter().enumerate().take(100) {\n        //     eprintln!(\"{} {:?}\", i, event);\n        // }\n\n        let event = &rhythm_track_events[44];\n        assert_eq!(event.tick, 4800);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[45];\n        assert_eq!(event.tick, 4915);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 39)));\n\n        let event = &rhythm_track_events[46];\n        assert_eq!(event.tick, 5040);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[47];\n        assert_eq!(event.tick, 5155);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 39)));\n\n        let event = &rhythm_track_events[48];\n        assert_eq!(event.tick, 5280);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[49];\n        assert_eq!(event.tick, 5395);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 39)));\n\n        let event = &rhythm_track_events[50];\n        assert_eq!(event.tick, 5400);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[51];\n        assert_eq!(event.tick, 5515);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 39)));\n\n        let event = &rhythm_track_events[52];\n        assert_eq!(event.tick, 5520);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[50];\n        assert_eq!(event.tick, 5400);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[51];\n        assert_eq!(event.tick, 5515);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 39)));\n\n        let event = &rhythm_track_events[52];\n        assert_eq!(event.tick, 5520);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOn(0, 39, 95)));\n\n        let event = &rhythm_track_events[53];\n        assert_eq!(event.tick, 5635);\n        assert_eq!(event.track, Some(0));\n        assert!(matches!(event.event, MidiEventType::NoteOff(0, 39)));\n    }\n\n    #[test]\n    fn playback_order_damage_control() {\n        const FILE_PATH: &str = \"test-files/John Petrucci - Damage Control (ver 6 by Feio666).gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        let headers = &song.measure_headers;\n\n        // discover repeat structure\n        let repeats: Vec<(usize, bool, i8, u8)> = headers\n            .iter()\n            .enumerate()\n            .filter(|(_, h)| h.repeat_open || h.repeat_close > 0 || h.repeat_alternative > 0)\n            .map(|(i, h)| (i, h.repeat_open, h.repeat_close, h.repeat_alternative))\n            .collect();\n        assert!(!repeats.is_empty(), \"Expected repeat markers\");\n\n        let order = compute_playback_order(headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n\n        // playback order should be longer than header count due to repeats\n        assert!(\n            order.len() > headers.len(),\n            \"Playback order ({}) should be > header count ({})\",\n            order.len(),\n            headers.len()\n        );\n\n        // verify the first repeat section has alternative endings\n        // find first measure with repeat_alternative\n        let first_alt = repeats.iter().find(|(_, _, _, alt)| *alt > 0);\n        assert!(first_alt.is_some(), \"Expected alternative endings\");\n\n        // verify repeated measures appear multiple times in playback order\n        let first_repeat_open = repeats.iter().find(|(_, open, _, _)| *open).unwrap().0;\n        let appearances = indices\n            .iter()\n            .filter(|&&idx| idx == first_repeat_open)\n            .count();\n        assert!(\n            appearances > 1,\n            \"First repeated measure should appear more than once\"\n        );\n\n        // verify all playback ticks are monotonically increasing\n        let playback_ticks: Vec<i64> = order\n            .iter()\n            .map(|(idx, offset)| i64::from(headers[*idx].start) + offset)\n            .collect();\n        for window in playback_ticks.windows(2) {\n            assert!(\n                window[0] < window[1],\n                \"Playback ticks not monotonically increasing: {} >= {}\",\n                window[0],\n                window[1]\n            );\n        }\n\n        // verify all measure indices are valid\n        for (idx, _) in &order {\n            assert!(*idx < headers.len(), \"Invalid measure index {idx}\");\n        }\n\n        // build MIDI events and verify they are sorted\n        let song = Rc::new(song);\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song(&song);\n        assert!(!events.is_empty());\n        assert!(\n            events.windows(2).all(|w| w[0].tick <= w[1].tick),\n            \"Events not sorted by tick\"\n        );\n    }\n\n    #[test]\n    fn triplet_feel_guthrie_eric() {\n        const FILE_PATH: &str = \"test-files/Guthrie Govan - Eric.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n\n        // verify triplet feel is parsed\n        let triplet_measure_indices: Vec<usize> = song\n            .measure_headers\n            .iter()\n            .enumerate()\n            .filter(|(_, h)| h.triplet_feel != TripletFeel::None)\n            .map(|(i, _)| i)\n            .collect();\n        assert!(\n            !triplet_measure_indices.is_empty(),\n            \"Expected triplet feel measures in Guthrie Govan - Eric\"\n        );\n\n        let first_triplet_idx = triplet_measure_indices[0];\n        let measure_start = song.measure_headers[first_triplet_idx].start;\n        let measure_end = measure_start + song.measure_headers[first_triplet_idx].length();\n\n        // build events and verify they are sorted\n        let song = Rc::new(song);\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song(&song);\n        assert!(!events.is_empty());\n        assert!(\n            events.windows(2).all(|w| w[0].tick <= w[1].tick),\n            \"Events not sorted by tick\"\n        );\n\n        let note_ons: Vec<u32> = events\n            .iter()\n            .filter(|e| {\n                e.tick >= measure_start\n                    && e.tick < measure_end\n                    && matches!(e.event, MidiEventType::NoteOn(_, _, _))\n            })\n            .map(|e| e.tick)\n            .collect();\n\n        // if there are consecutive eighth notes, the gaps should be uneven (640 + 320)\n        // rather than even (480 + 480)\n        if note_ons.len() >= 3 {\n            let gaps: Vec<u32> = note_ons.windows(2).map(|w| w[1] - w[0]).collect();\n            let has_uneven_gaps = gaps.windows(2).any(|w| w[0] != w[1]);\n            // at least some gaps should differ (swing feel)\n            assert!(\n                has_uneven_gaps || gaps.iter().all(|&g| g != 480),\n                \"Expected uneven note spacing from triplet feel in measure {} (gaps: {gaps:?})\",\n                first_triplet_idx + 1\n            );\n        }\n    }\n\n    #[test]\n    fn triplet_feel_none_no_change() {\n        let beat = Beat {\n            start: 960,\n            ..Beat::default()\n        };\n        let adj = apply_triplet_feel(&beat, None, None, TripletFeel::None);\n        assert_eq!(adj.start, 960);\n        assert_eq!(adj.duration, beat.duration.time());\n    }\n\n    #[test]\n    fn triplet_feel_eighth_first_beat() {\n        // first eighth note on quarter boundary → extended to 2/3 triplet * 2\n        let mut beat = Beat {\n            start: 960,\n            ..Beat::default()\n        };\n        beat.duration.value = u16::from(DURATION_EIGHTH);\n        let adj = apply_triplet_feel(&beat, None, None, TripletFeel::Eighth);\n        // triplet_duration = 480 * 2 / 3 = 320, long note = 640\n        assert_eq!(adj.start, 960);\n        assert_eq!(adj.duration, 640);\n    }\n\n    #[test]\n    fn triplet_feel_eighth_second_beat() {\n        // second eighth note on half-quarter boundary → shortened to 1/3 triplet\n        let mut beat = Beat {\n            start: 960 + 480, // half-quarter boundary\n            ..Beat::default()\n        };\n        beat.duration.value = u16::from(DURATION_EIGHTH);\n        let adj = apply_triplet_feel(&beat, None, None, TripletFeel::Eighth);\n        // triplet_duration = 320, short note, start shifts to 960 + 640 = 1600\n        assert_eq!(adj.start, 1600);\n        assert_eq!(adj.duration, 320);\n    }\n\n    #[test]\n    fn triplet_feel_preserves_total_time() {\n        // first + second beat durations should sum to the original pair\n        let mut first = Beat {\n            start: 960,\n            ..Beat::default()\n        };\n        first.duration.value = u16::from(DURATION_EIGHTH);\n        let mut second = Beat {\n            start: 960 + 480,\n            ..Beat::default()\n        };\n        second.duration.value = u16::from(DURATION_EIGHTH);\n        let adj1 = apply_triplet_feel(&first, None, Some(&second), TripletFeel::Eighth);\n        let adj2 = apply_triplet_feel(&second, Some(&first), None, TripletFeel::Eighth);\n        // total should be 960 (one quarter note)\n        assert_eq!(adj1.duration + adj2.duration, 960);\n        // second starts where first ends\n        assert_eq!(adj2.start, adj1.start + adj1.duration);\n    }\n\n    #[test]\n    fn triplet_feel_wrong_duration_no_change() {\n        // quarter note should not be affected by eighth triplet feel\n        let beat = Beat {\n            start: 960,\n            ..Beat::default()\n        };\n        // default duration is quarter (960), not eighth\n        let adj = apply_triplet_feel(&beat, None, None, TripletFeel::Eighth);\n        assert_eq!(adj.start, 960);\n        assert_eq!(adj.duration, 960);\n    }\n\n    #[test]\n    fn triplet_feel_sixteenth_pair() {\n        // sixteenth pair on eighth-note boundary\n        // target_duration = 240, boundary = 480\n        // triplet_duration = 240 * 2 / 3 = 160\n        let mut first = Beat {\n            start: 960,\n            ..Beat::default()\n        };\n        first.duration.value = u16::from(DURATION_SIXTEENTH);\n        let mut second = Beat {\n            start: 960 + 240,\n            ..Beat::default()\n        };\n        second.duration.value = u16::from(DURATION_SIXTEENTH);\n        let adj1 = apply_triplet_feel(&first, None, Some(&second), TripletFeel::Sixteenth);\n        let adj2 = apply_triplet_feel(&second, Some(&first), None, TripletFeel::Sixteenth);\n        assert_eq!(adj1.start, 960);\n        assert_eq!(adj1.duration, 320); // long: 160 * 2\n        assert_eq!(adj2.start, 1280); // 960 + 320\n        assert_eq!(adj2.duration, 160); // short: 160\n        assert_eq!(adj1.duration + adj2.duration, 480); // total = one eighth note\n    }\n\n    fn make_note(string: i8) -> Note {\n        let mut note = Note::new(NoteEffect::default());\n        note.string = string;\n        note.kind = NoteType::Normal;\n        note\n    }\n\n    #[test]\n    fn stroke_offsets_no_stroke() {\n        let beat = Beat::default();\n        let offsets = compute_stroke_offsets(&beat, 0, 6);\n        assert_eq!(offsets, vec![0, 0, 0, 0, 0, 0]);\n    }\n\n    #[test]\n    fn stroke_offsets_down_stroke() {\n        // down stroke: thickest string (6, index 5) plays first\n        let mut beat = Beat::default();\n        beat.effect.stroke.direction = BeatStrokeDirection::Down;\n        beat.notes = vec![make_note(1), make_note(3), make_note(5)];\n        let increment = 10;\n        let offsets = compute_stroke_offsets(&beat, increment, 6);\n        // string 5 (index 4) plays first (offset 0), string 3 (index 2) second, string 1 (index 0) third\n        assert_eq!(offsets[4], 0); // string 5: first\n        assert_eq!(offsets[2], 10); // string 3: second\n        assert_eq!(offsets[0], 20); // string 1: third\n        // strings without notes have 0 offset\n        assert_eq!(offsets[1], 0);\n        assert_eq!(offsets[3], 0);\n        assert_eq!(offsets[5], 0);\n    }\n\n    #[test]\n    fn stroke_offsets_up_stroke() {\n        // up stroke: thinnest string (1, index 0) plays first\n        let mut beat = Beat::default();\n        beat.effect.stroke.direction = BeatStrokeDirection::Up;\n        beat.notes = vec![make_note(1), make_note(3), make_note(5)];\n        let increment = 10;\n        let offsets = compute_stroke_offsets(&beat, increment, 6);\n        assert_eq!(offsets[0], 0); // string 1: first\n        assert_eq!(offsets[2], 10); // string 3: second\n        assert_eq!(offsets[4], 20); // string 5: third\n    }\n}\n"
  },
  {
    "path": "src/audio/midi_event.rs",
    "content": "/// A MIDI event.\n/// Try to keep this struct as small as possible because there will be a lot of them.\n#[derive(Debug, Clone, Eq, PartialEq)]\npub struct MidiEvent {\n    /// The tick at which the event occurs.\n    pub tick: u32,\n    /// The type of the event.\n    pub event: MidiEventType,\n    /// The track number of the event. None = info event.\n    pub track: Option<u8>,\n}\n\nimpl MidiEvent {\n    pub const fn is_midi_message(&self) -> bool {\n        matches!(self.event, MidiEventType::MidiMessage(_, _, _, _))\n    }\n\n    pub const fn is_note_event(&self) -> bool {\n        matches!(\n            self.event,\n            MidiEventType::NoteOn(_, _, _) | MidiEventType::NoteOff(_, _)\n        )\n    }\n\n    pub const fn new_note_on(\n        tick: u32,\n        track: usize,\n        key: i32,\n        velocity: i16,\n        channel: i32,\n    ) -> Self {\n        let event = MidiEventType::note_on(channel, key, velocity);\n        Self {\n            tick,\n            event,\n            track: Some(track as u8),\n        }\n    }\n\n    pub const fn new_note_off(tick: u32, track: usize, key: i32, channel: i32) -> Self {\n        let event = MidiEventType::note_off(channel, key);\n        Self {\n            tick,\n            event,\n            track: Some(track as u8),\n        }\n    }\n\n    pub const fn new_tempo_change(tick: u32, tempo: u32) -> Self {\n        let event = MidiEventType::tempo_change(tempo);\n        Self {\n            tick,\n            event,\n            track: None,\n        }\n    }\n\n    pub const fn new_midi_message(\n        tick: u32,\n        track: usize,\n        channel: i32,\n        command: i32,\n        data1: i32,\n        data2: i32,\n    ) -> Self {\n        let event = MidiEventType::midi_message(channel, command, data1, data2);\n        Self {\n            tick,\n            event,\n            track: Some(track as u8),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Eq, PartialEq, Hash)]\npub enum MidiEventType {\n    NoteOn(i32, i32, i16),           // channel, note, velocity\n    NoteOff(i32, i32),               // channel, note\n    TempoChange(u32),                // tempo in BPM\n    MidiMessage(i32, i32, i32, i32), // channel: i32, command: i32, data1: i32, data2: i32\n}\n\nimpl MidiEventType {\n    const fn note_on(channel: i32, key: i32, velocity: i16) -> Self {\n        Self::NoteOn(channel, key, velocity)\n    }\n\n    const fn note_off(channel: i32, key: i32) -> Self {\n        Self::NoteOff(channel, key)\n    }\n\n    const fn tempo_change(tempo: u32) -> Self {\n        Self::TempoChange(tempo)\n    }\n\n    const fn midi_message(channel: i32, command: i32, data1: i32, data2: i32) -> Self {\n        Self::MidiMessage(channel, command, data1, data2)\n    }\n}\n"
  },
  {
    "path": "src/audio/midi_player.rs",
    "content": "use crate::audio::FIRST_TICK;\nuse crate::audio::midi_builder::MidiBuilder;\nuse crate::audio::midi_event::MidiEventType;\nuse crate::audio::midi_player_params::MidiPlayerParams;\nuse crate::audio::midi_sequencer::MidiSequencer;\nuse crate::parser::song_parser::Song;\nuse cpal::DefaultStreamConfigError;\nuse cpal::traits::{DeviceTrait, HostTrait, StreamTrait};\nuse rustysynth::{SoundFont, Synthesizer, SynthesizerSettings};\nuse std::fs::File;\nuse std::path::PathBuf;\nuse std::rc::Rc;\nuse std::sync::atomic::{AtomicU32, Ordering};\nuse std::sync::{Arc, Mutex};\nuse tokio::sync::Notify;\n\nconst DEFAULT_SAMPLE_RATE: u32 = 44100; // number of samples per second\n\n/// Default sound font file is embedded in the binary (6MB)\nconst TIMIDITY_SOUND_FONT: &[u8] = include_bytes!(\"../../resources/TimGM6mb.sf2\");\n\npub struct AudioPlayer {\n    is_playing: bool,\n    song: Rc<Song>,                       // Song to play (shared with app)\n    stream: Option<Rc<cpal::Stream>>,     // Stream is not Send & Sync\n    sequencer: Arc<Mutex<MidiSequencer>>, // Need a handle to reset sequencer\n    player_params: Arc<MidiPlayerParams>, // Lock-free playback parameters\n    synthesizer: Arc<Mutex<Synthesizer>>, // Synthesizer for audio output\n    sound_font: Arc<SoundFont>,           // Sound font for synthesizer\n    current_tick: Arc<AtomicU32>,         // Latest tick reached by the audio callback\n    beat_notify: Arc<Notify>,             // Wake UI when current_tick changes\n    measure_playback_ticks: Vec<u32>,     // first playback tick per measure (for seeking)\n}\n\nimpl AudioPlayer {\n    pub fn new(\n        song: Rc<Song>,\n        song_tempo: u32,\n        tempo_percentage: u32,\n        sound_font_file: Option<PathBuf>,\n        current_tick: Arc<AtomicU32>,\n        beat_notify: Arc<Notify>,\n        playback_order: &[(usize, i64)],\n    ) -> Result<Self, AudioPlayerError> {\n        // default to no solo track\n        let solo_track_id = None;\n\n        // player params\n        let player_params = Arc::new(MidiPlayerParams::new(\n            song_tempo,\n            tempo_percentage,\n            solo_track_id,\n        ));\n\n        // midi sequencer initialization\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song_with_order(&song, playback_order);\n\n        // build first-playback-tick lookup per measure (for seeking)\n        let measure_count = song.measure_headers.len();\n        let mut measure_playback_ticks = vec![0_u32; measure_count];\n        let mut seen = vec![false; measure_count];\n        for &(measure_index, tick_offset) in playback_order {\n            if !seen[measure_index] {\n                seen[measure_index] = true;\n                let header = &song.measure_headers[measure_index];\n                measure_playback_ticks[measure_index] =\n                    (i64::from(header.start) + tick_offset) as u32;\n            }\n        }\n\n        // sound font setup\n        let sound_font = if let Some(ref sound_font_file) = sound_font_file {\n            let mut sf2 = File::open(sound_font_file).map_err(|e| {\n                AudioPlayerError::SoundFontFileError(format!(\"{}: {e}\", sound_font_file.display()))\n            })?;\n            SoundFont::new(&mut sf2).map_err(|e| {\n                AudioPlayerError::SoundFontLoadError(format!(\"{}: {e}\", sound_font_file.display()))\n            })?\n        } else {\n            let mut sf2 = TIMIDITY_SOUND_FONT;\n            SoundFont::new(&mut sf2)\n                .map_err(|e| AudioPlayerError::SoundFontLoadError(format!(\"embedded: {e}\")))?\n        };\n        let sound_font = Arc::new(sound_font);\n\n        // build new default synthesizer for the stream\n        let synthesizer = Self::make_synthesizer(sound_font.clone(), DEFAULT_SAMPLE_RATE)?;\n        let midi_sequencer = MidiSequencer::new(events);\n\n        let synthesizer = Arc::new(Mutex::new(synthesizer));\n        let sequencer = Arc::new(Mutex::new(midi_sequencer));\n        Ok(Self {\n            is_playing: false,\n            song,\n            stream: None,\n            sequencer,\n            player_params,\n            synthesizer,\n            sound_font,\n            current_tick,\n            beat_notify,\n            measure_playback_ticks,\n        })\n    }\n\n    fn make_synthesizer(\n        sound_font: Arc<SoundFont>,\n        sample_rate: u32,\n    ) -> Result<Synthesizer, AudioPlayerError> {\n        let synthesizer_settings = SynthesizerSettings::new(sample_rate as i32);\n        let synthesizer_settings = Arc::new(synthesizer_settings);\n        debug_assert_eq!(synthesizer_settings.sample_rate, sample_rate as i32);\n        Synthesizer::new(&sound_font, &synthesizer_settings)\n            .map_err(|e| AudioPlayerError::SynthesizerError(e.to_string()))\n    }\n\n    pub const fn is_playing(&self) -> bool {\n        self.is_playing\n    }\n\n    pub fn solo_track_id(&self) -> Option<usize> {\n        self.player_params.solo_track_id()\n    }\n\n    pub fn toggle_solo_mode(&self, new_track_id: usize) {\n        if self.player_params.solo_track_id() == Some(new_track_id) {\n            log::info!(\"Disable solo mode on track {new_track_id}\");\n            self.player_params.set_solo_track_id(None);\n        } else {\n            log::info!(\"Enable solo mode on track {new_track_id}\");\n            self.player_params.set_solo_track_id(Some(new_track_id));\n        }\n    }\n\n    pub fn set_tempo_percentage(&self, new_tempo_percentage: u32) {\n        self.player_params\n            .set_tempo_percentage(new_tempo_percentage);\n    }\n\n    pub fn master_volume(&self) -> f32 {\n        self.player_params.master_volume()\n    }\n\n    pub fn set_master_volume(&self, volume: f32) {\n        self.player_params.set_master_volume(volume);\n    }\n\n    pub fn stop(&mut self) {\n        // Pause stream\n        if let Some(stream) = &self.stream {\n            log::info!(\"Stopping audio stream\");\n            stream.pause().unwrap();\n        }\n        self.is_playing = false;\n\n        // reset ticks\n        let mut sequencer_guard = self.sequencer.lock().unwrap();\n        sequencer_guard.reset_last_time();\n        sequencer_guard.reset_ticks();\n        drop(sequencer_guard);\n\n        // stop all sound in synthesizer\n        let mut synthesizer_guard = self.synthesizer.lock().unwrap();\n        synthesizer_guard.note_off_all(false);\n        drop(synthesizer_guard);\n\n        // reset the UI cursor to the first playable tick so the measure lookup resolves cleanly\n        self.current_tick.store(FIRST_TICK, Ordering::Relaxed);\n        self.beat_notify.notify_one();\n\n        // Drop stream\n        self.stream.take();\n    }\n\n    /// Toggle play/pause. Returns an error message if playback fails.\n    pub fn toggle_play(&mut self) -> Option<String> {\n        log::info!(\"Toggle audio stream\");\n        if let Some(ref stream) = self.stream {\n            if self.is_playing {\n                self.is_playing = false;\n                if let Err(err) = stream.pause() {\n                    return Some(format!(\"Failed to pause audio stream: {err}\"));\n                }\n            } else {\n                self.is_playing = true;\n                // reset last time to not advance time too fast on resume\n                self.sequencer.lock().unwrap().reset_last_time();\n                if let Err(err) = stream.play() {\n                    return Some(format!(\"Failed to resume audio stream: {err}\"));\n                }\n            }\n        } else {\n            self.is_playing = true;\n\n            // Initialize audio output stream\n            let stream = new_output_stream(\n                self.sequencer.clone(),\n                self.player_params.clone(),\n                self.synthesizer.clone(),\n                self.sound_font.clone(),\n                self.current_tick.clone(),\n                self.beat_notify.clone(),\n            );\n\n            match stream {\n                Ok(stream) => {\n                    self.stream = Some(Rc::new(stream));\n                }\n                Err(err) => {\n                    self.is_playing = false;\n                    self.stream = None;\n                    return Some(format!(\"Failed to create audio stream: {err}\"));\n                }\n            }\n        }\n        None\n    }\n\n    pub fn focus_measure(&self, measure_id: usize) {\n        log::debug!(\"Focus audio player on measure:{measure_id}\");\n        let measure = &self.song.measure_headers[measure_id];\n        let measure_start_tick = self.measure_playback_ticks[measure_id];\n        let tempo = measure.tempo.value;\n\n        // move sequencer to measure start tick\n        let mut sequencer_guard = self.sequencer.lock().unwrap();\n        sequencer_guard.set_tick(measure_start_tick);\n        drop(sequencer_guard);\n\n        // stop current sound\n        let mut synthesizer_guard = self.synthesizer.lock().unwrap();\n        synthesizer_guard.note_off_all(false);\n        drop(synthesizer_guard);\n\n        // set tempo for focuses measure\n        self.player_params.set_tempo(tempo);\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum AudioPlayerError {\n    #[error(\"audio device not found\")]\n    CpalDeviceNotFound,\n    #[error(\"no output configuration found: {0}\")]\n    CpalOutputConfigNotFound(DefaultStreamConfigError),\n    #[error(\"failed to open sound font file: {0}\")]\n    SoundFontFileError(String),\n    #[error(\"failed to load sound font: {0}\")]\n    SoundFontLoadError(String),\n    #[error(\"failed to create synthesizer: {0}\")]\n    SynthesizerError(String),\n    #[error(\"failed to create audio stream: {0}\")]\n    StreamError(String),\n}\n\n/// Create a new output stream for audio playback.\nfn new_output_stream(\n    sequencer: Arc<Mutex<MidiSequencer>>,\n    player_params: Arc<MidiPlayerParams>,\n    synthesizer: Arc<Mutex<Synthesizer>>,\n    sound_font: Arc<SoundFont>,\n    current_tick: Arc<AtomicU32>,\n    beat_notify: Arc<Notify>,\n) -> Result<cpal::Stream, AudioPlayerError> {\n    let host = cpal::default_host();\n    let Some(device) = host.default_output_device() else {\n        return Err(AudioPlayerError::CpalDeviceNotFound);\n    };\n\n    let config = device\n        .default_output_config()\n        .map_err(AudioPlayerError::CpalOutputConfigNotFound)?;\n\n    if !config.sample_format().is_float() {\n        return Err(AudioPlayerError::StreamError(format!(\n            \"Unsupported sample format {}\",\n            config.sample_format()\n        )));\n    }\n    let stream_config: cpal::StreamConfig = config.into();\n    let sample_rate = stream_config.sample_rate;\n\n    log::info!(\"Audio output stream config: {stream_config:?}\");\n\n    let mut synthesizer_guard = synthesizer.lock().unwrap();\n    if sample_rate != DEFAULT_SAMPLE_RATE {\n        // audio output is not using the default sample rate - recreate synthesizer with proper sample rate\n        let new_synthesizer = AudioPlayer::make_synthesizer(sound_font, sample_rate)?;\n        *synthesizer_guard = new_synthesizer;\n    }\n\n    // Apply events at tick=FIRST_TICK to set up synthesizer state\n    // otherwise clicking on a measure *before* playing does not produce the correct instrument sound\n    sequencer\n        .lock()\n        .unwrap()\n        .events()\n        .iter()\n        .take_while(|event| event.tick == FIRST_TICK)\n        .filter(|event| event.is_midi_message())\n        .for_each(|event| {\n            if let MidiEventType::MidiMessage(channel, command, data1, data2) = event.event {\n                synthesizer_guard.process_midi_message(channel, command, data1, data2);\n            }\n        });\n\n    drop(synthesizer_guard);\n\n    // Size left and right buffers according to sample rate.\n    // The buffer accounts for 0.1 second of audio.\n    // e.g. 4410 samples at 44100 Hz is 0.1 second\n    let channel_sample_count = sample_rate / 10;\n\n    // reuse buffer for left and right channels across all calls\n    let mut left: Vec<f32> = vec![0_f32; channel_sample_count as usize];\n    let mut right: Vec<f32> = vec![0_f32; channel_sample_count as usize];\n\n    let err_fn = |err| log::error!(\"an error occurred on stream: {err}\");\n\n    let stream = device.build_output_stream(\n        &stream_config,\n        move |output: &mut [f32], _: &cpal::OutputCallbackInfo| {\n            let mut sequencer_guard = sequencer.lock().unwrap();\n            sequencer_guard.advance(player_params.adjusted_tempo());\n            let mut synthesizer_guard = synthesizer.lock().unwrap();\n            // process midi events for current tick\n            if let Some(events) = sequencer_guard.get_next_events() {\n                let tick = sequencer_guard.get_tick();\n                let last_tick = sequencer_guard.get_last_tick();\n                if !events.is_empty() {\n                    log::debug!(\n                        \"---> Increase {} ticks [{} -> {}] ({} events)\",\n                        tick - last_tick,\n                        last_tick,\n                        tick,\n                        events.len()\n                    );\n                }\n                let solo_track_id = player_params.solo_track_id();\n                if events\n                    .iter()\n                    .any(super::midi_event::MidiEvent::is_note_event)\n                {\n                    current_tick.store(tick, Ordering::Release);\n                    beat_notify.notify_one();\n                }\n                for midi_event in events {\n                    match midi_event.event {\n                        MidiEventType::NoteOn(channel, key, velocity) => {\n                            if let Some(track_id) = solo_track_id {\n                                // skip note on events for other tracks in solo mode\n                                if midi_event.track != Some(track_id as u8) {\n                                    continue;\n                                }\n                            }\n                            log::debug!(\n                                \"[{}] Note on: channel={}, key={}, velocity={}\",\n                                midi_event.tick,\n                                channel,\n                                key,\n                                velocity\n                            );\n                            synthesizer_guard.note_on(channel, key, i32::from(velocity));\n                        }\n                        MidiEventType::NoteOff(channel, key) => {\n                            log::debug!(\n                                \"[{}] Note off: channel={}, key={}\",\n                                midi_event.tick,\n                                channel,\n                                key\n                            );\n                            synthesizer_guard.note_off(channel, key);\n                        }\n                        MidiEventType::TempoChange(tempo) => {\n                            log::info!(\"Tempo changed to {tempo}\");\n                            player_params.set_tempo(tempo);\n                        }\n                        MidiEventType::MidiMessage(channel, command, data1, data2) => {\n                            log::debug!(\n                                \"[{}] Midi message: channel={}, command={}, data1={}, data2={}\",\n                                midi_event.tick,\n                                channel,\n                                command,\n                                data1,\n                                data2\n                            );\n                            synthesizer_guard.process_midi_message(channel, command, data1, data2);\n                        }\n                    }\n                }\n            }\n            // Split buffer for this run between left and right\n            let mut output_channel_len = output.len() / 2;\n\n            if left.len() < output_channel_len || right.len() < output_channel_len {\n                log::info!(\n                    \"Output buffer larger than expected channel size {} > {}\",\n                    output_channel_len,\n                    left.len()\n                );\n                output_channel_len = left.len();\n            }\n\n            // Render the waveform.\n            synthesizer_guard.render(\n                &mut left[..output_channel_len],\n                &mut right[..output_channel_len],\n            );\n\n            let master_volume = player_params.master_volume();\n\n            // Drop locks\n            drop(sequencer_guard);\n            drop(synthesizer_guard);\n\n            // Interleave the left and right channels into the output buffer.\n            for i in 0..output_channel_len {\n                output[i * 2] = left[i] * master_volume;\n                output[i * 2 + 1] = right[i] * master_volume;\n            }\n        },\n        err_fn,\n        None, // blocking stream\n    );\n    let stream = stream.map_err(|e| AudioPlayerError::StreamError(e.to_string()))?;\n    stream\n        .play()\n        .map_err(|e| AudioPlayerError::StreamError(e.to_string()))?;\n    Ok(stream)\n}\n"
  },
  {
    "path": "src/audio/midi_player_params.rs",
    "content": "use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};\n\nconst SOLO_NONE: i32 = -1;\n\n/// Playback parameters shared lock-free between UI and audio callback.\npub struct MidiPlayerParams {\n    tempo: AtomicU32,\n    tempo_percentage: AtomicU32,\n    solo_track_id: AtomicI32, // -1 == None\n    master_volume: AtomicU32, // f32 bits\n}\n\nimpl MidiPlayerParams {\n    pub fn new(tempo: u32, tempo_percentage: u32, solo_track_id: Option<usize>) -> Self {\n        Self {\n            tempo: AtomicU32::new(tempo),\n            tempo_percentage: AtomicU32::new(tempo_percentage),\n            solo_track_id: AtomicI32::new(solo_track_id.map_or(SOLO_NONE, |id| id as i32)),\n            master_volume: AtomicU32::new(1.0_f32.to_bits()),\n        }\n    }\n\n    pub fn master_volume(&self) -> f32 {\n        f32::from_bits(self.master_volume.load(Ordering::Relaxed))\n    }\n\n    pub fn set_master_volume(&self, volume: f32) {\n        self.master_volume\n            .store(volume.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);\n    }\n\n    pub fn solo_track_id(&self) -> Option<usize> {\n        match self.solo_track_id.load(Ordering::Relaxed) {\n            SOLO_NONE => None,\n            id => Some(id as usize),\n        }\n    }\n\n    pub fn set_solo_track_id(&self, solo_track_id: Option<usize>) {\n        self.solo_track_id.store(\n            solo_track_id.map_or(SOLO_NONE, |id| id as i32),\n            Ordering::Relaxed,\n        );\n    }\n\n    pub fn adjusted_tempo(&self) -> u32 {\n        let tempo = self.tempo.load(Ordering::Relaxed);\n        let pct = self.tempo_percentage.load(Ordering::Relaxed);\n        (tempo as f32 * pct as f32 / 100.0) as u32\n    }\n\n    pub fn set_tempo(&self, tempo: u32) {\n        self.tempo.store(tempo, Ordering::Relaxed);\n    }\n\n    pub fn set_tempo_percentage(&self, tempo_percentage: u32) {\n        self.tempo_percentage\n            .store(tempo_percentage, Ordering::Relaxed);\n    }\n}\n"
  },
  {
    "path": "src/audio/midi_sequencer.rs",
    "content": "use crate::audio::midi_event::MidiEvent;\nuse std::time::Instant;\n\nconst QUARTER_TIME: f32 = 960.0; // 1 quarter note = 960 ticks\n\npub struct MidiSequencer {\n    current_tick: u32,             // current Midi tick\n    last_tick: u32,                // last Midi tick\n    last_time: Instant,            // last time in milliseconds\n    sorted_events: Vec<MidiEvent>, // sorted Midi events\n}\n\nimpl MidiSequencer {\n    pub fn new(sorted_events: Vec<MidiEvent>) -> Self {\n        // events are sorted by tick\n        assert!(\n            sorted_events\n                .as_slice()\n                .windows(2)\n                .all(|w| w[0].tick <= w[1].tick)\n        );\n        Self {\n            current_tick: 0,\n            last_tick: 0,\n            last_time: Instant::now(),\n            sorted_events,\n        }\n    }\n\n    #[allow(clippy::missing_const_for_fn)]\n    pub fn events(&self) -> &[MidiEvent] {\n        &self.sorted_events\n    }\n\n    #[allow(clippy::missing_const_for_fn)]\n    pub fn set_tick(&mut self, tick: u32) {\n        // set last_tick before the target so get_next_events includes events at target tick\n        // set current_tick = last_tick so advance() triggers the init path (bumps by 1)\n        let adjusted = tick.saturating_sub(1);\n        self.last_tick = adjusted;\n        self.current_tick = adjusted;\n    }\n\n    pub fn reset_last_time(&mut self) {\n        self.last_time = Instant::now();\n    }\n\n    #[allow(clippy::missing_const_for_fn)]\n    pub fn reset_ticks(&mut self) {\n        self.current_tick = 0;\n        self.last_tick = 0;\n    }\n\n    pub const fn get_tick(&self) -> u32 {\n        self.current_tick\n    }\n\n    pub const fn get_last_tick(&self) -> u32 {\n        self.last_tick\n    }\n\n    pub fn get_next_events(&self) -> Option<&[MidiEvent]> {\n        // do not return events if tick did not change\n        if self.last_tick == self.current_tick {\n            return Some(&[]);\n        }\n\n        assert!(self.last_tick <= self.current_tick);\n\n        // get all events between last tick and next tick using binary search\n        // TODO could be improved by saving `end_index` to the next `start_index`\n        let start_index = match self\n            .sorted_events\n            .binary_search_by_key(&self.last_tick, |event| event.tick)\n        {\n            Ok(position) => position + 1,\n            Err(position) => {\n                // exit if end reached\n                if position == self.sorted_events.len() {\n                    return None;\n                }\n                position\n            }\n        };\n\n        let end_index = match self.sorted_events[start_index..]\n            .binary_search_by_key(&self.current_tick, |event| event.tick)\n        {\n            Ok(next_position) => start_index + next_position,\n            Err(next_position) => {\n                if next_position == 0 {\n                    // no matching elements\n                    return Some(&[]);\n                }\n                // return slice until the last event\n                start_index + next_position - 1\n            }\n        };\n        Some(&self.sorted_events[start_index..=end_index])\n    }\n\n    pub fn advance(&mut self, tempo: u32) {\n        // init sequencer if first advance after reset\n        if self.current_tick == self.last_tick {\n            self.current_tick += 1;\n            self.last_time = Instant::now();\n            return;\n        }\n        // check how many ticks have passed since last advance\n        let now = Instant::now();\n        let elapsed = now.duration_since(self.last_time);\n        let elapsed_secs = elapsed.as_secs_f32();\n        let tick_increase = tick_increase(tempo, elapsed_secs);\n        self.last_time = now;\n        self.last_tick = self.current_tick;\n        self.current_tick += tick_increase;\n    }\n\n    #[cfg(test)]\n    #[allow(clippy::missing_const_for_fn)]\n    pub fn advance_tick(&mut self, tick: u32) {\n        self.last_tick = self.current_tick;\n        self.current_tick += tick;\n    }\n}\n\nfn tick_increase(tempo_bpm: u32, elapsed_seconds: f32) -> u32 {\n    let tempo_bps = tempo_bpm as f32 / 60.0;\n    let bump = QUARTER_TIME * tempo_bps * elapsed_seconds;\n    bump as u32\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::audio::midi_builder::MidiBuilder;\n    use crate::audio::midi_event::MidiEventType;\n    use crate::parser::song_parser_tests::parse_gp_file;\n    use std::rc::Rc;\n    use std::time::Duration;\n\n    #[test]\n    fn test_tick_increase() {\n        let tempo = 100;\n        let elapsed = Duration::from_millis(32);\n        let result = tick_increase(tempo, elapsed.as_secs_f32());\n        assert_eq!(result, 51);\n    }\n\n    #[test]\n    fn test_tick_increase_bis() {\n        let tempo = 120;\n        let elapsed = Duration::from_millis(100);\n        let result = tick_increase(tempo, elapsed.as_secs_f32());\n        assert_eq!(result, 192);\n    }\n    #[test]\n    fn test_sequence_demo_song() {\n        const FILE_PATH: &str = \"test-files/Demo v5.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        let song = Rc::new(song);\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song(&song);\n        let events_len = 4693;\n        assert_eq!(events.len(), events_len);\n        assert_eq!(events[0].tick, 1);\n        let mut sequencer = MidiSequencer::new(events.clone());\n\n        // last_tick:0 current_tick:0\n        let batch = sequencer.get_next_events().unwrap();\n        assert_eq!(batch.len(), 0);\n\n        // advance time by 1 tick\n        sequencer.advance_tick(1);\n\n        // last_tick:0 current_tick:1\n        let batch = sequencer.get_next_events().unwrap();\n        let count_1 = batch.len();\n        assert_eq!(&events[0..count_1], batch);\n        assert!(batch.iter().all(MidiEvent::is_midi_message));\n\n        let mut pos = count_1;\n        loop {\n            let prev_tick = sequencer.get_tick();\n            // advance time by 112 tick\n            sequencer.advance_tick(112);\n            let next_tick = sequencer.get_tick();\n            assert_eq!(next_tick - prev_tick, 112);\n\n            if let Some(batch) = sequencer.get_next_events() {\n                let count = batch.len();\n                assert_eq!(&events[pos..pos + count], batch);\n                pos += count;\n            } else {\n                break;\n            }\n        }\n        assert_eq!(pos, events.len());\n    }\n\n    #[test]\n    fn set_tick_includes_events_at_target() {\n        // events at ticks 100, 200, 300\n        let events = vec![\n            MidiEvent {\n                tick: 100,\n                event: MidiEventType::NoteOn(0, 60, 95),\n                track: Some(0),\n            },\n            MidiEvent {\n                tick: 200,\n                event: MidiEventType::NoteOn(0, 62, 95),\n                track: Some(0),\n            },\n            MidiEvent {\n                tick: 300,\n                event: MidiEventType::NoteOn(0, 64, 95),\n                track: Some(0),\n            },\n        ];\n        let mut sequencer = MidiSequencer::new(events);\n\n        // seek to tick 200 — set_tick sets both ticks to 199\n        sequencer.set_tick(200);\n        // first advance_tick triggers init: last_tick stays 199, current_tick becomes 200\n        sequencer.advance_tick(1);\n        let batch = sequencer.get_next_events().unwrap();\n\n        // should include the event at tick 200\n        assert!(\n            batch.iter().any(|e| e.tick == 200),\n            \"set_tick should include events at the target tick, got: {batch:?}\"\n        );\n        // should NOT include event at tick 100 (before target)\n        assert!(\n            !batch.iter().any(|e| e.tick == 100),\n            \"set_tick should not include events before target tick\"\n        );\n    }\n\n    #[test]\n    fn set_tick_on_song_with_repeats() {\n        // verify seeking works correctly with repeat-expanded events\n        const FILE_PATH: &str = \"test-files/John Petrucci - Damage Control (ver 6 by Feio666).gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        let playback_order =\n            crate::audio::playback_order::compute_playback_order(&song.measure_headers);\n\n        // build measure_playback_ticks (same logic as AudioPlayer::new)\n        let measure_count = song.measure_headers.len();\n        let mut measure_playback_ticks = vec![0_u32; measure_count];\n        let mut seen = vec![false; measure_count];\n        for &(measure_index, tick_offset) in &playback_order {\n            if !seen[measure_index] {\n                seen[measure_index] = true;\n                let header = &song.measure_headers[measure_index];\n                measure_playback_ticks[measure_index] =\n                    (i64::from(header.start) + tick_offset) as u32;\n            }\n        }\n\n        let song = Rc::new(song);\n        let builder = MidiBuilder::new();\n        let events = builder.build_for_song(&song);\n        let mut sequencer = MidiSequencer::new(events.clone());\n\n        // seek to measure 5 (index 4)\n        let target_measure = 4;\n        let target_tick = measure_playback_ticks[target_measure];\n        assert!(\n            target_tick > 0,\n            \"Measure 5 should have a non-zero playback tick\"\n        );\n\n        sequencer.set_tick(target_tick);\n        sequencer.advance_tick(1);\n        let batch = sequencer.get_next_events().unwrap();\n\n        // verify we get events at or near the target tick, not from earlier measures\n        if !batch.is_empty() {\n            let min_tick = batch.iter().map(|e| e.tick).min().unwrap();\n            assert!(\n                min_tick >= target_tick,\n                \"After seeking to tick {target_tick}, got events at tick {min_tick}\"\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "src/audio/mod.rs",
    "content": "pub mod midi_builder;\npub mod midi_event;\npub mod midi_player;\nmod midi_player_params;\npub mod midi_sequencer;\npub mod playback_order;\n\n/// First tick of a song\npub const FIRST_TICK: u32 = 1;\n"
  },
  {
    "path": "src/audio/playback_order.rs",
    "content": "use crate::parser::song_parser::{MeasureHeader, QUARTER_TIME};\nuse std::collections::HashMap;\n\n/// Tracks the state of repeat section navigation during playback order computation.\nstruct RepeatState {\n    start_stack: Vec<usize>,    // stack of repeat_open indices (for nesting)\n    visits: HashMap<usize, i8>, // how many times each repeat_close has been hit\n    current_repetition: i8,     // 0-based: 0 = first play, 1 = first repeat, etc.\n    jumping_back: bool,         // true when looping back to a repeat_open\n}\n\nimpl RepeatState {\n    fn new() -> Self {\n        Self {\n            start_stack: vec![0], // implicit start at measure 0\n            visits: HashMap::new(),\n            current_repetition: 0,\n            jumping_back: false,\n        }\n    }\n\n    /// Process a repeat_open marker. Pushes onto the stack on first entry,\n    /// skips the push when looping back (to preserve the repetition counter).\n    fn enter_repeat(&mut self, measure_index: usize) {\n        if !self.jumping_back {\n            self.current_repetition = 0;\n            self.start_stack.push(measure_index);\n        }\n        self.jumping_back = false;\n    }\n\n    /// Check if the current repetition matches an alternative ending bitmask.\n    fn matches_alternative(&self, repeat_alternative: u8) -> bool {\n        // clamp to 7 to avoid shift overflow on u8\n        let clamped = self.current_repetition.min(7);\n        let bit = 1_u8 << clamped;\n        repeat_alternative & bit != 0\n    }\n\n    /// Process a repeat_close marker. Returns the index to jump back to,\n    /// or None if all repetitions are done.\n    fn close_repeat(&mut self, measure_index: usize, repeat_close: i8) -> Option<usize> {\n        let visits = self.visits.entry(measure_index).or_insert(0);\n        if *visits < repeat_close {\n            *visits += 1;\n            self.current_repetition += 1;\n            self.jumping_back = true;\n            let repeat_start = *self.start_stack.last().unwrap_or(&0);\n            // clear visit counts for inner repeats so they replay on next outer pass\n            self.visits\n                .retain(|&k, _| k <= repeat_start || k >= measure_index);\n            Some(repeat_start)\n        } else {\n            // done repeating\n            self.visits.remove(&measure_index);\n            self.start_stack.pop();\n            None\n        }\n    }\n}\n\n/// Compute the playback order of measures, expanding repeats and alternative endings.\n///\n/// Used by the MIDI builder to generate events at the correct ticks,\n/// and by the tablature to map playback ticks back to visual measures.\n/// Returns a Vec of (measure_index, tick_offset) pairs.\n/// The tick_offset is the difference between the playback tick and the original measure tick.\npub fn compute_playback_order(headers: &[MeasureHeader]) -> Vec<(usize, i64)> {\n    let mut order: Vec<(usize, i64)> = Vec::new();\n    let mut running_tick: u32 = QUARTER_TIME; // same starting tick as parser\n    let mut repeat = RepeatState::new();\n    let mut i = 0;\n\n    while i < headers.len() {\n        let header = &headers[i];\n\n        if header.repeat_open {\n            repeat.enter_repeat(i);\n        }\n\n        // check alternative ending: skip this measure if it doesn't match current repetition\n        if header.repeat_alternative != 0 && !repeat.matches_alternative(header.repeat_alternative)\n        {\n            // still check for repeat_close on this skipped measure\n            if header.repeat_close > 0\n                && let Some(jump_to) = repeat.close_repeat(i, header.repeat_close)\n            {\n                i = jump_to;\n                continue;\n            }\n            i += 1;\n            continue;\n        }\n\n        // add this measure to the playback order\n        let tick_offset = i64::from(running_tick) - i64::from(header.start);\n        order.push((i, tick_offset));\n        running_tick += header.length();\n\n        // handle repeat close\n        if header.repeat_close > 0\n            && let Some(jump_to) = repeat.close_repeat(i, header.repeat_close)\n        {\n            i = jump_to;\n            continue;\n        }\n\n        i += 1;\n    }\n\n    order\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn make_header(start: u32, repeat_open: bool, repeat_close: i8) -> MeasureHeader {\n        MeasureHeader {\n            start,\n            repeat_open,\n            repeat_close,\n            ..MeasureHeader::default()\n        }\n    }\n\n    #[test]\n    fn no_repeats() {\n        let headers = vec![\n            make_header(960, false, 0),\n            make_header(4800, false, 0),\n            make_header(8640, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        assert_eq!(order.len(), 3);\n        assert_eq!(order[0], (0, 0));\n        assert_eq!(order[1], (1, 0));\n        assert_eq!(order[2], (2, 0));\n    }\n\n    #[test]\n    fn simple_repeat() {\n        // |: M0 | M1 :|  M2\n        // Plays: M0 M1 M0 M1 M2\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 0),\n            make_header(960 + measure_len, false, 1),\n            make_header(960 + measure_len * 2, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 1, 0, 1, 2]);\n\n        // tick offsets: first pass is 0, second pass shifts by 2 measures\n        assert_eq!(order[0].1, 0);\n        assert_eq!(order[1].1, 0);\n        assert_eq!(order[2].1, i64::from(measure_len) * 2);\n        assert_eq!(order[3].1, i64::from(measure_len) * 2);\n        assert_eq!(order[4].1, i64::from(measure_len) * 2);\n    }\n\n    #[test]\n    fn repeat_three_times() {\n        // |: M0 :| x3  M1\n        // Plays: M0 M0 M0 M1\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 2),\n            make_header(960 + measure_len, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 0, 0, 1]);\n    }\n\n    #[test]\n    fn two_repeat_sections() {\n        // |: M0 :|  |: M1 :|  M2\n        // Plays: M0 M0 M1 M1 M2\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 1),\n            make_header(960 + measure_len, true, 1),\n            make_header(960 + measure_len * 2, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 0, 1, 1, 2]);\n    }\n\n    #[test]\n    fn alternative_endings() {\n        // |: M0 | M1[1.] | M2[2.] :|  M3\n        // Plays: M0 M1 M0 M2 M3\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 0),\n            MeasureHeader {\n                start: 960 + measure_len,\n                repeat_alternative: 1,\n                ..MeasureHeader::default()\n            },\n            MeasureHeader {\n                start: 960 + measure_len * 2,\n                repeat_alternative: 2,\n                repeat_close: 1,\n                ..MeasureHeader::default()\n            },\n            make_header(960 + measure_len * 3, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 1, 0, 2, 3]);\n    }\n\n    #[test]\n    fn three_alternatives() {\n        // |: M0 | M1[1.] | M2[2.] | M3[3.] :|x3  M4\n        // Plays: M0 M1 M0 M2 M0 M3 M4\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 0),\n            MeasureHeader {\n                start: 960 + measure_len,\n                repeat_alternative: 1,\n                ..MeasureHeader::default()\n            },\n            MeasureHeader {\n                start: 960 + measure_len * 2,\n                repeat_alternative: 2,\n                ..MeasureHeader::default()\n            },\n            MeasureHeader {\n                start: 960 + measure_len * 3,\n                repeat_alternative: 4,\n                repeat_close: 2,\n                ..MeasureHeader::default()\n            },\n            make_header(960 + measure_len * 4, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 1, 0, 2, 0, 3, 4]);\n    }\n\n    #[test]\n    fn nested_repeats() {\n        // |: M0 |: M1 :| M2 :|  M3\n        // Plays: M0 M1 M1 M2 M0 M1 M1 M2 M3\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 0),\n            make_header(960 + measure_len, true, 1),\n            make_header(960 + measure_len * 2, false, 1),\n            make_header(960 + measure_len * 3, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 1, 1, 2, 0, 1, 1, 2, 3]);\n    }\n\n    #[test]\n    fn repeat_close_without_open() {\n        // M0 | M1 :|  M2\n        // Plays: M0 M1 M0 M1 M2\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, false, 0),\n            make_header(960 + measure_len, false, 1),\n            make_header(960 + measure_len * 2, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 1, 0, 1, 2]);\n    }\n\n    #[test]\n    fn single_measure_repeat() {\n        // |: M0 :|\n        // Plays: M0 M0\n        let headers = vec![make_header(960, true, 1)];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 0]);\n    }\n\n    #[test]\n    fn tick_offsets_are_consistent() {\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 0),\n            make_header(960 + measure_len, false, 1),\n            make_header(960 + measure_len * 2, false, 0),\n        ];\n        let order = compute_playback_order(&headers);\n        let playback_ticks: Vec<i64> = order\n            .iter()\n            .map(|(idx, offset)| i64::from(headers[*idx].start) + offset)\n            .collect();\n        assert!(playback_ticks.windows(2).all(|w| w[0] < w[1]));\n    }\n\n    #[test]\n    fn alternative_on_last_pass_with_close() {\n        // |: M0 | M1[1.+2.] :|\n        // Plays: M0 M1 M0 M1\n        let measure_len = 3840_u32;\n        let headers = vec![\n            make_header(960, true, 0),\n            MeasureHeader {\n                start: 960 + measure_len,\n                repeat_alternative: 3,\n                repeat_close: 1,\n                ..MeasureHeader::default()\n            },\n        ];\n        let order = compute_playback_order(&headers);\n        let indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();\n        assert_eq!(indices, vec![0, 1, 0, 1]);\n    }\n\n    #[test]\n    fn empty() {\n        let headers: Vec<MeasureHeader> = vec![];\n        let order = compute_playback_order(&headers);\n        assert!(order.is_empty());\n    }\n}\n"
  },
  {
    "path": "src/config.rs",
    "content": "use std::{\n    env::home_dir,\n    fs::{File, create_dir_all},\n    io::{BufReader, Write},\n    path::PathBuf,\n};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::RuxError;\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n    tabs_folder: Option<PathBuf>,\n}\n\nimpl Config {\n    // folder placed in $HOME directory\n    const FOLDER: &'static str = \".config/ruxguitar\";\n\n    pub fn get_tabs_folder(&self) -> Option<PathBuf> {\n        self.tabs_folder.clone()\n    }\n\n    pub fn set_tabs_folder(&mut self, new_tabs_folder: Option<PathBuf>) -> Result<(), RuxError> {\n        if self.tabs_folder == new_tabs_folder {\n            // no op\n            Ok(())\n        } else {\n            self.tabs_folder = new_tabs_folder;\n            self.save_config()\n        }\n    }\n\n    fn get_base_path() -> Result<PathBuf, RuxError> {\n        let home = home_dir()\n            .ok_or_else(|| RuxError::ConfigError(\"Could not find home directory\".to_string()))?;\n        let path = home.join(Self::FOLDER);\n        Ok(path)\n    }\n\n    fn get_path() -> Result<PathBuf, RuxError> {\n        let base = Self::get_base_path()?;\n        Ok(base.join(\"config.json\"))\n    }\n\n    /// Creates config if it does not exist\n    pub fn read_config() -> Result<Self, RuxError> {\n        let base_path = Self::get_base_path()?;\n        if !base_path.exists() {\n            create_dir_all(base_path)?;\n        }\n        let config_path = Self::get_path()?;\n        if !config_path.exists() {\n            // create empty config\n            Config::default().save_config()?;\n        }\n        let file = File::open(&config_path)?;\n        let reader = BufReader::new(file);\n        match serde_json::from_reader(reader) {\n            Ok(config) => Ok(config),\n            Err(err) => {\n                log::warn!(\n                    \"Could not read local configuration {}: {err}, resetting to default\",\n                    config_path.display()\n                );\n                let default_config = Config::default();\n                default_config.save_config()?;\n                Ok(default_config)\n            }\n        }\n    }\n\n    /// Assumes the config folder exists\n    pub fn save_config(&self) -> Result<(), RuxError> {\n        let config_path = Self::get_path()?;\n        let json = serde_json::to_string_pretty(self).map_err(|err| {\n            RuxError::ConfigError(format!(\"Could not save local configuration {err:}\"))\n        })?;\n        let mut file = File::create(config_path)?;\n        file.write_all(json.as_bytes())?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "use crate::RuxError::ConfigError;\nuse crate::ui::application::RuxApplication;\nuse clap::Parser;\nuse config::Config;\nuse std::io;\nuse std::path::PathBuf;\n\nmod audio;\nmod config;\nmod parser;\nmod ui;\n\nfn main() {\n    let result = main_result();\n    std::process::exit(match result {\n        Ok(()) => 0,\n        Err(err) => {\n            // use Display instead of Debug for user friendly error messages\n            log::error!(\"{err}\");\n            1\n        }\n    });\n}\n\npub fn main_result() -> Result<(), RuxError> {\n    // setup logging\n    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(\"ruxguitar=info\"))\n        .init();\n\n    // args\n    let mut args = CliArgs::parse();\n    let sound_font_file = args.sound_font_file.take();\n    let tab_file_path = args.tab_file_path.take();\n\n    // check if sound font file exists\n    if let Some(sound_font_file) = &sound_font_file {\n        if !sound_font_file.exists() {\n            let err = ConfigError(format!(\"Sound font file not found {sound_font_file:?}\"));\n            return Err(err);\n        }\n        log::info!(\"Starting with custom sound font file {sound_font_file:?}\");\n    }\n\n    // check if tab file exists\n    if let Some(tab_file_path) = &tab_file_path {\n        if !tab_file_path.exists() {\n            let err = ConfigError(format!(\"Tab file not found {tab_file_path:?}\"));\n            return Err(err);\n        }\n        log::info!(\"Starting with tab file {tab_file_path:?}\");\n    }\n\n    // read local config\n    let local_config = Config::read_config()?;\n\n    // bundle application args\n    let args = ApplicationArgs {\n        sound_font_bank: sound_font_file,\n        tab_file_path,\n        no_antialiasing: args.no_antialiasing,\n        local_config,\n    };\n\n    // go!\n    RuxApplication::start(args)?;\n    Ok(())\n}\n\n#[derive(Parser, Debug)]\n#[command(version, about, long_about = None)]\npub struct CliArgs {\n    /// Optional path to a sound font file.\n    #[arg(long)]\n    sound_font_file: Option<PathBuf>,\n    /// Optional path to tab file to by-pass the file picker.\n    #[arg(long)]\n    tab_file_path: Option<PathBuf>,\n    /// Disable antialiasing.\n    #[arg(long, default_value_t = false)]\n    no_antialiasing: bool,\n}\n\n#[derive(Debug, Clone)]\npub struct ApplicationArgs {\n    sound_font_bank: Option<PathBuf>,\n    tab_file_path: Option<PathBuf>,\n    no_antialiasing: bool,\n    local_config: Config,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum RuxError {\n    #[error(\"iced error: {0}\")]\n    IcedError(iced::Error),\n    #[error(\"configuration error: {0}\")]\n    ConfigError(String),\n    #[error(\"parsing error: {0}\")]\n    ParsingError(String),\n    #[error(\"other error: {0}\")]\n    OtherError(String),\n}\n\nimpl From<iced::Error> for RuxError {\n    fn from(error: iced::Error) -> Self {\n        Self::IcedError(error)\n    }\n}\n\nimpl From<io::Error> for RuxError {\n    fn from(error: io::Error) -> Self {\n        Self::OtherError(error.to_string())\n    }\n}\n"
  },
  {
    "path": "src/parser/mod.rs",
    "content": "mod music_parser;\nmod primitive_parser;\npub mod song_parser;\npub mod song_parser_tests;\n"
  },
  {
    "path": "src/parser/music_parser.rs",
    "content": "use crate::parser::primitive_parser::{\n    parse_byte_size_string, parse_i8, parse_int, parse_int_byte_sized_string, parse_u8, skip,\n};\nuse crate::parser::song_parser::{\n    Beat, GpVersion, MAX_VOICES, Measure, Note, NoteEffect, NoteType, QUARTER_TIME, Song, Track,\n    Voice, convert_velocity, parse_beat_effects, parse_chord, parse_color, parse_duration,\n    parse_measure_headers, parse_note_effects,\n};\nuse nom::multi::count;\nuse nom::{IResult, Parser};\n\npub struct MusicParser {\n    song: Song,\n}\n\nimpl MusicParser {\n    pub const fn new(song: Song) -> Self {\n        Self { song }\n    }\n    pub fn take_song(&mut self) -> Song {\n        std::mem::take(&mut self.song)\n    }\n\n    pub fn parse_music_data<'a>(&'a mut self, i: &'a [u8]) -> IResult<&'a [u8], ()> {\n        let mut i = i;\n        let song_version = self.song.version;\n\n        if song_version >= GpVersion::GP5 {\n            // skip directions & master reverb\n            i = skip(i, 42);\n        }\n\n        let (i, (measure_count, track_count)) = (\n            parse_int, // Measure count\n            parse_int, // Track count\n        )\n            .parse(i)?;\n\n        log::debug!(\n            \"Parsing music data -> track_count: {track_count} measure_count {measure_count}\"\n        );\n\n        let song_tempo = self.song.tempo.value;\n        let (i, measure_headers) =\n            parse_measure_headers(measure_count, song_tempo, song_version)(i)?;\n        self.song.measure_headers = measure_headers;\n\n        let (i, tracks) = self.parse_tracks(track_count as usize)(i)?;\n        self.song.tracks = tracks;\n\n        let (i, _measures) = self.parse_measures(measure_count, track_count)(i)?;\n\n        Ok((i, ()))\n    }\n\n    fn parse_tracks(\n        &mut self,\n        tracks_count: usize,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], Vec<Track>> + '_ {\n        move |i| {\n            log::debug!(\"Parsing {tracks_count} tracks\");\n            let mut i = i;\n            let mut tracks = Vec::with_capacity(tracks_count);\n            for index in 1..=tracks_count {\n                let (inner, track) = self.parse_track(index)(i)?;\n                i = inner;\n                tracks.push(track);\n            }\n            // tracks done\n            if self.song.version == GpVersion::GP5 {\n                i = skip(i, 2);\n            }\n\n            if self.song.version > GpVersion::GP5 {\n                i = skip(i, 1);\n            }\n\n            Ok((i, tracks))\n        }\n    }\n\n    fn parse_track(&mut self, number: usize) -> impl FnMut(&[u8]) -> IResult<&[u8], Track> + '_ {\n        move |i| {\n            log::debug!(\"--------\");\n            log::debug!(\"Parsing track {number}\");\n            let mut i = skip(i, 1);\n            let mut track = Track::default();\n\n            if self.song.version >= GpVersion::GP5\n                && (number == 1 || self.song.version == GpVersion::GP5)\n            {\n                i = skip(i, 1);\n            };\n\n            track.number = number as i32;\n\n            // track name\n            let (inner, name) = parse_byte_size_string(40)(i)?;\n            i = inner;\n            log::debug!(\"Track name:{name}\");\n            track.name = name;\n\n            // string count\n            let (inner, string_count) = parse_int(i)?;\n            i = inner;\n            log::debug!(\"String count: {string_count}\");\n            assert!(string_count > 0);\n\n            // tunings\n            let (inner, tunings) = count(parse_int, 7).parse(i)?;\n            i = inner;\n            log::debug!(\"Tunings: {tunings:?}\");\n            track.strings = tunings\n                .iter()\n                .enumerate()\n                .filter(|(i, _)| (*i as i32) < string_count)\n                .map(|(i, &t)| (i as i32 + 1, t))\n                .collect();\n\n            // midi port\n            let (inner, port) = parse_int(i)?;\n            log::debug!(\"Midi port: {port:?}\");\n            i = inner;\n            track.midi_port = port as u8;\n\n            // parse track channel info\n            let (inner, channel_id) = self.parse_track_channel()(i)?;\n            log::debug!(\"Midi channel id: {channel_id:?}\");\n            track.channel_id = channel_id as u8;\n            i = inner;\n\n            // fret\n            let (inner, fret_count) = parse_int(i)?;\n            log::debug!(\"Fret count: {fret_count:?}\");\n            i = inner;\n            track.fret_count = fret_count as u8;\n\n            // offset\n            let (inner, offset) = parse_int(i)?;\n            log::debug!(\"Offset: {offset:?}\");\n            i = inner;\n            track.offset = offset;\n\n            // color\n            let (inner, color) = parse_color(i)?;\n            log::debug!(\"Color: {color:?}\");\n            i = inner;\n            track.color = color;\n\n            if self.song.version == GpVersion::GP5 {\n                // skip 44\n                i = skip(i, 44);\n            } else if self.song.version == GpVersion::GP5_10 {\n                // skip 49\n                i = skip(i, 49);\n            };\n\n            if self.song.version > GpVersion::GP5 {\n                let (inner, _) = parse_int_byte_sized_string(i)?;\n                i = inner;\n                let (inner, _) = parse_int_byte_sized_string(i)?;\n                i = inner;\n            };\n            Ok((i, track))\n        }\n    }\n\n    /// Read MIDI channel. MIDI channel in Guitar Pro is represented by two integers.\n    /// First is zero-based number of channel, second is zero-based number of channel used for effects.\n    fn parse_track_channel(&mut self) -> impl FnMut(&[u8]) -> IResult<&[u8], i32> + '_ {\n        log::debug!(\"Parsing track channel\");\n        |i| {\n            let (i, (mut gm_channel_1, mut gm_channel_2)) = (parse_int, parse_int).parse(i)?;\n            gm_channel_1 -= 1;\n            gm_channel_2 -= 1;\n\n            log::debug!(\"Track channel gm1: {gm_channel_1} gm2: {gm_channel_2}\");\n\n            if let Some(channel) = self.song.midi_channels.get_mut(gm_channel_1 as usize) {\n                // if not percussion - set effect channel\n                if channel.channel_id != 9 {\n                    channel.effect_channel_id = gm_channel_2 as u8;\n                }\n            } else {\n                log::debug!(\"channel {gm_channel_1} not found\");\n                debug_assert!(false, \"channel {gm_channel_1} not found\");\n            }\n            Ok((i, gm_channel_1))\n        }\n    }\n\n    /// Read measures. Measures are written in the following order:\n    /// - measure 1/track 1\n    /// - measure 1/track 2\n    /// - ...\n    /// - measure 1/track m\n    /// - measure 2/track 1\n    /// - measure 2/track 2\n    /// - ...\n    /// - measure 2/track m\n    /// - ...\n    /// - measure n/track 1\n    /// - measure n/track 2\n    /// - ...\n    /// - measure n/track m\n    fn parse_measures(\n        &mut self,\n        measure_count: i32,\n        track_count: i32,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], ()> + '_ {\n        move |i: &[u8]| {\n            log::debug!(\"--------\");\n            log::debug!(\"Parsing measures\");\n            let mut start = QUARTER_TIME;\n            let mut i = i;\n            for measure_index in 0..measure_count as usize {\n                // set header start\n                self.song.measure_headers[measure_index].start = start;\n                for track_index in 0..track_count as usize {\n                    let (inner, measure) =\n                        self.parse_measure(start, measure_index, track_index)(i)?;\n                    i = inner;\n                    // push measure on track\n                    self.song.tracks[track_index].measures.push(measure);\n                    if self.song.version >= GpVersion::GP5 {\n                        i = skip(i, 1);\n                    }\n                }\n                // update start with measure length\n                let measure_length = self.song.measure_headers[measure_index].length();\n                assert!(measure_length > 0, \"Measure length is 0\");\n                start += measure_length;\n            }\n            Ok((i, ()))\n        }\n    }\n\n    fn parse_measure(\n        &mut self,\n        measure_start: u32,\n        measure_index: usize,\n        track_index: usize,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], Measure> + '_ {\n        move |i: &[u8]| {\n            log::debug!(\"--------\");\n            log::debug!(\"Parsing measure {measure_index} for track {track_index}\");\n            let mut i = i;\n            let mut measure = Measure {\n                header_index: measure_index,\n                track_index,\n                ..Default::default()\n            };\n            let voice_count = if self.song.version >= GpVersion::GP5 {\n                MAX_VOICES\n            } else {\n                1\n            };\n            for voice_index in 0..voice_count {\n                // voices have the same start value\n                let beat_start = measure_start;\n                log::debug!(\"--------\");\n                log::debug!(\"Parsing voice {voice_index}\");\n                let (inner, voice) = self.parse_voice(beat_start, track_index, measure_index)(i)?;\n                i = inner;\n                measure.voices.push(voice);\n            }\n            Ok((i, measure))\n        }\n    }\n\n    fn parse_voice(\n        &mut self,\n        mut beat_start: u32,\n        track_index: usize,\n        measure_index: usize,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], Voice> + '_ {\n        move |i: &[u8]| {\n            let mut i = i;\n            let (inner, beats) = parse_int(i)?;\n            i = inner;\n            let mut voice = Voice {\n                measure_index: measure_index as i16,\n                ..Default::default()\n            };\n            log::debug!(\"--------\");\n            log::debug!(\"...with {beats} beats\");\n            for b in 1..=beats {\n                log::debug!(\"--------\");\n                log::debug!(\"Parsing beat {b}\");\n                let (inner, beat) = self.parse_beat(beat_start, track_index, measure_index)(i)?;\n                if !beat.empty {\n                    beat_start += beat.duration.time();\n                }\n                i = inner;\n                voice.beats.push(beat);\n            }\n            Ok((i, voice))\n        }\n    }\n\n    fn parse_beat(\n        &mut self,\n        start: u32,\n        track_index: usize,\n        measure_index: usize,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], Beat> + '_ {\n        move |i: &[u8]| {\n            let mut i = i;\n            let (inner, flags) = parse_u8(i)?;\n            i = inner;\n\n            // make new beat at starting time\n            let mut beat = Beat {\n                start,\n                ..Default::default()\n            };\n\n            // beat type\n            if (flags & 0x40) != 0 {\n                let (inner, beat_type) = parse_u8(i)?;\n                i = inner;\n                beat.empty = beat_type & 0x02 == 0;\n            }\n\n            // beat duration is an eighth note\n            let (inner, duration) = parse_duration(flags)(i)?;\n            beat.duration = duration;\n            i = inner;\n\n            // beat chords\n            if (flags & 0x02) != 0 {\n                let track = &self.song.tracks[track_index];\n                let (inner, chord) = parse_chord(track.strings.len() as u8)(i)?;\n                i = inner;\n                beat.effect.chord = Some(chord);\n            }\n\n            // beat text\n            if (flags & 0x04) != 0 {\n                let (inner, text) = parse_int_byte_sized_string(i)?;\n                i = inner;\n                log::debug!(\"Beat text: {text}\");\n                beat.text = text;\n            }\n\n            let mut note_effect = NoteEffect::default();\n            // beat effect\n            if (flags & 0x08) != 0 {\n                let (inner, ()) = parse_beat_effects(&mut beat, &mut note_effect)(i)?;\n                i = inner;\n            }\n\n            // parse mix change\n            if (flags & 0x10) != 0 {\n                let (inner, ()) = self.parse_mix_change(measure_index)(i)?;\n                i = inner;\n            }\n\n            // parse notes\n            let (inner, string_flags) = parse_u8(i)?;\n            i = inner;\n            let track = &self.song.tracks[track_index];\n            log::debug!(\n                \"Parsing notes for beat strings:{}, flags:{string_flags:08b}\",\n                track.strings.len()\n            );\n            assert!(!track.strings.is_empty());\n            for (string_id, string_value) in track.strings.iter().enumerate() {\n                if string_flags & (1 << (7 - string_value.0)) > 0 {\n                    log::debug!(\"Parsing note for string {}\", string_id + 1);\n                    let mut note = Note::new(note_effect.clone());\n                    let (inner, ()) = self.parse_note(&mut note, string_value, track_index)(i)?;\n                    i = inner;\n                    beat.notes.push(note);\n                }\n            }\n\n            if self.song.version >= GpVersion::GP5 {\n                i = skip(i, 1);\n                let (inner, read) = parse_u8(i)?;\n                i = inner;\n                if (read & 0x08) != 0 {\n                    i = skip(i, 1);\n                }\n            }\n            Ok((i, beat))\n        }\n    }\n\n    /// Get note value of tied note\n    fn get_tied_note_value(&self, string_index: i8, track_index: usize) -> i16 {\n        let track = &self.song.tracks[track_index];\n        for m in (0usize..track.measures.len()).rev() {\n            for v in (0usize..track.measures[m].voices.len()).rev() {\n                for b in 0..track.measures[m].voices[v].beats.len() {\n                    for n in 0..track.measures[m].voices[v].beats[b].notes.len() {\n                        if track.measures[m].voices[v].beats[b].notes[n].string == string_index {\n                            return track.measures[m].voices[v].beats[b].notes[n].value;\n                        }\n                    }\n                }\n            }\n        }\n        -1\n    }\n\n    fn parse_mix_change(\n        &mut self,\n        measure_index: usize,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], ()> + '_ {\n        move |i: &[u8]| {\n            log::debug!(\"Parsing mix change\");\n            let mut i = i;\n\n            // instrument\n            let (inner, _) = parse_i8(i)?;\n            i = inner;\n\n            if self.song.version >= GpVersion::GP5 {\n                i = skip(i, 16);\n            }\n\n            let (inner, (volume, pan, chorus, reverb, phaser, tremolo)) =\n                (parse_i8, parse_i8, parse_i8, parse_i8, parse_i8, parse_i8).parse(i)?;\n            i = inner;\n\n            let tempo_name = if self.song.version >= GpVersion::GP5 {\n                let (inner, tempo_name_tmp) = parse_int_byte_sized_string(i)?;\n                log::debug!(\"Tempo name: {tempo_name_tmp}\");\n                i = inner;\n                tempo_name_tmp\n            } else {\n                String::new()\n            };\n\n            let (inner, tempo_value) = parse_int(i)?;\n            i = inner;\n\n            if volume >= 0 {\n                i = skip(i, 1);\n            }\n            if pan >= 0 {\n                i = skip(i, 1);\n            }\n            if chorus >= 0 {\n                i = skip(i, 1);\n            }\n            if reverb >= 0 {\n                i = skip(i, 1);\n            }\n            if phaser >= 0 {\n                i = skip(i, 1);\n            }\n            if tremolo >= 0 {\n                i = skip(i, 1);\n            }\n\n            if tempo_value >= 0 {\n                // update tempo value for all next measure headers\n                self.song.measure_headers[measure_index..]\n                    .iter_mut()\n                    .for_each(|mh| {\n                        mh.tempo.value = tempo_value as u32;\n                        mh.tempo.name = Some(tempo_name.clone());\n                    });\n                i = skip(i, 1);\n                if self.song.version > GpVersion::GP5 {\n                    i = skip(i, 1);\n                }\n            }\n\n            i = skip(i, 1);\n\n            if self.song.version >= GpVersion::GP5 {\n                i = skip(i, 1);\n                if self.song.version > GpVersion::GP5 {\n                    let (inner, _) =\n                        (parse_int_byte_sized_string, parse_int_byte_sized_string).parse(i)?;\n                    i = inner;\n                }\n            }\n\n            Ok((i, ()))\n        }\n    }\n\n    fn parse_note<'a>(\n        &'a self,\n        note: &'a mut Note,\n        guitar_string: &'a (i32, i32),\n        track_index: usize,\n    ) -> impl FnMut(&[u8]) -> IResult<&[u8], ()> + 'a {\n        move |i| {\n            log::debug!(\"Parsing note {guitar_string:?}\");\n            let mut i = i;\n            let (inner, flags) = parse_u8(i)?;\n            i = inner;\n            let string = guitar_string.0 as i8;\n            note.string = string;\n            note.effect.heavy_accentuated_note = (flags & 0x02) == 0x02;\n            note.effect.ghost_note = (flags & 0x04) == 0x04;\n            note.effect.accentuated_note = (flags & 0x40) == 0x40;\n\n            // note type\n            if (flags & 0x20) != 0 {\n                let (inner, note_type) = parse_u8(i)?;\n                i = inner;\n                note.kind = NoteType::get_note_type(note_type);\n            }\n\n            // duration percent GP4\n            if (flags & 0x01) != 0 && self.song.version <= GpVersion::GP4_06 {\n                i = skip(i, 2);\n            }\n\n            // note velocity\n            if (flags & 0x10) != 0 {\n                let (inner, velocity) = parse_i8(i)?;\n                i = inner;\n                note.velocity = convert_velocity(i16::from(velocity));\n            }\n\n            // note value\n            if (flags & 0x20) != 0 {\n                let (inner, fret) = parse_i8(i)?;\n                i = inner;\n\n                let value = if note.kind == NoteType::Tie {\n                    self.get_tied_note_value(string, track_index)\n                } else {\n                    i16::from(fret)\n                };\n                // value is between 0 and 99\n                if (0..100).contains(&value) {\n                    note.value = value;\n                } else {\n                    note.value = 0;\n                }\n            }\n\n            // fingering\n            if (flags & 0x80) != 0 {\n                i = skip(i, 2);\n            }\n\n            if self.song.version >= GpVersion::GP5 {\n                // duration percent GP5\n                if (flags & 0x01) != 0 {\n                    i = skip(i, 8);\n                }\n\n                // swap accidentals\n                let (inner, swap) = parse_u8(i)?;\n                i = inner;\n                note.swap_accidentals = swap & 0x02 == 0x02;\n            }\n\n            if (flags & 0x08) != 0 {\n                let (inner, ()) = parse_note_effects(note, self.song.version)(i)?;\n                i = inner;\n            }\n\n            Ok((i, ()))\n        }\n    }\n}\n"
  },
  {
    "path": "src/parser/primitive_parser.rs",
    "content": "use encoding_rs::WINDOWS_1252;\nuse nom::combinator::{flat_map, map};\nuse nom::{IResult, Parser, bytes, number};\n\n/// Parse signed byte\npub fn parse_i8(i: &[u8]) -> IResult<&[u8], i8> {\n    number::complete::le_i8(i)\n}\n\n/// Parse unsigned byte\npub fn parse_u8(i: &[u8]) -> IResult<&[u8], u8> {\n    number::complete::le_u8(i)\n}\n\n/// Parse signed 32\npub fn parse_int(i: &[u8]) -> IResult<&[u8], i32> {\n    number::complete::le_i32(i)\n}\n\n/// Parse bool\npub fn parse_bool(i: &[u8]) -> IResult<&[u8], bool> {\n    map(number::complete::le_u8, |b| b == 1).parse(i)\n}\n\n/// Parse signed short\npub fn parse_short(i: &[u8]) -> IResult<&[u8], i16> {\n    number::complete::le_i16(i)\n}\n\n/// Skip `n` bytes.\npub fn skip(i: &[u8], n: usize) -> &[u8] {\n    if i.is_empty() {\n        return i;\n    }\n    log::debug!(\"skip: {n}\");\n    &i[n..]\n}\n\n/// Materialize properly encoded String\nfn make_string(i: &[u8]) -> String {\n    let (cow, encoding_used, had_errors) = WINDOWS_1252.decode(i);\n    if had_errors {\n        log::debug!(\"Error parsing string with {encoding_used:?}\");\n        match std::str::from_utf8(i) {\n            Ok(s) => s.to_string(),\n            Err(e) => {\n                log::debug!(\"Error UTF-8 string parsing:{e}\");\n                String::new()\n            }\n        }\n    } else {\n        cow.to_string()\n    }\n}\n\n/// Parse string of length `len`.\nfn parse_string(len: i32) -> impl FnMut(&[u8]) -> IResult<&[u8], String> {\n    parse_string_field(len as usize, len as usize)\n}\n\n/// Parse string field of length `string_len` with total size to consume `field_size`\nfn parse_string_field(\n    field_size: usize,\n    string_len: usize,\n) -> impl FnMut(&[u8]) -> IResult<&[u8], String> {\n    move |i: &[u8]| {\n        log::debug!(\"Parsing string field: field_size={field_size}, string_len={string_len}\");\n\n        // Read exactly the field size\n        let (rest, field) = bytes::complete::take(field_size)(i)?;\n\n        log::debug!(\"Raw field raw={field:02X?}\");\n\n        // Decode only the meaningful string bytes\n        let string = make_string(&field[..std::cmp::min(string_len, field_size)]);\n\n        Ok((rest, string))\n    }\n}\n\n/// Size of string encoded as Int.\n/// [i32 string_len][size bytes field]\npub fn parse_int_sized_string(i: &[u8]) -> IResult<&[u8], String> {\n    flat_map(parse_int, parse_string).parse(i)\n}\n\n/// Size of Strings provided\n/// `size`:   real string length\n/// `length`: optional provided length (in case of blank chars after the string)\npub fn parse_byte_size_string(size: usize) -> impl FnMut(&[u8]) -> IResult<&[u8], String> {\n    move |i: &[u8]| {\n        let (i, length) = parse_u8(i)?;\n        log::debug!(\"Parsing byte sized string of length {length} for String size {size}\");\n        parse_string_field(size, length as usize)(i)\n    }\n}\n\n/// Size of string encoded as Int, but the size is encoded as a byte.\npub fn parse_int_byte_sized_string(i: &[u8]) -> IResult<&[u8], String> {\n    flat_map(parse_int, |len| {\n        flat_map(parse_u8, move |str_len| {\n            log::debug!(\"Parsing int byte sized string int_len={len} u8_len={str_len}\");\n            parse_string_field(len as usize - 1, str_len as usize)\n        })\n    })\n    .parse(i)\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::parser::primitive_parser::parse_byte_size_string;\n\n    #[test]\n    fn test_read_byte_size_string() {\n        let data: Vec<u8> = vec![\n            0x18, 0x46, 0x49, 0x43, 0x48, 0x49, 0x45, 0x52, 0x20, 0x47, 0x55, 0x49, 0x54, 0x41,\n            0x52, 0x20, 0x50, 0x52, 0x4f, 0x20, 0x76, 0x33, 0x2e, 0x30, 0x30, 0x00, 0x00, 0x00,\n            0x00, 0x00, 0x00,\n        ];\n        let (_rest, res) = parse_byte_size_string(30)(&data).unwrap();\n        assert_eq!(res, \"FICHIER GUITAR PRO v3.00\");\n    }\n}\n"
  },
  {
    "path": "src/parser/song_parser.rs",
    "content": "use crate::RuxError;\nuse crate::parser::music_parser::MusicParser;\nuse crate::parser::primitive_parser::{\n    parse_bool, parse_byte_size_string, parse_i8, parse_int, parse_int_byte_sized_string,\n    parse_int_sized_string, parse_short, parse_u8, skip,\n};\nuse nom::IResult;\nuse nom::Parser;\nuse nom::bytes::complete::take;\nuse nom::combinator::{cond, flat_map, map};\nuse nom::multi::count;\nuse nom::sequence::preceded;\nuse std::fmt::Debug;\n\n// GP4 docs at <https://dguitar.sourceforge.net/GP4format.html>\n// GP5 docs thanks to Tuxguitar and <https://github.com/slundi/guitarpro> for the help\n\npub const MAX_VOICES: u32 = 2;\n\npub const QUARTER_TIME: u32 = 960;\npub const QUARTER: u16 = 4;\n\npub const DURATION_EIGHTH: u8 = 8;\npub const DURATION_SIXTEENTH: u8 = 16;\npub const DURATION_THIRTY_SECOND: u8 = 32;\npub const DURATION_SIXTY_FOURTH: u8 = 64;\n\npub const BEND_EFFECT_MAX_POSITION_LENGTH: f32 = 12.0;\n\npub const SEMITONE_LENGTH: f32 = 1.0;\npub const GP_BEND_SEMITONE: f32 = 25.0;\npub const GP_BEND_POSITION: f32 = 60.0;\n\npub const SHARP_NOTES: [&str; 12] = [\n    \"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\",\n];\n\npub const DEFAULT_PERCUSSION_BANK: u8 = 128;\n\npub const DEFAULT_BANK: u8 = 0;\n\npub const MIN_VELOCITY: i16 = 15;\npub const VELOCITY_INCREMENT: i16 = 16;\npub const DEFAULT_VELOCITY: i16 = MIN_VELOCITY + VELOCITY_INCREMENT * 5; // FORTE\n\n/// Convert Guitar Pro dynamic value to raw MIDI velocity\npub const fn convert_velocity(v: i16) -> i16 {\n    MIN_VELOCITY + (VELOCITY_INCREMENT * v) - VELOCITY_INCREMENT\n}\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Default)]\npub enum GpVersion {\n    #[default]\n    GP3,\n    GP4,\n    GP4_06,\n    GP5,\n    GP5_10,\n}\n\n#[derive(Debug, PartialEq, Default)]\npub struct Song {\n    pub version: GpVersion,\n    pub song_info: SongInfo,\n    pub triplet_feel: Option<bool>, // only < GP5\n    pub lyrics: Option<Lyrics>,\n    pub page_setup: Option<PageSetup>,\n    pub tempo: Tempo,\n    pub hide_tempo: Option<bool>,\n    pub key_signature: i8,\n    pub octave: Option<i8>,\n    pub midi_channels: Vec<MidiChannel>,\n    pub measure_headers: Vec<MeasureHeader>,\n    pub tracks: Vec<Track>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct MidiChannel {\n    pub channel_id: u8,\n    pub effect_channel_id: u8,\n    pub instrument: i32,\n    pub volume: i8,\n    pub balance: i8,\n    pub chorus: i8,\n    pub reverb: i8,\n    pub phaser: i8,\n    pub tremolo: i8,\n    pub bank: u8,\n}\n\nimpl MidiChannel {\n    pub const fn is_percussion(&self) -> bool {\n        self.bank == DEFAULT_PERCUSSION_BANK\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct Padding {\n    pub right: i32,\n    pub top: i32,\n    pub left: i32,\n    pub bottom: i32,\n}\n#[derive(Debug, PartialEq, Eq)]\npub struct Point {\n    pub x: i32,\n    pub y: i32,\n}\n\n#[derive(Debug, PartialEq)]\npub struct PageSetup {\n    pub page_size: Point,\n    pub page_margin: Padding,\n    pub score_size_proportion: f32,\n    pub header_and_footer: i16,\n    pub title: String,\n    pub subtitle: String,\n    pub artist: String,\n    pub album: String,\n    pub words: String,\n    pub music: String,\n    pub word_and_music: String,\n    pub copyright: String,\n    pub page_number: String,\n}\n#[derive(Debug, PartialEq, Eq)]\npub struct Lyrics {\n    pub track_choice: i32,\n    pub lines: Vec<(i32, String)>,\n}\n\n#[derive(Debug, PartialEq, Eq, Default)]\npub struct SongInfo {\n    pub name: String,\n    pub subtitle: String,\n    pub artist: String,\n    pub album: String,\n    pub author: String,\n    pub words: Option<String>,\n    pub copyright: String,\n    pub writer: String,\n    pub instructions: String,\n    pub notices: Vec<String>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct Marker {\n    pub title: String,\n    pub color: i32,\n}\n\npub const KEY_SIGNATURES: [&str; 34] = [\n    \"F♭ major\",\n    \"C♭ major\",\n    \"G♭ major\",\n    \"D♭ major\",\n    \"A♭ major\",\n    \"E♭ major\",\n    \"B♭ major\",\n    \"F major\",\n    \"C major\",\n    \"G major\",\n    \"D major\",\n    \"A major\",\n    \"E major\",\n    \"B major\",\n    \"F# major\",\n    \"C# major\",\n    \"G# major\",\n    \"D♭ minor\",\n    \"A♭ minor\",\n    \"E♭ minor\",\n    \"B♭ minor\",\n    \"F minor\",\n    \"C minor\",\n    \"G minor\",\n    \"D minor\",\n    \"A minor\",\n    \"E minor\",\n    \"B minor\",\n    \"F# minor\",\n    \"C# minor\",\n    \"G# minor\",\n    \"D# minor\",\n    \"A# minor\",\n    \"E# minor\",\n];\n\n#[derive(Debug, PartialEq, Eq)]\npub struct KeySignature {\n    pub key: i8,\n    pub is_minor: bool,\n}\n\nimpl KeySignature {\n    pub const fn new(key: i8, is_minor: bool) -> Self {\n        KeySignature { key, is_minor }\n    }\n}\n\nimpl std::fmt::Display for KeySignature {\n    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n        let index: usize = if self.is_minor {\n            (23i8 + self.key) as usize\n        } else {\n            (8i8 + self.key) as usize\n        };\n        write!(f, \"{}\", KEY_SIGNATURES[index])\n    }\n}\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\npub enum TripletFeel {\n    None,\n    Eighth,\n    Sixteenth,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct Tempo {\n    pub value: u32,\n    pub name: Option<String>,\n}\n\nimpl Tempo {\n    const fn new(value: u32, name: Option<String>) -> Self {\n        Tempo { value, name }\n    }\n}\n\nimpl Default for Tempo {\n    fn default() -> Self {\n        Tempo {\n            value: 120,\n            name: None,\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct MeasureHeader {\n    pub start: u32,\n    pub time_signature: TimeSignature,\n    pub tempo: Tempo,\n    pub marker: Option<Marker>,\n    pub repeat_open: bool,\n    pub repeat_alternative: u8,\n    pub repeat_close: i8,\n    pub triplet_feel: TripletFeel,\n    pub key_signature: KeySignature,\n}\n\nimpl Default for MeasureHeader {\n    fn default() -> Self {\n        MeasureHeader {\n            start: QUARTER_TIME,\n            time_signature: TimeSignature::default(),\n            tempo: Tempo::default(),\n            marker: None,\n            repeat_open: false,\n            repeat_alternative: 0,\n            repeat_close: 0,\n            triplet_feel: TripletFeel::None,\n            key_signature: KeySignature::new(0, false),\n        }\n    }\n}\n\nimpl MeasureHeader {\n    pub fn length(&self) -> u32 {\n        let numerator = u32::from(self.time_signature.numerator);\n        let denominator = self.time_signature.denominator.time();\n        numerator * denominator\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub struct TimeSignature {\n    pub numerator: u8,\n    pub denominator: Duration,\n}\n\nimpl Default for TimeSignature {\n    fn default() -> Self {\n        TimeSignature {\n            numerator: 4,\n            denominator: Duration::default(),\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct Duration {\n    pub value: u16,\n    pub dotted: bool,\n    pub double_dotted: bool,\n    pub tuplet_enters: u8,\n    pub tuplet_times: u8,\n}\n\nimpl Default for Duration {\n    fn default() -> Self {\n        Duration {\n            value: QUARTER,\n            dotted: false,\n            double_dotted: false,\n            tuplet_enters: 1,\n            tuplet_times: 1,\n        }\n    }\n}\n\nimpl Duration {\n    pub fn convert_time(&self, time: u32) -> u32 {\n        log::debug!(\n            \"time:{} tuplet_times:{} tuplet_enters:{}\",\n            time,\n            self.tuplet_times,\n            self.tuplet_enters\n        );\n        time * u32::from(self.tuplet_times) / u32::from(self.tuplet_enters)\n    }\n\n    pub fn time(&self) -> u32 {\n        let mut time = QUARTER_TIME as f32 * (4.0 / f32::from(self.value));\n        if self.dotted {\n            time += time / 2.0;\n        } else if self.double_dotted {\n            time += (time / 4.0) * 3.0;\n        }\n        self.convert_time(time as u32)\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct BendPoint {\n    pub position: u8,\n    pub value: i8,\n}\n\nimpl BendPoint {\n    pub fn get_time(&self, duration: u32) -> u32 {\n        let time = duration as f32 * f32::from(self.position) / BEND_EFFECT_MAX_POSITION_LENGTH;\n        time as u32\n    }\n}\n\n#[derive(Debug, Default, Clone, PartialEq, Eq)]\npub struct BendEffect {\n    pub points: Vec<BendPoint>,\n}\n\nimpl BendEffect {\n    pub fn direction(&self) -> isize {\n        if self.points.len() < 2 {\n            return 0;\n        }\n        let first = self.points[0].value;\n        for p in &self.points[1..] {\n            match first.cmp(&p.value) {\n                std::cmp::Ordering::Greater => return -1,\n                std::cmp::Ordering::Less => return 1,\n                std::cmp::Ordering::Equal => (),\n            }\n        }\n        0\n    }\n}\n\n#[derive(Debug, Default, Clone, PartialEq, Eq)]\npub struct TremoloBarEffect {\n    pub points: Vec<BendPoint>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct GraceEffect {\n    pub duration: u8,\n    pub fret: i8,\n    pub is_dead: bool,\n    pub is_on_beat: bool,\n    pub transition: GraceEffectTransition,\n    pub velocity: i16,\n}\n\nimpl GraceEffect {\n    pub fn duration_time(&self) -> f32 {\n        (QUARTER_TIME as f32 / 16.00) * f32::from(self.duration)\n    }\n}\n\nimpl Default for GraceEffect {\n    fn default() -> Self {\n        GraceEffect {\n            duration: 0,\n            fret: 0,\n            is_dead: false,\n            is_on_beat: false,\n            transition: GraceEffectTransition::None,\n            velocity: 0,\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum GraceEffectTransition {\n    /// No transition\n    None = 0,\n    /// Slide from the grace note to the real one.\n    Slide,\n    /// Perform a bend from the grace note to the real one.\n    Bend,\n    /// Perform a hammer on.\n    Hammer,\n}\n\nimpl GraceEffectTransition {\n    pub fn get_grace_effect_transition(value: i8) -> Self {\n        match value {\n            0 => Self::None,\n            1 => Self::Slide,\n            2 => Self::Bend,\n            3 => Self::Hammer,\n            _ => panic!(\"Cannot get transition for the grace effect\"),\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct PitchClass {\n    pub note: String,\n    pub just: i8,\n    /// flat (-1), none (0) or sharp (1).\n    pub accidental: i8,\n    pub value: i8,\n    pub sharp: bool,\n}\n\nimpl PitchClass {\n    pub fn from(just: i8, accidental: Option<i8>, sharp: Option<bool>) -> PitchClass {\n        let mut p = PitchClass {\n            just,\n            accidental: 0,\n            value: -1,\n            sharp: true,\n            note: String::with_capacity(2),\n        };\n        let pitch: i8;\n        let accidental2: i8;\n        if let Some(a) = accidental {\n            pitch = p.just;\n            accidental2 = a;\n        } else {\n            let value = p.just % 12;\n            p.note = if value >= 0 {\n                String::from(SHARP_NOTES[value as usize])\n            } else {\n                String::from(SHARP_NOTES[(12 + value) as usize])\n            };\n            if p.note.ends_with('b') {\n                accidental2 = -1;\n                p.sharp = false;\n            } else if p.note.ends_with('#') {\n                accidental2 = 1;\n            } else {\n                accidental2 = 0;\n            }\n            pitch = value - accidental2;\n        }\n        p.just = pitch % 12;\n        p.accidental = accidental2;\n        p.value = p.just + accidental2;\n        if sharp.is_none() {\n            p.sharp = p.accidental >= 0;\n        }\n        p\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum HarmonicType {\n    Natural,\n    Artificial,\n    Tapped,\n    Pinch,\n    Semi,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum Octave {\n    None,\n    Ottava,\n    Quindicesima,\n    OttavaBassa,\n    QuindicesimaBassa,\n}\n\nimpl Octave {\n    pub fn get_octave(value: u8) -> Self {\n        match value {\n            0 => Self::None,\n            1 => Self::Ottava,\n            2 => Self::Quindicesima,\n            3 => Self::OttavaBassa,\n            4 => Self::QuindicesimaBassa,\n            _ => panic!(\"Cannot get octave value\"),\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct HarmonicEffect {\n    pub kind: HarmonicType,\n    // artificial harmonic\n    pub pitch: Option<PitchClass>,\n    pub octave: Option<Octave>,\n    // tapped harmonic\n    pub right_hand_fret: Option<i8>,\n}\n\nimpl Default for HarmonicEffect {\n    fn default() -> Self {\n        HarmonicEffect {\n            kind: HarmonicType::Natural,\n            pitch: None,\n            octave: None,\n            right_hand_fret: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SlideType {\n    IntoFromAbove,\n    IntoFromBelow,\n    ShiftSlideTo,\n    LegatoSlideTo,\n    OutDownwards,\n    OutUpWards,\n}\n\n#[derive(Debug, Default, Clone, PartialEq, Eq)]\npub struct TrillEffect {\n    pub fret: i8,\n    pub duration: Duration,\n}\n\nimpl TrillEffect {\n    fn from_trill_period(period: i8) -> u16 {\n        match period {\n            1 => u16::from(DURATION_SIXTEENTH),\n            2 => u16::from(DURATION_THIRTY_SECOND),\n            3 => u16::from(DURATION_SIXTY_FOURTH),\n            other => panic!(\"Cannot get trill period - got {other}\"),\n        }\n    }\n}\n\n#[derive(Debug, Default, Clone, PartialEq, Eq)]\npub struct TremoloPickingEffect {\n    pub duration: Duration,\n}\n\nimpl TremoloPickingEffect {\n    fn from_tremolo_value(value: i8) -> u16 {\n        match value {\n            1 => u16::from(DURATION_EIGHTH),\n            3 => u16::from(DURATION_SIXTEENTH),\n            2 => u16::from(DURATION_THIRTY_SECOND),\n            other => panic!(\"Cannot get tremolo value - got {other}\"),\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub enum NoteType {\n    Rest,\n    Normal,\n    Tie,\n    Dead,\n    Unknown(u8),\n}\n\nimpl NoteType {\n    pub const fn get_note_type(value: u8) -> Self {\n        match value {\n            0 => Self::Rest,\n            1 => Self::Normal,\n            2 => Self::Tie,\n            3 => Self::Dead,\n            _ => Self::Unknown(value),\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct NoteEffect {\n    pub accentuated_note: bool,\n    pub bend: Option<BendEffect>,\n    pub ghost_note: bool,\n    pub grace: Option<GraceEffect>,\n    pub hammer: bool,\n    pub harmonic: Option<HarmonicEffect>,\n    pub heavy_accentuated_note: bool,\n    pub let_ring: bool,\n    pub palm_mute: bool,\n    pub slide: Option<SlideType>,\n    pub staccato: bool,\n    pub tremolo_picking: Option<TremoloPickingEffect>,\n    pub trill: Option<TrillEffect>,\n    pub fade_in: bool,\n    pub vibrato: bool,\n    pub slap: SlapEffect,\n    pub tremolo_bar: Option<TremoloBarEffect>,\n}\n\nimpl Default for NoteEffect {\n    fn default() -> Self {\n        NoteEffect {\n            accentuated_note: false,\n            bend: None,\n            ghost_note: false,\n            grace: None,\n            hammer: false,\n            harmonic: None,\n            heavy_accentuated_note: false,\n            let_ring: false,\n            palm_mute: false,\n            slide: None,\n            staccato: false,\n            tremolo_picking: None,\n            trill: None,\n            fade_in: false,\n            vibrato: false,\n            slap: SlapEffect::None,\n            tremolo_bar: None,\n        }\n    }\n}\n\n#[derive(Debug, Default, PartialEq, Eq)]\npub struct Chord {\n    pub length: u8,\n    pub sharp: Option<bool>,\n    pub root: Option<PitchClass>,\n    pub bass: Option<PitchClass>,\n    pub add: Option<bool>,\n    pub name: String,\n    pub first_fret: Option<u32>,\n    pub strings: Vec<i8>,\n    pub omissions: Vec<bool>,\n    pub show: Option<bool>,\n    pub new_format: Option<bool>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub enum BeatStrokeDirection {\n    None,\n    Up,\n    Down,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct BeatStroke {\n    pub direction: BeatStrokeDirection,\n    pub value: u16,\n}\n\nimpl BeatStroke {\n    pub fn is_empty(&self) -> bool {\n        self.direction == BeatStrokeDirection::None || self.value == 0\n    }\n\n    // A small time increment that depends on note duration and stroke intensity.\n    pub fn increment_for_duration(&self, beat_duration: u32) -> u32 {\n        if self.value == 0 {\n            return 0;\n        }\n        // stroke speed is based on the smallest rhythmic value\n        let duration = beat_duration.min(QUARTER_TIME);\n        ((duration as f32 / 8.0) * (4.0 / f32::from(self.value))).round() as u32\n    }\n}\n\n/// Convert raw GP stroke byte to a duration value.\nconst fn to_stroke_value(raw: i8) -> u16 {\n    match raw {\n        1 | 2 => DURATION_SIXTY_FOURTH as u16,\n        3 => DURATION_THIRTY_SECOND as u16,\n        4 => DURATION_SIXTEENTH as u16,\n        5 => DURATION_EIGHTH as u16,\n        6 => QUARTER,\n        _ => DURATION_SIXTY_FOURTH as u16,\n    }\n}\n\nimpl Default for BeatStroke {\n    fn default() -> Self {\n        BeatStroke {\n            direction: BeatStrokeDirection::None,\n            value: 0,\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SlapEffect {\n    None,\n    Tapping,\n    Slapping,\n    Popping,\n}\n\n#[derive(Debug, Default, PartialEq, Eq)]\npub struct BeatEffects {\n    pub stroke: BeatStroke,\n    pub chord: Option<Chord>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct Note {\n    pub value: i16,\n    pub velocity: i16,\n    pub string: i8,\n    pub effect: NoteEffect,\n    pub swap_accidentals: bool,\n    pub kind: NoteType,\n    tuplet: Option<i8>,\n}\n\nimpl Note {\n    pub const fn new(note_effect: NoteEffect) -> Self {\n        Note {\n            value: 0,\n            velocity: DEFAULT_VELOCITY,\n            string: 1,\n            effect: note_effect,\n            swap_accidentals: false,\n            kind: NoteType::Rest,\n            tuplet: None,\n        }\n    }\n}\n\n#[derive(Debug, Default, PartialEq, Eq)]\npub struct Beat {\n    pub notes: Vec<Note>,\n    pub duration: Duration,\n    pub empty: bool,\n    pub text: String,\n    pub start: u32,\n    pub effect: BeatEffects,\n}\n\n#[derive(Debug, Default, PartialEq, Eq)]\npub struct Voice {\n    pub measure_index: i16,\n    pub beats: Vec<Beat>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct Measure {\n    pub key_signature: KeySignature,\n    pub time_signature: TimeSignature,\n    pub track_index: usize,\n    pub header_index: usize,\n    pub voices: Vec<Voice>,\n}\n\nimpl Default for Measure {\n    fn default() -> Self {\n        Measure {\n            key_signature: KeySignature::new(0, false),\n            time_signature: TimeSignature::default(),\n            track_index: 0,\n            header_index: 0,\n            voices: vec![],\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct Track {\n    pub number: i32,\n    pub offset: i32,\n    pub channel_id: u8,\n    pub solo: bool,\n    pub mute: bool,\n    pub visible: bool,\n    pub name: String,\n    pub strings: Vec<(i32, i32)>,\n    pub color: i32,\n    pub midi_port: u8,\n    pub fret_count: u8,\n    pub measures: Vec<Measure>,\n}\n\nimpl Default for Track {\n    fn default() -> Self {\n        Track {\n            number: 1,\n            offset: 0,\n            channel_id: 0,\n            solo: false,\n            mute: false,\n            visible: true,\n            name: String::new(),\n            strings: vec![],\n            color: 0,\n            midi_port: 0,\n            fret_count: 24,\n            measures: vec![],\n        }\n    }\n}\n\npub fn parse_chord(string_count: u8) -> impl FnMut(&[u8]) -> IResult<&[u8], Chord> {\n    move |i| {\n        log::debug!(\"Parsing chords for {string_count} strings\");\n        let mut i = i;\n        let mut chord = Chord {\n            strings: vec![-1; string_count.into()],\n            ..Default::default()\n        };\n        let (inner, chord_gp4_header) = parse_u8(i)?;\n        i = inner;\n\n        // chord header defines the version as well\n        if (chord_gp4_header & 0x01) == 0 {\n            log::debug!(\"Parsing simple chord\");\n            let (inner, chord_name) = parse_int_byte_sized_string(i)?;\n            log::debug!(\"Chord name {chord_name}\");\n            i = inner;\n            chord.name = chord_name;\n            let (inner, first_fret) = parse_int(i)?;\n            i = inner;\n            log::debug!(\"Chord first fret {first_fret}\");\n            chord.first_fret = Some(first_fret as u32);\n            if first_fret != 0 {\n                for c in 0..6 {\n                    let (inner, fret) = parse_int(i)?;\n                    if c < string_count {\n                        chord.strings[c as usize] = fret as i8;\n                    }\n                    i = inner;\n                }\n            }\n        } else {\n            log::debug!(\"Parsing diagram style chord\");\n            i = skip(i, 16);\n            let (inner, chord_name) = parse_byte_size_string(21)(i)?;\n            i = inner;\n            log::debug!(\"Chord name {chord_name}\");\n            chord.name = chord_name;\n            i = skip(i, 4);\n            let (inner, first_fret) = parse_int(i)?;\n            i = inner;\n            log::debug!(\"Chord first fret {first_fret}\");\n            chord.first_fret = Some(first_fret as u32);\n            for c in 0..7 {\n                let (inner, fret) = parse_int(i)?;\n                i = inner;\n                log::debug!(\"Chord fret {c}:{fret}\");\n                if c < string_count {\n                    chord.strings[c as usize] = fret as i8;\n                }\n            }\n            i = skip(i, 32);\n        }\n        Ok((i, chord))\n    }\n}\n\npub fn parse_note_effects(\n    note: &mut Note,\n    version: GpVersion,\n) -> impl FnMut(&[u8]) -> IResult<&[u8], ()> + '_ {\n    move |i| {\n        log::debug!(\"Parsing note effects\");\n        let mut i = i;\n        let (inner, (flags1, flags2)) = (parse_u8, parse_u8).parse(i)?;\n        i = inner;\n        note.effect.hammer = (flags1 & 0x02) == 0x02;\n        note.effect.let_ring = (flags1 & 0x08) == 0x08;\n\n        note.effect.staccato = (flags2 & 0x01) == 0x01;\n        note.effect.palm_mute = (flags2 & 0x02) == 0x02;\n        note.effect.vibrato = (flags2 & 0x40) == 0x40 || note.effect.vibrato;\n\n        if (flags1 & 0x01) != 0 {\n            let (inner, bend_effect) = parse_bend_effect(i)?;\n            i = inner;\n            note.effect.bend = Some(bend_effect);\n        }\n\n        if (flags1 & 0x10) != 0 {\n            let (inner, grace_effect) = parse_grace_effect(version)(i)?;\n            i = inner;\n            note.effect.grace = Some(grace_effect);\n        }\n\n        if (flags2 & 0x04) != 0 {\n            let (inner, tremolo_picking) = parse_tremolo_picking(i)?;\n            i = inner;\n            note.effect.tremolo_picking = Some(tremolo_picking);\n        }\n\n        if (flags2 & 0x08) != 0 {\n            let (inner, slide_type) = parse_slide_type(i)?;\n            i = inner;\n            note.effect.slide = slide_type;\n        }\n\n        if (flags2 & 0x10) != 0 {\n            let (inner, harmonic_effect) = parse_harmonic_effect(version)(i)?;\n            i = inner;\n            note.effect.harmonic = Some(harmonic_effect);\n        }\n\n        if (flags2 & 0x20) != 0 {\n            let (inner, trill_effect) = parse_trill_effect(i)?;\n            i = inner;\n            note.effect.trill = Some(trill_effect);\n        }\n\n        Ok((i, ()))\n    }\n}\n\npub fn parse_trill_effect(i: &[u8]) -> IResult<&[u8], TrillEffect> {\n    log::debug!(\"Parsing trill effect\");\n    let mut trill_effect = TrillEffect::default();\n    let (inner, (fret, period)) = (parse_i8, parse_i8).parse(i)?;\n    trill_effect.fret = fret;\n    trill_effect.duration.value = TrillEffect::from_trill_period(period);\n    Ok((inner, trill_effect))\n}\n\npub fn parse_harmonic_effect(\n    version: GpVersion,\n) -> impl FnMut(&[u8]) -> IResult<&[u8], HarmonicEffect> {\n    move |i| {\n        let mut i = i;\n        let mut he = HarmonicEffect::default();\n        let (inner, harmonic_type) = parse_i8(i)?;\n        i = inner;\n        log::debug!(\"Parsing harmonic effect {harmonic_type}\");\n        match harmonic_type {\n            1 => he.kind = HarmonicType::Natural,\n            2 => {\n                he.kind = HarmonicType::Artificial;\n                if version >= GpVersion::GP5 {\n                    let (inner, (semitone, accidental, octave)) =\n                        (parse_u8, parse_i8, parse_u8).parse(i)?;\n                    i = inner;\n                    he.pitch = Some(PitchClass::from(semitone as i8, Some(accidental), None));\n                    he.octave = Some(Octave::get_octave(octave));\n                }\n            }\n            3 => {\n                he.kind = HarmonicType::Tapped;\n                if version >= GpVersion::GP5 {\n                    let (inner, fret) = parse_u8(i)?;\n                    i = inner;\n                    he.right_hand_fret = Some(fret as i8);\n                }\n            }\n            4 => he.kind = HarmonicType::Pinch,\n            5 => he.kind = HarmonicType::Semi,\n            15 => {\n                assert!(\n                    version < GpVersion::GP5,\n                    \"Cannot read artificial harmonic type for GP4\"\n                );\n                he.kind = HarmonicType::Artificial;\n            }\n            17 => {\n                assert!(\n                    version < GpVersion::GP5,\n                    \"Cannot read artificial harmonic type for GP4\"\n                );\n                he.kind = HarmonicType::Artificial;\n            }\n            22 => {\n                assert!(\n                    version < GpVersion::GP5,\n                    \"Cannot read artificial harmonic type for GP4\"\n                );\n                he.kind = HarmonicType::Artificial;\n            }\n            x => panic!(\"Cannot read harmonic type {x}\"),\n        };\n        Ok((i, he))\n    }\n}\n\npub fn parse_slide_type(i: &[u8]) -> IResult<&[u8], Option<SlideType>> {\n    map(parse_i8, |t| {\n        log::debug!(\"Parsing slide type {t}\");\n        if (t & 0x01) == 0x01 {\n            Some(SlideType::ShiftSlideTo)\n        } else if (t & 0x02) == 0x02 {\n            Some(SlideType::LegatoSlideTo)\n        } else if (t & 0x04) == 0x04 {\n            Some(SlideType::OutDownwards)\n        } else if (t & 0x08) == 0x08 {\n            Some(SlideType::OutUpWards)\n        } else if (t & 0x10) == 0x10 {\n            Some(SlideType::IntoFromBelow)\n        } else if (t & 0x20) == 0x20 {\n            Some(SlideType::IntoFromAbove)\n        } else {\n            None\n        }\n    })\n    .parse(i)\n}\n\npub fn parse_tremolo_picking(i: &[u8]) -> IResult<&[u8], TremoloPickingEffect> {\n    log::debug!(\"Parsing tremolo picking\");\n    map(parse_u8, |tp| {\n        let value = TremoloPickingEffect::from_tremolo_value(tp as i8);\n        let mut tremolo_picking_effect = TremoloPickingEffect::default();\n        tremolo_picking_effect.duration.value = value;\n        tremolo_picking_effect\n    })\n    .parse(i)\n}\n\npub fn parse_grace_effect(version: GpVersion) -> impl FnMut(&[u8]) -> IResult<&[u8], GraceEffect> {\n    move |i| {\n        log::debug!(\"Parsing grace effect\");\n        let mut i = i;\n        let mut grace_effect = GraceEffect::default();\n\n        // fret\n        let (inner, fret) = parse_u8(i)?;\n        i = inner;\n        grace_effect.fret = fret as i8;\n\n        // velocity\n        let (inner, velocity) = parse_u8(i)?;\n        i = inner;\n        grace_effect.velocity = convert_velocity(i16::from(velocity));\n\n        // transition\n        let (inner, transition) = parse_i8(i)?;\n        i = inner;\n        grace_effect.transition = GraceEffectTransition::get_grace_effect_transition(transition);\n\n        // duration\n        let (inner, duration) = parse_u8(i)?;\n        i = inner;\n        grace_effect.duration = duration;\n\n        if version >= GpVersion::GP5 {\n            // flags\n            let (inner, flags) = parse_u8(i)?;\n            i = inner;\n            grace_effect.is_dead = (flags & 0x01) == 0x01;\n            grace_effect.is_on_beat = (flags & 0x02) == 0x02;\n        }\n\n        Ok((i, grace_effect))\n    }\n}\n\npub fn parse_beat_effects<'a>(\n    beat: &'a mut Beat,\n    note_effect: &'a mut NoteEffect,\n) -> impl FnMut(&[u8]) -> IResult<&[u8], ()> + 'a {\n    move |i| {\n        log::debug!(\"Parsing beat effects\");\n        let mut i = i;\n        let (inner, (flags1, flags2)) = (parse_u8, parse_u8).parse(i)?;\n        i = inner;\n\n        note_effect.fade_in = flags1 & 0x10 != 0;\n        note_effect.vibrato = flags1 & 0x02 != 0;\n\n        if flags1 & 0x20 != 0 {\n            let (inner, effect) = parse_u8(i)?;\n            i = inner;\n            log::debug!(\"Parsing tapping effect {effect}\");\n            note_effect.slap = match effect {\n                1 => SlapEffect::Tapping,\n                2 => SlapEffect::Slapping,\n                3 => SlapEffect::Popping,\n                _ => SlapEffect::None,\n            };\n        }\n\n        if flags2 & 0x04 != 0 {\n            let (inner, effect) = parse_tremolo_bar(i)?;\n            i = inner;\n            note_effect.tremolo_bar = Some(effect);\n        }\n\n        if flags1 & 0x40 != 0 {\n            log::debug!(\"Parsing stroke effect\");\n            let (inner, (stroke_up, stroke_down)) = (parse_i8, parse_i8).parse(i)?;\n            i = inner;\n            if stroke_up > 0 {\n                beat.effect.stroke.value = to_stroke_value(stroke_up);\n                beat.effect.stroke.direction = BeatStrokeDirection::Up;\n            }\n            if stroke_down > 0 {\n                beat.effect.stroke.value = to_stroke_value(stroke_down);\n                beat.effect.stroke.direction = BeatStrokeDirection::Down;\n            }\n        }\n\n        if flags2 & 0x02 != 0 {\n            i = skip(i, 1);\n        }\n\n        Ok((i, ()))\n    }\n}\n\npub fn parse_bend_effect(i: &[u8]) -> IResult<&[u8], BendEffect> {\n    log::debug!(\"Parsing bend effect\");\n    let mut i = skip(i, 5);\n    let mut bend_effect = BendEffect::default();\n    let (inner, num_points) = parse_int(i)?;\n    i = inner;\n    for _ in 0..num_points {\n        let (inner, (bend_position, bend_value, _vibrato)) =\n            (parse_int, parse_int, parse_i8).parse(i)?;\n        i = inner;\n\n        let point_position =\n            bend_position as f32 * BEND_EFFECT_MAX_POSITION_LENGTH / GP_BEND_POSITION;\n        let point_value = bend_value as f32 * SEMITONE_LENGTH / GP_BEND_SEMITONE;\n        bend_effect.points.push(BendPoint {\n            position: point_position.round() as u8,\n            value: point_value.round() as i8,\n        });\n    }\n    Ok((i, bend_effect))\n}\n\npub fn parse_tremolo_bar(i: &[u8]) -> IResult<&[u8], TremoloBarEffect> {\n    log::debug!(\"Parsing tremolo bar\");\n    let mut i = skip(i, 5);\n    let mut tremolo_bar_effect = TremoloBarEffect::default();\n    let (inner, num_points) = parse_int(i)?;\n    i = inner;\n    for _ in 0..num_points {\n        let (inner, (position, value, _vibrato)) = (parse_int, parse_int, parse_i8).parse(i)?;\n        i = inner;\n\n        let point_position = position as f32 * BEND_EFFECT_MAX_POSITION_LENGTH / GP_BEND_POSITION;\n        let point_value = value as f32 / GP_BEND_SEMITONE * 2.0f32;\n        tremolo_bar_effect.points.push(BendPoint {\n            position: point_position.round() as u8,\n            value: point_value.round() as i8,\n        });\n    }\n    Ok((i, tremolo_bar_effect))\n}\n\n/// Read beat duration.\n/// Duration is composed of byte signifying duration and an integer that maps to `Tuplet`. The byte maps to following values:\n///\n/// * *-2*: whole note\n/// * *-1*: half note\n/// * *0*: quarter note\n/// * *1*: eighth note\n/// * *2*: sixteenth note\n/// * *3*: thirty-second note\n///\n/// If flag at *0x20* is true, the tuplet is read\npub fn parse_duration(flags: u8) -> impl FnMut(&[u8]) -> IResult<&[u8], Duration> {\n    move |i: &[u8]| {\n        log::debug!(\"Parsing duration\");\n        let mut i = i;\n        let mut d = Duration::default();\n        let (inner, value) = parse_i8(i)?;\n        i = inner;\n        d.value = (2_u32.pow((value + 4) as u32) / 4) as u16;\n        log::debug!(\"Duration value: {}\", d.value);\n        d.dotted = flags & 0x01 != 0;\n\n        if (flags & 0x20) != 0 {\n            let (inner, i_tuplet) = parse_int(i)?;\n            i = inner;\n\n            match i_tuplet {\n                3 => {\n                    d.tuplet_enters = i_tuplet as u8;\n                    d.tuplet_times = 2;\n                }\n                5..=7 => {\n                    d.tuplet_enters = i_tuplet as u8;\n                    d.tuplet_times = 4;\n                }\n                9..=13 => {\n                    d.tuplet_enters = i_tuplet as u8;\n                    d.tuplet_times = 8;\n                }\n                x => log::debug!(\"Unknown tuplet: {x}\"),\n            }\n        }\n\n        Ok((i, d))\n    }\n}\n\npub fn parse_color(i: &[u8]) -> IResult<&[u8], i32> {\n    log::debug!(\"Parsing RGB color\");\n    map(\n        (parse_u8, parse_u8, parse_u8, parse_u8),\n        |(r, g, b, _alpha)| (i32::from(r) << 16) | (i32::from(g) << 8) | i32::from(b),\n    )\n    .parse(i)\n}\n\npub fn parse_marker(i: &[u8]) -> IResult<&[u8], Marker> {\n    log::debug!(\"Parsing marker\");\n    map(\n        (parse_int_byte_sized_string, parse_color),\n        |(title, color)| Marker { title, color },\n    )\n    .parse(i)\n}\n\npub fn parse_triplet_feel(i: &[u8]) -> IResult<&[u8], TripletFeel> {\n    log::debug!(\"Parsing triplet feel\");\n    map(parse_i8, |triplet_feel| match triplet_feel {\n        0 => TripletFeel::None,\n        1 => TripletFeel::Eighth,\n        2 => TripletFeel::Sixteenth,\n        x => panic!(\"Unknown triplet feel: {x}\"),\n    })\n    .parse(i)\n}\n\n/// Parse measure header.\n/// the time signature is propagated to the next measure\npub fn parse_measure_header(\n    previous_time_signature: TimeSignature,\n    song_tempo: u32,\n    song_version: GpVersion,\n) -> impl FnMut(&[u8]) -> IResult<&[u8], MeasureHeader> {\n    move |i: &[u8]| {\n        log::debug!(\"Parsing measure header\");\n        let (mut i, flags) = parse_u8(i)?;\n        log::debug!(\"Flags: {flags:08b}\");\n        let mut mh = MeasureHeader::default();\n        mh.tempo.value = song_tempo; // value updated later when parsing beats\n        mh.repeat_open = (flags & 0x04) != 0;\n        // propagate time signature\n        mh.time_signature = previous_time_signature.clone();\n\n        // Numerator of the (key) signature\n        if (flags & 0x01) != 0 {\n            log::debug!(\"Parsing numerator\");\n            let (inner, numerator) = parse_i8(i)?;\n            i = inner;\n            mh.time_signature.numerator = numerator as u8;\n        }\n\n        // Denominator of the (key) signature\n        if (flags & 0x02) != 0 {\n            log::debug!(\"Parsing denominator\");\n            let (inner, denominator_value) = parse_i8(i)?;\n            i = inner;\n            let denominator = Duration {\n                value: denominator_value as u16,\n                ..Default::default()\n            };\n            mh.time_signature.denominator = denominator;\n        }\n\n        if song_version >= GpVersion::GP5 {\n            // Beginning of repeat\n            if (flags & 0x08) != 0 {\n                log::debug!(\"Parsing repeat close\");\n                let (inner, repeat_close) = parse_i8(i)?;\n                i = inner;\n                mh.repeat_close = repeat_close - 1; // GP5 specific logic\n            }\n\n            // Presence of a marker\n            if (flags & 0x20) != 0 {\n                let (inner, marker) = parse_marker(i)?;\n                i = inner;\n                mh.marker = Some(marker);\n            }\n\n            // Tonality of the measure\n            if (flags & 0x40) != 0 {\n                log::debug!(\"Parsing key signature\");\n                let (inner, key_signature) = parse_i8(i)?;\n                mh.key_signature.key = key_signature;\n                i = inner;\n                let (inner, is_minor) = parse_i8(i)?;\n                i = inner;\n                mh.key_signature.is_minor = is_minor != 0;\n            }\n\n            if (flags & 0x01) != 0 || (flags & 0x02) != 0 {\n                log::debug!(\"Skip 4\");\n                i = skip(i, 4);\n            }\n\n            // Number of alternate ending\n            if (flags & 0x10) != 0 {\n                log::debug!(\"Parsing repeat alternative\");\n                let (inner, alternative) = parse_u8(i)?;\n                i = inner;\n                mh.repeat_alternative = alternative;\n            }\n\n            if (flags & 0x10) == 0 {\n                log::debug!(\"Skip one\");\n                i = skip(i, 1);\n            }\n\n            // Triplet feel\n            let (inner, triplet_feel) = parse_triplet_feel(i)?;\n            i = inner;\n            mh.triplet_feel = triplet_feel;\n        } else if song_version <= GpVersion::GP4_06 {\n            // Beginning of repeat\n            if (flags & 0x08) != 0 {\n                log::debug!(\"Parsing repeat close\");\n                let (inner, repeat_close) = parse_i8(i)?;\n                i = inner;\n                mh.repeat_close = repeat_close;\n            }\n\n            // Number of alternate ending\n            if (flags & 0x10) != 0 {\n                log::debug!(\"Parsing repeat alternative\");\n                let (inner, alternative) = parse_u8(i)?;\n                i = inner;\n                mh.repeat_alternative = alternative;\n            }\n\n            // Presence of a marker\n            if (flags & 0x20) != 0 {\n                let (inner, marker) = parse_marker(i)?;\n                i = inner;\n                mh.marker = Some(marker);\n            }\n\n            // Tonality of the measure\n            if (flags & 0x40) != 0 {\n                log::debug!(\"Parsing key signature\");\n                let (inner, key_signature) = parse_i8(i)?;\n                mh.key_signature.key = key_signature;\n                i = inner;\n                let (inner, is_minor) = parse_i8(i)?;\n                i = inner;\n                mh.key_signature.is_minor = is_minor != 0;\n            }\n        }\n\n        log::debug!(\"{mh:?}\");\n\n        Ok((i, mh))\n    }\n}\n\npub fn parse_measure_headers(\n    measure_count: i32,\n    song_tempo: u32,\n    version: GpVersion,\n) -> impl FnMut(&[u8]) -> IResult<&[u8], Vec<MeasureHeader>> {\n    move |i: &[u8]| {\n        log::debug!(\"Parsing {measure_count} measure headers\");\n        // parse first header to account for the byte in between each header in GP5\n        let (mut i, first_header) =\n            parse_measure_header(TimeSignature::default(), song_tempo, version)(i)?;\n        let mut previous_time_signature = first_header.time_signature.clone();\n        let mut headers = vec![first_header];\n        for _ in 1..measure_count {\n            let (rest, header) = preceded(\n                cond(version >= GpVersion::GP5, parse_u8),\n                parse_measure_header(previous_time_signature, song_tempo, version),\n            )\n            .parse(i)?;\n            // propagate time signature\n            previous_time_signature = header.time_signature.clone();\n            i = rest;\n            headers.push(header);\n        }\n        debug_assert_eq!(headers.len(), measure_count as usize);\n        Ok((i, headers))\n    }\n}\n\npub fn parse_midi_channels(i: &[u8]) -> IResult<&[u8], Vec<MidiChannel>> {\n    log::debug!(\"Parsing midi channels\");\n    let mut channels = Vec::with_capacity(64);\n    let mut i = i;\n    for channel_index in 0..64 {\n        let (inner, channel) = parse_midi_channel(channel_index)(i)?;\n        i = inner;\n        channels.push(channel);\n    }\n    Ok((i, channels))\n}\n\npub fn parse_midi_channel(channel_id: i32) -> impl FnMut(&[u8]) -> IResult<&[u8], MidiChannel> {\n    move |i: &[u8]| {\n        map(\n            (\n                parse_int, parse_i8, parse_i8, parse_i8, parse_i8, parse_i8, parse_i8, parse_u8,\n                parse_u8,\n            ),\n            |(\n                mut instrument,\n                volume,\n                balance,\n                chorus,\n                reverb,\n                phaser,\n                tremolo,\n                _blank,\n                _blank2,\n            )| {\n                let bank = if channel_id == 9 {\n                    DEFAULT_PERCUSSION_BANK\n                } else {\n                    DEFAULT_BANK\n                };\n                if instrument < 0 {\n                    instrument = 0;\n                }\n                MidiChannel {\n                    channel_id: channel_id as u8,\n                    effect_channel_id: 0, // filled at the track level\n                    instrument,\n                    volume,\n                    balance,\n                    chorus,\n                    reverb,\n                    phaser,\n                    tremolo,\n                    bank,\n                }\n            },\n        )\n        .parse(i)\n    }\n}\n\npub fn parse_page_setup(i: &[u8]) -> IResult<&[u8], PageSetup> {\n    log::debug!(\"Parsing page setup\");\n    map(\n        (\n            parse_point,\n            parse_padding,\n            parse_int,\n            parse_short,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n            parse_int_sized_string,\n        ),\n        |(\n            page_size,\n            page_margin,\n            score_size_proportion,\n            header_and_footer,\n            title,\n            subtitle,\n            artist,\n            album,\n            words,\n            music,\n            word_and_music,\n            copyright_1,\n            copyright_2,\n            page_number,\n        )| PageSetup {\n            page_size,\n            page_margin,\n            score_size_proportion: score_size_proportion as f32 / 100.0,\n            header_and_footer,\n            title,\n            subtitle,\n            artist,\n            album,\n            words,\n            music,\n            word_and_music,\n            copyright: format!(\"{copyright_1}\\n{copyright_2}\"),\n            page_number,\n        },\n    )\n    .parse(i)\n}\n\npub fn parse_point(i: &[u8]) -> IResult<&[u8], Point> {\n    log::debug!(\"Parsing point\");\n    map((parse_int, parse_int), |(x, y)| Point { x, y }).parse(i)\n}\n\npub fn parse_padding(i: &[u8]) -> IResult<&[u8], Padding> {\n    log::debug!(\"Parsing padding\");\n    map(\n        (parse_int, parse_int, parse_int, parse_int),\n        |(right, top, left, bottom)| Padding {\n            right,\n            top,\n            left,\n            bottom,\n        },\n    )\n    .parse(i)\n}\n\npub fn parse_lyrics(i: &[u8]) -> IResult<&[u8], Lyrics> {\n    log::debug!(\"Parsing lyrics\");\n    map(\n        (parse_int, count((parse_int, parse_int_sized_string), 5)),\n        |(track_choice, lines)| Lyrics {\n            track_choice,\n            lines,\n        },\n    )\n    .parse(i)\n}\n\n/// Parse the version string from the file header.\n///\n/// 30 character string (not counting the byte announcing the real length of the string)\n///\n/// <https://dguitar.sourceforge.net/GP4format.html#VERSIONS>\npub fn parse_gp_version(i: &[u8]) -> IResult<&[u8], GpVersion> {\n    log::debug!(\"Parsing GP version\");\n    parse_byte_size_string(30)(i).map(|(i, version_string)| match version_string.as_str() {\n        \"FICHIER GUITAR PRO v3.00\" => (i, GpVersion::GP3),\n        \"FICHIER GUITAR PRO v4.00\" => (i, GpVersion::GP4),\n        \"FICHIER GUITAR PRO v4.06\" => (i, GpVersion::GP4_06),\n        \"FICHIER GUITAR PRO v5.00\" => (i, GpVersion::GP5),\n        \"FICHIER GUITAR PRO v5.10\" => (i, GpVersion::GP5_10),\n        _ => panic!(\"Unsupported GP version: {version_string}\"),\n    })\n}\n\nfn parse_notices(i: &[u8]) -> IResult<&[u8], Vec<String>> {\n    flat_map(parse_int, |notice_count| {\n        log::debug!(\"Notice count: {notice_count}\");\n        count(parse_int_byte_sized_string, notice_count as usize)\n    })\n    .parse(i)\n}\n\n/// Par information about the piece of music.\n/// <https://dguitar.sourceforge.net/GP4format.html#Information_About_the_Piece>\nfn parse_info(version: GpVersion) -> impl FnMut(&[u8]) -> IResult<&[u8], SongInfo> {\n    move |i: &[u8]| {\n        log::debug!(\"Parsing song info\");\n        map(\n            (\n                parse_int_byte_sized_string,\n                parse_int_byte_sized_string,\n                parse_int_byte_sized_string,\n                parse_int_byte_sized_string,\n                parse_int_byte_sized_string,\n                cond(version >= GpVersion::GP5, parse_int_byte_sized_string),\n                parse_int_byte_sized_string,\n                parse_int_byte_sized_string,\n                parse_int_byte_sized_string,\n                parse_notices,\n            ),\n            |(\n                name,\n                subtitle,\n                artist,\n                album,\n                author,\n                words,\n                copyright,\n                writer,\n                instructions,\n                notices,\n            )| {\n                SongInfo {\n                    name,\n                    subtitle,\n                    artist,\n                    album,\n                    author,\n                    words,\n                    copyright,\n                    writer,\n                    instructions,\n                    notices,\n                }\n            },\n        )\n        .parse(i)\n    }\n}\n\npub fn parse_gp_data(file_data: &[u8]) -> Result<Song, RuxError> {\n    let (rest, base_song) = flat_map(parse_gp_version, |version| {\n        map(\n            (\n                parse_info(version),                                     // Song info\n                cond(version < GpVersion::GP5, parse_bool),              // Triplet feel\n                cond(version >= GpVersion::GP4, parse_lyrics),           // Lyrics\n                cond(version >= GpVersion::GP5_10, take(19usize)),       // Skip RSE master effect\n                cond(version >= GpVersion::GP5, parse_page_setup),       // Page setup\n                cond(version >= GpVersion::GP5, parse_int_sized_string), // Tempo name\n                parse_int,                                               // Tempo value\n                cond(version > GpVersion::GP5, parse_bool),              // Tempo hide\n                parse_i8,                                                // Key signature\n                take(3usize),                                            // unknown\n                cond(version > GpVersion::GP3, parse_i8),                // Octave\n                parse_midi_channels,                                     // Midi channels\n            ),\n            move |(\n                song_info,\n                triplet_feel,\n                lyrics,\n                _master_effect,\n                page_setup,\n                tempo_name,\n                tempo,\n                hide_tempo,\n                key_signature,\n                _unknown,\n                octave,\n                midi_channels,\n            )| {\n                // init base song\n                let tempo = Tempo::new(tempo as u32, tempo_name);\n                Song {\n                    version,\n                    song_info,\n                    triplet_feel,\n                    lyrics,\n                    page_setup,\n                    tempo,\n                    hide_tempo,\n                    key_signature,\n                    octave,\n                    midi_channels,\n                    measure_headers: vec![],\n                    tracks: vec![],\n                }\n            },\n        )\n    })\n    .parse(file_data)\n    .map_err(|_err| {\n        log::error!(\"Failed to parse GP data\");\n        RuxError::ParsingError(\"Failed to parse GP data\".to_string())\n    })?;\n\n    // make parser and parse music data\n    let mut parser = MusicParser::new(base_song);\n    let (_rest, ()) = parser.parse_music_data(rest).map_err(|e| {\n        log::error!(\"Failed to parse music data: {e:?}\");\n        RuxError::ParsingError(\"Failed to parse music data\".to_string())\n    })?;\n    let mut song = parser.take_song();\n\n    // For GP4 and earlier, triplet feel is defined at the song level.\n    // Propagate it to all measure headers so the MIDI builder can apply it.\n    if song.version < GpVersion::GP5\n        && let Some(true) = song.triplet_feel\n    {\n        for header in &mut song.measure_headers {\n            header.triplet_feel = TripletFeel::Eighth;\n        }\n    }\n\n    Ok(song)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_gp_ordering() {\n        assert!(GpVersion::GP4 < GpVersion::GP5);\n        assert!(GpVersion::GP5 >= GpVersion::GP5);\n        assert!(GpVersion::GP3 < GpVersion::GP4);\n        assert!(GpVersion::GP3 < GpVersion::GP5);\n    }\n}\n"
  },
  {
    "path": "src/parser/song_parser_tests.rs",
    "content": "#[cfg(test)]\nuse crate::RuxError;\n#[cfg(test)]\nuse crate::parser::song_parser::{Song, parse_gp_data};\n#[cfg(test)]\nuse std::io::Read;\n\n#[cfg(test)]\npub fn parse_gp_file(file_path: &str) -> Result<Song, RuxError> {\n    let mut file = std::fs::File::open(file_path)?;\n    let mut file_data: Vec<u8> = vec![];\n    file.read_to_end(&mut file_data)?;\n    parse_gp_data(&file_data)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::parser::song_parser::{\n        BendEffect, BendPoint, Duration, GpVersion, KeySignature, Marker, NoteType, Padding, Point,\n        TripletFeel,\n    };\n\n    fn init_logger() {\n        env_logger::builder()\n            .is_test(true)\n            .try_init()\n            .unwrap_or_default();\n    }\n\n    fn parse_all_files_successfully(with_extension: &str) {\n        init_logger();\n        let test_dir = std::path::Path::new(\"test-files\");\n        for entry in std::fs::read_dir(test_dir).unwrap() {\n            let entry = entry.unwrap();\n            let path = entry.path();\n            if path.is_dir() {\n                continue;\n            }\n            if path.extension().unwrap() != with_extension {\n                continue;\n            }\n            let file_name = path.file_name().unwrap().to_str().unwrap();\n            eprintln!(\"Parsing file: {file_name}\");\n            let file_path = path.to_str().unwrap();\n            let song = parse_gp_file(file_path)\n                .unwrap_or_else(|err| panic!(\"Failed to parse file: {file_name}\\n{err}\"));\n            // no empty tracks\n            assert!(!song.tracks.is_empty(), \"File: {file_name}\");\n            // assert global invariant across all measures\n            for (t_id, t) in song.tracks.iter().enumerate() {\n                assert_eq!(\n                    t.measures.len(),\n                    song.measure_headers.len(),\n                    \"Track:{t_id} File:{file_name}\"\n                );\n                for (m_id, m) in t.measures.iter().enumerate() {\n                    assert_eq!(\n                        m.track_index, t_id,\n                        \"Track:{t_id} Measure:{m_id} File:{file_name}\"\n                    );\n                    assert_eq!(\n                        m.header_index, m_id,\n                        \"Track:{t_id} Measure:{m_id} File:{file_name}\"\n                    );\n                    let voice_count = if with_extension == \"gp4\" { 1 } else { 2 };\n                    assert_eq!(\n                        m.voices.len(),\n                        voice_count,\n                        \"Track:{t_id} Measure:{m_id} File:{file_name}\"\n                    );\n                    let measure_header = &song.measure_headers[m_id];\n                    let measure_start = measure_header.start;\n                    for v in &m.voices {\n                        v.beats.iter().enumerate().for_each(|(i, b)| {\n                            assert!(\n                                b.start >= measure_start,\n                                \"track:{t_id} measure:{m_id} beat:{i} file:{file_name}\"\n                            );\n                        });\n                    }\n                }\n            }\n        }\n    }\n\n    #[test]\n    fn parse_all_gp5_files_successfully() {\n        parse_all_files_successfully(\"gp5\");\n    }\n\n    #[test]\n    fn parse_all_gp4_files_successfully() {\n        parse_all_files_successfully(\"gp4\");\n    }\n\n    #[test]\n    fn parse_gp4_06_canon_rock() {\n        init_logger();\n        const FILE_PATH: &str = \"test-files/canon_rock.gp4\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        assert_eq!(song.version, GpVersion::GP4_06);\n        assert_eq!(song.tempo.value, 90);\n        assert_eq!(song.tracks.len(), 1);\n        assert_eq!(song.tracks[0].name, \"\\u{ad}µ\\u{ad}y 1\");\n        assert_eq!(song.tracks[0].number, 1);\n        assert_eq!(song.tracks[0].offset, 0);\n        assert_eq!(song.tracks[0].channel_id, 0);\n\n        // inspect headers\n        assert_eq!(song.measure_headers.len(), 220);\n        assert_eq!(song.tracks[0].measures.len(), 220);\n    }\n\n    #[test]\n    fn parse_gp5_00_demo() {\n        init_logger();\n        // test file from https://github.com/slundi/guitarpro/tree/master/test\n        const FILE_PATH: &str = \"test-files/Demo v5.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        assert_eq!(song.version, GpVersion::GP5);\n        assert_eq!(song.tempo.value, 165);\n        assert_eq!(song.tracks.len(), 5);\n        assert_eq!(song.tracks[0].name, \"Rhythm Guitar\");\n        assert_eq!(song.tracks[0].number, 1);\n        assert_eq!(song.tracks[0].offset, 0);\n        assert_eq!(song.tracks[0].channel_id, 0);\n\n        assert_eq!(song.tracks[1].name, \"Solo Guitar\");\n        assert_eq!(song.tracks[1].number, 2);\n        assert_eq!(song.tracks[1].offset, 0);\n        assert_eq!(song.tracks[1].channel_id, 2);\n\n        assert_eq!(song.tracks[2].name, \"Melody\");\n        assert_eq!(song.tracks[2].number, 3);\n        assert_eq!(song.tracks[2].offset, 0);\n        assert_eq!(song.tracks[2].channel_id, 6);\n\n        assert_eq!(song.tracks[3].name, \"Bass\");\n        assert_eq!(song.tracks[3].number, 4);\n        assert_eq!(song.tracks[3].offset, 0);\n        assert_eq!(song.tracks[3].channel_id, 4);\n\n        assert_eq!(song.tracks[4].name, \"Percussions\");\n        assert_eq!(song.tracks[4].number, 5);\n        assert_eq!(song.tracks[4].offset, 0);\n        assert_eq!(song.tracks[4].channel_id, 9);\n\n        // inspect headers\n        assert_eq!(song.measure_headers.len(), 49);\n        assert_eq!(song.tracks[0].measures.len(), 49);\n\n        let header = &song.measure_headers[0];\n        assert_eq!(header.start, 960);\n        assert_eq!(header.tempo.value, 165);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(\n            header.time_signature.denominator,\n            Duration {\n                value: 4,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1,\n            }\n        );\n        assert_eq!(header.time_signature.denominator.time(), 960);\n        // In a 4/4 time signature, the total measure duration is the equivalent of 4 quarter notes.\n        // In this case, the duration of a quarter note is 960 ticks.\n        // Therefore, the total measure duration is 3840 ticks.\n        // 4*960 = 3840\n        assert_eq!(header.length(), 3840);\n        assert_eq!(\n            header.marker,\n            Some(Marker {\n                title: \"Intro\".to_string(),\n                color: 16_711_680\n            })\n        );\n        assert!(header.repeat_open);\n        assert_eq!(header.repeat_close, 0);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        let header = &song.measure_headers[1];\n        // 3840 + 960 (offset of previous measure) = 4800\n        assert_eq!(header.start, 4800);\n        assert_eq!(header.tempo.value, 165);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(header.length(), 3840);\n        assert_eq!(header.marker, None);\n        assert!(!header.repeat_open);\n        assert_eq!(header.repeat_close, 0);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        let header = &song.measure_headers[2];\n        assert_eq!(header.start, 8640);\n        assert_eq!(header.tempo.value, 165);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(header.length(), 3840);\n        assert_eq!(header.marker, None);\n        assert!(!header.repeat_open);\n        assert_eq!(header.repeat_close, 0);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        let header = &song.measure_headers[3];\n        assert_eq!(header.start, 12480);\n        assert_eq!(header.tempo.value, 165);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(header.length(), 3840);\n        assert_eq!(header.marker, None);\n        assert!(!header.repeat_open);\n        assert_eq!(header.repeat_close, 1);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        // first measure\n        let measure = &song.tracks[0].measures[0];\n        assert_eq!(measure.track_index, 0);\n        assert_eq!(measure.voices.len(), 2);\n\n        assert_eq!(measure.voices[1].beats.len(), 1);\n        assert_eq!(measure.voices[1].beats[0].notes.len(), 0);\n        assert_eq!(measure.voices[1].beats[0].start, 960);\n        assert!(measure.voices[1].beats[0].empty);\n\n        assert_eq!(measure.voices[0].beats.len(), 8);\n\n        assert_eq!(measure.voices[0].beats[0].start, 960);\n        // if there are 8 beats per measure, then each beat is an eighth note long (quarter note / 2 == 960/2).\n        assert_eq!(measure.voices[0].beats[0].duration.time(), 480);\n        assert!(!measure.voices[0].beats[0].empty);\n        assert_eq!(measure.voices[0].beats[0].notes.len(), 3); // C5 chord\n\n        assert_eq!(measure.voices[0].beats[1].start, 1440);\n        assert_eq!(measure.voices[0].beats[1].duration.time(), 480);\n        assert!(!measure.voices[0].beats[1].empty);\n        assert_eq!(measure.voices[0].beats[1].notes.len(), 1); // E2 single\n\n        assert_eq!(measure.voices[0].beats[2].start, 1920);\n        assert_eq!(measure.voices[0].beats[2].duration.time(), 480);\n        assert!(!measure.voices[0].beats[2].empty);\n        assert_eq!(measure.voices[0].beats[2].notes.len(), 1); // E2 single\n\n        assert_eq!(measure.voices[0].beats[3].start, 2400);\n        assert_eq!(measure.voices[0].beats[3].duration.time(), 480);\n        assert!(!measure.voices[0].beats[3].empty);\n        assert_eq!(measure.voices[0].beats[3].notes.len(), 3); // C5 chord\n\n        assert_eq!(measure.voices[0].beats[4].start, 2880);\n        assert_eq!(measure.voices[0].beats[4].duration.time(), 480);\n        assert!(!measure.voices[0].beats[4].empty);\n        assert_eq!(measure.voices[0].beats[4].notes.len(), 1); // E2 single\n\n        assert_eq!(measure.voices[0].beats[5].start, 3360);\n        assert_eq!(measure.voices[0].beats[5].duration.time(), 480);\n        assert!(!measure.voices[0].beats[5].empty);\n        assert_eq!(measure.voices[0].beats[5].notes.len(), 1); // E2 single\n\n        assert_eq!(measure.voices[0].beats[6].start, 3840);\n        assert_eq!(measure.voices[0].beats[6].duration.time(), 480);\n        assert!(!measure.voices[0].beats[6].empty);\n        assert_eq!(measure.voices[0].beats[6].notes.len(), 3); // C5 chord\n\n        assert_eq!(measure.voices[0].beats[7].start, 4320);\n        assert_eq!(measure.voices[0].beats[7].duration.time(), 480);\n        assert!(!measure.voices[0].beats[7].empty);\n        assert_eq!(measure.voices[0].beats[7].notes.len(), 1); // E2 single\n\n        // second measure\n        let measure = &song.tracks[0].measures[1];\n        assert_eq!(measure.track_index, 0);\n        assert_eq!(measure.voices.len(), 2);\n\n        assert_eq!(measure.voices[1].beats.len(), 1);\n        assert_eq!(measure.voices[1].beats[0].notes.len(), 0);\n        assert_eq!(measure.voices[1].beats[0].start, 4800);\n        assert!(measure.voices[1].beats[0].empty);\n\n        assert_eq!(measure.voices[0].beats.len(), 8);\n\n        assert_eq!(measure.voices[0].beats[0].start, 4800);\n        assert_eq!(measure.voices[0].beats[0].duration.time(), 480);\n        assert!(!measure.voices[0].beats[0].empty);\n        assert_eq!(measure.voices[0].beats[0].notes.len(), 3); // C5 chord\n\n        assert_eq!(measure.voices[0].beats[1].start, 5280);\n        assert_eq!(measure.voices[0].beats[1].duration.time(), 480);\n        assert!(!measure.voices[0].beats[1].empty);\n        assert_eq!(measure.voices[0].beats[1].notes.len(), 1); // E2 single\n\n        // inspect midi channels\n        assert_eq!(song.midi_channels.len(), 64);\n        assert_eq!(song.midi_channels[0].channel_id, 0);\n        assert_eq!(song.midi_channels[0].effect_channel_id, 1);\n        assert_eq!(song.midi_channels[0].instrument, 29);\n\n        assert_eq!(song.midi_channels[1].channel_id, 1);\n        assert_eq!(song.midi_channels[1].effect_channel_id, 0);\n        assert_eq!(song.midi_channels[1].instrument, 29);\n\n        assert_eq!(song.midi_channels[2].channel_id, 2);\n        assert_eq!(song.midi_channels[2].effect_channel_id, 3);\n        assert_eq!(song.midi_channels[2].instrument, 30);\n\n        assert_eq!(song.midi_channels[3].channel_id, 3);\n        assert_eq!(song.midi_channels[3].effect_channel_id, 0);\n        assert_eq!(song.midi_channels[3].instrument, 30);\n    }\n\n    #[test]\n    fn parse_gp5_10_bleed() {\n        init_logger();\n        const FILE_PATH: &str = \"test-files/Meshuggah - Bleed.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        assert_eq!(song.version, GpVersion::GP5_10);\n        assert_eq!(song.song_info.name, \"Bleed\");\n\n        assert_eq!(song.tracks.len(), 7);\n        assert_eq!(song.tracks[0].name, \"Fredrik Thordendal (Guitar 1)\");\n        assert_eq!(song.tracks[1].name, \"Marten Hagstrom (Guitar 2)\");\n        assert_eq!(song.tracks[2].name, \"Dick Lovgren (Bass)\");\n        assert_eq!(song.tracks[3].name, \"Tomas Haake (Drums)\");\n        assert_eq!(song.tracks[4].name, \"Fredrik Thodendal (Solo/Atmospheric)\");\n        assert_eq!(song.tracks[5].name, \"(bass tone)\");\n        assert_eq!(song.tracks[6].name, \"(more atmosphere)\");\n\n        // inspect headers\n        assert_eq!(song.measure_headers.len(), 209);\n        assert_eq!(song.tracks[0].measures.len(), 209);\n\n        let header = &song.measure_headers[0];\n        assert_eq!(header.start, 960);\n        assert_eq!(header.tempo.value, 115);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(\n            header.time_signature.denominator,\n            Duration {\n                value: 4,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1,\n            }\n        );\n        assert_eq!(header.time_signature.denominator.time(), 960);\n        // In a 4/4 time signature, the total measure duration is the equivalent of 4 quarter notes.\n        // In this case, the duration of a quarter note is 960 ticks.\n        // Therefore, the total measure duration is 3840 ticks.\n        // 4*960 = 3840\n        assert_eq!(header.length(), 3840);\n        assert_eq!(\n            header.marker,\n            Some(Marker {\n                title: \"BLEED\".to_string(),\n                color: 0\n            })\n        );\n        assert!(!header.repeat_open);\n        assert_eq!(header.repeat_close, 0);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        let header = &song.measure_headers[1];\n        // 3840 + 960 (offset of previous measure) = 4800\n        assert_eq!(header.start, 4800);\n        assert_eq!(header.tempo.value, 115);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(header.length(), 3840);\n        assert_eq!(header.marker, None);\n        assert!(!header.repeat_open);\n        assert_eq!(header.repeat_close, 0);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        let header = &song.measure_headers[2];\n        assert_eq!(header.start, 8640);\n        assert_eq!(header.tempo.value, 115);\n        assert_eq!(header.time_signature.numerator, 4);\n        assert_eq!(header.length(), 3840);\n        assert_eq!(header.marker, None);\n        assert!(!header.repeat_open);\n        assert_eq!(header.repeat_close, 0);\n        assert_eq!(header.triplet_feel, TripletFeel::None);\n\n        // second measure with the low bends\n        let measure = &song.tracks[0].measures[1];\n        assert_eq!(measure.track_index, 0);\n        assert_eq!(measure.voices.len(), 2);\n\n        assert_eq!(measure.voices[1].beats.len(), 1);\n        assert_eq!(measure.voices[1].beats[0].notes.len(), 0);\n        assert_eq!(measure.voices[1].beats[0].start, 4800);\n        assert!(measure.voices[1].beats[0].empty);\n\n        assert_eq!(measure.voices[0].beats.len(), 21);\n\n        assert_eq!(measure.voices[0].beats[0].start, 4800);\n        assert_eq!(measure.voices[0].beats[0].duration.time(), 240);\n        assert!(!measure.voices[0].beats[0].empty);\n        assert_eq!(measure.voices[0].beats[0].notes.len(), 1);\n\n        assert_eq!(measure.voices[0].beats[1].start, 5040);\n        assert_eq!(measure.voices[0].beats[1].duration.time(), 240);\n        assert!(!measure.voices[0].beats[1].empty);\n        assert_eq!(measure.voices[0].beats[1].notes.len(), 1);\n\n        assert_eq!(measure.voices[0].beats[2].start, 5280);\n        assert_eq!(measure.voices[0].beats[2].duration.time(), 120);\n        assert!(!measure.voices[0].beats[2].empty);\n        assert_eq!(measure.voices[0].beats[2].notes.len(), 1);\n\n        assert_eq!(measure.voices[0].beats[3].start, 5400);\n        assert_eq!(measure.voices[0].beats[3].duration.time(), 120);\n        assert!(!measure.voices[0].beats[3].empty);\n        assert_eq!(measure.voices[0].beats[3].notes.len(), 1);\n\n        assert_eq!(measure.voices[0].beats[4].start, 5520);\n        assert_eq!(measure.voices[0].beats[4].duration.time(), 240);\n        assert_eq!(\n            measure.voices[0].beats[4].duration,\n            Duration {\n                value: 16,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1\n            }\n        );\n        assert!(!measure.voices[0].beats[4].empty);\n        assert_eq!(measure.voices[0].beats[4].notes.len(), 1);\n        let note = &measure.voices[0].beats[4].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.effect.bend, None);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n\n        assert_eq!(measure.voices[0].beats[5].start, 5760);\n        assert_eq!(measure.voices[0].beats[5].duration.time(), 240);\n        assert_eq!(\n            measure.voices[0].beats[5].duration,\n            Duration {\n                value: 16,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1\n            }\n        );\n        assert!(!measure.voices[0].beats[5].empty);\n        assert_eq!(measure.voices[0].beats[5].notes.len(), 1);\n        let note = &measure.voices[0].beats[5].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n        assert_eq!(\n            note.effect.bend,\n            Some(BendEffect {\n                points: vec![\n                    BendPoint {\n                        position: 0,\n                        value: 0\n                    },\n                    BendPoint {\n                        position: 12,\n                        value: 1\n                    }\n                ]\n            })\n        );\n\n        assert_eq!(measure.voices[0].beats[6].start, 6000);\n        assert_eq!(measure.voices[0].beats[6].duration.time(), 120);\n        assert_eq!(\n            measure.voices[0].beats[6].duration,\n            Duration {\n                value: 32,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1\n            }\n        );\n        assert!(!measure.voices[0].beats[6].empty);\n        assert_eq!(measure.voices[0].beats[6].notes.len(), 1);\n        let note = &measure.voices[0].beats[6].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n        assert_eq!(\n            note.effect.bend,\n            Some(BendEffect {\n                points: vec![\n                    BendPoint {\n                        position: 0,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 12,\n                        value: 1\n                    }\n                ]\n            })\n        );\n\n        assert_eq!(measure.voices[0].beats[7].start, 6120);\n        assert_eq!(measure.voices[0].beats[7].duration.time(), 120);\n        assert!(!measure.voices[0].beats[7].empty);\n        assert_eq!(measure.voices[0].beats[7].notes.len(), 1);\n        let note = &measure.voices[0].beats[7].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n        assert_eq!(\n            note.effect.bend,\n            Some(BendEffect {\n                points: vec![\n                    BendPoint {\n                        position: 0,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 3,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 12,\n                        value: 1\n                    }\n                ]\n            })\n        );\n\n        assert_eq!(measure.voices[0].beats[8].start, 6240);\n        assert_eq!(measure.voices[0].beats[8].duration.time(), 240);\n        assert!(!measure.voices[0].beats[8].empty);\n        assert_eq!(measure.voices[0].beats[8].notes.len(), 1);\n        let note = &measure.voices[0].beats[8].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n        assert_eq!(\n            note.effect.bend,\n            Some(BendEffect {\n                points: vec![\n                    BendPoint {\n                        position: 0,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 12,\n                        value: 1\n                    }\n                ]\n            })\n        );\n\n        assert_eq!(measure.voices[0].beats[9].start, 6480);\n        assert_eq!(measure.voices[0].beats[9].duration.time(), 240);\n        assert!(!measure.voices[0].beats[9].empty);\n        assert_eq!(measure.voices[0].beats[9].notes.len(), 1);\n        let note = &measure.voices[0].beats[9].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n        assert_eq!(\n            note.effect.bend,\n            Some(BendEffect {\n                points: vec![\n                    BendPoint {\n                        position: 0,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 3,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 12,\n                        value: 1\n                    }\n                ]\n            })\n        );\n\n        assert_eq!(measure.voices[0].beats[10].start, 6720);\n        assert_eq!(measure.voices[0].beats[10].start, 6720);\n        assert_eq!(measure.voices[0].beats[10].duration.time(), 120);\n        assert!(!measure.voices[0].beats[10].empty);\n        assert_eq!(measure.voices[0].beats[10].notes.len(), 1);\n        let note = &measure.voices[0].beats[10].notes[0];\n        assert_eq!(note.value, 5);\n        assert_eq!(note.string, 6);\n        assert_eq!(note.velocity, 95);\n        assert_eq!(note.kind, NoteType::Normal);\n        assert!(note.effect.palm_mute);\n        assert_eq!(\n            note.effect.bend,\n            Some(BendEffect {\n                points: vec![\n                    BendPoint {\n                        position: 0,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 3,\n                        value: 1\n                    },\n                    BendPoint {\n                        position: 12,\n                        value: 1\n                    }\n                ]\n            })\n        );\n    }\n\n    #[test]\n    fn parse_gp5_10_ghost() {\n        init_logger();\n        const FILE_PATH: &str = \"test-files/Ghost - Cirice.gp5\";\n        let song = parse_gp_file(FILE_PATH).unwrap();\n        assert_eq!(song.version, GpVersion::GP5_10);\n        assert_eq!(song.song_info.name, \"Cirice\");\n        assert_eq!(song.song_info.subtitle, \"\");\n        assert_eq!(song.song_info.artist, \"Ghost\");\n        assert_eq!(song.song_info.album, \"Meliora\");\n        assert_eq!(song.song_info.author, \"A Ghoul Writer\");\n        assert_eq!(song.song_info.words, Some(\"A Ghoul Writer\".to_string()));\n        assert_eq!(song.song_info.copyright, \"\");\n        assert_eq!(song.song_info.writer, \"TheManPF\");\n        assert_eq!(song.song_info.instructions, \"\");\n        assert!(song.song_info.notices.is_empty());\n        assert_eq!(song.triplet_feel, None);\n        assert!(song.lyrics.is_some());\n        let lyrics = song.lyrics.unwrap();\n        assert_eq!(lyrics.track_choice, 0);\n        assert_eq!(lyrics.lines.len(), 5);\n        assert_eq!(\n            lyrics.lines[0].1,\n            \"I feel your presence amongst us\\r\\nYou cannot hide in the darkness\\r\\nCan you hear the rumble?\\r\\nCan you hear the rumble that's calling?\\r\\n\\r\\nI know your soul is not tainted\\r\\nEven though you've been told so\\r\\nCan you hear the rumble?\\r\\nCan you hear the rumble that's calling?\\r\\n\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\n\\r\\nA candle casting a faint glow\\r\\nYou and I see eye to eye\\r\\nCan you hear the thunder?\\r\\nOh can you hear the thunder that's breaking?\\r\\n\\r\\nNow there is nothing between us\\r\\nFor now our merge is eternal\\r\\nCan't you see that you're lost?\\r\\nCan't you see that you're lost without me?\\r\\n\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\n\\r\\nCan't you see that you're lost without me?\\r\\n\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\n\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\\r\\nI can feel the thunder that's breaking in your heart\\r\\nI can see through the scars inside you\"\n        );\n        assert!(song.page_setup.is_some());\n        let page_setup = song.page_setup.unwrap();\n        assert_eq!(page_setup.page_size, Point { x: 216, y: 279 });\n        assert_eq!(\n            page_setup.page_margin,\n            Padding {\n                right: 10,\n                top: 10,\n                left: 15,\n                bottom: 10\n            }\n        );\n\n        assert_eq!(page_setup.score_size_proportion, 1.0);\n        assert_eq!(page_setup.header_and_footer, 511);\n        assert_eq!(page_setup.title, \"\\u{7}%TITLE%\");\n        assert_eq!(page_setup.subtitle, \"\\n%SUBTITLE%\");\n        assert_eq!(page_setup.artist, \"\\u{8}%ARTIST%\");\n        assert_eq!(page_setup.album, \"\\u{7}%ALBUM%\");\n        assert_eq!(page_setup.words, \"\\u{10}Words by %WORDS%\");\n        assert_eq!(page_setup.music, \"\\u{10}Music by %MUSIC%\");\n        assert_eq!(\n            page_setup.word_and_music,\n            \"\\u{1d}Words & Music by %WORDSMUSIC%\"\n        );\n        assert_eq!(\n            page_setup.copyright,\n            \"\\u{15}Copyright %COPYRIGHT%\\n5All Rights Reserved - International Copyright Secured\"\n        );\n        assert_eq!(page_setup.page_number, \"\\u{c}Page %N%/%P%\");\n\n        assert_eq!(song.tempo.name, Some(\"\\u{8}Moderate\".to_string()));\n        assert_eq!(song.tempo.value, 90);\n        assert_eq!(song.hide_tempo, Some(false));\n        assert_eq!(song.key_signature, 0);\n        assert_eq!(song.octave, Some(0));\n\n        assert_eq!(song.midi_channels.len(), 64);\n        assert_eq!(song.midi_channels[0].effect_channel_id, 0);\n        assert_eq!(song.midi_channels[0].instrument, 25);\n        assert_eq!(song.midi_channels[0].volume, 16);\n        assert_eq!(song.midi_channels[0].balance, 0);\n        assert_eq!(song.midi_channels[0].chorus, 0);\n        assert_eq!(song.midi_channels[0].reverb, 0);\n        assert_eq!(song.midi_channels[0].phaser, 0);\n        assert_eq!(song.midi_channels[0].tremolo, 0);\n        assert_eq!(song.midi_channels[0].bank, 0);\n\n        assert_eq!(song.tracks.len(), 14);\n        assert_eq!(song.tracks[0].name, \"Vocals\");\n        assert_eq!(song.tracks[1].name, \"Acoustic Guitar L\");\n        assert_eq!(song.tracks[2].name, \"Acoustic Guitar R\");\n        assert_eq!(song.tracks[3].name, \"Rythm Guitar L\");\n        assert_eq!(song.tracks[4].name, \"Rythm Guitar R\");\n        assert_eq!(song.tracks[5].name, \"Lead Guitar\");\n        assert_eq!(song.tracks[6].name, \"Bass\");\n        assert_eq!(song.tracks[7].name, \"Piano\");\n        assert_eq!(song.tracks[8].name, \"Organ\");\n        assert_eq!(song.tracks[9].name, \"Synth\");\n        assert_eq!(song.tracks[10].name, \"Strings\");\n        assert_eq!(song.tracks[11].name, \"Drums\");\n        assert_eq!(song.tracks[12].name, \"Timpani\");\n        assert_eq!(song.tracks[13].name, \"Reverse\");\n\n        for t in &song.tracks {\n            assert_eq!(t.measures.len(), song.measure_headers.len());\n        }\n\n        let guitar_track = &song.tracks[2];\n        assert_eq!(guitar_track.name, \"Acoustic Guitar R\");\n        assert_eq!(guitar_track.strings.len(), 6);\n        assert_eq!(guitar_track.strings[0].1, 62);\n        assert_eq!(guitar_track.strings[1].1, 57);\n        assert_eq!(guitar_track.strings[2].1, 53);\n        assert_eq!(guitar_track.strings[3].1, 48);\n        assert_eq!(guitar_track.strings[4].1, 43);\n        assert_eq!(guitar_track.strings[5].1, 38);\n\n        assert_eq!(guitar_track.measures.len(), 124);\n        let measure = &guitar_track.measures[0];\n        assert_eq!(measure.time_signature.numerator, 4);\n        assert_eq!(\n            measure.time_signature.denominator,\n            Duration {\n                value: 4,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1\n            }\n        );\n        assert_eq!(measure.key_signature, KeySignature::new(0, false));\n\n        assert_eq!(measure.voices.len(), 2);\n        for v in &measure.voices {\n            assert_eq!(v.beats.len(), 1);\n            assert_eq!(v.measure_index, 0);\n            assert!(v.beats[0].notes.is_empty());\n            assert!(v.beats[0].empty);\n        }\n        let beat = &measure.voices[1].beats[0];\n        assert_eq!(beat.start, 960);\n        assert_eq!(beat.text, \"\");\n        assert_eq!(\n            beat.duration,\n            Duration {\n                value: 4,\n                dotted: false,\n                double_dotted: false,\n                tuplet_enters: 1,\n                tuplet_times: 1\n            }\n        );\n        assert_eq!(beat.notes.len(), 0);\n    }\n\n    #[test]\n    fn gp_version_ordering() {\n        // GpVersion derives PartialOrd from variant declaration order.\n        // This test ensures the ordering is correct and catches accidental reordering.\n        assert!(GpVersion::GP3 < GpVersion::GP4);\n        assert!(GpVersion::GP4 < GpVersion::GP4_06);\n        assert!(GpVersion::GP4_06 < GpVersion::GP5);\n        assert!(GpVersion::GP5 < GpVersion::GP5_10);\n    }\n}\n"
  },
  {
    "path": "src/ui/application.rs",
    "content": "use iced::advanced::text::Shaping::Auto;\nuse iced::widget::operation::scroll_to;\nuse iced::widget::space::horizontal;\nuse iced::widget::{Id, Text, column, container, pick_list, row, rule, selector, slider, text};\nuse iced::{\n    Alignment, Border, Element, Length, Size, Subscription, Task, Theme, keyboard, stream, window,\n};\nuse std::fmt::Display;\n\nuse crate::ApplicationArgs;\nuse crate::audio::midi_player::AudioPlayer;\nuse crate::audio::playback_order::compute_playback_order;\nuse crate::config::Config;\nuse crate::parser::song_parser::{GpVersion, MeasureHeader, QUARTER_TIME, Song, parse_gp_data};\nuse crate::ui::icons::{open_icon, pause_icon, play_icon, solo_icon, stop_icon};\nuse crate::ui::picker::{FilePickerError, load_file, open_file_dialog};\nuse crate::ui::tablature::Tablature;\nuse crate::ui::tuning::tuning_label;\nuse crate::ui::utils::{action_gated, action_toggle, modal, untitled_text_table_box};\nuse iced::futures::{SinkExt, Stream};\nuse iced::keyboard::key::Named::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp, F11, Space};\nuse iced::widget::scrollable::AbsoluteOffset;\nuse std::path::PathBuf;\nuse std::rc::Rc;\nuse std::sync::Arc;\nuse std::sync::atomic::{AtomicU32, Ordering};\nuse tokio::sync::Notify;\n\nconst ICONS_FONT: &[u8] = include_bytes!(\"../../resources/icons.ttf\");\n\npub struct RuxApplication {\n    song_info: Option<SongDisplayInfo>, // parsed song\n    track_selection: TrackSelection,    // selected track\n    all_tracks: Vec<TrackSelection>,    // all possible tracks\n    tablature: Option<Tablature>,       // loaded tablature\n    tablature_id: Id,                   // tablature container id\n    tempo_selection: TempoSelection,    // tempo percentage for playback\n    audio_player: Option<AudioPlayer>,  // audio player\n    tab_file_is_loading: bool,          // file loading flag in progress\n    sound_font_file: Option<PathBuf>,   // sound font file\n    current_tick: Arc<AtomicU32>,       // latest tick published by audio callback\n    beat_notify: Arc<Notify>,           // wake-up signal from audio callback\n    config: Config,                     // local configuration\n    error_message: Option<String>,      // error message to display\n    is_fullscreen: bool,                // F11 toggles fullscreen + hides chrome\n}\n\n#[derive(Debug)]\nstruct SongDisplayInfo {\n    name: String,\n    artist: String,\n    subtitle: String,\n    album: String,\n    author: String,\n    writer: String,\n    copyright: String,\n    gp_version: GpVersion,\n    file_name: String,\n}\n\nimpl SongDisplayInfo {\n    fn new(song: &Song, file_name: String) -> Self {\n        Self {\n            name: song.song_info.name.clone(),\n            artist: song.song_info.artist.clone(),\n            subtitle: song.song_info.subtitle.clone(),\n            album: song.song_info.album.clone(),\n            author: song.song_info.author.clone(),\n            writer: song.song_info.writer.clone(),\n            copyright: song.song_info.copyright.clone(),\n            gp_version: song.version,\n            file_name,\n        }\n    }\n\n    /// Metadata fields joined with \" \\u{2022} \", skipping empty ones.\n    /// Returns `None` if no metadata is available.\n    fn metadata_line(&self) -> Option<String> {\n        let parts: Vec<&str> = [\n            self.subtitle.as_str(),\n            self.album.as_str(),\n            self.author.as_str(),\n            self.writer.as_str(),\n            self.copyright.as_str(),\n        ]\n        .into_iter()\n        .filter(|s| !s.is_empty())\n        .collect();\n        if parts.is_empty() {\n            None\n        } else {\n            Some(parts.join(\" \\u{2022} \"))\n        }\n    }\n}\n\n#[derive(Debug, Copy, Clone, Eq, PartialEq)]\npub struct TempoSelection {\n    percentage: u32,\n}\n\nimpl Default for TempoSelection {\n    fn default() -> Self {\n        Self::new(100)\n    }\n}\n\nimpl TempoSelection {\n    const fn new(percentage: u32) -> Self {\n        Self { percentage }\n    }\n\n    const PRESET: [Self; 9] = {\n        [\n            Self::new(25),\n            Self::new(50),\n            Self::new(60),\n            Self::new(70),\n            Self::new(80),\n            Self::new(90),\n            Self::new(100),\n            Self::new(150),\n            Self::new(200),\n        ]\n    };\n}\n\nimpl Display for TempoSelection {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}%\", self.percentage)\n    }\n}\n\n#[derive(Debug, Default, Clone, Eq, PartialEq)]\npub struct TrackSelection {\n    index: usize,\n    name: String,\n    tuning: Option<String>,\n}\n\nimpl TrackSelection {\n    const fn new(index: usize, name: String, tuning: Option<String>) -> Self {\n        Self {\n            index,\n            name,\n            tuning,\n        }\n    }\n}\n\nimpl Display for TrackSelection {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{} - {}\", self.index + 1, self.name)?;\n        if let Some(tuning) = &self.tuning {\n            write!(f, \" ({tuning})\")?;\n        }\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone)]\npub enum Message {\n    OpenFileDialog,    // open file dialog\n    OpenFile(PathBuf), // open file path\n    FileOpened(Result<(Vec<u8>, Option<PathBuf>, String), FilePickerError>), // file content, parent folder & file name\n    TrackSelected(TrackSelection),                                           // track selection\n    FocusMeasure(usize),           // used when clicking on measure in tablature\n    FocusTick(u32),                // focus on a specific tick in the tablature\n    NextMeasure,                   // focus next measure\n    PreviousMeasure,               // focus previous measure\n    PlayPause,                     // toggle play/pause\n    StopPlayer,                    // stop playback\n    ToggleSolo,                    // toggle solo mode\n    WindowResized,                 // window resized\n    TablatureResized(Size),        // tablature resized\n    TempoSelected(TempoSelection), // tempo selected\n    IncreaseTempo,                 // increase tempo\n    DecreaseTempo,                 // decrease selection\n    ClearError,                    // clear error message\n    ReportError(String),           // report error message\n    ToggleFullscreen,              // toggle fullscreen + hide chrome\n    MasterVolumeChanged(f32),      // master volume slider (0.0 .. 1.0)\n}\n\nimpl RuxApplication {\n    fn new(sound_font_file: Option<PathBuf>, config: Config) -> Self {\n        Self {\n            song_info: None,\n            track_selection: TrackSelection::default(),\n            all_tracks: vec![],\n            tablature: None,\n            tablature_id: Id::new(\"tablature-outer-container\"),\n            tempo_selection: TempoSelection::default(),\n            audio_player: None,\n            tab_file_is_loading: false,\n            sound_font_file,\n            current_tick: Arc::new(AtomicU32::new(0)),\n            beat_notify: Arc::new(Notify::new()),\n            config,\n            error_message: None,\n            is_fullscreen: false,\n        }\n    }\n\n    fn boot(args: &ApplicationArgs) -> (Self, Task<Message>) {\n        let app = Self::new(args.sound_font_bank.clone(), args.local_config.clone());\n\n        let init_task = args\n            .tab_file_path\n            .as_ref()\n            .map_or_else(Task::none, |f| Task::done(Message::OpenFile(f.clone())));\n        (app, init_task)\n    }\n\n    pub fn start(args: ApplicationArgs) -> iced::Result {\n        let antialiasing = !args.no_antialiasing;\n        iced::application(move || Self::boot(&args), Self::update, Self::view)\n            .title(Self::title)\n            .subscription(Self::subscription)\n            .default_font(iced::Font::MONOSPACE)\n            .theme(Self::theme)\n            .font(ICONS_FONT)\n            .window_size((1150.0, 768.0))\n            .centered()\n            .antialiasing(antialiasing)\n            .run()\n    }\n\n    fn title(&self) -> String {\n        match &self.song_info {\n            Some(song_info) => format!(\"Ruxguitar - {}\", song_info.file_name),\n            None => String::from(\"Ruxguitar - untitled\"),\n        }\n    }\n\n    fn focus_measure_with_scroll(&mut self, measure_id: usize) -> Task<Message> {\n        let Some(tablature) = &mut self.tablature else {\n            return Task::none();\n        };\n        tablature.focus_on_measure(measure_id);\n        let scroll_offset = tablature.scroll_offset_for_measure(measure_id);\n        let scroll_id = tablature.scroll_id.clone();\n        if let Some(audio_player) = &self.audio_player {\n            audio_player.focus_measure(measure_id);\n        }\n        scroll_offset.map_or_else(Task::none, |y| {\n            scroll_to(scroll_id, AbsoluteOffset { x: 0.0, y })\n        })\n    }\n\n    fn update(&mut self, message: Message) -> Task<Message> {\n        match message {\n            Message::TrackSelected(selection) => {\n                if let Some(tablature) = self.tablature.as_mut() {\n                    tablature.update_track(selection.index);\n                }\n                self.track_selection = selection;\n                Task::none()\n            }\n            Message::OpenFileDialog => {\n                if self.tab_file_is_loading {\n                    Task::none()\n                } else {\n                    self.tab_file_is_loading = true;\n                    Task::perform(\n                        open_file_dialog(self.config.get_tabs_folder()),\n                        Message::FileOpened,\n                    )\n                }\n            }\n            Message::OpenFile(path) => {\n                if self.tab_file_is_loading {\n                    Task::none()\n                } else {\n                    self.tab_file_is_loading = true;\n                    Task::perform(load_file(path), Message::FileOpened)\n                }\n            }\n            Message::FileOpened(result) => {\n                self.tab_file_is_loading = false;\n                // stop previous audio player if any\n                if let Some(audio_player) = &mut self.audio_player {\n                    audio_player.stop();\n                }\n                match result {\n                    Ok((contents, parent_folder, file_name)) => {\n                        if let Err(err) = self.config.set_tabs_folder(parent_folder) {\n                            return Task::done(Message::ReportError(format!(\n                                \"Failed to set tabs folder: {err}\"\n                            )));\n                        }\n                        if let Ok(song) = parse_gp_data(&contents) {\n                            // build all tracks selection\n                            let track_selections: Vec<_> = song\n                                .tracks\n                                .iter()\n                                .enumerate()\n                                .map(|(index, track)| {\n                                    let tuning = song\n                                        .midi_channels\n                                        .iter()\n                                        .find(|c| c.channel_id == track.channel_id)\n                                        .filter(|c| !c.is_percussion())\n                                        .and_then(|_| tuning_label(&track.strings));\n                                    TrackSelection::new(index, track.name.clone(), tuning)\n                                })\n                                .collect();\n                            if track_selections.is_empty() {\n                                return Task::done(Message::ReportError(\n                                    \"No tracks found in GP file\".to_string(),\n                                ));\n                            }\n                            self.all_tracks.clone_from(&track_selections);\n                            self.song_info = Some(SongDisplayInfo::new(&song, file_name));\n                            // select first track by default\n                            let default_track = 0;\n                            let default_track_selection = track_selections[default_track].clone();\n                            self.track_selection = default_track_selection;\n                            // share song ownership with tablature and player\n                            let song_rc = Rc::new(song);\n                            let playback_order = compute_playback_order(&song_rc.measure_headers);\n                            let tablature_scroll_id = Id::new(\"tablature-scroll-elements\");\n                            let tablature = Tablature::new(\n                                song_rc.clone(),\n                                default_track,\n                                tablature_scroll_id.clone(),\n                                &playback_order,\n                            );\n                            self.tablature = Some(tablature);\n                            // audio player initialization\n                            match AudioPlayer::new(\n                                song_rc.clone(),\n                                song_rc.tempo.value,\n                                self.tempo_selection.percentage,\n                                self.sound_font_file.clone(),\n                                self.current_tick.clone(),\n                                self.beat_notify.clone(),\n                                &playback_order,\n                            ) {\n                                Ok(audio_player) => {\n                                    self.audio_player = Some(audio_player);\n                                    // reset tablature scroll and trigger layout computation\n                                    Task::batch([\n                                        scroll_to(\n                                            tablature_scroll_id,\n                                            AbsoluteOffset::<f32>::default(),\n                                        ),\n                                        Task::done(Message::WindowResized),\n                                    ])\n                                }\n                                Err(err) => Task::done(Message::ReportError(format!(\n                                    \"Failed to initialize audio: {err}\"\n                                ))),\n                            }\n                        } else {\n                            Task::done(Message::ReportError(\"Failed to parse GP file\".to_string()))\n                        }\n                    }\n                    Err(err) => {\n                        Task::done(Message::ReportError(format!(\"Failed to open file: {err}\")))\n                    }\n                }\n            }\n            Message::FocusMeasure(measure_id) => {\n                // focus measure in tablature\n                if let Some(tablature) = &mut self.tablature {\n                    tablature.focus_on_measure(measure_id);\n                }\n                // focus measure in player\n                if let Some(audio_player) = &self.audio_player {\n                    audio_player.focus_measure(measure_id);\n                }\n                Task::none()\n            }\n            Message::FocusTick(tick) => {\n                if let Some(tablature) = &mut self.tablature\n                    && let Some(scroll_offset) = tablature.focus_on_tick(tick)\n                {\n                    // scroll to the focused measure\n                    return scroll_to(\n                        tablature.scroll_id.clone(),\n                        AbsoluteOffset {\n                            x: 0.0,\n                            y: scroll_offset,\n                        },\n                    );\n                }\n                Task::none()\n            }\n            Message::NextMeasure => {\n                let target = self.tablature.as_ref().and_then(|t| {\n                    let next = t.focused_measure() + 1;\n                    (next < t.measure_count()).then_some(next)\n                });\n                target.map_or_else(Task::none, |m| self.focus_measure_with_scroll(m))\n            }\n            Message::PreviousMeasure => {\n                let target = self\n                    .tablature\n                    .as_ref()\n                    .and_then(|t| t.focused_measure().checked_sub(1));\n                target.map_or_else(Task::none, |m| self.focus_measure_with_scroll(m))\n            }\n            Message::PlayPause => {\n                if self.tab_file_is_loading {\n                    return Task::none();\n                }\n                if let Some(audio_player) = &mut self.audio_player\n                    && let Some(err) = audio_player.toggle_play()\n                {\n                    return Task::done(Message::ReportError(err));\n                }\n                // Hack to make sure the tablature is aware of its size\n                Task::done(Message::WindowResized)\n            }\n            Message::StopPlayer => {\n                if let (Some(audio_player), Some(tablature)) =\n                    (&mut self.audio_player, &mut self.tablature)\n                {\n                    // stop audio player\n                    audio_player.stop();\n                    // reset tablature focus\n                    tablature.focus_on_measure(0);\n                    // reset tablature scroll\n                    scroll_to(\n                        tablature.scroll_id.clone(),\n                        AbsoluteOffset::<f32>::default(),\n                    )\n                } else {\n                    Task::none()\n                }\n            }\n            Message::ToggleSolo => {\n                if let Some(audio_player) = &self.audio_player {\n                    let track = self.track_selection.index;\n                    audio_player.toggle_solo_mode(track);\n                }\n                Task::none()\n            }\n            Message::WindowResized => {\n                // query tablature container size\n                selector::find(self.tablature_id.clone()).map(|rect| {\n                    Message::TablatureResized(rect.unwrap().visible_bounds().unwrap().size())\n                })\n            }\n            Message::TablatureResized(tablature_container_size) => {\n                if let Some(tablature) = &mut self.tablature {\n                    tablature.update_container_width(tablature_container_size.width);\n                }\n                Task::none()\n            }\n            Message::TempoSelected(tempos_selection) => {\n                if let Some(audio_player) = &self.audio_player {\n                    audio_player.set_tempo_percentage(tempos_selection.percentage);\n                }\n                self.tempo_selection = tempos_selection;\n                Task::none()\n            }\n            Message::IncreaseTempo => {\n                if self.tab_file_is_loading {\n                    return Task::none();\n                }\n                if let Some(current_index) = TempoSelection::PRESET\n                    .iter()\n                    .position(|t| t == &self.tempo_selection)\n                    && current_index < TempoSelection::PRESET.len() - 1\n                {\n                    let next_tempo = TempoSelection::PRESET[current_index + 1];\n                    return Task::done(Message::TempoSelected(next_tempo));\n                }\n                Task::none()\n            }\n            Message::DecreaseTempo => {\n                if self.tab_file_is_loading {\n                    return Task::none();\n                }\n                if let Some(current_index) = TempoSelection::PRESET\n                    .iter()\n                    .position(|t| t == &self.tempo_selection)\n                    && current_index > 0\n                {\n                    let previous_tempo = TempoSelection::PRESET[current_index - 1];\n                    return Task::done(Message::TempoSelected(previous_tempo));\n                }\n                Task::none()\n            }\n            Message::ToggleFullscreen => {\n                self.is_fullscreen = !self.is_fullscreen;\n                let mode = if self.is_fullscreen {\n                    window::Mode::Fullscreen\n                } else {\n                    window::Mode::Windowed\n                };\n                window::latest().and_then(move |id| window::set_mode(id, mode))\n            }\n            Message::MasterVolumeChanged(volume) => {\n                if let Some(audio_player) = &self.audio_player {\n                    audio_player.set_master_volume(volume);\n                }\n                Task::none()\n            }\n            Message::ClearError => {\n                self.error_message = None;\n                Task::none()\n            }\n            Message::ReportError(error) => {\n                log::warn!(\"{error}\");\n                self.error_message = Some(error);\n                Task::none()\n            }\n        }\n    }\n\n    fn view(&self) -> Element<'_, Message> {\n        let open_file = action_gated(\n            open_icon(),\n            \"Open file\",\n            (!self.tab_file_is_loading).then_some(Message::OpenFileDialog),\n        );\n\n        let player_control = if let Some(audio_player) = &self.audio_player {\n            let (icon, message) = if audio_player.is_playing() {\n                (pause_icon(), \"Pause\")\n            } else {\n                (play_icon(), \"Play\")\n            };\n            let play_button = action_gated(icon, message, Some(Message::PlayPause));\n            let stop_button = action_gated(stop_icon(), \"Stop\", Some(Message::StopPlayer));\n            let counter = self\n                .tablature\n                .as_ref()\n                .map(|tab| {\n                    let headers = &tab.song.measure_headers;\n                    let focused = tab.focused_measure();\n                    let total_measures = tab.measure_count();\n                    let current_seconds = song_time_up_to_measure(headers, focused);\n                    let total_seconds = song_time_up_to_measure(headers, total_measures);\n                    format!(\n                        \"Measure {}/{} \\u{2022} {}/{}\",\n                        focused + 1,\n                        total_measures,\n                        format_mmss(current_seconds),\n                        format_mmss(total_seconds),\n                    )\n                })\n                .unwrap_or_default();\n            row![play_button, stop_button, text(counter).size(14)]\n                .spacing(10)\n                .align_y(Alignment::Center)\n        } else {\n            row![horizontal()]\n        };\n\n        let track_control = if self.all_tracks.is_empty() {\n            row![horizontal()]\n        } else {\n            let tempo_label = text(\"Tempo\").size(14);\n            let tempo_percentage = pick_list(\n                TempoSelection::PRESET,\n                Some(&self.tempo_selection),\n                Message::TempoSelected,\n            )\n            .text_size(14)\n            .padding([5, 10]);\n\n            let solo_mode = action_toggle(\n                solo_icon(),\n                \"Solo\",\n                Message::ToggleSolo,\n                self.audio_player\n                    .as_ref()\n                    .is_some_and(|p| p.solo_track_id().is_some()),\n            );\n\n            let track_pick_list = pick_list(\n                self.all_tracks.as_slice(),\n                Some(&self.track_selection),\n                Message::TrackSelected,\n            )\n            .text_size(14)\n            .padding([5, 10]);\n\n            let volume_label = text(\"Volume\").size(14);\n            let current_volume = self\n                .audio_player\n                .as_ref()\n                .map_or(1.0, AudioPlayer::master_volume);\n            let volume_slider = slider(0.0..=1.0, current_volume, Message::MasterVolumeChanged)\n                .step(0.01_f32)\n                .width(100);\n\n            row![\n                tempo_label,\n                tempo_percentage,\n                volume_label,\n                volume_slider,\n                solo_mode,\n                track_pick_list,\n            ]\n            .spacing(10)\n            .align_y(Alignment::Center)\n        };\n\n        let controls = row![\n            open_file,\n            horizontal(),\n            player_control,\n            horizontal(),\n            track_control,\n        ]\n        .spacing(10)\n        .align_y(Alignment::Center);\n\n        let controls = container(controls)\n            .padding(10)\n            .style(|_theme| container::Style {\n                border: Border::default()\n                    .color(crate::ui::utils::COLOR_GRAY)\n                    .width(1),\n                ..Default::default()\n            });\n\n        let song_info = if let Some(song) = &self.song_info {\n            if !song.artist.is_empty() {\n                format!(\"{} - {}\", song.name, song.artist)\n            } else {\n                song.name.clone()\n            }\n        } else {\n            String::new()\n        };\n        let metadata_line = self\n            .song_info\n            .as_ref()\n            .and_then(SongDisplayInfo::metadata_line)\n            .unwrap_or_default();\n\n        let gp_version = if let Some(song) = &self.song_info {\n            format!(\"{:?}\", song.gp_version)\n        } else {\n            String::new()\n        };\n        let status = row![\n            container(Text::new(song_info).shaping(Auto))\n                .width(Length::FillPortion(1))\n                .align_x(Alignment::Start),\n            container(Text::new(metadata_line).shaping(Auto))\n                .width(Length::FillPortion(1))\n                .align_x(Alignment::Center),\n            container(text(gp_version))\n                .width(Length::FillPortion(1))\n                .align_x(Alignment::End),\n        ]\n        .spacing(10);\n\n        let status = container(status).padding(4);\n\n        let tablature_view = self\n            .tablature\n            .as_ref()\n            .map_or_else(|| untitled_text_table_box().into(), |t| t.view());\n\n        let tablature = container(tablature_view).id(self.tablature_id.clone());\n\n        let base: Element<Message> = if self.is_fullscreen {\n            column![tablature].spacing(20).padding(10).into()\n        } else {\n            column![controls, tablature, rule::horizontal(1), status,]\n                .spacing(20)\n                .padding(10)\n                .into()\n        };\n\n        // add error modal if any\n        if let Some(error_message) = &self.error_message {\n            let error_view = text(error_message).size(20);\n            modal(base, error_view, Message::ClearError)\n        } else {\n            base\n        }\n    }\n\n    #[allow(clippy::unused_self)]\n    const fn theme(&self) -> Theme {\n        Theme::Dark\n    }\n\n    fn audio_player_beat_subscription(\n        current_tick: Arc<AtomicU32>,\n        beat_notify: Arc<Notify>,\n    ) -> impl Stream<Item = Message> {\n        stream::channel(1, async move |mut output| {\n            loop {\n                beat_notify.notified().await;\n                let tick = current_tick.load(Ordering::Acquire);\n                output\n                    .send(Message::FocusTick(tick))\n                    .await\n                    .expect(\"send failed\");\n            }\n        })\n    }\n\n    fn subscription(&self) -> Subscription<Message> {\n        let mut subscriptions = Vec::with_capacity(4);\n\n        // keyboard event subscription\n        let keyboard_subscription = keyboard::listen().filter_map(|event| {\n            let keyboard::Event::KeyPressed {\n                modified_key,\n                modifiers,\n                ..\n            } = event\n            else {\n                return None;\n            };\n            match modified_key.as_ref() {\n                keyboard::Key::Named(Space) => Some(Message::PlayPause),\n                keyboard::Key::Named(ArrowUp) if modifiers.control() => {\n                    Some(Message::IncreaseTempo)\n                }\n                keyboard::Key::Named(ArrowDown) if modifiers.control() => {\n                    Some(Message::DecreaseTempo)\n                }\n                keyboard::Key::Named(ArrowLeft) => Some(Message::PreviousMeasure),\n                keyboard::Key::Named(ArrowRight) => Some(Message::NextMeasure),\n                keyboard::Key::Character(c) if c.eq_ignore_ascii_case(\"s\") => {\n                    Some(Message::ToggleSolo)\n                }\n                keyboard::Key::Named(F11) => Some(Message::ToggleFullscreen),\n                _ => None,\n            }\n        });\n        subscriptions.push(keyboard_subscription);\n\n        // next beat notifier subscription\n        subscriptions.push(Subscription::run_with(\n            BeatSubscriptionData(self.current_tick.clone(), self.beat_notify.clone()),\n            |data| Self::audio_player_beat_subscription(data.0.clone(), data.1.clone()),\n        ));\n\n        let window_resized = window::resize_events().map(|_| Message::WindowResized);\n        subscriptions.push(window_resized);\n\n        let file_dropped = window::events().filter_map(|(_, event)| {\n            if let window::Event::FileDropped(path) = event {\n                Some(Message::OpenFile(path))\n            } else {\n                None\n            }\n        });\n        subscriptions.push(file_dropped);\n\n        Subscription::batch(subscriptions)\n    }\n}\n\n/// Seconds elapsed from the song's start up to (but not including) `measure_idx`.\n/// Tempo changes across measures are honored. Repeats are ignored — we compute\n/// the song's linear duration, not expanded playback time.\nfn song_time_up_to_measure(headers: &[MeasureHeader], measure_idx: usize) -> f32 {\n    headers\n        .iter()\n        .take(measure_idx)\n        .enumerate()\n        .map(|(i, h)| {\n            let next_start = headers\n                .get(i + 1)\n                .map_or(h.start + h.length(), |next| next.start);\n            let duration_ticks = next_start.saturating_sub(h.start) as f32;\n            duration_ticks / QUARTER_TIME as f32 * 60.0 / h.tempo.value as f32\n        })\n        .sum()\n}\n\nfn format_mmss(seconds: f32) -> String {\n    let total = seconds.max(0.0) as u32;\n    format!(\"{}:{:02}\", total / 60, total % 60)\n}\n\nstruct BeatSubscriptionData(Arc<AtomicU32>, Arc<Notify>);\n\nimpl std::hash::Hash for BeatSubscriptionData {\n    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {\n        \"beat-subscription\".hash(state); // The ID is constant\n    }\n}\n\nimpl PartialEq for BeatSubscriptionData {\n    fn eq(&self, _other: &Self) -> bool {\n        true\n    }\n}\n\nimpl Eq for BeatSubscriptionData {}\n"
  },
  {
    "path": "src/ui/canvas_measure.rs",
    "content": "use crate::parser::song_parser::{\n    Beat, BeatStrokeDirection, HarmonicType, Note, NoteEffect, NoteType, SlapEffect, SlideType,\n    Song, TimeSignature,\n};\nuse crate::ui::application::Message;\nuse iced::advanced::mouse;\nuse iced::advanced::text::Shaping::Auto;\nuse iced::mouse::{Cursor, Interaction};\nuse iced::widget::canvas::{Cache, Event, Frame, Geometry, Path, Stroke, Text};\nuse iced::widget::text::Alignment;\nuse iced::widget::{Action, Canvas, canvas};\nuse iced::{Color, Element, Length, Point, Rectangle, Renderer, Size, Theme};\nuse std::rc::Rc;\n\n// Unicode symbols for musical notation\nconst TEMPO_SIGN: char = '\\u{1D15F}'; // 𝅗𝅥 https://unicodeplus.com/U+1D15F\nconst VIBRATO: char = '\\u{301C}'; // 〜 https://unicodeplus.com/U+301C\nconst HAMMER_ON: char = '\\u{25E0}'; // ◠ https://unicodeplus.com/U+25E0\nconst HORIZONTAL_BAR: char = '\\u{2015}'; // ― https://unicodeplus.com/U+2015\nconst SHIFT_SLIDE: char = '\\u{27CD}'; // ⟍ https://unicodeplus.com/U+27CD\nconst LEGATO_SLIDE: char = '\\u{27CB}'; // ⟋ https://unicodeplus.com/U+27CB\nconst ARROW_UP: char = '\\u{2191}'; // ↑ https://unicodeplus.com/U+2191\nconst ARROW_DOWN: char = '\\u{2193}'; // ↓ https://unicodeplus.com/U+2193\nconst TIE: char = '\\u{2323}'; // ⌣ https://unicodeplus.com/U+2323\n\n// Drawing constants\n\n// Vertical layout above the staff (y grows downward):\n//   y=3   MEASURE_ANNOTATION_Y   measure number / marker title\n//   y=15  CHORD_ANNOTATION_Y     chord name\n//   y=27  NOTE_EFFECT_ANNOTATION_Y  vibrato / hammer / slide labels\n//   y=38  BEAT_TEXT_ANNOTATION_Y beat.text (\"Verse\", \"fill\", ...)\n//   y=60  FIRST_STRING_Y         first tab line (leaves room for the\n//                                focus box to not sit on the first string)\nconst MEASURE_ANNOTATION_Y: f32 = 3.0;\nconst CHORD_ANNOTATION_Y: f32 = 15.0;\nconst NOTE_EFFECT_ANNOTATION_Y: f32 = 27.0;\nconst BEAT_TEXT_ANNOTATION_Y: f32 = 38.0;\nconst FIRST_STRING_Y: f32 = 60.0;\n\n// Space below the last string (just enough for focus box clearance).\nconst BOTTOM_PADDING: f32 = 16.0;\n\n// Distance between strings\nconst STRING_LINE_HEIGHT: f32 = 13.0;\n\n// Measure notes padding\nconst MEASURE_NOTES_PADDING: f32 = 20.0;\n\n// Length of a beat\nconst BEAT_LENGTH: f32 = 24.0;\n\nconst HALF_BEAT_LENGTH: f32 = BEAT_LENGTH / 2.0 + 1.0;\n\n// minimum measure width\nconst MIN_MEASURE_WIDTH: f32 = 60.0;\n\n#[derive(Debug)]\npub struct CanvasMeasure {\n    pub measure_id: usize,\n    track_id: usize,\n    song: Rc<Song>,\n    is_focused: bool,\n    focused_beat: usize,\n    canvas_cache: Cache,\n    measure_len: f32,\n    pub total_measure_len: f32,\n    pub vertical_measure_height: f32,\n    has_time_signature: bool,\n    pub is_first_on_line: bool,\n}\n\nimpl CanvasMeasure {\n    pub fn new(\n        measure_id: usize,\n        track_id: usize,\n        song: Rc<Song>,\n        focused: bool,\n        has_time_signature: bool,\n    ) -> Self {\n        let track = &song.tracks[track_id];\n        let measure = &track.measures[measure_id];\n        let measure_header = &song.measure_headers[measure_id];\n        let beat_count = measure.voices[0].beats.len();\n        let measure_len = MIN_MEASURE_WIDTH.max(beat_count as f32 * BEAT_LENGTH);\n        // total length of measure (padding on both sides)\n        let mut total_measure_len = measure_len + MEASURE_NOTES_PADDING * 2.0;\n        // extra space for time signature\n        if has_time_signature {\n            total_measure_len += BEAT_LENGTH;\n        }\n        // extra space for repeat open bar with dots\n        if measure_header.repeat_open {\n            total_measure_len += BEAT_LENGTH + HALF_BEAT_LENGTH;\n        }\n        // extra space for repeat close bar with dots\n        if measure_header.repeat_close > 0 {\n            total_measure_len += BEAT_LENGTH + HALF_BEAT_LENGTH;\n        }\n        let string_count = track.strings.len();\n        // total height of measure (same for all measures in track)\n        let vertical_measure_height = STRING_LINE_HEIGHT * (string_count - 1) as f32;\n        let vertical_measure_height = vertical_measure_height + FIRST_STRING_Y + BOTTOM_PADDING;\n        Self {\n            measure_id,\n            track_id,\n            song,\n            is_focused: focused,\n            focused_beat: 0,\n            canvas_cache: Cache::default(),\n            measure_len,\n            total_measure_len,\n            vertical_measure_height,\n            has_time_signature,\n            is_first_on_line: false,\n        }\n    }\n\n    pub const fn set_first_on_line(&mut self, value: bool) {\n        self.is_first_on_line = value;\n    }\n\n    pub fn view(&self) -> Element<'_, Message> {\n        let canvas = Canvas::new(self)\n            .height(self.vertical_measure_height)\n            .width(Length::Fixed(self.total_measure_len));\n        canvas.into()\n    }\n\n    /// View with FillPortion width for stretching to fill the row.\n    /// The portion is proportional to the natural width of the measure.\n    pub fn view_fill(&self) -> Element<'_, Message> {\n        // Use total_measure_len as the portion weight (rounded to u16)\n        let portion = (self.total_measure_len.round() as u16).max(1);\n        let canvas = Canvas::new(self)\n            .height(self.vertical_measure_height)\n            .width(Length::FillPortion(portion));\n        canvas.into()\n    }\n\n    /// The fixed overhead width (padding, time signature, repeats) that doesn't scale with beats.\n    fn overhead_width(&self) -> f32 {\n        self.total_measure_len - self.measure_len\n    }\n\n    pub fn toggle_focused(&mut self) {\n        // reset focus state\n        self.is_focused = !self.is_focused;\n        self.focused_beat = 0;\n        // clear cache\n        self.canvas_cache.clear();\n    }\n\n    pub fn focus_beat(&mut self, beat_id: usize) {\n        if self.focused_beat != beat_id {\n            self.focused_beat = beat_id;\n            self.canvas_cache.clear();\n        }\n    }\n\n    pub fn clear_canvas_cache(&self) {\n        self.canvas_cache.clear();\n    }\n}\n\n#[derive(Debug, Default)]\npub enum MeasureInteraction {\n    #[default]\n    None,\n    Clicked,\n}\n\nimpl canvas::Program<Message> for CanvasMeasure {\n    type State = MeasureInteraction;\n\n    fn update(\n        &self,\n        state: &mut Self::State,\n        event: &Event,\n        bounds: Rectangle,\n        cursor: Cursor,\n    ) -> Option<Action<Message>> {\n        if let Event::Mouse(mouse::Event::ButtonPressed(_)) = event\n            && let Some(_cursor_position) = cursor.position_in(bounds)\n        {\n            log::info!(\"Clicked on measure {:?}\", self.measure_id);\n            *state = MeasureInteraction::Clicked;\n            return Some(Action::publish(Message::FocusMeasure(self.measure_id)));\n        }\n        None\n    }\n\n    fn draw(\n        &self,\n        _state: &Self::State,\n        renderer: &Renderer,\n        _theme: &Theme,\n        bounds: Rectangle,\n        _cursor: Cursor,\n    ) -> Vec<Geometry> {\n        // the cache will not redraw its geometry unless the dimensions of its layer change, or it is explicitly cleared.\n        let tab = self.canvas_cache.draw(renderer, bounds.size(), |frame| {\n            log::debug!(\"Re-drawing measure {}\", self.measure_id);\n            let track = &self.song.tracks[self.track_id];\n            let strings = &track.strings;\n            let string_count = strings.len();\n\n            // use actual allocated width (may be larger than total_measure_len due to FillPortion)\n            let actual_width = frame.width();\n            // scale beat area: extra width goes to beat spacing\n            let actual_measure_len = actual_width - self.overhead_width();\n\n            // distance between lines of measures\n            let vertical_measure_height = STRING_LINE_HEIGHT * (string_count - 1) as f32;\n\n            // Positive x-values extend to the right, and positive y-values extend downwards.\n            let measure_start_x = 0.0;\n            let measure_start_y = FIRST_STRING_Y;\n\n            // colors\n            let color_gray = crate::ui::utils::COLOR_GRAY;\n            let color_dark_red = crate::ui::utils::COLOR_DARK_RED;\n\n            // draw focused box\n            if self.is_focused {\n                draw_focused_box(\n                    frame,\n                    actual_width,\n                    vertical_measure_height,\n                    measure_start_x,\n                    measure_start_y,\n                );\n            }\n\n            // draw string lines first (apply rest on top)\n            for (string_id, _fret) in strings.iter().enumerate() {\n                // down position\n                let local_start_y = string_id as f32 * STRING_LINE_HEIGHT;\n                // add 1 to x to avoid overlapping with vertical line\n                let start_point =\n                    Point::new(measure_start_x + 1.0, measure_start_y + local_start_y);\n                // draw at the same y until end of container\n                let end_point = Point::new(\n                    measure_start_x + actual_width,\n                    measure_start_y + local_start_y,\n                );\n                let line = Path::line(start_point, end_point);\n                let stroke = Stroke::default().with_width(0.8).with_color(color_gray);\n                frame.stroke(&line, stroke);\n            }\n\n            // measure headers\n            let measure_header = &self.song.measure_headers[self.measure_id];\n            let next_measure_header = &self.song.measure_headers.get(self.measure_id + 1);\n            let previous_measure_header = if self.measure_id > 0 {\n                Some(&self.song.measure_headers[self.measure_id - 1])\n            } else {\n                None\n            };\n\n            // display open measure bar\n            if measure_header.repeat_open {\n                draw_open_repeat(\n                    frame,\n                    measure_start_x,\n                    measure_start_y,\n                    vertical_measure_height,\n                );\n            } else if self.measure_id == 0 {\n                draw_open_section(\n                    frame,\n                    measure_start_x,\n                    measure_start_y,\n                    vertical_measure_height,\n                );\n            } else {\n                // draw first vertical line only for the first measure on a row\n                // otherwise it doubles with the end line of the previous measure\n                if self.is_first_on_line {\n                    draw_measure_vertical_line(\n                        frame,\n                        vertical_measure_height,\n                        measure_start_x,\n                        measure_start_y,\n                    );\n                }\n            }\n\n            // display time signature (if first measure OR if it changed)\n            if self.has_time_signature {\n                draw_time_signature(\n                    frame,\n                    &measure_header.time_signature,\n                    measure_start_x,\n                    string_count,\n                    measure_header.repeat_open, // need to offset if repeat dots present\n                );\n            }\n\n            // capture tempo label len to adjust next annotations\n            let mut tempo_label_len = 0;\n            // display measure tempo (if first measure OR if it changed)\n            if self.measure_id == 0\n                || measure_header.tempo != previous_measure_header.unwrap().tempo\n            {\n                let tempo_sign = TEMPO_SIGN;\n                let tempo_label = format!(\"{} = {}\", tempo_sign, measure_header.tempo.value);\n                tempo_label_len = tempo_label.chars().count() * 10;\n                let tempo_text = Text {\n                    shaping: Auto,\n                    content: tempo_label,\n                    color: Color::WHITE,\n                    size: 11.0.into(),\n                    position: Point::new(measure_start_x, MEASURE_ANNOTATION_Y),\n                    ..Text::default()\n                };\n                frame.fill_text(tempo_text);\n            }\n\n            // marker annotation\n            if let Some(marker) = &measure_header.marker {\n                // measure marker label\n                let marker_text = Text {\n                    shaping: Auto,\n                    content: marker.title.clone(),\n                    color: color_dark_red,\n                    size: 10.0.into(),\n                    position: Point::new(\n                        measure_start_x + MEASURE_NOTES_PADDING + tempo_label_len as f32,\n                        MEASURE_ANNOTATION_Y,\n                    ),\n                    ..Text::default()\n                };\n                frame.fill_text(marker_text);\n            }\n\n            // measure count label\n            let measure_count_text = Text {\n                shaping: Auto,\n                content: format!(\"{}\", self.measure_id + 1),\n                color: color_dark_red,\n                size: 10.0.into(),\n                position: Point::new(measure_start_x, FIRST_STRING_Y - 15.0),\n                ..Text::default()\n            };\n            frame.fill_text(measure_count_text);\n\n            // alternative ending bracket (e.g., \"1.\", \"2.\", \"1.2.\")\n            if measure_header.repeat_alternative > 0 {\n                draw_alternative_ending(\n                    frame,\n                    measure_header.repeat_alternative,\n                    measure_start_x,\n                    actual_width,\n                );\n            }\n\n            // add notes on top of strings\n            let measure = &track.measures[self.measure_id];\n            // TODO draw second voice if present (audio playback already handles all voices)\n            let beats = &measure.voices[0].beats;\n            let beats_len = beats.len();\n            log::debug!(\"{beats_len} beats\");\n            let mut beat_start = measure_start_x;\n            if self.has_time_signature {\n                beat_start += BEAT_LENGTH;\n            }\n            if measure_header.repeat_open {\n                beat_start += BEAT_LENGTH;\n            }\n            for (b_id, beat) in beats.iter().enumerate() {\n                // pick color if beat under focus\n                let beat_color = if self.is_focused && b_id == self.focused_beat {\n                    color_dark_red\n                } else {\n                    Color::WHITE\n                };\n                // draw beat\n                draw_beat(\n                    frame,\n                    actual_measure_len,\n                    beat_start,\n                    measure_start_y,\n                    beats_len,\n                    b_id,\n                    beat,\n                    beat_color,\n                );\n            }\n\n            // draw close measure\n            if measure_header.repeat_close > 0 {\n                draw_close_repeat(\n                    frame,\n                    measure_start_x + actual_width,\n                    measure_start_y,\n                    vertical_measure_height,\n                    measure_header.repeat_close,\n                );\n            } else if next_measure_header.is_none() {\n                draw_end_section(\n                    frame,\n                    measure_start_x + actual_width,\n                    measure_start_y,\n                    vertical_measure_height,\n                );\n            } else {\n                // vertical measure end\n                draw_measure_vertical_line(\n                    frame,\n                    vertical_measure_height,\n                    measure_start_x + actual_width, // end of measure\n                    measure_start_y,\n                );\n            }\n        });\n\n        vec![tab]\n    }\n\n    fn mouse_interaction(\n        &self,\n        _state: &Self::State,\n        _bounds: Rectangle,\n        _cursor: Cursor,\n    ) -> Interaction {\n        Interaction::default()\n    }\n}\n\nfn draw_focused_box(\n    frame: &mut Frame<Renderer>,\n    total_measure_len: f32,\n    vertical_measure_height: f32,\n    measure_start_x: f32,\n    measure_start_y: f32,\n) {\n    let padding = 8.0;\n\n    let focused_box = Rectangle {\n        x: measure_start_x + padding,\n        y: measure_start_y - padding,\n        width: total_measure_len - padding * 2.0,\n        height: vertical_measure_height + padding * 2.0,\n    };\n\n    let Rectangle {\n        x,\n        y,\n        width,\n        height,\n    } = focused_box;\n\n    let top_left = Point::new(x, y);\n    let rectangle_size = Size::new(width, height);\n    let stroke = Stroke::default().with_width(1.0).with_color(Color::WHITE);\n    frame.stroke_rectangle(top_left, rectangle_size, stroke);\n}\n\nfn draw_measure_vertical_line(\n    frame: &mut Frame<Renderer>,\n    vertical_measure_height: f32,\n    measure_start_x: f32,\n    measure_start_y: f32,\n) {\n    let start_point = Point::new(measure_start_x, measure_start_y);\n    let end_point = Point::new(measure_start_x, measure_start_y + vertical_measure_height);\n    let vertical_line = Path::line(start_point, end_point);\n    let stroke = Stroke::default().with_width(1.5).with_color(Color::WHITE);\n    frame.stroke(&vertical_line, stroke);\n}\n\n#[allow(clippy::too_many_arguments)]\nfn draw_beat(\n    frame: &mut Frame<Renderer>,\n    measure_len: f32,\n    measure_start_x: f32,\n    measure_start_y: f32,\n    beats_len: usize,\n    b_id: usize,\n    beat: &Beat,\n    beat_color: Color,\n) {\n    // position to draw beat\n    let width_per_beat = measure_len / beats_len as f32;\n    let beat_position_offset = b_id as f32 * width_per_beat;\n    let beat_position_x = measure_start_x + MEASURE_NOTES_PADDING + beat_position_offset;\n\n    // Annotate chord effect\n    if let Some(chord) = &beat.effect.chord {\n        let note_effect_text = Text {\n            shaping: Auto,\n            content: chord.name.clone(),\n            color: Color::WHITE,\n            size: 8.0.into(),\n            position: Point::new(beat_position_x + 3.0, CHORD_ANNOTATION_Y),\n            ..Text::default()\n        };\n        frame.fill_text(note_effect_text);\n    }\n    if !beat.effect.stroke.is_empty() && !beat.notes.is_empty() {\n        draw_stroke_arrow(frame, beat, beat_position_x, measure_start_y);\n    }\n\n    // Annotate note effect above (same position for all notes)\n    let mut beat_annotations = Vec::new();\n\n    // draw notes for beat\n    for note in &beat.notes {\n        beat_annotations.extend(above_note_effect_annotation(&note.effect));\n        draw_note(\n            frame,\n            measure_start_y,\n            beat_position_x,\n            width_per_beat,\n            note,\n            beat_color,\n        );\n    }\n\n    // merge and display beat annotations\n    if !beat_annotations.is_empty() {\n        beat_annotations.sort_unstable();\n        beat_annotations.dedup();\n        let merged_annotations = beat_annotations.join(\"\\n\");\n        let y_position = NOTE_EFFECT_ANNOTATION_Y - 4.0 * (beat_annotations.len() - 1) as f32;\n        let note_effect_text = Text {\n            shaping: Auto,\n            content: merged_annotations,\n            color: Color::WHITE,\n            size: 9.0.into(),\n            position: Point::new(beat_position_x - 3.0, y_position),\n            ..Text::default()\n        };\n        frame.fill_text(note_effect_text);\n    }\n\n    // user-authored text attached to the beat (e.g. \"Verse\", \"fill\")\n    if !beat.text.is_empty() {\n        let beat_text = Text {\n            shaping: Auto,\n            content: beat.text.clone(),\n            color: Color::WHITE,\n            size: 8.0.into(),\n            position: Point::new(beat_position_x + 3.0, BEAT_TEXT_ANNOTATION_Y),\n            ..Text::default()\n        };\n        frame.fill_text(beat_text);\n    }\n}\n\nfn draw_note(\n    frame: &mut Frame<Renderer>,\n    measure_start_y: f32,\n    beat_position_x: f32,\n    width_per_beat: f32,\n    note: &Note,\n    beat_color: Color,\n) {\n    // note label (pushed down on the right string)\n    let note_label = note_value(note);\n    let local_beat_position_y = (f32::from(note.string) - 1.0) * STRING_LINE_HEIGHT;\n    // center the notes with more than one char\n    let note_position_x = beat_position_x + 3.0 - note_label.chars().count() as f32 / 2.0;\n    let note_position_y = measure_start_y + local_beat_position_y - 5.0;\n    let note_text = Text {\n        shaping: Auto,\n        content: note_label,\n        color: beat_color,\n        size: 10.0.into(),\n        position: Point::new(note_position_x, note_position_y),\n        align_x: Alignment::Center,\n        ..Text::default()\n    };\n    frame.fill_text(note_text);\n\n    // Annotate some effects on the string after the note\n    let inlined_annotation_width = 10.0;\n    let inlined_annotation_label = inlined_note_effect_annotation(&note.effect);\n    // note_x + half of inter-beat space - half of annotation width\n    let annotation_position_x =\n        note_position_x + width_per_beat / 2.0 - inlined_annotation_width / 2.0;\n    let note_effect_text = Text {\n        shaping: Auto,\n        content: inlined_annotation_label,\n        color: Color::WHITE,\n        size: inlined_annotation_width.into(),\n        position: Point::new(annotation_position_x, note_position_y),\n        ..Text::default()\n    };\n    frame.fill_text(note_effect_text);\n}\n\nfn draw_open_section(\n    frame: &mut Frame<Renderer>,\n    measure_start_x: f32,\n    measure_start_y: f32,\n    vertical_measure_height: f32,\n) {\n    let position_x = measure_start_x;\n\n    // draw first thick one\n    let start_point = Point::new(position_x, measure_start_y);\n    let end_point = Point::new(position_x, measure_start_y + vertical_measure_height);\n    let tick_vertical_line = Path::line(start_point, end_point);\n    let stroke = Stroke::default().with_width(4.0).with_color(Color::WHITE);\n    frame.stroke(&tick_vertical_line, stroke);\n\n    // then thin one\n    draw_measure_vertical_line(\n        frame,\n        vertical_measure_height,\n        measure_start_x + 6.0,\n        measure_start_y,\n    );\n}\n\nfn draw_open_repeat(\n    frame: &mut Frame<Renderer>,\n    measure_start_x: f32,\n    measure_start_y: f32,\n    vertical_measure_height: f32,\n) {\n    draw_open_section(\n        frame,\n        measure_start_x,\n        measure_start_y,\n        vertical_measure_height,\n    );\n    // draw repeat dots\n    draw_repeat_dots(\n        frame,\n        measure_start_x + HALF_BEAT_LENGTH,\n        measure_start_y,\n        vertical_measure_height,\n    );\n}\n\nfn draw_close_repeat(\n    frame: &mut Frame<Renderer>,\n    measure_end_x: f32,\n    measure_start_y: f32,\n    vertical_measure_height: f32,\n    repeat_count: i8,\n) {\n    draw_end_section(\n        frame,\n        measure_end_x,\n        measure_start_y,\n        vertical_measure_height,\n    );\n    // draw repeat dots\n    draw_repeat_dots(\n        frame,\n        measure_end_x - HALF_BEAT_LENGTH,\n        measure_start_y,\n        vertical_measure_height,\n    );\n    // add repeat count text\n    let repeat_count_text = Text {\n        shaping: Auto,\n        content: format!(\"x{repeat_count}\"),\n        color: Color::WHITE,\n        size: 9.0.into(),\n        position: Point::new(measure_end_x - 12.0, FIRST_STRING_Y - 15.0),\n        ..Text::default()\n    };\n    frame.fill_text(repeat_count_text);\n}\n\nfn draw_stroke_arrow(\n    frame: &mut Frame<Renderer>,\n    beat: &Beat,\n    beat_position_x: f32,\n    measure_start_y: f32,\n) {\n    let min_string = beat.notes.iter().map(|n| n.string).min().unwrap_or(1);\n    let max_string = beat.notes.iter().map(|n| n.string).max().unwrap_or(1);\n    let top_y = measure_start_y + (f32::from(min_string) - 1.0) * STRING_LINE_HEIGHT;\n    let bottom_y = measure_start_y + (f32::from(max_string) - 1.0) * STRING_LINE_HEIGHT;\n    let arrow_x = beat_position_x + 10.0;\n    let arrow_size = 3.0;\n\n    let stroke = Stroke::default().with_width(0.8).with_color(Color::WHITE);\n\n    // vertical line spanning the chord\n    frame.stroke(\n        &Path::line(Point::new(arrow_x, top_y), Point::new(arrow_x, bottom_y)),\n        stroke,\n    );\n\n    // arrowhead: down stroke = pick goes low-to-high strings = arrowhead at top\n    match beat.effect.stroke.direction {\n        BeatStrokeDirection::Down => {\n            let tip = Point::new(arrow_x, top_y - arrow_size);\n            frame.stroke(\n                &Path::line(Point::new(arrow_x - arrow_size, top_y), tip),\n                stroke,\n            );\n            frame.stroke(\n                &Path::line(Point::new(arrow_x + arrow_size, top_y), tip),\n                stroke,\n            );\n        }\n        BeatStrokeDirection::Up => {\n            let tip = Point::new(arrow_x, bottom_y + arrow_size);\n            frame.stroke(\n                &Path::line(Point::new(arrow_x - arrow_size, bottom_y), tip),\n                stroke,\n            );\n            frame.stroke(\n                &Path::line(Point::new(arrow_x + arrow_size, bottom_y), tip),\n                stroke,\n            );\n        }\n        BeatStrokeDirection::None => {}\n    }\n}\n\nfn draw_alternative_ending(\n    frame: &mut Frame<Renderer>,\n    repeat_alternative: u8,\n    measure_start_x: f32,\n    measure_width: f32,\n) {\n    let bracket_y = MEASURE_ANNOTATION_Y;\n    let bracket_height = 10.0;\n    let bracket_start = measure_start_x + 2.0;\n    let bracket_end = measure_start_x + measure_width;\n\n    let stroke = Stroke::default().with_width(1.0).with_color(Color::WHITE);\n\n    // vertical line down\n    let start = Point::new(bracket_start, bracket_y);\n    let down = Point::new(bracket_start, bracket_y + bracket_height);\n    frame.stroke(&Path::line(start, down), stroke);\n\n    // horizontal line across\n    let right = Point::new(bracket_end, bracket_y);\n    frame.stroke(&Path::line(start, right), stroke);\n\n    // build label from bitmask (e.g., 1 → \"1.\", 2 → \"2.\", 3 → \"1.2.\")\n    let mut label = String::new();\n    for bit in 0..8_u8 {\n        if repeat_alternative & (1 << bit) != 0 {\n            if !label.is_empty() {\n                label.push('.');\n            }\n            label.push_str(&(bit + 1).to_string());\n        }\n    }\n    label.push('.');\n\n    let label_text = Text {\n        shaping: Auto,\n        content: label,\n        color: Color::WHITE,\n        size: 9.0.into(),\n        position: Point::new(bracket_start + 3.0, bracket_y),\n        ..Text::default()\n    };\n    frame.fill_text(label_text);\n}\n\nfn draw_repeat_dots(\n    frame: &mut Frame<Renderer>,\n    start_x: f32,\n    start_y: f32,\n    vertical_measure_height: f32,\n) {\n    // top dot\n    let top_position_y = start_y + vertical_measure_height / 3.0;\n    let center = Point::new(start_x, top_position_y);\n    let circle = Path::circle(center, 1.0);\n\n    frame.stroke(\n        &circle,\n        Stroke::default().with_width(2.0).with_color(Color::WHITE),\n    );\n\n    // bottom dot\n    let bottom_position_y = start_y + (vertical_measure_height / 3.0) * 2.0;\n    let center = Point::new(start_x, bottom_position_y);\n    let circle = Path::circle(center, 1.0);\n\n    frame.stroke(\n        &circle,\n        Stroke::default().with_width(2.0).with_color(Color::WHITE),\n    );\n}\n\nfn draw_end_section(\n    frame: &mut Frame<Renderer>,\n    measure_end_x: f32,\n    measure_start_y: f32,\n    vertical_measure_height: f32,\n) {\n    // draw first thin one\n    draw_measure_vertical_line(\n        frame,\n        vertical_measure_height,\n        measure_end_x - 8.0,\n        measure_start_y,\n    );\n\n    // then thick one\n    let position_x = measure_end_x - 2.0;\n    let start_point = Point::new(position_x, measure_start_y);\n    let end_point = Point::new(position_x, measure_start_y + vertical_measure_height);\n    let thick_vertical_line = Path::line(start_point, end_point);\n    let stroke = Stroke::default().with_width(4.0).with_color(Color::WHITE);\n    frame.stroke(&thick_vertical_line, stroke);\n}\n\nfn draw_time_signature(\n    frame: &mut Frame<Renderer>,\n    time_signature: &TimeSignature,\n    measure_start_x: f32,\n    string_count: usize,\n    has_repeat: bool,\n) {\n    let position_x = if has_repeat {\n        BEAT_LENGTH\n    } else {\n        HALF_BEAT_LENGTH\n    };\n    let position_y = if string_count > 4 {\n        (STRING_LINE_HEIGHT * (string_count - 4) as f32) / 2.0\n    } else {\n        0.0\n    };\n    let numerator = time_signature.numerator;\n    let denominator = time_signature.denominator.value;\n    let tempo_text = Text {\n        shaping: Auto,\n        content: format!(\"{numerator}\\n{denominator}\"),\n        color: Color::WHITE,\n        size: 17.into(),\n        position: Point::new(\n            measure_start_x + position_x,\n            (FIRST_STRING_Y - 1.0) + position_y,\n        ),\n        ..Text::default()\n    };\n    frame.fill_text(tempo_text);\n}\n\n// Similar to `https://www.tuxguitar.app/files/1.6.0/desktop/help/edit_effects.html`\nfn above_note_effect_annotation(note_effect: &NoteEffect) -> Vec<String> {\n    let mut annotations: Vec<String> = vec![];\n    if note_effect.accentuated_note {\n        annotations.push(\">\".to_string());\n    }\n    if note_effect.heavy_accentuated_note {\n        annotations.push(\"^\".to_string());\n    }\n    if note_effect.palm_mute {\n        annotations.push(\"P.M\".to_string());\n    }\n    if note_effect.let_ring {\n        annotations.push(\"L.R\".to_string());\n    }\n    if note_effect.fade_in {\n        annotations.push(\"<\".to_string());\n    }\n    if let Some(harmonic) = &note_effect.harmonic {\n        match harmonic.kind {\n            HarmonicType::Natural => annotations.push(\"N.H\".to_string()),\n            HarmonicType::Artificial => annotations.push(\"A.H\".to_string()),\n            HarmonicType::Tapped => annotations.push(\"T.H\".to_string()),\n            HarmonicType::Pinch => annotations.push(\"P.H\".to_string()),\n            HarmonicType::Semi => annotations.push(\"S.H\".to_string()),\n        }\n    }\n    if note_effect.vibrato {\n        let vibrato = VIBRATO.to_string();\n        annotations.push(vibrato.repeat(2));\n    }\n    if note_effect.trill.is_some() {\n        annotations.push(\"Tr\".to_string());\n    }\n    if note_effect.tremolo_picking.is_some() {\n        annotations.push(\"T.P\".to_string());\n    }\n    if note_effect.tremolo_bar.is_some() {\n        annotations.push(\"T.B\".to_string());\n    }\n    if note_effect.slap == SlapEffect::Tapping {\n        annotations.push(\"T\".to_string());\n    }\n    annotations\n}\n\nfn inlined_note_effect_annotation(note_effect: &NoteEffect) -> String {\n    let mut annotation = String::new();\n    if note_effect.hammer {\n        // https://unicodeplus.com/U+25E0\n        annotation.push(HAMMER_ON);\n    }\n    if let Some(slide) = &note_effect.slide {\n        match slide {\n            SlideType::IntoFromAbove => annotation.push(HORIZONTAL_BAR),\n            SlideType::IntoFromBelow => annotation.push(HORIZONTAL_BAR),\n            SlideType::ShiftSlideTo => annotation.push(SHIFT_SLIDE),\n            SlideType::LegatoSlideTo => annotation.push(LEGATO_SLIDE),\n            SlideType::OutDownwards => annotation.push(HORIZONTAL_BAR),\n            SlideType::OutUpWards => annotation.push(LEGATO_SLIDE),\n        }\n    }\n    if let Some(bend) = &note_effect.bend {\n        let direction_up = bend.direction() >= 0;\n        // TODO display bend properly\n        if direction_up {\n            annotation.push(ARROW_UP);\n        } else {\n            annotation.push(ARROW_DOWN);\n        }\n    }\n    annotation\n}\n\nfn note_value(note: &Note) -> String {\n    match note.kind {\n        NoteType::Rest => {\n            log::debug!(\"NoteType Rest\");\n            String::new()\n        }\n        NoteType::Normal => {\n            if note.effect.ghost_note {\n                format!(\"({})\", note.value)\n            } else {\n                note.value.to_string()\n            }\n        }\n        NoteType::Tie => {\n            // https://unicodeplus.com/U+2323\n            TIE.into()\n        }\n        NoteType::Dead => \"x\".to_string(),\n        NoteType::Unknown(i) => {\n            log::warn!(\"NoteType Unknown({i})\");\n            String::new()\n        }\n    }\n}\n"
  },
  {
    "path": "src/ui/icons.rs",
    "content": "//! Icons coming from <https://fontello.com/>\n\nuse iced::widget::text;\nuse iced::{Element, Font};\n\npub fn open_icon<'a, Message>() -> Element<'a, Message> {\n    icon('\\u{0f115}')\n}\n\npub fn solo_icon<'a, Message>() -> Element<'a, Message> {\n    text('S').into()\n}\n\npub fn pause_icon<'a, Message>() -> Element<'a, Message> {\n    icon('\\u{0e802}')\n}\n\npub fn play_icon<'a, Message>() -> Element<'a, Message> {\n    icon('\\u{0e800}')\n}\n\npub fn stop_icon<'a, Message>() -> Element<'a, Message> {\n    icon('\\u{0e801}')\n}\n\nfn icon<'a, Message>(codepoint: char) -> Element<'a, Message> {\n    const ICON_FONT: Font = Font::with_name(\"ruxguitar-icons\");\n\n    text(codepoint).font(ICON_FONT).into()\n}\n"
  },
  {
    "path": "src/ui/mod.rs",
    "content": "pub mod application;\nmod canvas_measure;\nmod icons;\nmod picker;\nmod tablature;\nmod tuning;\nmod utils;\n"
  },
  {
    "path": "src/ui/picker.rs",
    "content": "use std::path::PathBuf;\n\n#[derive(Debug, Clone, thiserror::Error)]\npub enum FilePickerError {\n    #[error(\"dialog window closed without selecting a file\")]\n    DialogClosed,\n    #[error(\"IO error: {0}\")]\n    IoError(String),\n}\n\n/// Opens a file dialog and returns the content of the picked file.\npub async fn open_file_dialog(\n    picker_folder: Option<PathBuf>,\n) -> Result<(Vec<u8>, Option<PathBuf>, String), FilePickerError> {\n    let mut picker = rfd::AsyncFileDialog::new()\n        .add_filter(\"Guitar Pro files\", &[\"gp5\", \"gp4\"])\n        .set_title(\"Select a Guitar Pro file\");\n\n    if let Some(folder) = picker_folder {\n        picker = picker.set_directory(folder);\n    }\n\n    let picked_file = picker\n        .pick_file()\n        .await\n        .ok_or(FilePickerError::DialogClosed)?;\n    load_file(picked_file).await\n}\n\n/// Loads the content of a file at the given path.\n///\n/// Return the content of the file and its name.\npub async fn load_file(\n    path: impl Into<PathBuf>,\n) -> Result<(Vec<u8>, Option<PathBuf>, String), FilePickerError> {\n    let path = path.into();\n    let file_extension = path\n        .extension()\n        .and_then(|e| e.to_str())\n        .map(str::to_lowercase)\n        .unwrap_or_default();\n    if file_extension != \"gp5\" && file_extension != \"gp4\" {\n        return Err(FilePickerError::IoError(format!(\n            \"Unsupported file extension: {file_extension}\"\n        )));\n    }\n    let file_name = path\n        .file_name()\n        .and_then(|f| f.to_str())\n        .map(ToString::to_string)\n        .unwrap_or_default();\n    let parent_folder = path.parent().and_then(|parent| {\n        // make sure relative path from CLI is returned as absolute path\n        let absolute_path = std::fs::canonicalize(parent);\n        absolute_path.ok()\n    });\n    log::info!(\"Loading file: {file_name:?}\");\n    tokio::fs::read(&path)\n        .await\n        .map_err(|error| FilePickerError::IoError(error.to_string()))\n        .map(|content| (content, parent_folder, file_name))\n}\n"
  },
  {
    "path": "src/ui/tablature.rs",
    "content": "use crate::parser::song_parser::Song;\nuse crate::ui::application::Message;\nuse crate::ui::canvas_measure::CanvasMeasure;\nuse iced::widget::{Id, Row, column, scrollable};\nuse iced::{Element, Length};\nuse std::collections::BTreeMap;\nuse std::rc::Rc;\n\nconst INNER_PADDING: f32 = 10.0;\nconst SCROLLBAR_WIDTH: f32 = 10.0; // iced default scrollbar width (iced_widget/src/scrollable.rs)\n\npub struct Tablature {\n    pub song: Rc<Song>,\n    pub track_id: usize,\n    pub canvas_measures: Vec<CanvasMeasure>,\n    canvas_measure_height: f32,\n    focused_measure: usize,\n    line_tracker: LineTracker,\n    pub scroll_id: Id,\n    measure_per_tick: BTreeMap<u32, u32>, // tick to measure index as u32\n}\n\nimpl Tablature {\n    pub fn new(\n        song: Rc<Song>,\n        track_id: usize,\n        scroll_id: Id,\n        playback_order: &[(usize, i64)],\n    ) -> Self {\n        let measure_count = song.measure_headers.len();\n        // build tick-to-measure map including expanded repeat ticks\n        let mut measure_per_tick = BTreeMap::new();\n        for (measure_index, tick_offset) in playback_order {\n            let header = &song.measure_headers[*measure_index];\n            let playback_tick = (i64::from(header.start) + tick_offset) as u32;\n            measure_per_tick.insert(playback_tick, *measure_index as u32);\n        }\n        let mut tab = Self {\n            song,\n            track_id,\n            canvas_measures: Vec::with_capacity(measure_count),\n            canvas_measure_height: 0.0,\n            focused_measure: 0,\n            line_tracker: LineTracker::default(),\n            scroll_id,\n            measure_per_tick,\n        };\n        tab.load_measures();\n        tab\n    }\n\n    pub fn load_measures(&mut self) {\n        // clear existing measures\n        self.canvas_measures.clear();\n\n        // load new measures\n        let track = &self.song.tracks[self.track_id];\n        let measures = track.measures.len();\n        for i in 0..measures {\n            let measure_header = &self.song.measure_headers[i];\n            let previous_measure_header = if i > 0 {\n                self.song.measure_headers.get(i - 1)\n            } else {\n                None\n            };\n            let focused = self.focused_measure == i;\n            let has_time_signature = i == 0\n                || measure_header.time_signature != previous_measure_header.unwrap().time_signature;\n            let measure = CanvasMeasure::new(\n                i,\n                self.track_id,\n                self.song.clone(),\n                focused,\n                has_time_signature,\n            );\n            if i == 0 {\n                // all measures have the same height - grab first one\n                self.canvas_measure_height = measure.vertical_measure_height;\n            }\n            self.canvas_measures.push(measure);\n        }\n        // recompute line tracker with existing width\n        let existing_width = self.line_tracker.tablature_container_width;\n        self.line_tracker = LineTracker::make(&self.canvas_measures, existing_width);\n        self.update_first_on_line();\n    }\n\n    pub fn update_container_width(&mut self, width: f32) {\n        // recompute line tracker on width change\n        self.line_tracker = LineTracker::make(\n            &self.canvas_measures,\n            width - (INNER_PADDING * 2.0) - SCROLLBAR_WIDTH, // remove padding and scrollbar\n        );\n        // mark which measures start a new line and clear caches\n        self.update_first_on_line();\n    }\n\n    /// Update the `is_first_on_line` flag on each measure based on the line tracker\n    /// and clear caches for measures that changed line assignment.\n    fn update_first_on_line(&mut self) {\n        let mut prev_line = 0_u32;\n        for cm in &mut self.canvas_measures {\n            let line = self.line_tracker.get_line(cm.measure_id);\n            let is_first = line != prev_line;\n            if cm.is_first_on_line != is_first {\n                cm.set_first_on_line(is_first);\n                cm.clear_canvas_cache();\n            }\n            prev_line = line;\n        }\n    }\n\n    /// Get the measure and beat indexes for the given tick\n    /// The measure index is the first measure containing the tick\n    ///\n    /// | measure 0 | measure 1 | measure 2 | measure 3 |\n    /// |-----------|-----------|-----------|-----------|\n    /// | 0         | 100       | 200       | 300       |\n    ///\n    ///\n    /// tick: 50\n    /// measure_index: 0\n    ///\n    /// tick: 100\n    /// measure_index: 1\n    ///\n    /// tick: 150\n    /// measure_index: 1\n    ///\n    /// tick: 250\n    /// measure_index: 2\n    ///\n    /// Returns the measure and beat indexes\n    pub fn get_measure_beat_indexes_for_tick(&self, track_id: usize, tick: u32) -> (usize, usize) {\n        // range scan on `measure_per_tick` to find measure index and playback start tick\n        let (playback_start, measure_index) = self\n            .measure_per_tick\n            .range(0..=tick)\n            .next_back()\n            .map(|(&event_tick, &m_id)| (event_tick, m_id as usize))\n            .unwrap_or_else(|| {\n                log::warn!(\"No measure index found for tick:{tick}\");\n                (0, 0)\n            });\n\n        // compute tick offset between playback position and original measure position\n        let original_start = self.song.measure_headers[measure_index].start;\n        let tick_offset = i64::from(playback_start) - i64::from(original_start);\n\n        // get beat index within the measure containing the tick\n        // adjust tick by removing the offset to compare with original beat.start values\n        let original_tick = (i64::from(tick) - tick_offset) as u32;\n        let voice = &self.song.tracks[track_id].measures[measure_index].voices[0];\n        let beat_index = voice\n            .beats\n            .partition_point(|beat| beat.start <= original_tick)\n            .saturating_sub(1);\n        (measure_index, beat_index)\n    }\n\n    /// Focus on the beat at the given tick\n    ///\n    /// Returns the amount of scroll needed to focus on the beat\n    pub fn focus_on_tick(&mut self, tick: u32) -> Option<f32> {\n        let (new_measure_id, new_beat_id) = if tick == 1 {\n            (0, 0)\n        } else {\n            self.get_measure_beat_indexes_for_tick(self.track_id, tick)\n        };\n        let current_focus_id = self.focused_measure;\n        let current_canvas = self.canvas_measures.get_mut(current_focus_id).unwrap();\n        if current_focus_id == new_measure_id {\n            // focus on beat id within the same measure\n            current_canvas.focus_beat(new_beat_id);\n        } else {\n            // move to next measure\n            current_canvas.toggle_focused();\n            let next_focus_id = new_measure_id;\n            if next_focus_id < self.canvas_measures.len() {\n                self.focused_measure = next_focus_id;\n                let next_canvas = self.canvas_measures.get_mut(next_focus_id).unwrap();\n                next_canvas.toggle_focused();\n                return self.scroll_offset_for_measure(next_focus_id);\n            }\n        }\n        None\n    }\n\n    pub fn focus_on_measure(&mut self, new_measure_id: usize) {\n        let current_focus_id = self.focused_measure;\n        if current_focus_id != new_measure_id {\n            let current_canvas = self.canvas_measures.get_mut(current_focus_id).unwrap();\n            current_canvas.toggle_focused();\n            self.focused_measure = new_measure_id;\n            let next_canvas = self.canvas_measures.get_mut(new_measure_id).unwrap();\n            next_canvas.toggle_focused();\n        }\n    }\n\n    pub const fn focused_measure(&self) -> usize {\n        self.focused_measure\n    }\n\n    pub const fn measure_count(&self) -> usize {\n        self.canvas_measures.len()\n    }\n\n    pub fn scroll_offset_for_measure(&self, measure_id: usize) -> Option<f32> {\n        let focus_line = self.line_tracker.get_line(measure_id);\n        if focus_line < 2 {\n            return None;\n        }\n        let scroll_line = focus_line.saturating_sub(2);\n        Some(INNER_PADDING + scroll_line as f32 * self.canvas_measure_height)\n    }\n\n    pub fn view(&self) -> Element<'_, Message> {\n        let has_layout = self.line_tracker.tablature_container_width > 0.0;\n\n        let content: Element<Message> = if has_layout {\n            // Build explicit rows using LineTracker line assignments.\n            // Each measure uses FillPortion to stretch and fill the row width.\n            let row_width = self.line_tracker.tablature_container_width;\n            let mut rows: Vec<Element<Message>> = Vec::new();\n            let mut current_row: Vec<Element<Message>> = Vec::new();\n            let mut current_line = 0_u32;\n\n            for cm in &self.canvas_measures {\n                let line = self.line_tracker.get_line(cm.measure_id);\n                if line != current_line && !current_row.is_empty() {\n                    rows.push(\n                        Row::with_children(std::mem::take(&mut current_row))\n                            .width(row_width)\n                            .into(),\n                    );\n                }\n                current_line = line;\n                current_row.push(cm.view_fill());\n            }\n            if !current_row.is_empty() {\n                rows.push(Row::with_children(current_row).width(row_width).into());\n            }\n\n            column(rows).padding(INNER_PADDING).into()\n        } else {\n            // Before container size is known, use wrapping layout with natural widths\n            let measure_elements = self\n                .canvas_measures\n                .iter()\n                .map(|m| m.view())\n                .collect::<Vec<Element<Message>>>();\n\n            column![Row::with_children(measure_elements).wrap()]\n                .padding(INNER_PADDING)\n                .into()\n        };\n\n        scrollable(content)\n            .id(self.scroll_id.clone())\n            .height(Length::Fill)\n            .width(Length::Fill)\n            .direction(scrollable::Direction::default())\n            .into()\n    }\n\n    pub fn update_track(&mut self, track: usize) {\n        // No op if track is the same\n        if track != self.track_id {\n            self.track_id = track;\n            self.load_measures();\n        }\n    }\n}\n\n#[derive(Default)]\nstruct LineTracker {\n    measure_to_line: Vec<u32>, // measure id to line number\n    tablature_container_width: f32,\n}\n\nimpl LineTracker {\n    pub fn make(measures: &[CanvasMeasure], tablature_container_width: f32) -> Self {\n        let widths: Vec<f32> = measures.iter().map(|m| m.total_measure_len).collect();\n        Self::make_from_widths(&widths, tablature_container_width)\n    }\n\n    fn make_from_widths(widths: &[f32], tablature_container_width: f32) -> Self {\n        let mut line_tracker = Self {\n            measure_to_line: vec![0; widths.len()],\n            tablature_container_width,\n        };\n        let mut current_line = 1;\n        let mut horizontal_cursor = 0.0;\n        for (i, &width) in widths.iter().enumerate() {\n            horizontal_cursor += width;\n            if horizontal_cursor > tablature_container_width {\n                current_line += 1;\n                horizontal_cursor = width;\n            }\n            line_tracker.measure_to_line[i] = current_line;\n        }\n        line_tracker\n    }\n\n    pub fn get_line(&self, measure_id: usize) -> u32 {\n        self.measure_to_line[measure_id]\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn line_tracker_single_line() {\n        let widths = vec![100.0, 100.0, 100.0];\n        let tracker = LineTracker::make_from_widths(&widths, 500.0);\n        assert_eq!(tracker.get_line(0), 1);\n        assert_eq!(tracker.get_line(1), 1);\n        assert_eq!(tracker.get_line(2), 1);\n    }\n\n    #[test]\n    fn line_tracker_wraps_to_multiple_lines() {\n        let widths = vec![100.0, 100.0, 100.0, 100.0];\n        let tracker = LineTracker::make_from_widths(&widths, 250.0);\n        // first two fit (200 < 250), third overflows (300 >= 250)\n        assert_eq!(tracker.get_line(0), 1);\n        assert_eq!(tracker.get_line(1), 1);\n        assert_eq!(tracker.get_line(2), 2);\n        assert_eq!(tracker.get_line(3), 2);\n    }\n\n    #[test]\n    fn line_tracker_exact_fit_stays() {\n        // measures that exactly fill the width should stay on the same line\n        let widths = vec![100.0, 100.0, 100.0];\n        let tracker = LineTracker::make_from_widths(&widths, 200.0);\n        assert_eq!(tracker.get_line(0), 1);\n        assert_eq!(tracker.get_line(1), 1); // 200 == 200, fits exactly\n        assert_eq!(tracker.get_line(2), 2); // 300 > 200, wraps\n    }\n\n    #[test]\n    fn line_tracker_single_wide_measure() {\n        // a measure wider than the container gets its own line\n        let widths = vec![50.0, 300.0, 50.0];\n        let tracker = LineTracker::make_from_widths(&widths, 200.0);\n        assert_eq!(tracker.get_line(0), 1);\n        assert_eq!(tracker.get_line(1), 2);\n        assert_eq!(tracker.get_line(2), 3);\n    }\n\n    #[test]\n    fn line_tracker_varying_widths() {\n        let widths = vec![80.0, 60.0, 90.0, 70.0, 50.0];\n        let tracker = LineTracker::make_from_widths(&widths, 200.0);\n        // line 1: 80 + 60 = 140 < 200\n        // line 1: 140 + 90 = 230 >= 200 → wrap\n        // line 2: 90 + 70 = 160 < 200\n        // line 2: 160 + 50 = 210 >= 200 → wrap\n        assert_eq!(tracker.get_line(0), 1);\n        assert_eq!(tracker.get_line(1), 1);\n        assert_eq!(tracker.get_line(2), 2);\n        assert_eq!(tracker.get_line(3), 2);\n        assert_eq!(tracker.get_line(4), 3);\n    }\n\n    #[test]\n    fn line_tracker_empty() {\n        let widths: Vec<f32> = vec![];\n        let tracker = LineTracker::make_from_widths(&widths, 500.0);\n        assert_eq!(tracker.measure_to_line.len(), 0);\n    }\n\n    #[test]\n    fn first_on_line_detection() {\n        let widths = vec![100.0, 100.0, 100.0, 100.0];\n        let tracker = LineTracker::make_from_widths(&widths, 250.0);\n        // lines: [1, 1, 2, 2]\n        let mut prev_line = 0_u32;\n        let mut first_on_line = Vec::new();\n        for i in 0..widths.len() {\n            let line = tracker.get_line(i);\n            first_on_line.push(line != prev_line);\n            prev_line = line;\n        }\n        assert_eq!(first_on_line, vec![true, false, true, false]);\n    }\n}\n"
  },
  {
    "path": "src/ui/tuning.rs",
    "content": "/// Returns a human-readable tuning label for a stringed track.\n/// Returns `None` for tracks with no strings (non-string instruments).\npub fn tuning_label(strings: &[(i32, i32)]) -> Option<String> {\n    if strings.is_empty() {\n        return None;\n    }\n    let mut pitches: Vec<i32> = strings.iter().map(|(_, pitch)| *pitch).collect();\n    pitches.sort_unstable();\n\n    if let Some(preset) = preset_name(&pitches) {\n        return Some(preset.to_string());\n    }\n\n    Some(\n        pitches\n            .iter()\n            .map(|p| note_name(*p))\n            .collect::<Vec<_>>()\n            .join(\" \"),\n    )\n}\n\nfn preset_name(pitches_sorted: &[i32]) -> Option<&'static str> {\n    match pitches_sorted {\n        // 6-string guitar\n        [40, 45, 50, 55, 59, 64] => Some(\"Standard E\"),\n        [39, 44, 49, 54, 58, 63] => Some(\"Half-step down\"),\n        [38, 43, 48, 53, 57, 62] => Some(\"Standard D\"),\n        [37, 42, 47, 52, 56, 61] => Some(\"Standard C#\"),\n        [36, 41, 46, 51, 55, 60] => Some(\"Standard C\"),\n        [35, 40, 45, 50, 54, 59] => Some(\"Standard B\"),\n        [34, 39, 44, 49, 53, 58] => Some(\"Standard A#\"),\n        [38, 45, 50, 55, 59, 64] => Some(\"Drop D\"),\n        [36, 43, 48, 53, 57, 62] => Some(\"Drop C\"),\n        [33, 40, 45, 50, 54, 59] => Some(\"Drop A\"),\n        [38, 45, 50, 55, 57, 62] => Some(\"DADGAD\"),\n        [38, 43, 50, 55, 59, 62] => Some(\"Open G\"),\n        [38, 45, 50, 53, 57, 64] => Some(\"Open D minor (high E)\"),\n        // 7-string guitar\n        [35, 40, 45, 50, 55, 59, 64] => Some(\"Standard B\"),\n        [34, 39, 44, 49, 54, 58, 63] => Some(\"Half-step down\"),\n        [33, 38, 43, 48, 53, 57, 62] => Some(\"Standard A\"),\n        [33, 40, 45, 50, 55, 59, 64] => Some(\"Drop A\"),\n        [29, 34, 39, 44, 49, 53, 58] => Some(\"Standard F\"),\n        // 4-string bass\n        [28, 33, 38, 43] => Some(\"Standard E\"),\n        [27, 32, 37, 42] => Some(\"Half-step down\"),\n        [26, 31, 36, 41] => Some(\"Standard D\"),\n        [26, 33, 38, 43] => Some(\"Drop D\"),\n        // 5-string bass\n        [23, 28, 33, 38, 43] => Some(\"Standard B\"),\n        [22, 27, 32, 37, 42] => Some(\"Half-step down\"),\n        [21, 28, 33, 38, 43] => Some(\"Drop A\"),\n        // 6-string bass\n        [23, 28, 33, 38, 43, 48] => Some(\"Standard B\"),\n        _ => None,\n    }\n}\n\nfn note_name(midi_pitch: i32) -> String {\n    const NOTES: [&str; 12] = [\n        \"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\",\n    ];\n    let note = NOTES[midi_pitch.rem_euclid(12) as usize];\n    let octave = midi_pitch / 12 - 1;\n    format!(\"{note}{octave}\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn standard_e_guitar() {\n        let strings = vec![(1, 64), (2, 59), (3, 55), (4, 50), (5, 45), (6, 40)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard E\"));\n    }\n\n    #[test]\n    fn drop_d_guitar() {\n        let strings = vec![(1, 64), (2, 59), (3, 55), (4, 50), (5, 45), (6, 38)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Drop D\"));\n    }\n\n    #[test]\n    fn standard_d_guitar() {\n        // D G C F A D\n        let strings = vec![(1, 62), (2, 57), (3, 53), (4, 48), (5, 43), (6, 38)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard D\"));\n    }\n\n    #[test]\n    fn standard_c_sharp_guitar() {\n        // C# F# B E G# C#\n        let strings = vec![(1, 61), (2, 56), (3, 52), (4, 47), (5, 42), (6, 37)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard C#\"));\n    }\n\n    #[test]\n    fn standard_c_guitar() {\n        // C F A# D# G C\n        let strings = vec![(1, 60), (2, 55), (3, 51), (4, 46), (5, 41), (6, 36)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard C\"));\n    }\n\n    #[test]\n    fn standard_a_sharp_guitar() {\n        // A# D# G# C# F A#\n        let strings = vec![(1, 58), (2, 53), (3, 49), (4, 44), (5, 39), (6, 34)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard A#\"));\n    }\n\n    #[test]\n    fn standard_b_guitar() {\n        // B E A D F# B\n        let strings = vec![(1, 59), (2, 54), (3, 50), (4, 45), (5, 40), (6, 35)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard B\"));\n    }\n\n    #[test]\n    fn drop_a_7_string() {\n        // A E A D G B E\n        let strings = vec![\n            (1, 64),\n            (2, 59),\n            (3, 55),\n            (4, 50),\n            (5, 45),\n            (6, 40),\n            (7, 33),\n        ];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Drop A\"));\n    }\n\n    #[test]\n    fn standard_a_7_string() {\n        // A D G C F A D\n        let strings = vec![\n            (1, 62),\n            (2, 57),\n            (3, 53),\n            (4, 48),\n            (5, 43),\n            (6, 38),\n            (7, 33),\n        ];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard A\"));\n    }\n\n    #[test]\n    fn standard_f_7_string() {\n        // F A# D# G# C# F A#\n        let strings = vec![\n            (1, 58),\n            (2, 53),\n            (3, 49),\n            (4, 44),\n            (5, 39),\n            (6, 34),\n            (7, 29),\n        ];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard F\"));\n    }\n\n    #[test]\n    fn standard_bass() {\n        let strings = vec![(1, 43), (2, 38), (3, 33), (4, 28)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard E\"));\n    }\n\n    #[test]\n    fn open_d_minor_high_e() {\n        // D A D F A E\n        let strings = vec![(1, 64), (2, 57), (3, 53), (4, 50), (5, 45), (6, 38)];\n        assert_eq!(\n            tuning_label(&strings).as_deref(),\n            Some(\"Open D minor (high E)\")\n        );\n    }\n\n    #[test]\n    fn drop_a_6_string() {\n        // A E A D F# B\n        let strings = vec![(1, 59), (2, 54), (3, 50), (4, 45), (5, 40), (6, 33)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Drop A\"));\n    }\n\n    #[test]\n    fn drop_a_5_string_bass() {\n        // A E A D G\n        let strings = vec![(1, 43), (2, 38), (3, 33), (4, 28), (5, 21)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Drop A\"));\n    }\n\n    #[test]\n    fn standard_d_bass() {\n        // D G C F\n        let strings = vec![(1, 41), (2, 36), (3, 31), (4, 26)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard D\"));\n    }\n\n    #[test]\n    fn standard_b_6_string_bass() {\n        // B E A D G C\n        let strings = vec![(1, 48), (2, 43), (3, 38), (4, 33), (5, 28), (6, 23)];\n        assert_eq!(tuning_label(&strings).as_deref(), Some(\"Standard B\"));\n    }\n\n    #[test]\n    fn empty_strings_returns_none() {\n        assert_eq!(tuning_label(&[]), None);\n    }\n\n    #[test]\n    fn unknown_tuning_falls_back_to_notes() {\n        // arbitrary 6-string tuning\n        let strings = vec![(1, 65), (2, 60), (3, 56), (4, 51), (5, 46), (6, 41)];\n        assert_eq!(\n            tuning_label(&strings).as_deref(),\n            Some(\"F2 A#2 D#3 G#3 C4 F4\")\n        );\n    }\n\n    #[test]\n    fn note_name_e2() {\n        assert_eq!(note_name(40), \"E2\");\n    }\n\n    #[test]\n    fn note_name_middle_c() {\n        assert_eq!(note_name(60), \"C4\");\n    }\n}\n"
  },
  {
    "path": "src/ui/utils.rs",
    "content": "use crate::ui::application::Message;\nuse iced::widget::{\n    Container, Text, button, center, container, mouse_area, opaque, stack, tooltip,\n};\nuse iced::{Color, Element, Length};\n\n// Shared UI colors\npub const COLOR_GRAY: Color = Color::from_rgb8(0x40, 0x44, 0x4B);\npub const COLOR_DARK_RED: Color = Color::from_rgb8(200, 50, 50);\n\npub fn untitled_text_table_box() -> Container<'static, Message> {\n    let message = \"Tips:\\n \\\n        - use the space bar to play/pause\\n \\\n        - use ctrl+up/down to change the tempo\\n \\\n        - use left/right to navigate measures\\n \\\n        - use s to toggle solo mode\\n \\\n        - use F11 to toggle fullscreen\";\n    let text = Text::new(message).color(Color::WHITE);\n\n    Container::new(text)\n        .center_x(Length::Fill)\n        .center_y(Length::Fill)\n        .padding(20)\n}\n\npub fn action_gated<'a, Message: Clone + 'a>(\n    content: impl Into<Element<'a, Message>>,\n    label: &'a str,\n    on_press: Option<Message>,\n) -> Element<'a, Message> {\n    let action = button(container(content).center_x(30));\n\n    if let Some(on_press) = on_press {\n        tooltip(\n            action.on_press(on_press),\n            label,\n            tooltip::Position::FollowCursor,\n        )\n        .style(container::rounded_box)\n        .into()\n    } else {\n        action.style(button::secondary).into()\n    }\n}\n\npub fn action_toggle<'a, Message: Clone + 'a>(\n    content: impl Into<Element<'a, Message>>,\n    label: &'a str,\n    on_press: Message,\n    pressed: bool,\n) -> Element<'a, Message> {\n    let action = button(container(content).center_x(30));\n\n    let action = if pressed {\n        action.style(button::secondary)\n    } else {\n        action\n    };\n\n    tooltip(\n        action.on_press(on_press),\n        label,\n        tooltip::Position::FollowCursor,\n    )\n    .style(container::rounded_box)\n    .into()\n}\n\npub fn modal<'a, Message>(\n    base: impl Into<Element<'a, Message>>,\n    content: impl Into<Element<'a, Message>>,\n    on_blur: Message,\n) -> Element<'a, Message>\nwhere\n    Message: Clone + 'a,\n{\n    stack![\n        base.into(),\n        opaque(\n            mouse_area(center(opaque(content)).style(|_theme| {\n                container::Style {\n                    background: Some(\n                        Color {\n                            a: 0.8,\n                            ..Color::BLACK\n                        }\n                        .into(),\n                    ),\n                    ..container::Style::default()\n                }\n            }))\n            .on_press(on_blur)\n        )\n    ]\n    .into()\n}\n"
  }
]