diff --git a/CMakeLists.txt b/CMakeLists.txt index f7097712a503a..c7d7b80163cd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,7 @@ option(MUE_BUILD_ENGRAVING_PLAYBACK "Build engraving playback" ON) # IMPORT EXPORT MODULES option(MUE_BUILD_IMPEXP_BB_MODULE "Build importexport bb module" ON) +option(MUE_BUILD_IMPEXP_ENCORE_MODULE "Build importexport encore module" ON) option(MUE_BUILD_IMPEXP_BWW_MODULE "Build importexport bww module" ON) option(MUE_BUILD_IMPEXP_CAPELLA_MODULE "Build importexport capella module" ON) option(MUE_BUILD_IMPEXP_MIDI_MODULE "Build importexport midi module" ON) diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 4c3274d516fe8..ee7c9ac260f13 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -156,6 +156,7 @@ add_to_link_if_exists(converter) add_to_link_if_exists(engraving) add_to_link_if_exists(iex_audioexport) add_to_link_if_exists(iex_bb) +add_to_link_if_exists(iex_encore) add_to_link_if_exists(iex_bww) add_to_link_if_exists(iex_capella) add_to_link_if_exists(iex_guitarpro) diff --git a/src/app/app_config.h.in b/src/app/app_config.h.in index 6036be8ae7e1f..159b803f7fc57 100644 --- a/src/app/app_config.h.in +++ b/src/app/app_config.h.in @@ -35,6 +35,7 @@ /* Import Export Modules */ /* ============================================== */ #cmakedefine MUE_BUILD_IMPEXP_BB_MODULE 1 +#cmakedefine MUE_BUILD_IMPEXP_ENCORE_MODULE 1 #cmakedefine MUE_BUILD_IMPEXP_BWW_MODULE 1 #cmakedefine MUE_BUILD_IMPEXP_CAPELLA_MODULE 1 #cmakedefine MUE_BUILD_IMPEXP_MIDI_MODULE 1 diff --git a/src/app/appfactory.cpp b/src/app/appfactory.cpp index fff9a369d9225..2e7b2302e7496 100644 --- a/src/app/appfactory.cpp +++ b/src/app/appfactory.cpp @@ -171,6 +171,9 @@ #ifdef MUE_BUILD_IMPEXP_BB_MODULE #include "importexport/bb/bbmodule.h" #endif +#ifdef MUE_BUILD_IMPEXP_ENCORE_MODULE +#include "importexport/encore/enc-module.h" +#endif #ifdef MUE_BUILD_IMPEXP_BWW_MODULE #include "importexport/bww/bwwmodule.h" #endif @@ -379,6 +382,9 @@ std::shared_ptr AppFactory::newGuiApp(const std::shared_ptr< #ifdef MUE_BUILD_IMPEXP_BB_MODULE app->addModule(new mu::iex::bb::BBModule()); #endif +#ifdef MUE_BUILD_IMPEXP_ENCORE_MODULE + app->addModule(new mu::iex::enc::EncoreModule()); +#endif #ifdef MUE_BUILD_IMPEXP_BWW_MODULE app->addModule(new mu::iex::bww::BwwModule()); #endif @@ -503,6 +509,9 @@ static void addConsoleModules(std::shared_ptr app) #ifdef MUE_BUILD_IMPEXP_BB_MODULE app->addModule(new mu::iex::bb::BBModule()); #endif +#ifdef MUE_BUILD_IMPEXP_ENCORE_MODULE + app->addModule(new mu::iex::enc::EncoreModule()); +#endif #ifdef MUE_BUILD_IMPEXP_BWW_MODULE app->addModule(new mu::iex::bww::BwwModule()); #endif diff --git a/src/importexport/CMakeLists.txt b/src/importexport/CMakeLists.txt index 33a93a61a63c2..61d7a86be3e81 100644 --- a/src/importexport/CMakeLists.txt +++ b/src/importexport/CMakeLists.txt @@ -24,6 +24,9 @@ endif() if (MUE_BUILD_IMPEXP_BB_MODULE) add_subdirectory(bb) endif() +if (MUE_BUILD_IMPEXP_ENCORE_MODULE) + add_subdirectory(encore) +endif() if (MUE_BUILD_IMPEXP_BWW_MODULE) add_subdirectory(bww) endif() diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt new file mode 100644 index 0000000000000..b1233a561acf0 --- /dev/null +++ b/src/importexport/encore/CMakeLists.txt @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: GPL-3.0-only +# MuseScore-Studio-CLA-applies +# +# MuseScore Studio +# Music Composition & Notation +# +# Copyright (C) 2026 MuseScore Limited and others +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +muse_create_module(iex_encore) + +target_sources(iex_encore PRIVATE + enc-module.cpp + enc-module.h + ienc-importconfiguration.h + internal/enc-importconfiguration.h + internal/enc-importconfiguration.cpp + internal/parser/parsers-note.cpp + internal/parser/parsers-chord.cpp + internal/parser/parsers-ornament.cpp + internal/parser/parsers-lyric-tie.cpp + internal/parser/parsers-measure.cpp + internal/parser/parsers-metadata.cpp + internal/parser/parsers-text.cpp + internal/parser/parsers-root.cpp + internal/parser/parsers-encoding.h + internal/parser/parsers-encoding.cpp + internal/parser/readers.h + internal/parser/readers.cpp + internal/parser/readers-v0xa6.h + internal/parser/readers-v0xa6.cpp + internal/parser/readers-v0xc4-base.h + internal/parser/readers-v0xc4-base.cpp + internal/parser/readers-v0xc2.h + internal/parser/readers-v0xc2.cpp + internal/parser/readers-v0xc4.h + internal/parser/readers-v0xc4.cpp + internal/parser/elem.h + internal/importer/mappers-clefs.cpp + internal/importer/mappers-title.cpp + internal/importer/mappers-instruments.cpp + internal/importer/mappers-tempo.cpp + internal/importer/mappers-articulations.cpp + internal/importer/mappers.h + internal/parser/ticks.cpp + internal/parser/ticks.h + internal/importer/emitters-tuplets.cpp + internal/importer/emitters-tuplets-groups.cpp + internal/importer/emitters-tuplets.h + internal/notationencreader.cpp + internal/notationencreader.h + internal/importer/import.cpp + internal/importer/page-layout.h + internal/importer/page-layout.cpp + internal/importer/debug-dump.h + internal/importer/debug-dump.cpp + internal/importer/ctx.h + internal/importer/builders.h + internal/importer/builders-parts.cpp + internal/importer/builders-measures.cpp + internal/importer/emitters-internal.h + internal/importer/coords.h + internal/importer/coords.cpp + internal/importer/durations.h + internal/importer/durations.cpp + internal/importer/emitters-note.cpp + internal/importer/emitters-note-grace.cpp + internal/importer/emitters-note-artic.cpp + internal/importer/emitters-rest.cpp + internal/importer/emitters-orn.cpp + internal/importer/emitters-chords.cpp + internal/importer/emitters-lyrics.cpp + internal/importer/emitters-tempo.cpp + internal/importer/emitters-fill.cpp + internal/importer/emitters-overfill.cpp + internal/importer/emitters.cpp + internal/importer/resolvers.h + internal/importer/resolvers.cpp + internal/importer/resolvers-slur.cpp + internal/importer/resolvers-hairpin.cpp + internal/importer/resolvers-ornaments.cpp + internal/importer/resolvers-fingering.cpp + internal/importer/resolvers-ottava.cpp + internal/importer/import.h + internal/importer/import-options.h +) + +target_link_libraries(iex_encore PRIVATE engraving) + +if (MUE_BUILD_IMPORTEXPORT_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/importexport/encore/ENCORE_FORMAT.md b/src/importexport/encore/ENCORE_FORMAT.md new file mode 100644 index 0000000000000..688eef2032064 --- /dev/null +++ b/src/importexport/encore/ENCORE_FORMAT.md @@ -0,0 +1,1313 @@ +# Encore (.enc) binary format + +Binary format reference for Encore `.enc` files, independent of any implementation. +First documented by Felipe Castro (enc2ly) and Leon Vinken (Enc2MusicXML, GPL v3+), +extended by reverse engineering across a large corpus of files spanning every version. + +This document describes only the bytes in a `.enc` file: what is stored, where, and what it +means. How a parser turns those bytes into a score (gap filling, tuplet reconstruction, voice +remapping, spanner-endpoint resolution, and similar heuristics) is documented separately in +[ENCORE_IMPORTER.md](ENCORE_IMPORTER.md). + +## Provenance tags + +Every non-trivial fact carries one of three tags: + +| Tag | Meaning | +|--------------|-------------------------------------------------------------------------------| +| `[verified]` | Round-tripped against Encore itself, or confirmed by a binary diff of two saves that differ in one attribute. | +| `[observed]` | Consistent across many corpus files but not proven against Encore. | +| `[assumed]` | Plausible and consistent with the data, but neither round-tripped nor diffed. | + +Untagged statements are structural facts read directly from the byte stream (block framing, +element headers, field offsets a parser depends on to advance). + +--- + +## Overview + +### File anatomy + +A file is a fixed header followed by a sequence of length-prefixed blocks. Each block begins with +a 4-byte ASCII magic and a 4-byte size (`varsize`), then `varsize` bytes of content. + +| Block | Count | Contents | +|---------------|------------|------------------------------------------------------| +| header | 1 | version, counts, global staff size (fixed layout, no magic) | +| `TK00`..`TKnn`| 0..n | one per instrument: name, MIDI program, Key transposition | +| `PAGE` | 0..n | page geometry (not decoded) | +| `LINE` | 1 per system | staves, clef/key/size per staff | +| `MEAS` | 1 per measure | notes, rests, ornaments, and other elements | +| `PREC` | 0..1 | printer / page-setup state | +| `WINI` | 0..1 | page margins | +| `TITL` | 1 per page | title, subtitle, author, copyright, header, footer | +| `TEXT` | 0..n | text payloads referenced by staff-text ornaments | + +Blocks appear roughly in the order above, but a parser locates each one by scanning for its magic +rather than assuming a fixed order; unknown bytes between known magics are skipped. + +### Magics and byte order + +The first four bytes are the file magic. It selects the byte order used for every multi-byte +integer in the file (with one exception, the TK size field, noted under [Instrument block](#instrument-block)). + +| Magic | Storage | Byte order | Notes | +|--------|-----------|---------------|------------------------------| +| `SCOW` | plaintext | little-endian | Windows Encore, all versions | +| `SCO5` | plaintext | big-endian | macOS Encore 5 | +| `SCOX` | plaintext | , | rare variant `[observed]` | +| `SCOR` | plaintext | , | rare variant `[observed]` | +| `SCOS` | plaintext | , | rare variant `[observed]` | +| `ZBOT` | encrypted | , | Encore 4.x default `[observed]` | +| `ZBOP` | encrypted | , | encrypted variant `[observed]` | +| `ZBO6` | encrypted | , | encrypted variant `[observed]` | + +**Encryption.** In a `ZBOT` / `ZBOP` / `ZBO6` container only the first 42 bytes decrypt with a +known XOR key; beyond that the stream is algorithmically generated and unbroken `[observed]`. The +plaintext `SCOW` form is structurally different, so these files cannot be read without re-saving +from Encore as `SCOW`. + +### Version byte and release mapping + +The byte at file offset `0x04` (present only in the plaintext containers) is the format version. + +| Byte | Format | Encore release (app version at header `0x28`) | Tag | +|--------|--------|----------------------------------------------------------------|--------------| +| `0xA6` | v0xA6 | Encore 2.x only (app 592) | `[verified]` | +| `0xC2` | v0xC2 | Encore 3.x (app 773) and Encore 4.0-4.2 (app 775) | `[verified]` | +| `0xC4` | v0xC4 | Encore 4.5 through 5.x (app 1056) | `[verified]` | + +Notes on the mapping: + +- A genuine Windows Encore 3.0 save already uses `SCOW` with version byte `0xC2` and app version + 773, so `0xA6` is Encore 2.x only `[verified]`. Encore 4.5 opens a v0xA6 file with a font / + conversion prompt and re-saves it as v0xC4, so no 4.5 or later build produces v0xA6. +- v0xC4 spans Encore 4.5 through 5.x: both write version byte `0xC4` and app version 1056, so + neither field alone identifies the release. The byte at header offset `0x3E` is a + format-revision counter that does, constant for a given Encore build regardless of score + content: `0` on early pre-4.5 files, `1` on Encore 4.5, `4` on Encore 5.0 `[verified]`. Encore + 4.5 refuses a file whose `0x3E` revision is newer than it supports, which is why a 5.0 file + fails to open in 4.5. Dated saves corroborate the split: `0x3E=1` files run 1999-2008, `0x3E=4` + files never appear before 2009 `[observed]`. + +`SCO5` files carry no version byte at `0x04` (the byte order is big-endian and `chuMagio` is not +`0xC4`); they are the macOS variant of the v0xC4 format and are recognised by the `SCO5` magic +alone. + +### Conventions + +- **Element offsets.** Every element field offset in this document is measured from the element + start: `+0` is the first tick byte, so the type/voice byte is `+2`, the size byte `+3`, the + staff byte `+4`, and the element body begins at `+5`. This holds for all versions and all + element types, including the v0xA6 compact ornament (whose fields are given as element-relative + offsets even where the code computes them from the type/voice byte). +- **Ticks.** 240 ticks per quarter note; see [Rhythm encoding](#rhythm-encoding). +- **Text.** Text-bearing fields are Latin-1 or UTF-16 LE, chosen per field by a probe defined + once under [Encoding probe](#encoding-probe). +- **varsize.** The 4-byte size after each block magic gives the content length, excluding the + 8-byte magic + size header (except in the TK "total-size" variant, noted in place). + +--- + +## Header + +Fixed-layout region at the start of the file (no magic of its own). Ends at offset `0xC2` (194 +bytes) in v0xC2 / v0xC4 / SCO5, and at offset `0xA6` (166 bytes) in v0xA6 `[verified]`; the first +block begins immediately after. + +| Offset | Size | Description | +|--------|------|----------------------------------------------------------------------------| +| `0x00` | 4 | magic (`SCOW`, `SCO5`, ...) | +| `0x04` | 1 | format version byte (see [Version byte](#version-byte-and-release-mapping); absent in `SCO5`) | +| `0x28` | 2 | Encore app version: 592 (v0xA6), 773 / 775 (v0xC2), 1056 (v0xC4) | +| `0x2A` | 2 | purpose unconfirmed (possibly total LINE-staff-entry count) `[assumed]` | +| `0x2C` | 2 | default beat ticks (240 = quarter-note grid); matches MEAS `+0x04` | +| `0x2E` | 2 | number of system (LINE) blocks | +| `0x30` | 2 | number of pages | +| `0x32` | 1 | number of instrument (TK) blocks | +| `0x33` | 1 | staves per system | +| `0x34` | 2 | rendered measure count (see below) | +| `0x3E` | 1 | v0xC4 format revision: 0 = pre-4.5, 1 = 4.5, 4 = 5.0 `[verified]` | +| `0x52` | 1 | global staff-size selector (v0xC2 / v0xC4), 1-4 | +| `0x8D` | 1 | global staff-size selector (v0xA6), 1-4 | + +All other bytes up to the header end are padding. + +**Rendered measure count (`0x34`).** The number of measures Encore displays. A file can contain +extra "ghost" MEAS blocks left over from prior edits; only the first `0x34` MEAS blocks are real +(one observed file rendered 36 measures but held 56 MEAS blocks) `[observed]`. + +**Global staff-size selector.** A value 1-4 selecting one of four staff scales `[observed]`: + +| Selector | Scale | +|----------|-------| +| 1 | 60% | +| 2 | 75% | +| 3 | 100% | +| 4 | 130% | + +In v0xC2 / v0xC4 the selector lives at `0x52` and is only a global fallback; the authoritative +per-staff size is the LINE staff-entry byte `+13` (see [System block](#system-block-line)). In +Encore 4.x the `0x52` field is unrelated to size. In v0xA6 the selector lives at `0x8D` (byte +`0x52` is unrelated there) and applies to every staff, because v0xA6 has no per-staff LINE size. + +--- + +## Instrument block + +Magic `TK00`, `TK01`, ... one per instrument. Carries the instrument name, MIDI program, and Key +transposition. + +**Name.** Latin-1 or UTF-16 LE, chosen by the [Encoding probe](#encoding-probe), null-terminated +within the block. + +**Size-field byte order.** The 4-byte size field of a TK block is little-endian even in `SCO5` +files, whose other multi-byte fields are big-endian. A TK size of 112 is stored as `70 00 00 00`; +read big-endian that is `0x70000000`, whose low 16 bits are 0, which would bound every name length +to zero. Read this field little-endian (low 16 bits) regardless of the file byte order. + +### MIDI program and Key by layout + +The layout of the MIDI-program and Key-transposition fields depends on the TK block size +(`varsize`). `Key` is a signed `int8` in semitones matching Encore's Staff Sheet "Key" dropdown +(0 = sounds as written, -12 = octave lower, range about -33..+24); Encore shifts every note pitch +by this value at playback. MIDI program is a 1-indexed General MIDI number. + +| Layout | Detection | MIDI program offset | Key offset | +|----------------------------|------------------------------------------|--------------------------------|-------------------------------| +| Large-TK | `varsize > 250` | `2278 + n*2158` (fixed table) | `2278 - 23 + n*2158` | +| Small-TK, Encore 5.x | `0 < varsize <= 250`, stride = `varsize+8` | `contentStart + varsize + 76` | `contentStart + varsize + 53` | +| Small-TK, Encore 4.x total | `0 < varsize <= 250`, stride = `varsize` | `contentStart + 60` | `contentStart + 42` | +| v0xA6 | version byte `0xA6`, `varsize = 64` | `contentStart + 52` | `contentStart + 42` | + +Where `contentStart = TK_block_start + 8` (past the 8-byte magic + size) and `n` is the +instrument's sequential index. + +- In the **large-TK** layout the fixed table sits after the TK blocks (`base = 2278`, + `step = 2158`). +- The **small-TK Encore 4.x total-size** variant stores the total block size (including the + 8-byte header) in `varsize`, so the block stride equals `varsize` rather than `varsize + 8`; + actual content is `varsize - 8` bytes. Its MIDI/Key offsets match the v0xA6 layout `[verified]`. +- In **v0xA6** each TK block is 64 bytes total (56-byte content). The MIDI byte is at content + `+52`, NOT `+60` as in the 4.x total-size variant `[verified]`. The Key byte at content `+42` + is octave-only in practice (0, plus or minus 12/24). + +### No-TK-block files + +Some v0xC4 files and many v0xC2 files carry no TK blocks. The instrument metadata then lives in a +positional table; three sub-layouts occur, chosen by where the first PAGE/LINE/MEAS block starts +and whether a `~~~~` (`0x7E7E7E7E`) compact-table marker is present in the first 1 KiB. + +| Sub-layout | Detection | Name at | MIDI at | Key at | +|------------------------|------------------------------------|-----------------|------------------|----------------| +| Compact v0xC4 | first block <= 2278, no `~~~~` | `202 + n*112` | `390 + n*276` | `367` (1 instr)| +| Large-TK (no TK blocks)| first block > 2278 | `202 + n*2158` | `2278 + n*2158` | `2255 + n*2158`| +| Compact v0xC2 variant A| `~~~~` present | `314 + k*112` | `374 + k*112` | , | +| Compact v0xC2 variant B| no `~~~~`, first block <= 2278 | `202 + n*112` | `262 + n*112` | , | + +- In **variant A** (`~~~~` present) the entry table starts at 281 (name field `+33`, MIDI field + `+93`). Some instruments also have an explicit "primary block" at `202 + n*2158` (printable + ASCII at that offset marks it); such an instrument's MIDI is at `202 + n*2158 + 60`. +- In **variant B** (no `~~~~`) the entry table starts at 176 (name field `+26` giving base 202, + MIDI field `+86` giving base 262). All instruments are in this linear table. +- The name offset for instrument 0 is 202 in every compact layout; the step between consecutive + names is 2158 when the first block is beyond 2278, otherwise 112. + +An instrument that has its own TK block whose name field is empty is genuinely unnamed; the +positional offsets must not be probed for it (the formula offset `202 + n*2158` would land on +unrelated music bytes). + +**Percussion program.** Percussion tracks always report MIDI program 1 (General MIDI Grand +Piano) `[observed]`; the actual kit is not stored and can only be inferred from the track name. A +MIDI value of 113 or above (the General MIDI percussive range) indicates a drum instrument. + +--- + +## System block (LINE) + +Magic `LINE`, one per system. A 21-byte header followed by N staff entries of 30 bytes each, where +N is the header's staves-per-system. + +### LINE header (21 bytes) + +| Offset | Size | Description | +|--------|------|----------------------------------------------------| +| `+10` | 2 | 0-based index of the first measure in this system | +| `+12` | 1 | number of measures in this system | + +In `SCO5` (big-endian) the single-byte measure count reads as 0 because the meaningful byte sits +in the other half of a wider big-endian field, but the first-measure index stays correct. + +### LINE staff entry (30 bytes) + +| Offset | Size | Description | +|--------|------|-------------------------------------------------------------------------| +| `+13` | 1 | staff display size, 0-indexed selector (see below) | +| `+14` | 1 | clef type (see [Clef types](#clef-element)) | +| `+15` | 1 | key signature (see [Key encoding](#key-encoding)) | +| `+16` | 1 | page-row counter for this system (NOT the page number) | +| `+19` | 1 | visibility: `0x00` = hidden, non-zero = visible | +| `+20` | 1 | staff type: 0 = melody, 1 = tablature, 2 = single-line percussion | +| `+21` | 1 | packed staff index: bits 0-5 = instrument, bits 6-7 = staff within instrument | + +The display-size selector at `+13` is 0-indexed (0 = 60%, 1 = 70%, 2 = 75%, 3 = 100%) +`[observed]`; it is populated in both 4.x and 5.x files and is the authoritative per-staff size, +with header `0x52` as a global fallback. The packed staff index at `+21` uses the same encoding as +the element [Staff byte](#staff-byte-encoding). The staff type at `+20` is constant across all +LINE blocks for the same staff position. + +### v0xA6 staff size and clef + +v0xA6 reports staves-per-system as 0 and uses a 22-byte staff entry (not the 30-byte v0xC2/C4 +entry). Each entry carries a `0x0E 0xFC` marker at offset 16 that bounds the run of entries. +Consequences: + +- **Key signature** is at offset `+14` of each 22-byte entry (same index encoding as the v0xC2/C4 + key field) `[observed]`. It is not where v0xC2/C4 keep it, so it must be read from these entries + directly. +- **Staff size** is the single global value at header `0x8D`, applied to every staff. +- **Clef** has no located per-staff field in v0xA6 `[assumed]`. Encore renders the correct clefs + from these files, so the information is available to Encore, but the byte was not found in TK + content, the 22-byte LINE entries, MEAS blocks (no initial clef elements), or PAGE blocks. The + nearest candidate, TK content `+49`, reads 7 on a snare staff but 0 on a bass staff Encore still + shows in F, so it is more likely a staff-type field than a clef. Whether the clef is stored + elsewhere or derived from the instrument default is unproven; one file whose "Bajo" staff (MIDI + program 59) opens as Tuba and shows bass clef (the Tuba default) is consistent with the + instrument-derived hypothesis but does not prove it. A definitive test needs an Encore build + that writes v0xA6 (Encore 2.x), which was unavailable. + +--- + +## PAGE block + +Magic `PAGE`. Page geometry. Not decoded; a parser skips it. Page count comes from header `0x30`, +and page size / orientation / scale from the [PREC block](#prec-block). + +--- + +## Measure block (MEAS) + +Magic `MEAS`. A fixed header followed by a variable element stream terminated by a `0xFFFF` tick. +The header is 54 bytes (`0x36`) in v0xC2 / v0xC4 / SCO5 and 26 bytes (`0x1A`) in v0xA6; the element +stream begins right after `[verified]`. + +### Measure header + +| Offset | Size | Description | +|--------|------|----------------------------------------------------------------| +| `0x00` | 2 | quarter-note BPM; applies forward until the next change | +| `0x02` | 1 | time-signature glyph (see below) | +| `0x04` | 2 | beat ticks (standard 240 for x/4) | +| `0x06` | 2 | total ticks in the measure | +| `0x08` | 1 | time-signature numerator | +| `0x09` | 1 | time-signature denominator | +| `0x0C` | 1 | start barline type (see below) | +| `0x0D` | 1 | end barline type (same table) | +| `0x0F` | 1 | volta (repeat-alternative) bitmask (see below) | +| `0x1A` | 4 | repeat-mark field; low byte = repeat type (see below) | + +Bytes `0x10`..`0x35` outside the fields above hold layout data (measure width, x-offsets, a +"Writer" UTF-16 tag). An unrelated layout field at `+0x18` holds a constant 200 in v0xC4 files; +it is not a BPM. + +**Beat ticks vs whole-note ticks.** Standard beat-ticks are 240 for x/4, 120 for x/8, 480 for +x/2, and 360 for a compound dotted-quarter beat. Some builds store 240 even for 2/2 (correct value +480), so the whole-note tick count must not be computed from beat-ticks; see +[Rhythm encoding](#rhythm-encoding). + +**BPM** is always quarter-note BPM regardless of meter. In 3/8, 5/8, etc. Encore's UI shows +eighth-note BPM (twice the stored value) but the file stores quarter-note BPM. + +#### Time-signature glyph + +| Value | Meaning | +|----------------|----------------------------------------------------------------------------| +| `0x00` | numeric display; show numerator / denominator digits | +| `0x43` | common time "C" (numerator 4, denominator 4); Encore 3.x / 4.x | +| `0x63` | common time "C" (numerator 4, denominator 4); Encore 5.x | + +Other values (`0x01`, `0x02`, `0x06`, `0x07`) appear on unusual meter strings `[observed]`; treat +as numeric display. + +#### Barline types + +| Value | Meaning | +|-------|----------------| +| 0 | normal | +| 2 | repeat start | +| 3 | double (left) | +| 4 | repeat end | +| 5 | final | +| 6 | double (right) | +| 8 | dotted | + +A non-repeat divider (double or dotted) that visually sits between two measures is stored on the +right measure's start barline (`0x0C`), not the left measure's end barline (`0x0D`). A start +barline of type 3/6/8 therefore describes the divider at the end of the preceding measure; a +repeat-start (type 2) at `0x0C` genuinely belongs to its own measure. + +#### Volta bitmask + +Byte `0x0F` is a bitmask: bit `n` set means the measure belongs to ending `n+1` (bit 0 = ending 1, +bit 1 = ending 2, ...). Encore sets the same bitmask on every measure inside an ending, not only +the first. Consecutive measures with the same non-zero bitmask form one multi-ending bracket. + +Encore stores no explicit "times played" count for a repeat-end barline. The number of passes is +implied by the endings: it is the highest ending number among the brackets in the repeat. A first +ending of "1.-3." (bits 0+1+2 = `0x07`) followed by a "4." ending (bit 3) plays four times. A +repeat with no alternate endings plays twice. + +#### Repeat-mark ladder (low byte of `0x1A`) + +| Byte | Meaning | +|--------|---------------------------------------------------------------| +| `0x80` | D.C. al Coda | +| `0x81` | D.S. al Coda | +| `0x82` | D.C. al Fine | +| `0x83` | D.S. al Fine | +| `0x84` | D.S. | +| `0x85` | "To Coda" source (player jumps from here) | +| `0x86` | Fine | +| `0x87` | D.C. | +| `0x88` | Segno | +| `0x89` | Coda destination (player jumps to here) | + +`0x85` and `0x89` are a pair: `0x85` is the To-Coda navigation point, `0x89` the Coda target. +Ornament subtype `0xA5` is the parallel encoding of the same "To Coda" navigation point. + +--- + +## Element stream + +Every element begins with a fixed 5-byte header. + +| Offset | Size | Field | +|--------|------|--------------------------------------------------------------------| +| `+0` | 2 | tick within the measure; `0xFFFF` terminates the stream | +| `+2` | 1 | type/voice byte: high nibble = type, low nibble = voice | +| `+3` | 1 | size (total element length in bytes, from `+0`) | +| `+4` | 1 | staff byte (see below) | + +The element body begins at `+5`. In v0xA6 the on-disk element slot is twice the declared size: +stride = `size * 2` `[verified]`. + +### Element types + +| Type | Name | +|------|----------------------------| +| 0 | none | +| 1 | clef | +| 2 | key change | +| 3 | tie | +| 4 | beam | +| 5 | ornament | +| 6 | lyric | +| 7 | chord symbol (harmony text)| +| 8 | rest | +| 9 | note | +| 11 | MIDI control change | + +Type 7 is a chord SYMBOL (harmony text above the staff), not a note chord. + +### Staff byte encoding + +Byte `+4` names the destination staff, using the same encoding as the LINE packed staff index. + +| Bits | Mask | Meaning | +|------|--------|----------------------------------------------------------------------| +| 0-5 | `0x3F` | instrument index (0-based, sequential per instrument) | +| 6-7 | `0xC0` | staff within the instrument (0 = first, 1 = second, 2/3 = further) | + +The raw byte (staff-within bits in 6-7, instrument index in 0-5) equals the packed staff index the +LINE block stores for the target staff, and is resolved to a global staff by inverse lookup through +the LINE staff indices. The instrument index in bits 0-5 equals the LINE slot only when every +instrument has one staff; for multi-staff instruments it does not (see [Multi-staff instruments](#multi-staff-instruments)). + +### Voice field + +The low nibble of the type/voice byte is the voice, 0-7. Beyond the four ordinary voices two +special uses occur: + +- **voice 4** is a marker with two meanings resolved by the LINE multi-staff configuration. On a + grand-staff instrument it marks second-staff content and is combined with the `0x40` bit of the + staff byte (so the staff-within bits read 1). On a single-staff instrument it is a genuine second + melodic voice, or, in an SATB part whose only line is stored as voice 4, the sole voice. +- **voices 5, 6, 7** are additional voices on the element's own staff (they do not indicate a + second staff). + +A voice may carry more than one interleaved MIDI tick stream (for example from live recording); a +backwards tick, or a non-chord event arriving after the voice is full, marks a fresh stream. A +voice may also carry a redundant plain (non-tuplet) rest at the same tick as a real note (Encore +writes a rest slot for the voice even where the note sits); this differs from same-tick tuplet +members, which are genuine sequential members. + +### MIDI control change (type 11) + +Inline MIDI Control Change events, stored for playback only; they carry no notation. Always 12 +bytes. + +| Offset | Size | Description | +|--------|------|---------------------------------------------------------| +| `+0` | 2 | tick | +| `+2` | 1 | type/voice = `0xBn` | +| `+3` | 1 | size = 12 | +| `+4` | 1 | MIDI channel / track index | +| `+5` | 1 | CC event marker (`0xB0` = channel-0 control change) | +| `+6` | 4 | zeros | +| `+10` | 1 | controller number (64 = sustain, 7 = volume, 1 = modulation) | +| `+11` | 1 | value (127 = max/on, 0 = off) | + +### Implicit silences + +Encore does not always emit explicit rest elements. A gap between two consecutive same-voice +events (the next tick exceeds the previous tick plus the previous note's face-value duration) +represents a silence the user wrote as a rest. + +--- + +## Note element + +The note carries a face value (duration and notehead), a MIDI pitch, grace/tie flags, an optional +tuplet ratio, and optional articulations. Layout differs by version. + +**Face value (`+5`).** High nibble = notehead type (0 = normal, 1 = diamond, 2 = triangle-up, +3 = square, 4 = cross, 5 = X-in-circle, 6 = plus, 7 = slash, 8 = large open diamond, 9 = invisible). +Low nibble = duration (1 = whole, 2 = half, 3 = quarter, 4 = eighth, ... 8 = 128th). + +**Byte `+14` (`dotControl`).** In every version this is a layout/display byte, not a dot count: +bit 0 is an unreliable "dotted" hint that also appears set on plain notes (values such as `0x28`, +`0x30`, `0x39`, `0x60` on plain 8ths/16ths) `[observed]`. The reliable dot source is the sounding +duration relative to the face value; see [Rhythm encoding](#rhythm-encoding). + +### v0xC4 note (size 28) + +| Offset | Size | Description | +|--------|------|-------------------------------------------------------------------------| +| `+5` | 1 | face value | +| `+6` | 1 | grace1 (grace/cue flags, see below) | +| `+7` | 1 | grace2 | +| `+10` | 1 | xoffset (notated horizontal column; see [Chord column](#chord-column-xoffset)) | +| `+12` | 1 | staff-relative pitch, diatonic steps from C4 (display hint; see below) | +| `+13` | 1 | tuplet byte: high nibble = actual count, low nibble = normal count | +| `+14` | 1 | dotControl (see above) | +| `+15` | 1 | MIDI pitch (0-127) | +| `+16` | 2 | playback duration in ticks (recorded MIDI; diverges from notated for tuplets/ties) | +| `+19` | 1 | velocity | +| `+20` | 1 | options | +| `+21` | 1 | accidental / alteration glyph | +| `+24` | 1 | articulation byte (above slot) | +| `+26` | 1 | articulation byte (below slot) | + +The staff-relative pitch at `+12` counts diatonic steps from C4 (C4 = 0, D4 = 1, ... A5 = 12). On +pitched staves it is a legacy display hint. On a percussion-clef staff it instead encodes the +visual staff line: `line = max(-4, 10 - position)`, placing A4 on the middle line `[observed]`. + +### v0xC2 note (size 22 or 24) + +More compact than v0xC4. Two pitch-storage sub-variants exist, distinguished by whether `+15` +holds a plausible MIDI pitch (at least C0, MIDI 12): + +- **Sub-variant A:** `+15` is empty or a small stray flag (observed 1 or 3). MIDI pitch is at + `+13` (the slot v0xC4 uses for the tuplet byte); there is no explicit tuplet byte. +- **Sub-variant B:** `+15` holds a plausible pitch (the standard slot). `+13` then holds a genuine + tuplet ratio (for example `0x32` = 3:2). + +A value below C0 at `+15` cannot be a real note, so the pitch comes from `+13` in that case; a bare +"non-zero at `+15`" test is wrong in both directions. + +| Offset | Size | Description | +|--------|------|-------------------------------------------------------------------| +| `+5` | 1 | face value | +| `+6` | 1 | grace1 | +| `+7` | 1 | grace2 | +| `+10` | 1 | xoffset | +| `+13` | 1 | MIDI pitch (sub-variant A) or tuplet ratio (sub-variant B) | +| `+14` | 1 | dotControl | +| `+15` | 1 | MIDI pitch (sub-variant B); stray flag in sub-variant A | +| `+16` | 2 | playback duration in ticks | +| `+19` | 1 | velocity | +| `+20` | 1 | options | +| `+21` | 1 | alteration glyph | +| `+22` | 1 | articulation byte (size 24 only) | +| `+23` | 1 | placement/direction flag, size 24 only (`0x01` or `0x08`; not a second articulation) | + +Size 22 notes carry no articulation slot; size 24 notes add one at `+22`. A `dotControl` of `0xC0` +is characteristic of size-24 notes (a layout flag; bit 0 clear, so not dotted). + +In v0xC2, when a grace note is a tie sender its `grace1` low nibble is 1 (`grace1 & 0x0F == 1`); in +v0xA6 and v0xC4 that nibble is always 0. + +### v0xA6 note (size 10, on-disk slot 20) + +| Offset | Description | +|--------|----------------------------------------------------------| +| `+5` | face value | +| `+6` | grace1 (see below) | +| `+7` | tuplet byte (3:2 = `0x32`, 5:4 = `0x54`, ...) | +| `+9` | staff-position / diatonic line, NOT the MIDI pitch | +| `+11` | MIDI pitch (absolute 0-127) | + +A v0xA6 note that carries one articulation is written as size 11 (a 22-byte slot); the layout is +otherwise identical (pitch at `+11`, tuplet at `+7`) with the single articulation byte at `+18` +(`0x20` there is a fermata above) `[verified]`. + +### Grace and cue notes + +The grace/cue flags come from Encore's "Grace / Cue Note" dialog `[verified]`. + +| Bit | Meaning | +|-----------------|-----------------------------------------------------------------------------| +| `grace1 & 0x20` | small note (grace or cue); an ordinary note leaves it clear | +| `grace1 & 0x10` | member of a beamed grace group (e.g. a percussion ruff); does not change kind | +| `grace1 & 0x40` | attribute of the top chord member; unrelated to grace/cue (also a chord-extension marker, see [Known quirks](#known-quirks)) | +| `grace2 & 0x04` | slash (acciaccatura) | +| `grace2 & 0x01` | muted (playback off); a per-note flag independent of size and kind | + +Among small notes (`grace1 & 0x20`), a slash (`grace2 & 0x04`) marks an acciaccatura; a small +no-slash note is either an appoggiatura (when it ornaments an adjacent principal note) or a cue +(a full-value small note standing alone). The two are byte-identical and distinguished only by +context. A cue is small and muted by default, but any note can be muted and a cue can be un-muted. + +The played length is the playback duration at `+16`, equal to the dialog's "Scale duration by N%" +applied to the face value. A cue keeps its full beat value in the measure (a normal note drawn +small, muted by default); a grace occupies no measure time and borrows from an adjacent note. + +A slur can begin on a grace note stored at the same tick as its parent chord (a grace shares its +parent's written tick), so such a slur has no distinct start tick of its own. In v0xC4 Encore +serializes the main note before its grace at the same beat; in v0xC2 the grace is written first +`[verified]`. + +### v0xA6 grace note time-borrowing + +In v0xA6, grace notes occupy real tick positions (they are not co-located with the main note). A +grace at a real tick pushes the following notes forward, so the last real note in the group ends +with a raw gap to the measure end that is smaller than its face value: the grace "borrowed" that +time. Inner graces (`grace1 & 0x30 == 0x10`) after a leading grace (`grace1 & 0x30 == 0x20`) have a +strictly larger face-value number (a shorter note). (The reconstruction rule that restores the +borrowed duration lives in ENCORE_IMPORTER.md.) + +--- + +## REST element + +| Offset | Size | Description | +|--------|------|-------------------------------------------------------------------------| +| `+5` | 1 | face value (same encoding as a note) | +| `+10` | 1 | xoffset | +| `+13` | 1 | tuplet byte (same encoding as a note) | +| `+14` | 1 | dotControl (bitmask, not a tick count; bit 0 = unreliable dotted hint) | +| `+15` | 1 | multi-measure rest count (v0xC4; only when size > 15) | + +**Multi-measure rests.** When the count at `+15` is greater than 1, the single MEAS block +represents that many consecutive empty display measures (Encore draws one rest symbol with the +count above it). Multi-staff files emit one rest element per staff, all carrying the same count. + +The v0xC2 dotted-eighth timing quirk: the sixteenth of a dotted-eighth + sixteenth group has its +MIDI note-on stored at `tick + 120` (a plain eighth) rather than `tick + 180`, so the dotted eighth +reads as a plain eighth and its dotControl also lacks bit 0. The dot is not encoded in the bytes at +all in this case. + +--- + +## Articulation bytes + +Each articulation byte holds one or two glyphs. The combined range `0x22`-`0x2D` is laid out in +consecutive (below, above) pairs, one pair per glyph `[verified]`. + +| Value | Meaning | +|--------------|----------------------------------------------------| +| `0x01` | flat mark (not an articulation) | +| `0x02` | sharp / natural mark | +| `0x03` | 3-stroke tremolo (bare, no high-nibble flag) | +| `0x04` | trill (plain) | +| `0x05` | trill to minor 2nd (flat upper) | +| `0x06` | trill to augmented 2nd (sharp upper) | +| `0x07` | trill to major 2nd (natural upper) | +| `0x08` | turn | +| `0x09` | wave mark | +| `0x0A` | inverted mordent (short) | +| `0x0B` | mordent (simple lower) | +| `0x0C` | inverted mordent (long) | +| `0x0D`-`0x11`| fingering 1 to 5 | +| `0x12` | accent (>) | +| `0x13` | marcato (^) | +| `0x14` | staccato + heavy accent (below) | +| `0x15` | marcato + staccato | +| `0x16` | accent + staccatissimo | +| `0x17` | accent + staccato | +| `0x18` | up bow | +| `0x19` | down bow | +| `0x1A` | marcato (variant) | +| `0x1B` | stopped horn / brass (+) | +| `0x1C` | tenuto | +| `0x1D` | staccato | +| `0x1E`, `0x1F`| harmonic | +| `0x20`, `0x21`| fermata (or, on a tuplet note, tuplet-bracket placement) | +| `0x22`, `0x23`| tenuto + accent | +| `0x24`, `0x25`| tenuto + staccato (portato) | +| `0x26`, `0x27`| marcato + tenuto (heavy accent + tenuto) | +| `0x28`, `0x29`| staccatissimo | +| `0x2A`, `0x2B`| heavy accent + staccatissimo | +| `0x2C`, `0x2D`| tenuto + staccatissimo | +| `0x2E` | inverted turn | +| `0x2F` | mordent (double / long lower) | +| `0x30` | half-stopped horn (circle-plus) | +| `0x39`-`0x40`| scale string number 1 to 8 (`0x38 + N` = string N) | +| `0x41` | 1-stroke tremolo (8th) | +| `0x42` | 2-stroke tremolo (16th) | +| `0x43` | 3-stroke tremolo (32nd; some files render 4 strokes) | +| `0x44`, `0x45`| thumb position | +| `0x46` | open string (plain fingering "0") | +| `0x47` | drumstick ("stick") technique | +| `0x48` | brush | +| `0x49` | soft mallet | +| `0x4A` | hard mallet | + +"Heavy accent" is the wedge Encore writes as marcato. Values `0x44` and above are technical +markings, not tremolos. When at least one scale-string byte (`0x39`-`0x40`) appears in a measure, +every note in that measure with options bit 0 set displays its scale-degree position as a circled +string number. + +Each note in a chord carries its own articulation bytes. When several chord notes carry the same +byte it is simply repeated on each; the chord denotes at most one copy of each distinct glyph. + +--- + +## CLEF element + +Byte `+5` holds the clef type (same encoding as the LINE staff-entry clef byte). Later bytes are +padding. + +| Value | Clef | +|-------|------------| +| 0 | G (treble) | +| 1 | F (bass) | +| 2 | C3 (alto) | +| 3 | C4 (tenor) | +| 4 | G 8va | +| 5 | G 8vb | +| 6 | F 8vb | +| 7 | percussion | +| 8 | tablature | + +A clef element does not take effect at its own stored tick. It applies before the note or rest +that physically follows it in the stream on the same staff (Encore frequently stamps the clef with +an earlier tick). When a clef element is the last element on its staff in a measure, it is a +cautionary clef effective on the next measure's downbeat, drawn just before the current measure's +final barline. + +--- + +## Key encoding + +The key index (used by both the KEYCHANGE element and the LINE staff entry) maps to a position on +the circle of fifths: + +| Index | Fifths | Key | +|-------|--------|----------------| +| 0 | 0 | C | +| 1-7 | -1..-7 | F, Bb, ... Cb | +| 8-14 | +1..+7 | G, D, ... C# | + +Index 0 is a legitimate value (naturals cancel prior accidentals). + +## KEYCHANGE element + +Size 6. Byte `+5` is the key index above. + +--- + +## TIE element + +Size 16 or 18. Byte `+5` is a signed arc-curvature value (the vertical bow), NOT a bitfield; byte +`+6` is a tie-start flag. + +| Byte `+5` | Signed | Arc | +|-----------|--------|------------| +| `0x02` | +2 | curve down | +| `0x04` | +4 | curve down | +| `0xFE` | -2 | curve up | +| `0xFC` | -4 | curve up | + +All four values mark a real outgoing tie. Treating `+5` as a bitfield (for example +`(+5 & 0x80) || (+5 & 0x02)`) silently drops the equally valid `0x04`. + +**18-byte form.** Two additional bytes encode the visual x-positions of the arc endpoints, and +these are the authoritative forward-tie signal: + +| Offset | Description | +|--------|-----------------------------------------------------------------| +| `+10` | arc-start x (measure-relative pixels) | +| `+12` | arc-end x | +| `+14` | staff position of the source note (disambiguates chord members) | + +- `arcX1 < arcX2`: a genuine left-to-right span, a real forward tie regardless of `+5`. +- `arcX1 == arcX2`: zero horizontal extent, an intra-chord decorative arc (Encore connects two + chord notes vertically), NOT a forward tie, unless byte `+6` bit 7 is set, which marks a + cross-measure tie whose destination lives in the next measure and for which Encore stores + `arcX2 = arcX1` as a placeholder. Intra-chord arcs often appear in groups of 2-4 identical copies + at the same tick. + +**16-byte form.** With no arc x-positions, tie-start falls back to the byte signal +`(+5 & 0x80) || (+5 & 0x02) || (+6 & 0x80)`. + +A tie element marks only the start note; there is no matching tie-stop element. The receiver is the +next note of the same pitch on the same staff and voice. + +--- + +## Ornament element + +Type 5, variable size. Covers hairpins, slurs, trills, tempo marks, dynamics, staff text, breath +marks, tremolos, fingerings, bows, and articulation-like marks (distinguished by subtype). + +| Offset | Size | Description | +|--------|------|-------------------------------------------------------------------------| +| `+5` | 1 | subtype (see [Ornament subtypes](#ornament-subtypes)) | +| `+10` | 1 | xoffset (start x within the measure) | +| `+12` | 2 | signed s16 Cartesian y (negative = below staff, positive = above) | +| `+16` | 1 | v0xC2 forward measure-count (slurs; see [Slur](#slur)) | +| `+18` | 1 | forward measure-count to the end measure (v0xC4 spanners) | +| `+20` | 1 | end x within the target measure | +| `+26` | 1 | hairpin direction, bit 0: 0 = crescendo, 1 = diminuendo | +| `+28` | 1 | tempo beat unit (see below) | +| `+30` | 2 | tempo BPM (v0xC4; see below) | +| `+32` | 1 | staff-text entry index (when size >= 33; otherwise shared with `+30`) | + +The two low bits of the direction byte at `+26` are kept; see [hairpin direction](#hairpin-direction). + +**Tempo beat unit (`+28`).** Low 7 bits = note value (0 = whole, 1 = half, 2 = quarter, +3 = eighth, ...), high bit `0x80` = dotted; so `0x02` is a quarter, `0x82` a dotted quarter. A +value of 0 (or an out-of-range byte from an older format) means no explicit unit. + +**Tempo BPM.** In v0xC4 the BPM is at `+30`, expressed in the beat unit at `+28`. v0xC2 has two +layouts: newer files match v0xC4 (BPM at `+30`, beat-unit code 0-6 at `+28`), older files store +the BPM directly at `+28` with a constant unrelated byte (observed `0x34` = 52) in the `+30` slot +`[observed]`. Distinguish by `+28`: a valid beat-unit code means the BPM is at `+30`, otherwise +`+28` itself is the BPM. In that older layout the per-mark beat unit is at `+26` (same note-value +encoding), which matters in compound meters. + +**Staff-text entry index.** Present at `+32` only when the element is at least 33 bytes. In shorter +ornaments (notably v0xC2 size-32 staff-text elements) it is read from `+30`, sharing it with the +tempo byte. + +**v0xA6 compact ornament.** The v0xA6 ornament is compact (declared size 15, a 30-byte slot). Its +signed s16 y is at `+8` (not `+12`) and its staff-text entry index is at `+28` (not the size-based +slot) `[verified]`. + +### Ornament subtypes + +Subtype byte at `+5`. Sorted by value. + +| Value | Meaning | +|--------------|---------------------------------------------------------------------------| +| `0x10` | ottava 8va (line above staff) | +| `0x12` | ottava 8vb (line below staff) | +| `0x1C` | user-drawn graphic line (no musical meaning) | +| `0x1D` | hairpin start; end at forward measure-count (`+18`) and end-x (`+20`) | +| `0x1E` | staff text (payload in the TEXT block at entry index `+32`) | +| `0x21` | slur start; endpoint at forward measure-count and end-x | +| `0x22` | arpeggio | +| `0x28` | guitar bend (curved arrow up) | +| `0x29` | guitar bend (curved arrow) | +| `0x2A` | guitar prebend | +| `0x2B` | guitar prebend-release | +| `0x30` | guitar V-shape bend | +| `0x32` | tempo mark (BPM at `+30` in the beat unit at `+28`) | +| `0x35` | trill-span end (no visible glyph) | +| `0x36` | trill-span start (tr + wavy line) | +| `0x37` | secondary trill mark within a span (plain trill glyph) | +| `0x41` | slur stop (reserved; not emitted in practice) | +| `0x4D` | hairpin stop (reserved; not emitted in practice) | +| `0x80`-`0x87`| dynamics ppp, pp, p, mp, mf, f, ff, fff | +| `0x88` | dynamic sfz | +| `0x89` | dynamic sffz | +| `0x8A` | dynamic fp | +| `0xA2` | segno | +| `0xA3` | "%" repeat-last-bar glyph (size 16) | +| `0xA5` | "To Coda" navigation point | +| `0xA6` | Coda glyph | +| `0xA7` | caesura (//) after the preceding note | +| `0xA8` | comma breath mark after the preceding note | +| `0xAA` | dynamic fz | +| `0xAB` | dynamic sf | +| `0xAF` | single-chord triple tremolo (3 slashes = 32nd); always voice 0 | +| `0xB0` | standalone "tr" mark (size 16; plain trill, never a span) | +| `0xB6` | standalone short-trill mark (size 16; never a span) | +| `0xB8` | double lower mordent | +| `0xB9`-`0xBD`| standalone fingering digit 1 to 5 (size 16) | +| `0xBE` | accent (>) (v0xC4; v0xC2 uses `0xC4`) | +| `0xBF` | marcato (^, vertex up) | +| `0xC0` | marcato + staccato below | +| `0xC4` | up-bow (v0xC4); accent (v0xC2) | +| `0xC5` | down-bow | +| `0xC6` | marcato below (vertex down) | +| `0xC8` | tenuto dash above | +| `0xC9` | per-chord staccato dot | +| `0xCC` | standalone fermata above (size 16; y > 0) | +| `0xCD` | standalone fermata below (size 16; y < 0) | +| `0xE6`, `0xE7`| 1-slash tremolo (eighth speed) | +| `0xE9`, `0xEA`| 4-slash tremolo (sixty-fourth speed) | +| `0xEE` | 2-slash tremolo (sixteenth speed) | +| `0xEF` | alternate triple tremolo (element at `tick == durTicks`; 32nd speed) | + +Subtypes marked "confirmed by opening the file in Encore 5" are `[verified]`; the rest are +`[observed]`. A trill-span start (`0x36`) opens a trill + wavy-line span when a `0x35` or a +non-zero forward measure-count is present, otherwise it is a plain trill glyph. `0xB0` and `0xB6` +are always standalone. The accent, up-bow and down-bow marks (`0xBE`, `0xC4`, `0xC5`) carry a voice +byte that is always 0 regardless of the annotated note's voice; `0xC4` denotes an accent in v0xC2 +(where size-22 notes have no articulation slot) but an up-bow in v0xC4. + +### Hairpin direction + +Byte `+26` bit 0: 0 = crescendo, 1 = diminuendo. Encore 5 also sets bit 1 (crescendo = `0x02`, +diminuendo = `0x03`); legacy files use `0x00` / `0x01`. Test with `& 0x01`, not `== 0`. + +### Spanner endpoints + +Hairpins and slurs store no separate stop element. The end is described by the forward +measure-count (`+18`) plus the end-x within that target measure (`+20`). + +- In v0xC2 the `+18` count is unreliable for hairpins (often stale or zero), so it should not be + trusted for non-slur ornaments. +- Ottava elements (`0x10` / `0x12`) store no endpoint at all: `+14` is the visual right edge of the + "8va" text box (a cosmetic constant around 12 px), and the `+18` slot falls outside the element, + reading the next element's type/voice byte `[observed]`. + +**Ornament y sign.** The signed y at `+12` is negative for below the staff, positive for above. A +dynamic dragged up onto the staff above the one that owns it keeps its owner's staff byte but flips +its y positive. A dynamic or staff-text ornament whose tick exceeds the measure's total ticks is a +section-end marker (for example volta dynamics at the end of a short measure). A file can also +carry two dynamics at the same tick and x on one staff and voice (an identical pair, or a +score-view/part-view pair differing only in y placement); Encore renders one dynamic per beat. + +### Slur + +A slur start (`0x21`) stores the forward measure-count and end-x like other spanners, but the +reliable field differs by version: + +- **v0xC4 / SCO5:** the end-x at `+20` is meaningful. `endX - startX` equals the pixel distance + between the first and last covered notes (`endNote.xoffset - firstNote.xoffset`). xoffset is + stored as a signed byte but must be read unsigned for this arithmetic (values above 127 are + stored negative). +- **v0xC2:** the absolute end-x lives in a stale coordinate origin and must not be matched + directly. The slur's forward measure-count is at `+16` (not `+18`) and is usually reliable, + including the value 0 (a within-measure slur). It is unreliable for a whole file when any slur's + `+16` points past the last measure, or when the same multi-measure value (3 or more) repeats + across slurs starting in different measures (a per-staff constant rather than a real count). + +--- + +## Lyric element + +Variable size, null-terminated text (not fixed-width). The text offset differs by version. + +| Version | Anchor byte | Text offset | +|---------|-------------|-------------| +| v0xC4 | `+10` | `+0x14` | +| v0xC2 | `+10` | `+0x12` | +| v0xA6 | `+5` | `+6` | + +The anchor byte (`+10` in v0xC4/v0xC2, `+5` in v0xA6) is an x-offset-like layout value. In v0xA6 +there is no anchor-plus-gap run: a single control byte follows the staff byte, then the text; and +like every v0xA6 element the on-disk slot is twice the declared size. + +Observed sizes: v0xC4 24 (dash), 26 (word-break), 30/32/34 (2/3/4 chars); v0xC2 20 (dash), 22/24/26 +(1-5 chars); v0xA6 declared 5-8 (10-16 bytes on disk) for 2-6 character syllables `[observed]`. + +**Separators.** A `"-"` is a hyphen between syllables of one word; an empty string is a word-break +(resets hyphen state); anything else is a real syllable. A hyphen can open the measure after the +syllable it follows (a word breaking across a barline). + +**Multi-verse.** Verse N uses voice N-1 on the same staff; all verses anchor on the voice-0 chord. +Encore stores the first verse (voice 0) with correct per-syllable ticks, but every later verse +stores tick 0 on all its syllables and distinguishes their positions only by the anchor byte at +`+10`, which matches the first verse's x-offsets syllable for syllable. The syllables are not +necessarily stored in x-offset order. + +--- + +## CHORD symbol element + +Type 7, variable size. A chord symbol (harmony marking) above the staff. + +| Offset | Size | Field | Description | +|--------|------|---------|--------------------------------------------------------------------| +| `+5` | 1 | quality | chord quality index 0-63 (see table) | +| `+6` | 1 | flags | bit 0 = text present, bit 1 = bass note present, bit 2 = guitar frame drawn | +| `+10` | 1 | xoffset | horizontal display offset | +| `+12` | 1 | root | root note (see root encoding) | +| `+13` | 1 | bass | bass note (same encoding; valid only when flags bit 1) | +| `+14` | 36 | text | chord-text slot (present when flags bit 0; UTF-16 LE or Latin-1) | + +When flags bit 0 is set, the text slot overrides the quality and root (the name is taken from the +text). Flags bit 2 records whether Encore draws a guitar frame (fretboard diagram) above the +symbol; it is independent of whether the chord name is recognisable. + +**Root / bass encoding.** Low nibble = note name (0=C, 1=D, 2=E, 3=F, 4=G, 5=A, 6=B). High nibble = +accidental (0 = natural, 1 = sharp, 2 = flat). Examples: `0x05` = A, `0x26` = Bb, `0x13` = F#. + +**Quality table.** Encore's own chord palette, in palette order `[verified]`. Encore writes the +augmented fifth as "+5" (indices 15, 19); index 46 is "sus2,sus4". + +| Index | Suffix | Index | Suffix | +|-------|----------------|-------|------------| +| 0 | (major) | 32 | 9 | +| 1 | m | 33 | 9(b5) | +| 2 | + | 34 | 9(#11) | +| 3 | dim | 35 | 11 | +| 4 | dim7 | 36 | 13 | +| 5 | 5 | 37 | 13(b5) | +| 6 | 6 | 38 | 13(b9) | +| 7 | 6/9 | 39 | 13(#9) | +| 8 | (add2) | 40 | 13(#11) | +| 9 | (add9) | 41 | +7 | +| 10 | (omit3) | 42 | +7(b9) | +| 11 | (omit5) | 43 | +7(#9) | +| 12 | maj7 | 44 | +9 | +| 13 | maj7(b5) | 45 | sus2 | +| 14 | maj7(6/9) | 46 | sus2,sus4 | +| 15 | maj7(+5) | 47 | sus4 | +| 16 | maj7(#11) | 48 | 7sus4 | +| 17 | maj9 | 49 | 9sus4 | +| 18 | maj9(b5) | 50 | 13sus4 | +| 19 | maj9(+5) | 51 | m(add2) | +| 20 | maj9(#11) | 52 | m(add9) | +| 21 | maj13 | 53 | m6 | +| 22 | maj13(b5) | 54 | m6/9 | +| 23 | maj13(#11) | 55 | m7 | +| 24 | 7 | 56 | m(maj7) | +| 25 | 7(b5) | 57 | m7(b5) | +| 26 | 7(b9) | 58 | m7(add4) | +| 27 | 7(#9) | 59 | m7(add11) | +| 28 | 7(#11) | 60 | m9 | +| 29 | 7(b5,b9) | 61 | m9(maj7) | +| 30 | 7(b5,#9) | 62 | m11 | +| 31 | 7(b9,#9) | 63 | m13 | + +Encore renders chord symbols at beat positions: the symbol's tick carries a small MIDI offset from +the notated beat, so the symbol belongs to the beat at `floor(tick / beatTicks) * beatTicks`. + +--- + +## Multi-staff instruments + +For an instrument with more than one staff (piano, harp, organ), all staves share one MEAS element +stream. The destination staff is bits 6-7 of the [staff byte](#staff-byte-encoding): 0 = first +(treble), 1 = second (bass), 2/3 = further staves. All notes of the +instrument carry that instrument's sequential index in bits 0-5 (a piano+organ score where organ +is instrument 1 has all organ notes at bits 0-5 = 1, regardless of how many LINE slots piano +occupies). For a two-staff instrument, voices 0-1 belong to the first staff and voices 2-3 to the +second (renumbered 0-1 there). + +--- + +## Chord column (xoffset) + +The note `xoffset` byte (`+10`) is the notated horizontal column of the note. It exists in v0xC2, +v0xC4, and SCO5; v0xA6 does not store it. + +- Every member of one chord shares the same non-zero `xoffset`; successive chords occupy distinct + columns. A `xoffset` of 0 means no stored column. +- The layout runs strictly left to right, so `xoffset` increases with tick and is aligned across + the staves of a system: notes on the same beat share a column across staves. +- Adjacent columns lie at least a small minimum apart (around eight pixels in observed files), + while a chord's members share a column (give or take a notehead width) `[observed]`. +- The notes of one chord are not always stored at the same tick. A chord recorded live or given a + per-chord "strum" keeps its members at slightly staggered playback ticks (drift up to a sizeable + fraction of the note value has been observed) while still sharing one column `[observed]`. +- A note whose column matches an earlier beat but whose MIDI tick is later is a stale-tick artifact + (the note was moved in Encore and kept its old playback tick); Encore draws it at the column's + beat. + +(How a parser uses the column to rebuild chords, split tightly played tuplets, and correct +stale ticks is in ENCORE_IMPORTER.md.) + +--- + +## BEAM element + +Type 4. Explicit beaming, one element per beam level. + +| Size | Byte `+5` | Beam level | +|------|-----------|------------------------| +| 30 | `0x01` | 1st (eighth flag) | +| 46 | `0x02` | 2nd (sixteenth) | +| 62 | `0x03` | 3rd (thirty-second) | + +--- + +## TEXT block + +Magic `TEXT`. Text payloads referenced by staff-text ornaments (subtype `0x1E`), indexed by the +ornament's entry index (`tind`). A file may contain several TEXT blocks; each later block is a +part-view copy with the same strings reordered, and the ornament index is relative to the first +(score) block only. + +Block layout (after the 8-byte magic + varsize): + +| Offset | Size | Description | +|--------|------|---------------------| +| `+0` | 2 | sync (`0x0000`) | +| `+2` | 2 | entry count | +| `+4` | 4 | total content bytes | +| `+8` | var | entries | + +Each entry: + +| Offset | Size | Description | +|--------|------|-------------------------------------------------------------| +| `+0` | 2 | payload size | +| `+2` | var | rich-text run header (see below) | +| after | var | text (UTF-16 LE or Latin-1); lines separated by `0x04 0x00` | +| end | 2+ | `0x00 0x00` terminator (may be followed by padding) | + +**Rich-text run header.** Measured from the payload start (entry `+2`): + +| Payload offset | Size | Description | +|------------------|-----------------|---------------------------------------------------| +| `+0` | 2 | run-offset table count | +| `+2` | 2 | descriptor count | +| `+4` | tableCount * 4 | run-offset table (per-run character positions) | +| after table | descCount * 6 | style descriptors | +| after | var | the displayed text | + +The text starts at payload offset `4 + tableCount*4 + descCount*6`. Both words at `+0` and `+2` are +genuine counts (an entry can carry two descriptors as well as one); a single-run single-descriptor +comment puts the text at offset 14. When either count is 0 or the computed offset exceeds the entry, +fall back to offset 14. + +In v0xA6 the entry has no rich-text header: the text starts at payload offset 0 (entry `+2`) and is +null-terminated Latin-1. + +**Line separators.** `0x04 0x00` (U+0004) separates lines within one comment; it is not the +terminator. Every line, including the last, is followed by `0x04 0x00`, and the whole string ends +at `0x00 0x00`. Text length is bounded by the null terminator, not by the payload size (some +entries carry padding after the terminator). Dynamic marks are not in the TEXT block; they use +ornament subtypes. + +--- + +## TITL block + +Magic `TITL`. One block per page (page 2+ blocks are usually empty). Holds a title, 2 subtitles, 3 +instructions, 4 authors, 2 headers, 2 footers, and 6 copyright lines, in that order, each slot a +fixed-width field. + +**Encoding by varsize** `[verified]`: + +| varsize | Encoding | Bytes per line | +|-------------|------------|----------------| +| < 5000 | Latin-1 | 96 | +| >= 10000 | UTF-16 LE | 1056 | +| 5000..9999 | (rare) not disambiguated by varsize; fall back to the file's instrument-name encoding | | + +The two normal layouts differ by roughly 10x (Latin-1: 2 + 20 lines x 96 + 504 pad = 2426; +UTF-16: 2 + 20 lines x 1056 + 120 pad = 21242), so varsize resolves them unambiguously. + +Each line is a 30-byte prefix followed by the text field (66 Latin-1 bytes or 1026 UTF-16 bytes), +NUL-terminated and zero-padded; bytes after the NUL are prior-edit debris. + +**Header/footer alignment.** Prefix byte `+14`: `0x02` = right, `0x04` = left, `0x06` = center; +`0x00` on other line types. + +**Slot counts.** title 1, subtitle 2, instruction 3, author 4, header 2, footer 2, copyright 6. +Multiple non-empty slots in a category render as stacked lines. + +**Replaceable tokens (header/footer).** `#P` = page number, `#D` = date, `#T` = time. + +--- + +## WINI block + +Magic `WINI`. Page margins. Optional: files never taken through Encore's Page Setup have no WINI +block `[observed]`. + +Layout (after the 8-byte magic + varsize): + +| Offset | Size | Type | Description | +|--------|------|---------|----------------------------------------------------------------| +| `+0` | 24 | bytes | window/screen data (not page geometry) | +| `+24` | 4 | int32 | top margin | +| `+28` | 4 | int32 | left margin | +| `+32` | 4 | int32 | bottom edge of printable area (pageHeight - bottomMargin) | +| `+36` | 4 | int32 | right edge of printable area (pageWidth - rightMargin) | +| `+40` | 2 | uint16 | flags (observed 1) | + +Content size is 42 bytes; some older files omit the trailing uint16 (varsize 40). Both are valid. + +**Units, two variants** `[observed]`: + +- Encore 5.x stores margins in typographic points (1/72 inch). +- Earlier versions (4.x and some 3.x) store them in screen pixels at the monitor DPI (around 84 PPI, + the exact value being the screen the file was last saved on). Displayed margins are still inches; + only the stored unit is device pixels. + +Tell them apart by magnitude: when an edge exceeds the page dimension expressed in points +(rightEdge above pageWidth x 72, or bottomEdge above pageHeight x 72) the block is in screen pixels, +otherwise in points. In the screen-pixel case the page dimensions are not stored and must be +recovered from margin symmetry: `pageWidth = rightEdge + left`, `pageHeight = bottomEdge + top` +(assuming equal far margins), matched against standard paper sizes. All ISO A-series sizes share +the 1:root-2 ratio, so at most two fall in the plausible DPI range and the correct one minimises +`|dpiW - dpiH|`. + +Encore stores `round(inches x 72)` and displays `floor(pts / 72 x 1000) / 1000`, so 0.100" stores +as 7 pts and displays back as 0.097". All four values 0 means no stored margins. Encore's Page +Setup dialog may show a non-zero margin for a side with a zero stored value; that displayed value is +the active printer's hardware non-printable zone, read at display time and not stored in the file. + +--- + +## PREC block + +Magic `PREC`. Printer / page-setup state, 132 bytes to several KiB, in one of two encodings by +platform: + +- **Windows (`SCOW`):** a Windows DEVMODE structure. +- **macOS (`SCO5`):** a macOS NSPrintInfo XML plist (begins with `= 10000 UTF-16 (see [TITL](#titl-block)) | + +Forcing one encoding turns legacy Latin-1 into byte-swapped CJK gibberish, or drops the second half +of every UTF-16 code unit; the probe is always applied. + +--- + +## Known quirks + +### Per-version deltas + +Every systematic deviation, with a link back to the section that details it. + +| Aspect | v0xA6 | v0xC2 | v0xC4 / SCO5 | +|-------------------------------|--------------------------|--------------------------------|------------------------| +| [Header](#header) end | `0xA6` (166) | `0xC2` (194) | `0xC2` (194) | +| Size selector offset | `0x8D` | `0x52` | `0x52` | +| [MEAS](#measure-block-meas) header | `0x1A` (26) | `0x36` (54) | `0x36` (54) | +| Element slot stride | `size * 2` | `size` | `size` | +| [Note](#note-element) size | 10 / 11 | 22 / 24 | 28 | +| Note pitch offset | `+11` | `+13` or `+15` (sub-variant) | `+15` | +| Note tuplet offset | `+7` | `+13` (sub-variant B) | `+13` | +| [Lyric](#lyric-element) text | `+6` | `+0x12` | `+0x14` | +| [Ornament](#ornament-element) y | `+8` (compact) | `+12` | `+12` | +| Ornament staff-text index | `+28` (compact) | `+32` or `+30` | `+32` | +| Slur forward-count field | (no xoffset) | `+16` | `+18` / end-x at `+20` | +| [Chord column](#chord-column-xoffset) | not stored | stored | stored | +| [Key signature](#key-encoding)| LINE 22-byte entry `+14` | LINE 30-byte entry `+15` | LINE 30-byte entry `+15` | +| [TEXT](#text-block) run header | absent | present | present | +| Grace1 tie-sender nibble | always 0 | low nibble = 1 when tie sender | always 0 | +| [WINI](#wini-block) unit | screen pixels | pixels or points | points | + +### Other oddities + +- **Duplicate NOTE elements.** Some files encode the same pitch twice in one chord: either the + second copy has `grace1 & 0x40` set (a chord-extension marker), or, in v0xC2, both copies have + `grace1 = 0`. Either way it is a redundant notehead. +- **v0xA6 consecutive identical rests.** Two identical rest elements (same tick, staff, voice, face + value) in a row represent one rest. +- **Note spelling on transposing staves.** Encore stores only the sounding pitch plus the Key + offset (see [Instrument block](#instrument-block)); it never stores an explicit enharmonic + spelling. The bytes fix the audible pitch, not the written accidental. +- **Tempo words.** Italian tempo words ("Allegro", "Andante", ...) are stored as staff-text + ornaments, not as a tempo element. Numeric tempo marks use the ornament TEMPO subtype (`0x32`). + Each MEAS header also carries a quarter-note BPM that persists on every measure. The TEMPO + ornament's stored tick rarely matches the measure where the tempo applies, so the per-measure + header BPM is the reliable source for its position. +- **Largest legitimate block.** A MEAS block rarely exceeds 2 KiB; a significantly larger block + indicates a corrupt file or an undocumented variant. +- **Encore 5.0.2 instrument names.** Encore 5.0.2 writes UTF-16 instrument names regardless of the + size field, and may omit a TK header while the name is still present at the positional offset. diff --git a/src/importexport/encore/enc-module.cpp b/src/importexport/encore/enc-module.cpp new file mode 100644 index 0000000000000..a947c3c619532 --- /dev/null +++ b/src/importexport/encore/enc-module.cpp @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Wires the Encore module into the IoC: exports the configuration, registers the reader for "enc". + +#include "enc-module.h" + +#include "modularity/ioc.h" + +#include "project/inotationreadersregister.h" +#include "internal/notationencreader.h" +#include "internal/enc-importconfiguration.h" +#include "ienc-importconfiguration.h" + +#include "log.h" + +using namespace muse; +using namespace muse::modularity; +using namespace mu::iex::enc; +using namespace mu::project; + +std::string EncoreModule::moduleName() const +{ + return "iex_encore"; +} + +void EncoreModule::registerExports() +{ + m_configuration = std::make_shared(); + globalIoc()->registerExport(moduleName(), m_configuration); +} + +void EncoreModule::resolveImports() +{ + auto readers = globalIoc()->resolve(moduleName()); + if (readers) { + readers->reg({ "enc" }, std::make_shared()); + } +} + +void EncoreModule::onInit(const IApplication::RunMode&) +{ + m_configuration->init(); +} diff --git a/src/importexport/encore/enc-module.h b/src/importexport/encore/enc-module.h new file mode 100644 index 0000000000000..a1f84e0d3bf77 --- /dev/null +++ b/src/importexport/encore/enc-module.h @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Module setup for the Encore importer: registers the import configuration and the .enc reader. + +#ifndef MU_IMPORTEXPORT_ENC_MODULE_H +#define MU_IMPORTEXPORT_ENC_MODULE_H + +#include + +#include "modularity/imodulesetup.h" + +namespace mu::iex::enc { +class EncImportConfiguration; +class EncoreModule : public muse::modularity::IModuleSetup +{ +public: + std::string moduleName() const override; + void registerExports() override; + void resolveImports() override; + void onInit(const muse::IApplication::RunMode&) override; + +private: + std::shared_ptr m_configuration; +}; +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_MODULE_H diff --git a/src/importexport/encore/ienc-importconfiguration.h b/src/importexport/encore/ienc-importconfiguration.h new file mode 100644 index 0000000000000..73b67d2f00d76 --- /dev/null +++ b/src/importexport/encore/ienc-importconfiguration.h @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Interface for Encore import options: each user-facing toggle exposes a getter, setter and change channel. + +#pragma once + +#include "modularity/imoduleinterface.h" +#include "async/channel.h" + +#include "internal/importer/import-options.h" + +namespace mu::iex::enc { +class IEncImportConfiguration : MODULE_GLOBAL_INTERFACE +{ + INTERFACE_ID(IEncImportConfiguration) + +public: + virtual ~IEncImportConfiguration() = default; + + virtual bool importPageLayout() const = 0; + virtual void setImportPageLayout(bool value) = 0; + virtual muse::async::Channel importPageLayoutChanged() const = 0; + + virtual bool importPageBreaks() const = 0; + virtual void setImportPageBreaks(bool value) = 0; + virtual muse::async::Channel importPageBreaksChanged() const = 0; + + virtual bool importSystemLocks() const = 0; + virtual void setImportSystemLocks(bool value) = 0; + virtual muse::async::Channel importSystemLocksChanged() const = 0; + + virtual bool importStaffSize() const = 0; + virtual void setImportStaffSize(bool value) = 0; + virtual muse::async::Channel importStaffSizeChanged() const = 0; + + virtual bool importTempoTextSemantic() const = 0; + virtual void setImportTempoTextSemantic(bool value) = 0; + virtual muse::async::Channel importTempoTextSemanticChanged() const = 0; + + virtual bool importUnsupportedArticulationsAsText() const = 0; + virtual void setImportUnsupportedArticulationsAsText(bool value) = 0; + virtual muse::async::Channel importUnsupportedArticulationsAsTextChanged() const = 0; + + virtual InstrumentSearchMode instrumentSearchMode() const = 0; + virtual void setInstrumentSearchMode(InstrumentSearchMode value) = 0; + virtual muse::async::Channel instrumentSearchModeChanged() const = 0; + + virtual UnderfillStrategy underfillMeasureStrategy() const = 0; + virtual void setUnderfillMeasureStrategy(UnderfillStrategy value) = 0; + virtual muse::async::Channel underfillMeasureStrategyChanged() const = 0; + + virtual OverfillStrategy overfillMeasureStrategy() const = 0; + virtual void setOverfillMeasureStrategy(OverfillStrategy value) = 0; + virtual muse::async::Channel overfillMeasureStrategyChanged() const = 0; + + virtual bool firstMeasureIsPickup() const = 0; + virtual void setFirstMeasureIsPickup(bool value) = 0; + virtual muse::async::Channel firstMeasureIsPickupChanged() const = 0; + + virtual bool mergeVoices() const = 0; + virtual void setMergeVoices(bool value) = 0; + virtual muse::async::Channel mergeVoicesChanged() const = 0; +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/enc-importconfiguration.cpp b/src/importexport/encore/internal/enc-importconfiguration.cpp new file mode 100644 index 0000000000000..4667aa83c4346 --- /dev/null +++ b/src/importexport/encore/internal/enc-importconfiguration.cpp @@ -0,0 +1,272 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Persists each Encore import option in Settings and emits change channels on update. + +#include "enc-importconfiguration.h" + +#include "settings.h" + +using namespace mu; +using namespace muse; +using namespace mu::iex::enc; + +static const std::string module_name("iex_encore"); + +static const Settings::Key ENC_IMPORT_PAGE_LAYOUT_KEY(module_name, "import/encore/importPageLayout"); +static const Settings::Key ENC_IMPORT_PAGE_BREAKS_KEY(module_name, "import/encore/importPageBreaks"); +static const Settings::Key ENC_IMPORT_SYSTEM_LOCKS_KEY(module_name, "import/encore/importSystemLocks"); +static const Settings::Key ENC_IMPORT_STAFF_SIZE_KEY(module_name, "import/encore/importStaffSize"); +static const Settings::Key ENC_IMPORT_TEMPO_SEMANTIC_KEY(module_name, "import/encore/importTempoTextSemantic"); +static const Settings::Key ENC_IMPORT_ARTIC_AS_TEXT_KEY(module_name, "import/encore/importUnsupportedArticulationsAsText"); +static const Settings::Key ENC_INSTRUMENT_SEARCH_MODE_KEY(module_name, "import/encore/instrumentSearchMode"); +static const Settings::Key ENC_UNDERFILL_STRATEGY_KEY(module_name, "import/encore/underfillMeasureStrategy"); +static const Settings::Key ENC_OVERFILL_STRATEGY_KEY(module_name, "import/encore/overfillMeasureStrategy"); +static const Settings::Key ENC_FIRST_MEASURE_PICKUP_KEY(module_name, "import/encore/firstMeasureIsPickup"); +static const Settings::Key ENC_MERGE_VOICES_KEY(module_name, "import/encore/mergeVoices"); + +void EncImportConfiguration::init() +{ + settings()->setDefaultValue(ENC_IMPORT_PAGE_LAYOUT_KEY, Val(true)); + settings()->valueChanged(ENC_IMPORT_PAGE_LAYOUT_KEY).onReceive(this, [this](const Val& val) { + m_importPageLayoutChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_IMPORT_PAGE_BREAKS_KEY, Val(true)); + settings()->valueChanged(ENC_IMPORT_PAGE_BREAKS_KEY).onReceive(this, [this](const Val& val) { + m_importPageBreaksChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_IMPORT_SYSTEM_LOCKS_KEY, Val(true)); + settings()->valueChanged(ENC_IMPORT_SYSTEM_LOCKS_KEY).onReceive(this, [this](const Val& val) { + m_importSystemLocksChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_IMPORT_STAFF_SIZE_KEY, Val(true)); + settings()->valueChanged(ENC_IMPORT_STAFF_SIZE_KEY).onReceive(this, [this](const Val& val) { + m_importStaffSizeChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_IMPORT_TEMPO_SEMANTIC_KEY, Val(true)); + settings()->valueChanged(ENC_IMPORT_TEMPO_SEMANTIC_KEY).onReceive(this, [this](const Val& val) { + m_importTempoTextSemanticChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_IMPORT_ARTIC_AS_TEXT_KEY, Val(false)); + settings()->valueChanged(ENC_IMPORT_ARTIC_AS_TEXT_KEY).onReceive(this, [this](const Val& val) { + m_importUnsupportedArticulationsAsTextChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_INSTRUMENT_SEARCH_MODE_KEY, + Val(static_cast(InstrumentSearchMode::NameAndMidi))); + settings()->valueChanged(ENC_INSTRUMENT_SEARCH_MODE_KEY).onReceive(this, [this](const Val& val) { + m_instrumentSearchModeChanged.send(static_cast(val.toInt())); + }); + + // Shipped GUI defaults, authoritative for real imports. They intentionally differ from the + // EncImportOptions struct initializers (test-only fallbacks); do not assume the two should match. + settings()->setDefaultValue(ENC_UNDERFILL_STRATEGY_KEY, Val(static_cast(UnderfillStrategy::IrregularMeasure))); + settings()->valueChanged(ENC_UNDERFILL_STRATEGY_KEY).onReceive(this, [this](const Val& val) { + m_underfillMeasureStrategyChanged.send(static_cast(val.toInt())); + }); + + settings()->setDefaultValue(ENC_OVERFILL_STRATEGY_KEY, Val(static_cast(OverfillStrategy::IrregularMeasure))); + settings()->valueChanged(ENC_OVERFILL_STRATEGY_KEY).onReceive(this, [this](const Val& val) { + m_overfillMeasureStrategyChanged.send(static_cast(val.toInt())); + }); + + settings()->setDefaultValue(ENC_FIRST_MEASURE_PICKUP_KEY, Val(true)); + settings()->valueChanged(ENC_FIRST_MEASURE_PICKUP_KEY).onReceive(this, [this](const Val& val) { + m_firstMeasureIsPickupChanged.send(val.toBool()); + }); + + settings()->setDefaultValue(ENC_MERGE_VOICES_KEY, Val(true)); + settings()->valueChanged(ENC_MERGE_VOICES_KEY).onReceive(this, [this](const Val& val) { + m_mergeVoicesChanged.send(val.toBool()); + }); +} + +bool EncImportConfiguration::importPageLayout() const +{ + return settings()->value(ENC_IMPORT_PAGE_LAYOUT_KEY).toBool(); +} + +void EncImportConfiguration::setImportPageLayout(bool value) +{ + settings()->setSharedValue(ENC_IMPORT_PAGE_LAYOUT_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::importPageLayoutChanged() const +{ + return m_importPageLayoutChanged; +} + +bool EncImportConfiguration::importPageBreaks() const +{ + return settings()->value(ENC_IMPORT_PAGE_BREAKS_KEY).toBool(); +} + +void EncImportConfiguration::setImportPageBreaks(bool value) +{ + settings()->setSharedValue(ENC_IMPORT_PAGE_BREAKS_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::importPageBreaksChanged() const +{ + return m_importPageBreaksChanged; +} + +bool EncImportConfiguration::importSystemLocks() const +{ + return settings()->value(ENC_IMPORT_SYSTEM_LOCKS_KEY).toBool(); +} + +void EncImportConfiguration::setImportSystemLocks(bool value) +{ + settings()->setSharedValue(ENC_IMPORT_SYSTEM_LOCKS_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::importSystemLocksChanged() const +{ + return m_importSystemLocksChanged; +} + +bool EncImportConfiguration::importStaffSize() const +{ + return settings()->value(ENC_IMPORT_STAFF_SIZE_KEY).toBool(); +} + +void EncImportConfiguration::setImportStaffSize(bool value) +{ + settings()->setSharedValue(ENC_IMPORT_STAFF_SIZE_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::importStaffSizeChanged() const +{ + return m_importStaffSizeChanged; +} + +bool EncImportConfiguration::importTempoTextSemantic() const +{ + return settings()->value(ENC_IMPORT_TEMPO_SEMANTIC_KEY).toBool(); +} + +void EncImportConfiguration::setImportTempoTextSemantic(bool value) +{ + settings()->setSharedValue(ENC_IMPORT_TEMPO_SEMANTIC_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::importTempoTextSemanticChanged() const +{ + return m_importTempoTextSemanticChanged; +} + +bool EncImportConfiguration::importUnsupportedArticulationsAsText() const +{ + return settings()->value(ENC_IMPORT_ARTIC_AS_TEXT_KEY).toBool(); +} + +void EncImportConfiguration::setImportUnsupportedArticulationsAsText(bool value) +{ + settings()->setSharedValue(ENC_IMPORT_ARTIC_AS_TEXT_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::importUnsupportedArticulationsAsTextChanged() const +{ + return m_importUnsupportedArticulationsAsTextChanged; +} + +InstrumentSearchMode EncImportConfiguration::instrumentSearchMode() const +{ + return static_cast( + settings()->value(ENC_INSTRUMENT_SEARCH_MODE_KEY).toInt()); +} + +void EncImportConfiguration::setInstrumentSearchMode(InstrumentSearchMode value) +{ + settings()->setSharedValue(ENC_INSTRUMENT_SEARCH_MODE_KEY, Val(static_cast(value))); +} + +async::Channel EncImportConfiguration::instrumentSearchModeChanged() const +{ + return m_instrumentSearchModeChanged; +} + +UnderfillStrategy EncImportConfiguration::underfillMeasureStrategy() const +{ + return static_cast(settings()->value(ENC_UNDERFILL_STRATEGY_KEY).toInt()); +} + +void EncImportConfiguration::setUnderfillMeasureStrategy(UnderfillStrategy value) +{ + settings()->setSharedValue(ENC_UNDERFILL_STRATEGY_KEY, Val(static_cast(value))); +} + +async::Channel EncImportConfiguration::underfillMeasureStrategyChanged() const +{ + return m_underfillMeasureStrategyChanged; +} + +OverfillStrategy EncImportConfiguration::overfillMeasureStrategy() const +{ + return static_cast(settings()->value(ENC_OVERFILL_STRATEGY_KEY).toInt()); +} + +void EncImportConfiguration::setOverfillMeasureStrategy(OverfillStrategy value) +{ + settings()->setSharedValue(ENC_OVERFILL_STRATEGY_KEY, Val(static_cast(value))); +} + +async::Channel EncImportConfiguration::overfillMeasureStrategyChanged() const +{ + return m_overfillMeasureStrategyChanged; +} + +bool EncImportConfiguration::firstMeasureIsPickup() const +{ + return settings()->value(ENC_FIRST_MEASURE_PICKUP_KEY).toBool(); +} + +void EncImportConfiguration::setFirstMeasureIsPickup(bool value) +{ + settings()->setSharedValue(ENC_FIRST_MEASURE_PICKUP_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::firstMeasureIsPickupChanged() const +{ + return m_firstMeasureIsPickupChanged; +} + +bool EncImportConfiguration::mergeVoices() const +{ + return settings()->value(ENC_MERGE_VOICES_KEY).toBool(); +} + +void EncImportConfiguration::setMergeVoices(bool value) +{ + settings()->setSharedValue(ENC_MERGE_VOICES_KEY, Val(value)); +} + +async::Channel EncImportConfiguration::mergeVoicesChanged() const +{ + return m_mergeVoicesChanged; +} diff --git a/src/importexport/encore/internal/enc-importconfiguration.h b/src/importexport/encore/internal/enc-importconfiguration.h new file mode 100644 index 0000000000000..201e26d7db75c --- /dev/null +++ b/src/importexport/encore/internal/enc-importconfiguration.h @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Settings-backed implementation of the Encore import configuration. + +#pragma once + +#include "../ienc-importconfiguration.h" +#include "async/asyncable.h" + +namespace mu::iex::enc { +class EncImportConfiguration : public IEncImportConfiguration, public muse::async::Asyncable +{ +public: + void init(); + + bool importPageLayout() const override; + void setImportPageLayout(bool value) override; + muse::async::Channel importPageLayoutChanged() const override; + + bool importPageBreaks() const override; + void setImportPageBreaks(bool value) override; + muse::async::Channel importPageBreaksChanged() const override; + + bool importSystemLocks() const override; + void setImportSystemLocks(bool value) override; + muse::async::Channel importSystemLocksChanged() const override; + + bool importStaffSize() const override; + void setImportStaffSize(bool value) override; + muse::async::Channel importStaffSizeChanged() const override; + + bool importTempoTextSemantic() const override; + void setImportTempoTextSemantic(bool value) override; + muse::async::Channel importTempoTextSemanticChanged() const override; + + bool importUnsupportedArticulationsAsText() const override; + void setImportUnsupportedArticulationsAsText(bool value) override; + muse::async::Channel importUnsupportedArticulationsAsTextChanged() const override; + + InstrumentSearchMode instrumentSearchMode() const override; + void setInstrumentSearchMode(InstrumentSearchMode value) override; + muse::async::Channel instrumentSearchModeChanged() const override; + + UnderfillStrategy underfillMeasureStrategy() const override; + void setUnderfillMeasureStrategy(UnderfillStrategy value) override; + muse::async::Channel underfillMeasureStrategyChanged() const override; + + OverfillStrategy overfillMeasureStrategy() const override; + void setOverfillMeasureStrategy(OverfillStrategy value) override; + muse::async::Channel overfillMeasureStrategyChanged() const override; + + bool firstMeasureIsPickup() const override; + void setFirstMeasureIsPickup(bool value) override; + muse::async::Channel firstMeasureIsPickupChanged() const override; + + bool mergeVoices() const override; + void setMergeVoices(bool value) override; + muse::async::Channel mergeVoicesChanged() const override; + +private: + muse::async::Channel m_importPageLayoutChanged; + muse::async::Channel m_importPageBreaksChanged; + muse::async::Channel m_importSystemLocksChanged; + muse::async::Channel m_importStaffSizeChanged; + muse::async::Channel m_importTempoTextSemanticChanged; + muse::async::Channel m_importUnsupportedArticulationsAsTextChanged; + muse::async::Channel m_instrumentSearchModeChanged; + muse::async::Channel m_underfillMeasureStrategyChanged; + muse::async::Channel m_overfillMeasureStrategyChanged; + muse::async::Channel m_firstMeasureIsPickupChanged; + muse::async::Channel m_mergeVoicesChanged; +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/builders-measures.cpp b/src/importexport/encore/internal/importer/builders-measures.cpp new file mode 100644 index 0000000000000..9de8116120a2c --- /dev/null +++ b/src/importexport/encore/internal/importer/builders-measures.cpp @@ -0,0 +1,313 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Build MuseScore measures from EncMeasure data: time signatures, barlines, multi-measure-rest expansion, and initial clefs/keys. + +#include "builders.h" +#include "ctx.h" +#include "import.h" +#include "../parser/elem.h" +#include "mappers.h" +#include +#include +#include +#include +#include +#include "engraving/dom/clef.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/key.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/part.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/timesig.h" +#include "engraving/engravingerrors.h" +#include "log.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// True if every element in the measure is a REST with mrestCount > 1. +// Multi-staff files emit one REST per staff, so there can be N > 1 elements. +static bool encMeasHasMultiRest(const EncMeasure& m) +{ + if (m.elements.empty()) { + return false; + } + for (const auto& ep : m.elements) { + if (static_cast(ep->type) != EncElemType::REST) { + return false; + } + } + return static_cast(m.elements[0].get())->mrestCount > 1; +} + +// Returns the number of MuseScore measures to create for a single EncMeasure. +// Encore stores N consecutive empty measures as one MEAS block with mrestCount==N +// (byte +15 of REST element data). Expansion is suppressed when the predecessor is +// already an mrest block, preventing cascades from consecutive mrest blocks. +static int encMeasDisplayCount(const EncMeasure& m, const EncMeasure* prev) +{ + if (m.elements.empty()) { + return 1; + } + for (const auto& ep : m.elements) { + if (static_cast(ep->type) != EncElemType::REST) { + return 1; + } + } + const int cnt = static_cast(static_cast(m.elements[0].get())->mrestCount); + if (cnt <= 1) { + return 1; + } + if (prev && encMeasHasMultiRest(*prev)) { + return 1; + } + return cnt; +} + +// Number of times a repeat-end barline plays. Encore stores no explicit count; the pass count is +// the highest volta ending number belonging to this repeat (bitmask on the end-repeat measure plus +// the ending measures that follow it). A plain repeat with no endings falls back to 2. +static int encRepeatPlayCount(const std::vector& measures, size_t endIdx) +{ + quint8 bits = measures[endIdx].repeatAlternative; + for (size_t j = endIdx + 1; j < measures.size(); ++j) { + if (measures[j].startBarline() == EncBarlineType::REPEATSTART + || measures[j].repeatAlternative == 0) { + break; + } + bits |= measures[j].repeatAlternative; + } + int highest = 0; + for (int b = 0; b < 8; ++b) { + if (bits & (1 << b)) { + highest = b + 1; + } + } + return highest > 2 ? highest : 2; +} + +// Time signature of a measure, with a 4/4 fallback when the stored num/den are 0. +static Fraction encMeasTimeSig(const EncMeasure& m) +{ + const int num = m.timeSigNum > 0 ? m.timeSigNum : 4; + const int den = m.timeSigDen > 0 ? m.timeSigDen : 4; + return Fraction(num, den); +} + +void buildMeasures(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + + // Pickup measure: measure 0 holds the short duration, measure 1 the real sig. The "nominal" + // sig is measure 1's when present (it survives a pickup in measure 0), else measure 0's. + const Fraction sig0 = enc.measures.empty() ? Fraction(4, 4) : encMeasTimeSig(enc.measures[0]); + ctx.nominalTimeSig = (enc.measures.size() >= 2) ? encMeasTimeSig(enc.measures[1]) : sig0; + + // Nominal time-sig type: use measure 1's glyph when its sig differs from measure 0 (pickup), + // otherwise measure 0's. + const size_t nomIdx = (enc.measures.size() >= 2 && ctx.nominalTimeSig != sig0) ? 1 : 0; + if (nomIdx < enc.measures.size()) { + ctx.nominalTimeSigType = encTimeSigGlyph2Type(enc.measures[nomIdx].timeSigGlyph, + ctx.nominalTimeSig); + } + + int currentTick = 0; + bool firstMeasure = true; + size_t msIdxCounter = 0; + ctx.encToMsIdx.reserve(enc.measures.size()); + for (size_t mi = 0; mi < enc.measures.size(); ++mi) { + const EncMeasure& encMeas = enc.measures[mi]; + int num = encMeas.timeSigNum > 0 ? encMeas.timeSigNum : 4; + int den = encMeas.timeSigDen > 0 ? encMeas.timeSigDen : 4; + Fraction ts(num, den); + ctx.measTickToTimeSigType[currentTick] = encTimeSigGlyph2Type(encMeas.timeSigGlyph, ts); + + const EncMeasure* prev = (mi > 0) ? &enc.measures[mi - 1] : nullptr; + const int displayCount = encMeasDisplayCount(encMeas, prev); + + ctx.encToMsIdx.push_back(msIdxCounter); + + for (int di = 0; di < displayCount; ++di) { + Measure* measure = Factory::createMeasure(score->dummy()->system()); + measure->setTick(Fraction::fromTicks(currentTick)); + + // Case A: timeSig[0] != timeSig[1], pickup with explicit shorter sig; shorten now. + // Case B (same sig, partial content): detected post-emitters via actual cumTick. + // When firstMeasureIsPickup=false, bypass pickup detection and use the nominal sig. + const bool pickupEnabled = ctx.opts.firstMeasureIsPickup; + const bool isPickupA = pickupEnabled && firstMeasure && di == 0 + && ts != ctx.nominalTimeSig; + if (!pickupEnabled && firstMeasure && di == 0) { + measure->setTimesig(ctx.nominalTimeSig); + measure->setTicks(ctx.nominalTimeSig); + } else { + measure->setTimesig(isPickupA ? ctx.nominalTimeSig : ts); + measure->setTicks(ts); + } + + if (di == 0) { + if (encMeas.startBarline() == EncBarlineType::REPEATSTART) { + measure->setRepeatStart(true); + } + // A non-repeat special barline drawn at a measure's START (e.g. a double bar + // before this measure) belongs, in MuseScore's model, to the end of the + // previous measure. Encore stores it as this measure's startBarline; map it + // onto the preceding measure's end barline so the divider is not dropped. + if (encMeas.startBarline() == EncBarlineType::DOUBLEL + || encMeas.startBarline() == EncBarlineType::DOUBLER + || encMeas.startBarline() == EncBarlineType::DOTTED) { + if (Measure* prevMeas = score->lastMeasure()) { + if (prevMeas->endBarLineType() == BarLineType::NORMAL) { + const BarLineType t = (encMeas.startBarline() == EncBarlineType::DOTTED) + ? BarLineType::DOTTED : BarLineType::DOUBLE; + for (int s = 0; s < ctx.totalStaves; ++s) { + prevMeas->setEndBarLineType(t, static_cast(s) * VOICES); + } + } + } + } + if (encMeas.endBarline() == EncBarlineType::REPEATEND) { + measure->setRepeatEnd(true); + const int playCount = encRepeatPlayCount(enc.measures, mi); + if (playCount > 2) { + measure->setRepeatCount(playCount); + } + } else if (encMeas.endBarline() == EncBarlineType::FINAL + || encMeas.endBarline() == EncBarlineType::DOUBLEL + || encMeas.endBarline() == EncBarlineType::DOUBLER + || encMeas.endBarline() == EncBarlineType::DOTTED) { + BarLineType type = BarLineType::DOUBLE; + if (encMeas.endBarline() == EncBarlineType::FINAL) { + type = BarLineType::END; + } else if (encMeas.endBarline() == EncBarlineType::DOTTED) { + type = BarLineType::DOTTED; + } + for (int s = 0; s < ctx.totalStaves; ++s) { + measure->setEndBarLineType(type, static_cast(s) * VOICES); + } + } + } + + score->measures()->append(measure); + // Must match setTicks() above: both use nominalTimeSig in the no-pickup first-measure + // branch so subsequent measure positions are consistent with that measure's duration. + const bool usedNominal = !pickupEnabled && firstMeasure && di == 0; + currentTick += usedNominal ? ctx.nominalTimeSig.ticks() : ts.ticks(); + } + firstMeasure = false; + msIdxCounter += static_cast(displayCount); + } +} + +void buildInitialSignatures(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + if (!enc.measures.empty()) { + addInitialTimeSig(score, ctx.totalStaves, ctx.nominalTimeSig, ctx.nominalTimeSigType); + } + if (!enc.lines.empty()) { + const auto& firstLine = enc.lines[0]; + for (int si = 0; si < static_cast(firstLine.staffData.size()) && si < ctx.totalStaves; ++si) { + const auto& sd = firstLine.staffData[si]; + addInitialKeySig(score, si, sd.key); + const int keyOffset = si < static_cast(ctx.staffPitchOffset.size()) + ? ctx.staffPitchOffset[si] : 0; + // Drumset instruments always use PERC clef; LINE block clefs must not override it. + const Staff* st = score->staff(static_cast(si)); + const bool hasDrumset = st && st->part() && st->part()->instrument() + && st->part()->instrument()->drumset(); + const ClefType ct = hasDrumset ? ClefType::PERC + : pickStaffClef(sd.clef, keyOffset); + addInitialClef(score, si, ct); + } + + // v0xA6: staffData is empty (its header staffPerSystem reads 0 and the staff entry + // layout differs), so the loop above adds no key signature. The per-staff written + // key was parsed separately into staffKeys; apply it here. Clefs still come from the + // instrument template, handled by the !haveLineClefs block below. + if (firstLine.staffData.empty() && !firstLine.staffKeys.empty()) { + for (int si = 0; si < ctx.totalStaves; ++si) { + const size_t ki = std::min(static_cast(si), firstLine.staffKeys.size() - 1); + addInitialKeySig(score, si, firstLine.staffKeys[ki]); + } + } + } + + // Files without per-staff LINE clef data (v0xA6): the initial clef comes from the + // instrument template, which does not reflect an octave Key. The note pitches are already + // octave-shifted by the Key, so apply the matching octave-decorated clef to bring the + // display back to the written octave, mirroring what pickStaffClef does for v0xC4. + const bool haveLineClefs = !enc.lines.empty() && !enc.lines[0].staffData.empty(); + if (!haveLineClefs) { + for (int si = 0; si < ctx.totalStaves; ++si) { + const int keyOffset = si < static_cast(ctx.staffPitchOffset.size()) + ? ctx.staffPitchOffset[si] : 0; + if (keyOffset == 0 || keyOffset % 12 != 0) { + continue; // only pure-octave Keys need a compensating clef + } + const Staff* st = score->staff(static_cast(si)); + const bool hasDrumset = st && st->part() && st->part()->instrument() + && st->part()->instrument()->drumset(); + if (hasDrumset) { + continue; // percussion has no octave clef + } + const ClefType base = si < static_cast(ctx.staffTemplateConcertClef.size()) + ? ctx.staffTemplateConcertClef[si] : ClefType::INVALID; + if (base == ClefType::INVALID) { + continue; + } + const ClefType oct = applyOctaveToClef(base, keyOffset); + if (oct != base) { + addInitialClef(score, si, oct); + } + } + } + + // Emit TimeSig elements at change points. Use identical() not operator==: Fraction(6,8) + // == Fraction(3,4) via cross-multiplication, so operator== would miss 6/8 vs 3/4 changes. + Fraction prevTs = ctx.nominalTimeSig; + for (const Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + Fraction mTs = m->timesig(); + if (mTs.identical(prevTs)) { + continue; + } + Fraction mTick = m->tick(); + auto tsTypeIt = ctx.measTickToTimeSigType.find(mTick.ticks()); + TimeSigType tsType = (tsTypeIt != ctx.measTickToTimeSigType.end()) + ? tsTypeIt->second : TimeSigType::NORMAL; + for (int si = 0; si < ctx.totalStaves; ++si) { + Segment* seg = const_cast(m)->getSegment(SegmentType::TimeSig, mTick); + TimeSig* tsig = Factory::createTimeSig(seg); + tsig->setTrack(static_cast(si) * VOICES); + tsig->setSig(mTs, tsType); + seg->add(tsig); + } + prevTs = mTs; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/builders-parts.cpp b/src/importexport/encore/internal/importer/builders-parts.cpp new file mode 100644 index 0000000000000..ec7fde9e98979 --- /dev/null +++ b/src/importexport/encore/internal/importer/builders-parts.cpp @@ -0,0 +1,396 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Build score parts and staves, matching each Encore instrument to the best MuseScore instrument template. + +#include "builders.h" +#include "ctx.h" +#include "import.h" +#include "../parser/elem.h" +#include "mappers.h" +#include +#include +#include +#include +#include +#include "engraving/dom/clef.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/instrtemplate.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/part.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftype.h" +#include "engraving/engravingerrors.h" +#include "log.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Build a human-readable instrument name from a template id slug +// ("bass-clarinet" -> "Bass Clarinet"). Used only when a matched template +// carries no track name of its own (see applyInstrumentOrFallback). +static String humanizeTemplateId(const String& id) +{ + QString out; + bool startWord = true; + for (const QChar& ch : id.toQString()) { + if (ch == u'-' || ch == u'_') { + out.append(u' '); + startWord = true; + } else { + out.append(startWord ? ch.toUpper() : ch); + startWord = false; + } + } + return String(out); +} + +// Which matching rule selected the instrument template, in source-evaluation order. +// Used only for the debug log; an enum (not a bare int + parallel label array) keeps the +// label switch compiler-checked so reordering or adding a step cannot index out of range. +enum class MatchStep { + None, + PercClef, // PERC clef or GM percussive range -> drumset + NameMidiScore, // name+MIDI score match + DrumsetName, // name scoring over drumset templates + PercKeyword, // generic percussion keyword in the name -> drumset + RhythmStaff, // RHYTHM staff -> snare-drum + MidiProgram, // MIDI program lookup + MidiFamily, // nearest template in the same GM family (fallback before Grand Piano) +}; + +static const char* matchStepLabel(MatchStep step) +{ + switch (step) { + case MatchStep::None: return ""; + case MatchStep::PercClef: return "PERC clef"; + case MatchStep::NameMidiScore: return "name+MIDI score"; + case MatchStep::DrumsetName: return "drumset name"; + case MatchStep::PercKeyword: return "perc keyword"; + case MatchStep::RhythmStaff: return "RHYTHM staff"; + case MatchStep::MidiProgram: return "MIDI program"; + case MatchStep::MidiFamily: return "MIDI family"; + } + return ""; +} + +// Describe an Encore MIDI program for the debug log using MuseScore's own instrument names +// (localized like everything else: the template trackName is translated at load). Encore stores +// a 1-indexed GM program; map it to the template whose primary sound is that program and show its +// track name. No hardcoded GM table; an unmapped program shows just the number. +static std::string midiProgramInfo(const EncInstrument& instr) +{ + if (instr.midiProgram <= 0) { + return "MIDI program none"; + } + std::string label = "MIDI program " + std::to_string(instr.midiProgram); + if (const InstrumentTemplate* gm = findTemplateByMidi(instr.midiProgram - 1)) { + label += " (" + gm->trackName.toStdString() + ")"; + } + return label; +} + +// Apply a found template (or fallback to Grand Piano) and set the instrument's long name. +static void applyInstrumentOrFallback(Part* part, const InstrumentTemplate* tmpl, + const EncInstrument& instr, MatchStep matchStep) +{ + auto setInstrName = [&](Instrument& ins) { + if (!instr.name.isEmpty()) { + ins.setLongName(String(instr.name)); + } + ins.setShortName(String()); + ins.instrumentLabel().setAllowGroupName(false); + }; + if (tmpl) { + Instrument ins = Instrument::fromTemplate(tmpl); + setInstrName(ins); + // A few generic templates carry no track name in instruments.xml (their UI name comes from + // the muse instruments, unavailable here). Derive it from the template id so the mixer and + // Instruments panel are not left blank. + if (ins.trackName().isEmpty()) { + ins.setTrackName(humanizeTemplateId(tmpl->id)); + } + LOGD() << " instrument \"" << instr.name.toStdString() << "\" (" << midiProgramInfo(instr) + << "): step(" << matchStepLabel(matchStep) << ") -> " << ins.trackName().toStdString(); + part->setInstrument(ins); + return; + } + // Grand Piano fallback. + const InstrumentTemplate* pianoTmpl = searchTemplateForMidiProgram(0, 0, false); + if (pianoTmpl) { + LOGD() << " instrument \"" << instr.name.toStdString() << "\" (" << midiProgramInfo(instr) + << "): no match -> fallback: Grand Piano"; + Instrument ins = Instrument::fromTemplate(pianoTmpl); + setInstrName(ins); + part->setInstrument(ins); + } else { + LOGD() << " instrument \"" << instr.name.toStdString() << "\" (" << midiProgramInfo(instr) + << "): no match, no piano template -> bare MIDI"; + part->setMidiProgram(0, 0); + if (!instr.name.isEmpty()) { + part->setPlainLongName(String(instr.name)); + } + part->setPlainShortName(String()); + } +} + +// Step 2: name+MIDI score with transposition filter. A contains-only (substring) name match can +// outrank the right GM instrument via the MIDI bonus, so when the name match is neither exact nor +// unique, prefer the MIDI-program instrument if it differs. Exact and unique name matches are kept. +static const InstrumentTemplate* tryNameMidiScore(const EncInstrument& instr, int encMidi, + int encKey, bool isRhythm) +{ + bool nameExact = false; + bool nameUnique = false; + const InstrumentTemplate* nameTmpl = findEncoreInstrumentTemplate(instr.name, encMidi, encKey, &nameExact, &nameUnique); + if (nameTmpl && !nameExact && !nameUnique && !isRhythm && instr.midiProgram > 0) { + const InstrumentTemplate* midiTmpl = findTemplateByMidi(instr.midiProgram - 1); + if (midiTmpl && midiTmpl != nameTmpl) { + LOGD() << " instrument \"" << instr.name.toStdString() + << "\": weak name match \"" << nameTmpl->trackName.toStdString() + << "\" overridden by MIDI " << instr.midiProgram << " -> " + << midiTmpl->trackName.toStdString(); + nameTmpl = midiTmpl; + } + } + if (!nameTmpl && !instr.name.trimmed().isEmpty()) { + const InstrumentTemplate* rejected = findEncoreInstrumentTemplate(instr.name, encMidi); + if (rejected) { + LOGD() << " instrument \"" << instr.name.toStdString() + << "\": MIDI " << instr.midiProgram << " match \"" + << rejected->trackName.toStdString() + << "\" rejected (template chromatic=" << rejected->transpose.chromatic + << " vs encKey=" << encKey << "), trying MIDI"; + } + } + return nameTmpl; +} + +// Step 4: generic percussion keyword in the instrument name. +static bool nameHasPercKeyword(const EncInstrument& instr) +{ + const QString lname = instr.name.toLower(); + return lname.contains(QStringLiteral("perc")) + || lname.contains(QStringLiteral("drum")) + || lname.contains(QStringLiteral("bater")); +} + +// Step 6: MIDI program lookup (logs a transposition mismatch against the Encore key). +static const InstrumentTemplate* tryMidiProgram(const EncInstrument& instr, int encKey) +{ + if (instr.midiProgram <= 0) { + return nullptr; + } + const InstrumentTemplate* midiTmpl = findTemplateByMidi(instr.midiProgram - 1); + if (midiTmpl) { + const int tmplChr = midiTmpl->transpose.chromatic; + const bool transpMismatch = (tmplChr % 12 != 0) && (encKey % 12 != 0) + && ((((encKey % 12) + 12) % 12) != (((tmplChr % 12) + 12) % 12)); + if (transpMismatch) { + LOGD() << " instrument \"" << instr.name.toStdString() + << "\": MIDI " << instr.midiProgram << " match \"" + << midiTmpl->trackName.toStdString() + << "\" transposition differs (template chromatic=" << tmplChr + << " vs encKey=" << encKey << ")"; + } + } + return midiTmpl; +} + +static const InstrumentTemplate* applyBestInstrument(Part* part, + const EncInstrument& instr, + bool isPercByClef, + bool isRhythm, + bool encWantsTab, + InstrumentSearchMode searchMode) +{ + // Piano mode: skip all matching and go straight to fallback. + if (searchMode == InstrumentSearchMode::Piano) { + applyInstrumentOrFallback(part, nullptr, instr, MatchStep::None); + return nullptr; + } + + const int encMidi = instr.midiProgram > 0 ? instr.midiProgram - 1 : -1; + const int encKey = static_cast(instr.keyTransposeSemitones); + const bool nameTooShort = instr.name.trimmed().size() < 4; + const bool useNameSearch = (searchMode == InstrumentSearchMode::NameAndMidi); + + const InstrumentTemplate* tmpl = nullptr; + MatchStep matchStep = MatchStep::None; + auto tryStep = [&](MatchStep step, const InstrumentTemplate* candidate) { + if (!tmpl && candidate) { + tmpl = candidate; + matchStep = step; + } + }; + + // Step 1: PERC clef or GM Percussive range (113 to 128 1-indexed) -> drumset. + static constexpr int GM_PERC_FIRST = 113; + if (isPercByClef || instr.midiProgram >= GM_PERC_FIRST) { + tryStep(MatchStep::PercClef, searchTemplate(String(u"drumset"))); + } + + // Steps 2-4: name-based matching (skipped in MidiOnly mode). + if (useNameSearch) { + if (!tmpl) { + tryStep(MatchStep::NameMidiScore, tryNameMidiScore(instr, encMidi, encKey, isRhythm)); + } + // Step 3: name scoring over drumset templates. + if (!nameTooShort) { + tryStep(MatchStep::DrumsetName, findDrumsetTemplate(instr.name)); + } + // Step 4: generic percussion keywords. + if (!tmpl && !nameTooShort && nameHasPercKeyword(instr)) { + tryStep(MatchStep::PercKeyword, searchTemplate(String(u"drumset"))); + } + } + + // Step 5 (RHYTHM): snare-drum; skip MIDI to avoid program-0 piano override. + if (isRhythm) { + tryStep(MatchStep::RhythmStaff, searchTemplate(String(u"snare-drum"))); + } + + // Step 6: MIDI program lookup (skipped for RHYTHM staves). + if (!tmpl && !isRhythm) { + tryStep(MatchStep::MidiProgram, tryMidiProgram(instr, encKey)); + } + + // Step 7: nearest template in the same GM family. Catches configured programs that no + // template carries as its primary sound (Pizzicato/Tremolo Strings, Muted Trumpet, Synth + // Bass, Voice Oohs, …) so the part keeps its category instead of falling back to Grand Piano. + if (!tmpl && !isRhythm && encMidi >= 0) { + tryStep(MatchStep::MidiFamily, findTemplateByMidiFamily(encMidi)); + } + + // Encore stores the staff's notation/tablature choice in the clef; a name/MIDI match may + // land on the wrong variant (e.g. "Classical Guitar (tablature)" when Encore uses a normal + // clef). Swap to the standard or tablature sibling to match Encore's clef. + if (tmpl && !isRhythm && (tmpl->staffGroup == StaffGroup::TAB) != encWantsTab) { + if (const InstrumentTemplate* variant = findInstrumentVariant(tmpl, encWantsTab)) { + LOGD() << " instrument \"" << instr.name.toStdString() << "\": clef-driven variant " + << (encWantsTab ? "tablature" : "standard") << " -> " << variant->trackName.toStdString(); + tmpl = variant; + } + } + + applyInstrumentOrFallback(part, tmpl, instr, matchStep); + return tmpl; +} + +void buildParts(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + const char* searchModeLabel + =ctx.opts.instrumentSearchMode == InstrumentSearchMode::MidiOnly ? "MIDI only" + : ctx.opts.instrumentSearchMode == InstrumentSearchMode::Piano ? "Grand Piano for all" + : "name then MIDI"; + LOGD() << "---- Instrument assignment (using " << searchModeLabel << ") ----"; + int cumStaffIdx = 0; // running index into enc.lines[0].staffData + for (const auto& instr : enc.instruments) { + int ns = instr.nstaves > 0 ? instr.nstaves : 1; + Part* part = new Part(score); + + const EncLineStaffData* lsd = lineStaffDataAt(enc, cumStaffIdx); + const bool isPercByClef = lsd && lsd->clef == EncClefType::PERC; + const bool isRhythm = lsd && lsd->staffType == EncStaffType::RHYTHM; + // Tablature only when Encore explicitly marks it (clef==TAB or staffType==TAB). v0xA6 + // has no per-staff clef in the LINE block, so it defaults to standard notation. + const bool encWantsTab = lsd && (lsd->clef == EncClefType::TAB + || lsd->staffType == EncStaffType::TAB); + const InstrumentTemplate* tmpl = applyBestInstrument(part, instr, isPercByClef, isRhythm, + encWantsTab, ctx.opts.instrumentSearchMode); + + const bool showFromLine = !lsd || lsd->showStaff; + if (!showFromLine) { + part->setShow(false); + } + + const int pitchOffset = static_cast(instr.keyTransposeSemitones); + // Transposition handling depends on the offset: + // - non-octave and positive octave: set on the instrument so the display keeps the written + // pitch under a plain clef (the octave is a playback transposition, no 8va clef). + // - negative octave: left to the octave-down clef from pickStaffClef()/applyOctaveToClef() + // plus the template's own transposition. + Instrument* instrument = part->instrument(); + if (instrument) { + if (pitchOffset != 0 && (std::abs(pitchOffset) % 12 != 0 || pitchOffset > 0)) { + const Interval iv(pitchOffset); + instrument->setTranspose(iv); + static const char* const keyNames[] = { + "C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B" + }; + const int keyIdx = ((pitchOffset % 12) + 12) % 12; + LOGD() << " instrument \"" << instr.name.toStdString() + << "\": transposition in " << keyNames[keyIdx] + << " (chromatic=" << iv.chromatic << " diatonic=" << iv.diatonic << ")"; + } else if (pitchOffset == 0) { + // encKey=0 means "sounds as written" (ENCORE_FORMAT.md). Zero out any transposition + // the template carries, including octave offsets: notes are already at written pitch, + // so a template offset would shift the display by an octave. + const Interval tmplT = instrument->transpose(); + if (!tmplT.isZero()) { + instrument->setTranspose(Interval(0, 0)); + LOGD() << " instrument \"" << instr.name.toStdString() + << "\": encKey=0 (sounds as written) → zeroing template transposition"; + } + } + } + for (int s = 0; s < ns; ++s) { + Staff* staff = Factory::createStaff(part); + score->appendStaff(staff); + if (tmpl) { + staff->init(tmpl, nullptr, s); + // Encore does not store bracket/brace grouping data, so remove any + // bracket the template may have set to avoid spurious cross-part brackets. + staff->setBracketType(0, BracketType::NO_BRACKET); + staff->setBracketSpan(0, 0); + // Safety net: if no standard sibling template was found above and the staff is + // still tablature though Encore stores standard notation, force a standard staff. + if (!encWantsTab && staff->staffType(Fraction(0, 1))->group() == StaffGroup::TAB) { + if (const StaffType* stdType = StaffType::getDefaultPreset(StaffGroup::STANDARD)) { + staff->setStaffType(Fraction(0, 1), *stdType); + } + } + } + ctx.staffPitchOffset.push_back(pitchOffset); + ClefType cClef = ClefType::INVALID; + if (tmpl) { + cClef = tmpl->clefType(static_cast(s)).concertClef; + } + ctx.staffTemplateConcertClef.push_back(cClef); + ++ctx.totalStaves; + } + score->appendPart(part); + cumStaffIdx += ns; + } + if (ctx.totalStaves == 0) { + Part* part = new Part(score); + part->setMidiProgram(0, 0); + Staff* staff = Factory::createStaff(part); + score->appendStaff(staff); + score->appendPart(part); + ctx.totalStaves = 1; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/builders.h b/src/importexport/encore/internal/importer/builders.h new file mode 100644 index 0000000000000..1b2c9bde84124 --- /dev/null +++ b/src/importexport/encore/internal/importer/builders.h @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Top-level build pipeline entry points: create parts, create measures, set initial signatures, +// then emit the note/rest/element content into the score. + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_BUILDERS_H +#define MU_IMPORTEXPORT_ENC_IMPORT_BUILDERS_H + +#include "ctx.h" + +namespace mu::iex::enc { +void buildParts(BuildCtx& ctx); +void buildMeasures(BuildCtx& ctx); +void buildInitialSignatures(BuildCtx& ctx); +void emitMeasures(BuildCtx& ctx); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_BUILDERS_H diff --git a/src/importexport/encore/internal/importer/coords.cpp b/src/importexport/encore/internal/importer/coords.cpp new file mode 100644 index 0000000000000..edb4595a408f8 --- /dev/null +++ b/src/importexport/encore/internal/importer/coords.cpp @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +// Convert Encore tick/xoffset coordinates to MuseScore Fractions and snapped tick positions. + +#include "coords.h" + +#include +#include + +#include "../parser/elem.h" +#include "../parser/ticks.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +int encWholeNoteTicks(const EncMeasure& measure) +{ + if (measure.durTicks && measure.timeSigNum && measure.timeSigDen) { + return (static_cast(measure.durTicks) * static_cast(measure.timeSigDen)) + / static_cast(measure.timeSigNum); + } + return kEncWholeTicks; +} + +Fraction snapStartTickByXoffset(Fraction defaultTick, const EncMeasure& encMeas, + int staffIdx, int ornXoffset, Fraction measTick) +{ + const int wholeTicks = encWholeNoteTicks(encMeas); + const Fraction relTick = defaultTick - measTick; + const int defaultEncTick = (relTick.numerator() * wholeTicks) + / std::max(1, relTick.denominator()); + + // xoffset of the note/rest at the element's own tick (any voice on the staff). + int defaultCrXoff = -1; + forEachStaffNoteXoff(encMeas, staffIdx, /*includeRests*/ true, /*lineSlotByRawByte*/ nullptr, + [&](const EncMeasureElem* em, int xoff) { + if (static_cast(em->tick) != defaultEncTick) { + return true; + } + defaultCrXoff = xoff; + return false; // first match at that tick wins + }); + // Glyph sits at or after its own note: trust the tick. + if (defaultCrXoff >= 0 && ornXoffset >= defaultCrXoff) { + return defaultTick; + } + // Glyph drawn left of the note: snap back to the latest preceding chord/rest whose + // xoffset is <= the glyph xoffset. Also covers elements stored at durTicks (no CR there). + int bestTick = -1; + forEachStaffNoteXoff(encMeas, staffIdx, /*includeRests*/ true, /*lineSlotByRawByte*/ nullptr, + [&](const EncMeasureElem* em, int xoff) { + if (static_cast(em->tick) >= defaultEncTick) { + return true; + } + if (xoff <= ornXoffset && static_cast(em->tick) > bestTick) { + bestTick = static_cast(em->tick); + } + return true; + }); + if (bestTick < 0) { + return defaultTick; + } + return measTick + Fraction(bestTick, wholeTicks); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/coords.h b/src/importexport/encore/internal/importer/coords.h new file mode 100644 index 0000000000000..a19759c34cc56 --- /dev/null +++ b/src/importexport/encore/internal/importer/coords.h @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Shared tick-conversion and coordinate-anchoring helpers used by the spanner/ornament resolvers. + +#pragma once + +#include + +#include "engraving/types/fraction.h" + +#include "../parser/elem.h" + +namespace mu::iex::enc { +// Visit each NOTE (and, when includeRests is set, REST) element of `meas` that sits on `staffIdx`, +// invoking fn(em, xoff) in stream order. When lineSlotByRawByte is non-null the staff test uses the +// LINE-slot remap (grand-staff routed notes); otherwise it uses the raw em->staffIdx. fn returns +// false to stop the scan early. All comparison and tie-break logic stays in the caller's fn, so the +// visitor owns only the shared iterate / type-gate / staff-match skeleton. +template +inline void forEachStaffNoteXoff(const EncMeasure& meas, int staffIdx, bool includeRests, + const std::array* lineSlotByRawByte, Fn fn) +{ + for (const auto& elem : meas.elements) { + const EncMeasureElem* em = elem.get(); + const bool isNote = em->type == static_cast(EncElemType::NOTE); + const bool isRest = em->type == static_cast(EncElemType::REST); + if (!isNote && !(includeRests && isRest)) { + continue; + } + int slot = static_cast(em->staffIdx); + if (lineSlotByRawByte) { + const int mapped = (*lineSlotByRawByte)[static_cast(em->rawStaffByte())]; + slot = (mapped >= 0) ? mapped : static_cast(em->staffIdx); + } + if (slot != staffIdx) { + continue; + } + if (!fn(em, static_cast(em->xoffset))) { + break; + } + } +} + +// Ticks per whole note for a measure. Re-derived from the time signature (not beatTicks, which is +// wrong for compound meters), falling back to the fixed 960 grid. Single source of truth for the +// conversion used when snapping spanners and ornaments to Encore element ticks. +int encWholeNoteTicks(const EncMeasure& measure); + +// Coordinate-based anchoring helpers. A glyph's xoffset origin differs from the note xoffset origin +// by a per-file constant, so raw values cannot be compared directly. See ENCORE_IMPORTER.md +// "Coordinate-based anchoring of ornaments and spanners"; the column is ENCORE_FORMAT.md +// §Chord column (xoffset). + +// START anchor. Trusts the element's own tick; only when the glyph is drawn to the left of the note +// at that tick does it snap back to the latest preceding chord/rest whose xoffset is <= the glyph's. +mu::engraving::Fraction snapStartTickByXoffset( + mu::engraving::Fraction defaultTick, const EncMeasure& encMeas, int staffIdx, int ornXoffset, mu::engraving::Fraction measTick); +} diff --git a/src/importexport/encore/internal/importer/ctx.h b/src/importexport/encore/internal/importer/ctx.h new file mode 100644 index 0000000000000..0820634d7eff6 --- /dev/null +++ b/src/importexport/encore/internal/importer/ctx.h @@ -0,0 +1,325 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Shared importer state: the BuildCtx threaded through every phase, the deferred "pending" element +// structs drained in the post-pass, and the emit-phase scratch tables. + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_CTX_H +#define MU_IMPORTEXPORT_ENC_IMPORT_CTX_H + +#include "import-options.h" + +#include +#include +#include +#include +#include + +#include + +#include "../parser/elem.h" +#include "../parser/readers.h" +#include "emitters-tuplets.h" +#include "engraving/types/fraction.h" +#include "engraving/types/symid.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/ottava.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Reverse map from a raw staff byte (instrStaffIdx in the LINE block) to its LINE slot index; +// -1 = no staff with that byte. Needed when an earlier instrument owns more than one staff, so the +// raw byte no longer equals the slot index. Shared by the emitter routing and the slur resolver. +inline void buildLineSlotByRawByte(const EncRoot& enc, std::array& out) +{ + out.fill(-1); + if (enc.lines.empty()) { + return; + } + const auto& sd = enc.lines[0].staffData; + for (int s = 0; s < static_cast(sd.size()); ++s) { + out[static_cast(sd[s].instrStaffIdx)] = s; + } +} + +// LINE staff-data entry for the given running staff index, or nullptr when absent/out of range. +// Centralizes the "lines non-empty + index in range" guard used by the part/staff routing. +inline const EncLineStaffData* lineStaffDataAt(const EncRoot& enc, int idx) +{ + if (enc.lines.empty() || idx < 0 + || idx >= static_cast(enc.lines[0].staffData.size())) { + return nullptr; + } + return &enc.lines[0].staffData[static_cast(idx)]; +} + +struct PendingSlur { + Fraction startTick; + track_idx_t track; + int startMeasIdx; + int endMeasIdx; + int alMezuro; + bool alMezuroValid { true }; // false when format cannot guarantee measure-count semantics + int slurXoffset; + int slurXoffset2; + int staffIdx; + int encVoice; +}; + +// Hairpin end resolved in post-pass to the next Dynamic on the same track (not the barline). +struct PendingHairpin { + Fraction startTick; + Fraction maxEndTick; // end of alMezuro target measure (upper bound) + track_idx_t track; + HairpinType type; + int endMeasIdx; + int hairpinXoffset2; + int staffIdx; + int encVoice; +}; + +// Deferred: ORN precedes chord notes in MEAS order, so the chord does not exist at parse time. +struct PendingArpeggio { + Fraction tick; + track_idx_t track; +}; + +// Single-chord tremolo (tipo 0xAF/0xEF), deferred like ARPEGGIO. Post-pass falls back to latest chord before the tick. +struct PendingOrnTremolo { + Fraction tick; + Fraction measTick; + int staffIdx; + int msVoice; + TremoloType tremType; +}; + +// Trill intents (tipo 0x35/0x36/0x37), deferred for the same reason as ARPEGGIO. +struct PendingTrill { + Fraction tick; + track_idx_t track; + int alMezuro { 0 }; // forward measure count to trill end (0 = same measure) + size_t measIdx { 0 }; + int xoffset2 { 0 }; // end x-position hint for same-measure endpoint detection + bool isAlt { false }; // TRILL_ALT (0x37): secondary mark, always Ornament glyph + bool isSimple { false }; // TRILL_TR/TRILL_SHORT: standalone glyph, never a spanner + mu::engraving::SymId simpleSymId { mu::engraving::SymId::ornamentTrill }; +}; + +// Staccato (tipo 0xC9), deferred like ARPEGGIO. +struct PendingStaccato { + Fraction tick; + track_idx_t track; +}; + +// Fermata (tipo 0xCC/0xCD), deferred like ARPEGGIO. +struct PendingFermata { + Fraction tick; + track_idx_t track; + mu::engraving::SymId symId; +}; + +// Breath / caesura (tipo 0xA7/0xA8). +struct PendingBreath { + Fraction tick; + track_idx_t track; + mu::engraving::SymId symId; +}; + +// Measure repeat (tipo 0xA3). +struct PendingMeasureRepeat { + Fraction measTick; + int staffIdx; +}; + +// Bowing/stroke (tipo 0xC4/0xC5), deferred like ARPEGGIO. +// v0xC4: 0xC4=stringsUpBow, 0xC5=stringsDownBow; v0xC2: 0xC4=articAccentAbove. +struct PendingBowing { + Fraction tick; + track_idx_t track; + mu::engraving::SymId symId; + int measIdx = -1; + bool crossMeasure = false; // no voice=0 note at same enc tick; belongs to next measure + int ornXoffset = 0; // absolute horizontal pixel position (for xoffset-cluster correction) + int encTickRaw = 0; +}; + +// Fingering from stand-alone ORN elements (tipo 0xB9..0xBD), deferred like ARPEGGIO. +// Also used for standalone string-number ORNs (0xE6..0xEA = strings 2..6); those set +// isStringNum=true and the resolver uses TextStyleType::STRING_NUMBER. +struct PendingOrnFingering { + Fraction tick; + track_idx_t track; + int fingerNum; + int measIdx = -1; + bool crossMeasure = false; // ORN at last v0 tick, no v4 note there: belongs to next measure + bool preferSibling = false; // more ORNs than v0 notes at tick: belongs to 2nd-staff chord + bool isStringNum = false; // true → render as STRING_NUMBER (circled), not FINGERING +}; + +// Ottava lines (tipo 0x10=8va, 0x12=8vb). Endpoint = next ottava on same staff or scoreEnd. +struct PendingOttava { + Fraction startTick; + track_idx_t track; + int staffIdx; + mu::engraving::OttavaType ottavaType; +}; + +// Segno/Coda markers (tipo 0xA2/0xA6). +struct PendingMarker { + Fraction tick; + MarkerType type; +}; + +// Lyric syllables queued for attachment. encTick lets us find the correct chord even +// when queue index shifts due to ORN elements. +struct PendingLyric { + int encTick; + int xoffset; // horizontal anchor (EncLyric::kie); the reliable per-syllable position + String text; + bool hyphenBefore; + bool hyphenAfter; +}; + +// A grace chord held detached until the next normal chord, paired with the EncNote it came +// from so its articulations can be applied after attachment (see pendingGraces). +struct PendingGrace { + mu::engraving::Chord* gc { nullptr }; + const EncNote* en { nullptr }; + // Measure the grace was queued in, so a grace that never finds a principal chord (dangling at end + // of score) can be re-placed as a cue note in its own bar instead of being discarded. + mu::engraving::Measure* measure { nullptr }; +}; + +// Shared importer context threaded through builders, emitters and resolvers: the target score, +// the parsed Encore model, import options, derived staff/measure tables, the resolver "pending" +// queues drained in the post-pass, and (grouped in EmitState scratch) the emitter-only state. +struct BuildCtx +{ + mu::engraving::MasterScore* score; + const EncRoot& enc; + EncImportOptions opts; + + // Populated by buildParts(): + int totalStaves = 0; + std::vector staffPitchOffset {}; + std::vector staffTemplateConcertClef {}; + + // Nominal time sig of the score (differs from measures[0] when the first measure is a pickup). + Fraction nominalTimeSig { 4, 4 }; + TimeSigType nominalTimeSigType { TimeSigType::NORMAL }; + + // Tick → TimeSigType for measures with non-numeric display (e.g. common time "C"). + std::map measTickToTimeSigType {}; + + // encToMsIdx[i] = MuseScore measure index of the first measure from enc.measures[i]. + // Accounts for multi-measure rest expansion (mrestCount > 1). + std::vector encToMsIdx {}; + + std::vector measuresByIdx {}; + std::vector pendingHairpins {}; + std::vector pendingSlurs {}; + std::vector pendingArpeggios {}; + std::vector pendingOrnTremolos {}; + std::vector pendingTrills {}; + // TRILL_END ticks by track; consumed by resolveOrnaments() to compute span endpoints. + std::map > pendingTrillEnds {}; + std::vector pendingStaccatos {}; + std::vector pendingFermatas {}; + std::vector pendingBreaths {}; + std::vector pendingMeasureRepeats {}; + std::vector pendingBowings {}; + // (measIdx, staffIdx) → list of (enc_tick, note.xoffset) for bowing xoffset clustering. + std::map, std::vector > > noteXoffByMeasStaff {}; + std::vector pendingOrnFingerings {}; + std::vector pendingOttavas {}; + std::vector pendingMarkers {}; + + // Volta being coalesced: equal-bitmask runs collapse into one Volta. + Volta* activeVolta { nullptr }; + quint8 activeVoltaBits { 0 }; + // Bitmask of all volta endings already labelled in earlier brackets of the current repeat + // block; suppresses re-labelling them (e.g. after "1.-3." a {2,4} bracket shows only "4."). + quint8 usedVoltaBits { 0 }; + + // Per-measure / per-run emitter scratch, grouped so builders and resolvers don't touch it: + // only the emitters (emitMeasures and its helpers) use ctx.scratch.*. Lives for the emit phase. + struct EmitState { + // Elements routeElementStaffVoice dropped for referencing a missing staff (mostly orphan + // data from staves deleted in Encore) or an out-of-range voice. Counted, reported once. + // mutable: routeElementStaffVoice takes a const BuildCtx& and otherwise only reads. + mutable std::map droppedByMissingStaff {}; // staff index -> count + mutable int droppedByBadVoice { 0 }; + + // Tuplet trackers under construction, keyed by (staffIdx, msVoice); cleared each measure. + std::map, TupletTracker> tuplets {}; + // Inner (nested) TupletTrackers; cleared each measure alongside tuplets. + std::map, TupletTracker> innerTuplets {}; + + // Pending tie-start notes, persists across measures. key=(staffIdx, voice, pitch). + std::map, Note*> pendingTieNote {}; + + // Accumulated written position per (staffIdx, msVoice). + std::map, Fraction> cumTick {}; + // Last MIDI tick placed; same tick = same chord. + std::map, int> prevMidiTick {}; + // Encore voice of last note placed; guards against chord-extension misdetection. + std::map, int> prevEncVoice {}; + // Notated column (xoffset) of last note placed; near-simultaneous notes in different + // columns are separate events (e.g. tightly played tuplet members), not one chord. + std::map, int> prevXoffset {}; + std::map, Fraction> lastChordPos {}; + // Last enc tick at which a REST was placed; absorbs duplicate rests when multiple + // Encore voices route to the same MuseScore voice. + std::map, int> prevRestTick {}; + + // Grace chords held detached, attached to the next normal chord. The source EncNote is kept + // so articulations/fermatas are applied once the grace has a real segment (fermatas cannot + // anchor to the dummy segment of a detached grace). + std::map, std::vector > pendingGraces {}; + // Ticks borrowed by grace notes; suppresses spurious gap-snap rests after a grace group. + std::map, int> graceStolenTicks {}; + // Most recent grace chord per track and its enc tick. Encore stores each chord member as a + // note at the same tick, so a same-tick grace member merges here instead of spawning a new + // grace. Covers both before- and after-graces. + std::map, mu::engraving::Chord*> lastGraceChord {}; + std::map, int> lastGraceTick {}; + + // Lyric syllables queued for attachment; drained each measure by attachPendingLyrics. + std::map > pendingLyrics {}; + // True when the next syllable follows a hyphen; reset at measure boundary. + std::map nextLyricHyphenBefore {}; + // Last lyric attached on each track. Lets a hyphen that opens the next measure + // promote the previous measure's final syllable so the word stays hyphenated + // across the barline. + std::map lastAttachedLyric {}; + } scratch {}; +}; +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_CTX_H diff --git a/src/importexport/encore/internal/importer/debug-dump.cpp b/src/importexport/encore/internal/importer/debug-dump.cpp new file mode 100644 index 0000000000000..636fce56ac329 --- /dev/null +++ b/src/importexport/encore/internal/importer/debug-dump.cpp @@ -0,0 +1,241 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Debug-only summary of a parsed Encore file (logEncRootInfo). Never affects the imported +// score; the output goes to the debug log to help diagnose format/layout issues. + +#include "debug-dump.h" + +#include +#include + +#include +#include + +#include "../parser/elem.h" +#include "page-layout.h" + +#include "log.h" + +namespace mu::iex::enc { +void logEncRootInfo(const EncRoot& enc) +{ + const EncHeader& h = enc.header; + const char* fmtName = enc.fmt ? enc.fmt->formatName() : "unknown"; + + // app version (0x28) -> Encore release. 592 = 2.x, 773 = 3.x, 775 = 4.0-4.2, + // 1056 = 4.5 and 5.x, split by the format-revision byte at 0x3E (1 = 4.5, 4 = 5.0). + const char* encVer; + if (h.chuVersio >= 1000) { + encVer = (h.formatRev >= 4) ? "Encore 5.x" + : (h.formatRev >= 1) ? "Encore 4.5" + : "Encore 4.5/5.x"; + } else { + encVer = (h.chuVersio >= 775) ? "Encore 4.x" + : (h.chuVersio >= 700) ? "Encore 3.x" + : (h.chuVersio >= 500) ? "Encore 2.x" + : "Encore (legacy)"; + } + + LOGD() << "---- Encore file info ----"; + LOGD() << " Magic:" << h.magic.toStdString() + << " Format:0x" << QString::number(h.chuMagio, 16).toUpper().toStdString() + << "(" << fmtName << ") version=" << h.chuVersio + << " rev=" << static_cast(h.formatRev) << "(" << encVer << ")"; + LOGD() << " Lines:" << h.lineCount + << " Pages:" << h.pageCount + << " Instruments:" << h.instrumentCount + << " Staves/sys:" << h.staffPerSystem + << " Measures:" << h.measureCount; + // The UI language MuseScore runs in; the instrument names logged below are localized to it + // (QLocale default is set to the configured language at startup). + LOGD() << " UI locale:" << QLocale().name().toStdString(); + + LOGD() << "---- Titles ----"; + if (!enc.titleBlock.title.isEmpty()) { + LOGD() << " Title: " << enc.titleBlock.title.toStdString(); + } + if (!enc.titleBlock.subtitle.empty() && !enc.titleBlock.subtitle[0].isEmpty()) { + LOGD() << " Subtitle: " << enc.titleBlock.subtitle[0].toStdString(); + } + if (!enc.titleBlock.author.empty() && !enc.titleBlock.author[0].isEmpty()) { + LOGD() << " Author: " << enc.titleBlock.author[0].toStdString(); + } + if (!enc.titleBlock.copyright.empty() && !enc.titleBlock.copyright[0].isEmpty()) { + LOGD() << " Copyrt: " << enc.titleBlock.copyright[0].toStdString(); + } + + // Must match applyStaffScale's kScaleBySize in import.cpp: { 0.60, 0.75, 1.00, 1.30 }. + static const char* kSizeLabel[4] = { "60%", "75%", "100%", "130%" }; + + LOGD() << "---- Instruments ----"; + for (size_t i = 0; i < enc.instruments.size(); ++i) { + const EncInstrument& ins = enc.instruments[i]; + const int sz = staffDisplaySize(enc, static_cast(i)); + LOGD() << " [" << i << "] \"" << ins.name.toStdString() << "\"" + << " midi=" << ins.midiProgram + << " staves=" << ins.nstaves + << " key=" << ins.keyTransposeSemitones + << " size=" << sz << "(" << kSizeLabel[sz - 1] << ")"; + } + + LOGD() << "---- Systems ----"; + { + // One line per system is useless noise on large scores (hundreds of near-identical + // entries). Summarize as a histogram of measures-per-system instead; the per-system + // start offsets are derivable from the counts and rarely needed. + std::map measPerSys; // measures in a system -> how many systems + for (const EncLine& ln : enc.lines) { + ++measPerSys[static_cast(ln.measureCount)]; + } + std::string hist; + for (auto it = measPerSys.rbegin(); it != measPerSys.rend(); ++it) { + if (!hist.empty()) { + hist += ", "; + } + hist += std::to_string(it->first) + "x" + std::to_string(it->second); + } + LOGD() << " " << enc.lines.size() << " systems over " << h.pageCount + << " pages (measures/system: " << hist << ")"; + } + + LOGD() << "---- Tempo & meter ----"; + { + // One compact line of the tempo/time-signature changes (the measure count is already in + // the header), e.g. "20 changes: m0=3/4 180bpm, m48=2/4 120bpm, ...". + std::string changes; + int nChanges = 0; + quint8 lastNum = 0, lastDen = 0; + quint16 lastBpm = 0; + for (size_t i = 0; i < enc.measures.size(); ++i) { + const EncMeasure& m = enc.measures[i]; + const bool timeSigChanged = (m.timeSigNum != lastNum || m.timeSigDen != lastDen); + const bool bpmChanged = (m.bpm != 0 && m.bpm != lastBpm); + if (i == 0 || timeSigChanged || bpmChanged) { + if (!changes.empty()) { + changes += ", "; + } + changes += "m" + std::to_string(i) + "=" + std::to_string((int)m.timeSigNum) + + "/" + std::to_string((int)m.timeSigDen); + if (m.bpm) { + changes += " " + std::to_string(m.bpm) + "bpm"; + } + ++nChanges; + lastNum = m.timeSigNum; + lastDen = m.timeSigDen; + if (m.bpm) { + lastBpm = m.bpm; + } + } + } + LOGD() << " " << nChanges << " changes: " << changes; + } + // Diagnostics: non-notation elements the importer intentionally drops. MIDI Control Change + // events (sustain/volume/modulation) are playback-only and decoded here so the log says what + // they are instead of one "unknown" line per event. + { + int ccSustain = 0, ccVolume = 0, ccMod = 0, ccOther = 0, unknown1 = 0; + for (const EncMeasure& m : enc.measures) { + for (const auto& ep : m.elements) { + switch (static_cast(ep->type)) { + case EncElemType::MIDI_CC: + switch (static_cast(ep.get())->controller) { + case 64: ++ccSustain; + break; + case 7: ++ccVolume; + break; + case 1: ++ccMod; + break; + default: ++ccOther; + break; + } + break; + case EncElemType::UNKNOWN1: + ++unknown1; + break; + default: + break; + } + } + } + const int ccTotal = ccSustain + ccVolume + ccMod + ccOther; + if (ccTotal || unknown1) { + LOGD() << "---- Diagnostics ----"; + if (ccTotal) { + std::string by; + auto add = [&](const char* label, int n) { + if (n > 0) { + if (!by.empty()) { + by += " "; + } + by += std::string(label) + "=" + std::to_string(n); + } + }; + add("sustain", ccSustain); + add("volume", ccVolume); + add("modulation", ccMod); + add("other", ccOther); + LOGD() << " MIDI CC events (playback only, dropped): " << ccTotal + << " (" << by << ")"; + } + if (unknown1) { + LOGD() << " Unknown elements (type 0xA, dropped): " << unknown1; + } + } + } + + LOGD() << "---- Page setup ----"; + const EncPageSetup& ps = enc.pageSetup; + if (ps.hasData) { + // Derive all four margins (inches) for the summary: top/left are stored directly, while + // right/bottom come from the printable edges and the page size. The WINI unit (points vs + // screen pixels) is resolved from the PREC page size, same as applyPageMargins. + std::string marginStr; + double wIn = 0.0, hIn = 0.0; + if (precPageSizeInches(enc.printSetup, wIn, hIn) && wIn > 0.0 && hIn > 0.0) { + const double upi = winiUnitsPerInch(ps.rightEdge, ps.left, wIn); + marginStr = (" (in: T=" + QString::number(ps.top / upi, 'f', 3) + + " L=" + QString::number(ps.left / upi, 'f', 3) + + " R=" + QString::number(wIn - ps.rightEdge / upi, 'f', 3) + + " B=" + QString::number(hIn - ps.bottomEdge / upi, 'f', 3) + ")").toStdString(); + } + LOGD() << " WINI: top=" << ps.top << " left=" << ps.left + << " bottomEdge=" << ps.bottomEdge << " rightEdge=" << ps.rightEdge << marginStr; + } else if (enc.fmt && enc.fmt->usesUniformPageMargins()) { + LOGD() << " WINI: absent, margins set to 0.25 inches"; + } else { + LOGD() << " WINI: absent, margins from MuseScore defaults"; + } + const EncPrintSetup& pr = enc.printSetup; + if (pr.hasData) { + LOGD() << " PREC: orientation=" << pr.orientation + << " (" << (pr.orientation == 2 ? "landscape" : "portrait") << ")" + << " paperSize=" << pr.paperSize + << " paper=" << pr.paperWidth << "x" << pr.paperLength << " (0.1mm)" + << " scale/zoom=" << pr.scale << "%" + << " [scale not applied: needs spatium mapping]"; + } else { + LOGD() << " PREC: absent, page size from WINI/defaults"; + } + LOGD() << "--------------------------"; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/debug-dump.h b/src/importexport/encore/internal/importer/debug-dump.h new file mode 100644 index 0000000000000..459b9514604e7 --- /dev/null +++ b/src/importexport/encore/internal/importer/debug-dump.h @@ -0,0 +1,30 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +namespace mu::iex::enc { +struct EncRoot; + +// Dump a human-readable summary of the parsed Encore file to the debug log: +// header, titles, instruments, systems, tempo/time-signature changes, page setup. +void logEncRootInfo(const EncRoot& enc); +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/durations.cpp b/src/importexport/encore/internal/importer/durations.cpp new file mode 100644 index 0000000000000..412e057ef7985 --- /dev/null +++ b/src/importexport/encore/internal/importer/durations.cpp @@ -0,0 +1,208 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Resolve Encore face value and MIDI ticks into MuseScore duration types, dot counts and tuplet ratios. + +#include "durations.h" + +#include +#include + +#include "../parser/ticks.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +DurationType faceValue2DurationType(quint8 fv) +{ + switch (fv & 0x0F) { + case 1: return DurationType::V_WHOLE; + case 2: return DurationType::V_HALF; + case 3: return DurationType::V_QUARTER; + case 4: return DurationType::V_EIGHTH; + case 5: return DurationType::V_16TH; + case 6: return DurationType::V_32ND; + case 7: return DurationType::V_64TH; + case 8: return DurationType::V_128TH; + default: return DurationType::V_QUARTER; + } +} + +// Reject rdur-based dot promotion when rdur is inflated by gap-to-next-event, not a real dotted value. +static bool inflatedDottedPromotion(qint16 realDur, quint8 fv) +{ + int faceTicks = faceValue2ticks(fv); + return faceTicks > 0 && realDur > faceTicks && calcDots(realDur, fv) == 0; +} + +DurationType realDuration2DurationType(qint16 realDur, quint8 fv) +{ + if (realDur <= 0) { + return faceValue2DurationType(fv); + } + // Multi-stream MIDI overlap can shorten rdur below the written value; trust face value when rdur < faceTicks. + const int faceTicks = faceValue2ticks(fv); + if (faceTicks > 0 && realDur < faceTicks) { + return faceValue2DurationType(fv); + } + // rdur above faceTicks but not a dotted augmentation is a trailing gap to the next event; + // trust the written face value. + if (inflatedDottedPromotion(realDur, fv)) { + return faceValue2DurationType(fv); + } + switch (realDur) { + case 960: return DurationType::V_WHOLE; + case 480: return DurationType::V_HALF; + case 240: return DurationType::V_QUARTER; + case 120: return DurationType::V_EIGHTH; + case 60: return DurationType::V_16TH; + case 30: return DurationType::V_32ND; + case 15: return DurationType::V_64TH; + case 720: return DurationType::V_HALF; + case 360: return DurationType::V_QUARTER; + case 180: return DurationType::V_EIGHTH; + case 90: return DurationType::V_16TH; + case 45: return DurationType::V_32ND; + // Triplet rdur (160/80/40...) falls through; detectImpliedTuplet handles them. + // Mapping to longer type misrepresents notes not in a tuplet context. + default: return faceValue2DurationType(fv); + } +} + +int calcDots(qint16 realDur, quint8 fv) +{ + int base = faceValue2ticks(fv); + if (base <= 0 || realDur <= 0 || realDur == base) { + return 0; + } + // Only match when the dotted value is integral: truncated division (e.g. 112.5 to 112) would + // falsely match a note whose rdur equals the truncated value, so the divisibility check gates it. + if ((base * 3) % 2 == 0 && realDur == (base * 3) / 2) { + return 1; + } + if ((base * 7) % 4 == 0 && realDur == (base * 7) / 4) { + return 2; + } + if ((base * 15) % 8 == 0 && realDur == (base * 15) / 8) { + return 3; + } + return 0; +} + +int calcDotsSnap(qint16 dur, quint8 fv) +{ + static constexpr int DOTS_SNAP_TOL = 1; + + int base = faceValue2ticks(fv); + if (base <= 0 || dur <= 0) { + return 0; + } + auto near = [](int a, int b) { return std::abs(a - b) <= DOTS_SNAP_TOL; }; + if (near(dur, base)) { + return 0; + } + if ((base * 3) % 2 == 0 && near(dur, (base * 3) / 2)) { + return 1; + } + if ((base * 7) % 4 == 0 && near(dur, (base * 7) / 4)) { + return 2; + } + if ((base * 15) % 8 == 0 && near(dur, (base * 15) / 8)) { + return 3; + } + return 0; +} + +Fraction dottedAdvance(DurationType durationType, int dots) +{ + Fraction multiplier = Fraction(1, 1); + if (dots == 1) { + multiplier = Fraction(3, 2); + } else if (dots == 2) { + multiplier = Fraction(7, 4); + } else if (dots >= 3) { + multiplier = Fraction(15, 8); + } + return TDuration(durationType).fraction() * multiplier; +} + +int computeDotCount(quint8 dotControl, qint16 realDuration, quint8 faceValue, bool useBit0Fallback) +{ + if (dotControl > 0) { + const int dByCtrl = calcDots(static_cast(dotControl), faceValue); + if (dByCtrl > 0) { + return dByCtrl; + } + const int dBySnap = calcDotsSnap(realDuration, faceValue); + if (dBySnap > 0) { + return dBySnap; + } + if (useBit0Fallback && (dotControl & 1)) { + // A dotted note lasts longer than its face value, so only force a dot when rdur > + // faceTicks; otherwise the bit-0 flag is a spurious layout bit, not a dot indicator. + const int faceTicks = faceValue2ticks(faceValue); + if (faceTicks > 0 && realDuration > faceTicks) { + return 1; + } + } + return 0; + } + return calcDotsSnap(realDuration, faceValue); +} + +bool isStandardExplicitTuplet(int actualN, int normalN) +{ + if (actualN < 2 || normalN < 1) { + return false; + } + // normalN of 10/15/20 gives a fraction not representable as a TDuration; reject them. + if (normalN == 10 || normalN == 15 || normalN == 20) { + return false; + } + // TDuration-aligned ratios whose normalN is in {1,2,3,5,7} (normalN in {4,6,8} is handled by + // the blanket rule below). + static const std::pair kStandardRatios[] = { + { 3, 2 }, { 4, 3 }, { 2, 1 }, { 2, 3 }, { 5, 2 }, { 5, 3 }, { 6, 7 }, { 9, 5 }, { 4, 1 }, { 4, 2 }, + }; + for (const auto& r : kStandardRatios) { + if (actualN == r.first && normalN == r.second) { + return true; + } + } + // normalN in {4,6,8} always yields a standard TDuration for 2..64-tuplets. + if ((normalN == 4 || normalN == 6 || normalN == 8) + && actualN >= 2 && actualN <= 64) { + return true; + } + return false; +} + +bool isCompoundBeat(quint16 rawBeatTicks, Fraction timesig) +{ + // Explicit dotted-quarter beat, or a compound x/8 meter (6/8, 9/8, 12/8, ...) whose legacy + // files still store beatTicks=240. + return rawBeatTicks == 360 + || (timesig.denominator() == 8 + && timesig.numerator() % 3 == 0 + && timesig.numerator() > 3); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/durations.h b/src/importexport/encore/internal/importer/durations.h new file mode 100644 index 0000000000000..f1d1f35cc2b05 --- /dev/null +++ b/src/importexport/encore/internal/importer/durations.h @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +// Rendering decisions derived from Encore's raw ticks: MuseScore DurationType, dot count and +// tuplet shape. These are importer concerns (they translate the parser's raw tick/face-value +// model into engraving types), so they live in the importer layer and keep the engraving/dom +// dependency out of the parser. The parser exposes only the raw tick table (faceValue2ticks) +// and the pure-integer implied-tuplet probe (detectImpliedTuplet) in parser/ticks.h. + +#include + +#include "engraving/dom/durationtype.h" + +namespace mu::iex::enc { +mu::engraving::DurationType faceValue2DurationType(quint8 fv); +mu::engraving::DurationType realDuration2DurationType(qint16 realDur, quint8 fv); +int calcDots(qint16 realDur, quint8 fv); +int calcDotsSnap(qint16 dur, quint8 fv); +mu::engraving::Fraction dottedAdvance(mu::engraving::DurationType durationType, int dots); + +// Dot-count computation for note/rest handlers. See ENCORE_FORMAT.md §Note element (dotControl). +// When useBit0Fallback=true (notes only), bit 0 of dotControl is Encore's dotted flag. +int computeDotCount(quint8 dotControl, qint16 realDuration, quint8 faceValue, bool useBit0Fallback = false); + +// Standard Encore tuplet ratios (3:2, 4:3, 5:4, 6:4). +// Other actualN:normalN pairs are MIDI timing noise; caller should zero them. +bool isStandardExplicitTuplet(int actualN, int normalN); + +// True when a measure's beat is a dotted quarter (compound feel): either the raw beatTicks is the +// explicit 360, or the time signature is a compound x/8 (6/8, 9/8, 12/8, ...) whose legacy files +// still store beatTicks=240. Used for tempo-mark beat-unit display. See ENCORE_IMPORTER.md. +bool isCompoundBeat(quint16 rawBeatTicks, mu::engraving::Fraction timesig); +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-chords.cpp b/src/importexport/encore/internal/importer/emitters-chords.cpp new file mode 100644 index 0000000000000..58a86e9e0aa3b --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-chords.cpp @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Emit chord symbols (Harmony) from Encore CHORD elements. + +#include "emitters-internal.h" + +#include "../parser/ticks.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/fret.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" + +namespace mu::iex::enc { +// CHD elements often carry a MIDI timing offset from the note they annotate, so floor the CHD tick +// to the beat start and attach to the first ChordRest segment in [beatStart, chdTick]; this avoids +// near-miss snapping to a subdivision note. See ENCORE_IMPORTER.md §Chord symbol (harmony) import. +void handleChordSym(BuildCtx& ctx, const MeasEmitCtx& mc, const NoteElemCtx& ec) +{ + const EncChordSym* ecs = static_cast(ec.e); + const QString raw = ecs->chordName(); + if (raw.isEmpty()) { + return; + } + const int wt = kEncWholeTicks; + const int bt = static_cast(mc.encMeas->beatTicks ? mc.encMeas->beatTicks : 240); + const int chdEncTick = static_cast(ec.e->tick); + const int beatStart = (chdEncTick / bt) * bt; + const Fraction beatStartFrac(beatStart, wt); + const Fraction chdFrac(chdEncTick, wt); + Segment* seg = nullptr; + for (Segment* s = mc.measure->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + const Fraction sRel = s->tick() - mc.measTick; + if (sRel < beatStartFrac) { + continue; + } + if (sRel > chdFrac) { + break; + } + if (!seg) { + seg = s; + } + } + if (!seg) { + for (Segment* s = mc.measure->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + if (s->tick() - mc.measTick <= chdFrac) { + seg = s; + } else { + break; + } + } + } + if (!seg) { + seg = mc.measure->getSegment(SegmentType::ChordRest, ec.elemTick); + } + Harmony* h = Factory::createHarmony(ctx.score->dummy()->segment()); + h->setTrack(ec.track); + h->setHarmony(String(raw)); + + // Encore records per chord symbol whether a guitar frame is drawn above it (tipo bit 2). Only + // then wrap the harmony in a FretDiagram (segment annotation with the Harmony as its child); + // chords without the flag, or whose name the database cannot resolve, keep the plain text symbol. + if (ecs->hasFretDiagram) { + FretDiagram* fd = Factory::createFretDiagram(ctx.score->dummy()->segment()); + fd->setTrack(ec.track); + fd->updateDiagram(h->harmonyName()); + if (!fd->isClear()) { + seg->add(fd); + fd->add(h); + return; + } + delete fd; + } + seg->add(h); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-fill.cpp b/src/importexport/encore/internal/importer/emitters-fill.cpp new file mode 100644 index 0000000000000..15014fdc1b482 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-fill.cpp @@ -0,0 +1,427 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Adjust measure length: pickup shortening, trailing-gap fill and over/undershoot correction. + +#include "emitters-internal.h" + +#include + +#include "engraving/dom/chord.h" +#include "engraving/dom/durationtype.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/sig.h" +#include "engraving/dom/tuplet.h" + +namespace mu::iex::enc { +// Resize `measure` to `newLen` and shift every following measure and every pending element that +// caches an absolute tick (all carry absolute ticks and would otherwise be corrupted by the resize). +// Forward ticks at or after the measure's content boundary shift; ticks inside the kept content never +// move. +static void resizeMeasureAndShift(BuildCtx& ctx, Measure* measure, Fraction newLen) +{ + // Reduce to lowest terms so an irregular measure reads as a sensible time signature (99/96 -> 33/32); + // reduced() does not change the duration, so the shift arithmetic below is unaffected. + newLen = newLen.reduced(); + const Fraction oldLen = measure->ticks(); + const Fraction delta = newLen - oldLen; + if (delta == Fraction(0, 1)) { + return; + } + const Fraction measTick = measure->tick(); + // Shrinking removes the [newEnd, oldEnd) span, so a tick exactly at the new end stays put + // (strict >); extending inserts space at the old end, so a tick there is the next downbeat + // and moves (>=). + const bool shrinking = delta < Fraction(0, 1); + const Fraction boundary = measTick + std::min(oldLen, newLen); + + measure->setTicks(newLen); + for (Measure* m = measure->nextMeasure(); m; m = m->nextMeasure()) { + m->setTick(m->tick() + delta); + } + + auto shift = [&](Fraction& t) { + const bool past = shrinking ? (t > boundary) : (t >= boundary); + if (past) { + t += delta; + } + }; + + for (PendingHairpin& p : ctx.pendingHairpins) { + shift(p.startTick); + shift(p.maxEndTick); + } + for (PendingSlur& p : ctx.pendingSlurs) { + shift(p.startTick); + } + for (PendingArpeggio& p : ctx.pendingArpeggios) { + shift(p.tick); + } + for (PendingOrnTremolo& p : ctx.pendingOrnTremolos) { + shift(p.tick); + shift(p.measTick); + } + for (PendingTrill& p : ctx.pendingTrills) { + shift(p.tick); + } + for (auto& [tr, ends] : ctx.pendingTrillEnds) { + for (Fraction& e : ends) { + shift(e); + } + } + for (PendingStaccato& p : ctx.pendingStaccatos) { + shift(p.tick); + } + for (PendingFermata& p : ctx.pendingFermatas) { + shift(p.tick); + } + for (PendingBreath& p : ctx.pendingBreaths) { + shift(p.tick); + } + for (PendingMeasureRepeat& p : ctx.pendingMeasureRepeats) { + shift(p.measTick); + } + for (PendingBowing& p : ctx.pendingBowings) { + shift(p.tick); + } + for (PendingOrnFingering& p : ctx.pendingOrnFingerings) { + shift(p.tick); + } + for (PendingOttava& p : ctx.pendingOttavas) { + shift(p.startTick); + } + for (PendingMarker& p : ctx.pendingMarkers) { + shift(p.tick); + } +} + +// Fill a gap of length `len` at absolute tick `fillTick` in `track` with rests of exact rhythmic +// value, split per the measure's time signature, instead of one whole-measure rest (a V_MEASURE +// rest renders as a centered whole rest regardless of its actual duration, wrong for a partial +// gap). `makeGap` marks the rests invisible. No-op if the first segment is already occupied. +static void addGapRests(Measure* measure, const Fraction& fillTick, const Fraction& len, + track_idx_t track, bool makeGap) +{ + if (len <= Fraction(0, 1)) { + return; + } + const Fraction rtick = fillTick - measure->tick(); + Fraction pos = fillTick; + for (const TDuration& d : toRhythmicDurationList(len, true /*isRest*/, rtick, + measure->timesig(), measure, 0 /*maxDots*/)) { + Segment* seg = measure->getSegment(SegmentType::ChordRest, pos); + if (seg->element(track)) { + break; + } + Rest* r = Factory::createRest(seg, d); + r->setTicks(d.isMeasure() ? measure->ticks() : d.fraction()); + r->setTrack(track); + r->setGap(makeGap); + seg->add(r); + pos += r->actualTicks(); + } +} + +// Pickup adjustment: if measure 0 has the same timesig as the full nominal length but the note +// loop placed less content, shorten it to the actual cumTick (and shift following measures). +void adjustPickupMeasure(BuildCtx& ctx, Measure* measure, int measIdx) +{ + if (!ctx.opts.firstMeasureIsPickup) { + return; + } + if (measIdx != 0 || measure->timesig() != measure->ticks()) { + return; + } + Fraction maxCumTick { 0, 1 }; + for (auto& [key, ct] : ctx.scratch.cumTick) { + if (ct > maxCumTick) { + maxCumTick = ct; + } + } + if (maxCumTick <= Fraction(0, 1) || maxCumTick >= measure->ticks()) { + return; + } + resizeMeasureAndShift(ctx, measure, maxCumTick); +} + +// Pre-fill trailing silence with rests so checkMeasure does not add its own. +// InvisibleRests (default): gap rests keep the score clean. +// VisibleRests: normal rests so the user can see the empty beats. +// IrregularMeasure: no rests added; the measure actual duration is shortened to match content. +// Only applies to voices that have some content (cumTick > 0). +void fillTrailingGaps(BuildCtx& ctx, Measure* measure, Fraction measTick) +{ + const bool makeGap = (ctx.opts.underfillMeasureStrategy != UnderfillStrategy::VisibleRests + && ctx.opts.underfillMeasureStrategy != UnderfillStrategy::IrregularMeasure); + const bool irregular = (ctx.opts.underfillMeasureStrategy == UnderfillStrategy::IrregularMeasure); + + for (int si = 0; si < ctx.totalStaves; ++si) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + const auto key = std::make_pair(si, static_cast(v)); + if (!ctx.scratch.cumTick.count(key)) { + continue; + } + const Fraction voicePos = ctx.scratch.cumTick.at(key); + if (voicePos <= Fraction(0, 1)) { + continue; + } + const Fraction remaining = measure->ticks() - voicePos; + if (remaining <= Fraction(0, 1)) { + continue; + } + if (irregular) { + continue; + } + const track_idx_t tr = static_cast(si * VOICES + v); + const Fraction fillTick = measTick + voicePos; + addGapRests(measure, fillTick, remaining, tr, makeGap); + } + } + + if (irregular) { + // Shrink to the longest staff's content, but ONLY when every staff is genuinely short. + // A staff with no content is a whole-bar rest = the full nominal length, so the bar is + // not short and must stay nominal (short staves are filled to it by checkMeasure). + Fraction maxPos { 0, 1 }; + bool anyStaffSilent = false; + for (int si = 0; si < ctx.totalStaves && !anyStaffSilent; ++si) { + Fraction staffLen { 0, 1 }; + for (voice_idx_t v = 0; v < VOICES; ++v) { + const auto k = std::make_pair(si, static_cast(v)); + if (ctx.scratch.cumTick.count(k) && ctx.scratch.cumTick.at(k) > staffLen) { + staffLen = ctx.scratch.cumTick.at(k); + } + } + if (staffLen <= Fraction(0, 1)) { + anyStaffSilent = true; // whole-bar rest: this staff fills the nominal length + } else if (staffLen > maxPos) { + maxPos = staffLen; + } + } + if (!anyStaffSilent && maxPos > Fraction(0, 1) && maxPos < measure->ticks()) { + resizeMeasureAndShift(ctx, measure, maxPos); + } + } +} + +// Maximum measure-length correction: 1/24 of a whole note (about one 32nd-note triplet). +// Corrections larger than this indicate genuine notation errors, not rounding noise. +static const Fraction kFillMaxDelta(1, 24); + +// Fix over/undershoots up to kFillMaxDelta: overshoot removes smallest gap rests, undershoot +// adds exact-valued gap rests. +void correctMeasureLength(BuildCtx& ctx, Measure* measure) +{ + const bool makeGap = (ctx.opts.underfillMeasureStrategy != UnderfillStrategy::VisibleRests); + const Fraction mLen = measure->ticks(); + const Fraction maxDelta = kFillMaxDelta; + for (int si = 0; si < ctx.totalStaves; ++si) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + track_idx_t tr = static_cast(si * VOICES + v); + std::vector crs; + Fraction voiceSum = collectVoice(measure, tr, crs); + if (crs.empty()) { + continue; // no content in this voice + } + std::vector gapRests; + for (ChordRest* cr : crs) { + if (cr->isRest() && toRest(cr)->isGap()) { + gapRests.push_back(toRest(cr)); + } + } + // Overshoot: remove gap rests smallest-first. + // Skip for IrregularMeasure overfill, capMeasureLength will extend instead. + if (voiceSum > mLen && (voiceSum - mLen) <= maxDelta + && ctx.opts.overfillMeasureStrategy != OverfillStrategy::IrregularMeasure) { + std::stable_sort(gapRests.begin(), gapRests.end(), + [](Rest* a, Rest* b) { + return a->actualTicks() < b->actualTicks(); + }); + for (Rest* gr : gapRests) { + if (voiceSum <= mLen) { + break; + } + Fraction at = gr->actualTicks(); + voiceSum -= at; + Segment* gseg = gr->segment(); + gseg->remove(gr); + delete gr; + } + } + // Undershoot: fill the residual with exact-valued rests. + const Fraction deficit = mLen - voiceSum; + if (deficit > Fraction(0, 1) && deficit <= maxDelta) { + addGapRests(measure, measure->tick() + voiceSum, deficit, tr, makeGap); + } + } + } +} + +// Extend the measure to the maximum voice content (IrregularMeasure behavior), shifting +// later measures and pending hairpins, and filling short voices with a visible rest. +// Used by the IrregularMeasure strategy and as the Stretch fallback when a tuplet cannot +// be compressed enough to be musical. +void extendMeasureIrregular(BuildCtx& ctx, Measure* measure) +{ + const Fraction mLen = measure->ticks(); + const Fraction measTick = measure->tick(); + + std::vector crs; + Fraction maxVoiceSum { 0, 1 }; + for (int si = 0; si < ctx.totalStaves; ++si) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + const Fraction voiceSum = collectVoice(measure, static_cast(si * VOICES + v), crs); + if (voiceSum > maxVoiceSum) { + maxVoiceSum = voiceSum; + } + } + } + if (maxVoiceSum > mLen) { + resizeMeasureAndShift(ctx, measure, maxVoiceSum); + // Fill all voices that fall short of the extended measure length. + // Staves whose content stopped at the original measure length now sit + // inside a longer measure; a visible rest covers the added time. + for (int si = 0; si < ctx.totalStaves; ++si) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + const track_idx_t tr = static_cast(si * VOICES + v); + const Fraction voiceSum = collectVoice(measure, tr, crs); + if (voiceSum <= Fraction(0, 1) || voiceSum >= maxVoiceSum) { + continue; + } + addGapRests(measure, measTick + voiceSum, maxVoiceSum - voiceSum, tr, false); + } + } + } +} + +// Nuclear hard-cap: remove trailing ChordRest elements from any voice that +// still overshoots after correctMeasureLength, then fill any residual deficit +// with a rest. Guarantees no measure has wrong total duration. +// Exception: IrregularMeasure overfill extends the measure to the maximum voice +// content instead of truncating, preserving all notes and their spanner endpoints. +void capMeasureLength(BuildCtx& ctx, Measure* measure) +{ + const bool makeGap = (ctx.opts.underfillMeasureStrategy != UnderfillStrategy::VisibleRests); + const Fraction mLen = measure->ticks(); + + if (ctx.opts.overfillMeasureStrategy == OverfillStrategy::IrregularMeasure) { + extendMeasureIrregular(ctx, measure); + return; + } + + for (int si = 0; si < ctx.totalStaves; ++si) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + const track_idx_t tr = static_cast(si * VOICES + v); + std::vector crs; + Fraction voiceSum = collectVoice(measure, tr, crs); + if (voiceSum <= mLen || crs.empty()) { + continue; + } + while (voiceSum > mLen && !crs.empty()) { + ChordRest* last = crs.back(); + // A tuplet is atomic: dissolve it whole (members revert to plain face value) + // rather than removing one member and leaving an invalid partial tuplet, then + // re-collect and keep trimming the now-plain notes. + if (last->tuplet()) { + dissolveTuplet(last->tuplet()); + voiceSum = collectVoice(measure, tr, crs); + continue; + } + crs.pop_back(); + voiceSum -= last->actualTicks(); + Segment* lseg = last->segment(); + lseg->remove(last); + delete last; + } + const Fraction deficit = mLen - voiceSum; + if (deficit > Fraction(0, 1)) { + addGapRests(measure, measure->tick() + voiceSum, deficit, tr, makeGap); + } + } + } +} + +void handleDanglingGraces(BuildCtx& ctx) +{ + // Grace chords that never found a principal chord (no following downbeat to ornament). Rather + // than dropping them, re-place them as small audible cue notes in the spare cue voice of their + // own bar, flush to the barline, so the figure and its timing survive. The rest of the cue + // voice is filled with invisible gap rests. + for (auto& [key, vec] : ctx.scratch.pendingGraces) { + const int staffIdx = key.first; + const track_idx_t track = static_cast(staffIdx * static_cast(VOICES) + kCueVoice); + + std::map > byMeasure; + for (PendingGrace& g : vec) { + byMeasure[g.measure].push_back(&g); + } + for (auto& [measure, graces] : byMeasure) { + if (!measure) { + for (PendingGrace* g : graces) { + delete g->gc; + } + continue; + } + Fraction total(0, 1); + for (PendingGrace* g : graces) { + total += g->gc->ticks(); + } + const Fraction mLen = measure->ticks(); + const Fraction measTick = measure->tick(); + Fraction pos = (total < mLen) ? (mLen - total) : Fraction(0, 1); + if (pos > Fraction(0, 1)) { + addGapRests(measure, measTick, pos, track, true /*gap*/); + } + for (PendingGrace* g : graces) { + Chord* src = g->gc; + const Fraction d = src->ticks(); + Segment* seg = measure->getSegment(SegmentType::ChordRest, measTick + pos); + Chord* c = Factory::createChord(seg); + c->setTrack(track); + c->setDurationType(src->durationType()); + c->setTicks(d); + c->setDots(0); + for (Note* sn : src->notes()) { + Note* n = Factory::createNote(c); + n->setPitch(sn->pitch()); + n->setTpc1(sn->tpc1()); + n->setTpc2(sn->tpc2()); + n->setSmall(true); + n->setPlay(!g->en->isMuted()); + c->add(n); + } + seg->add(c); + pos += d; + delete src; + } + if (pos < mLen) { + addGapRests(measure, measTick + pos, mLen - pos, track, true /*gap*/); + } + } + } + ctx.scratch.pendingGraces.clear(); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h new file mode 100644 index 0000000000000..0c968fe6ed5af --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Internal interface shared across the emitters-*.cpp files: the per-measure and per-element +// context structs, element handlers (note/rest/ornament/chord symbol), staff/voice routing, +// measure gap-fill and overfull-fit helpers, lyrics and tempo emission. + +#ifndef MU_IEX_ENCORE_NOTELOOP_INTERNAL_H +#define MU_IEX_ENCORE_NOTELOOP_INTERNAL_H + +#include "ctx.h" +#include "emitters-tuplets.h" +#include "../parser/elem.h" +#include "engraving/types/types.h" +#include +#include +#include +#include +#include +#include +#include + +namespace mu::engraving { +class Measure; +class Note; +class ChordRest; +class Tuplet; +} + +namespace mu::iex::enc { +// Spare voice reserved for Encore cue notes (and cue-ified dangling graces), kept separate from the +// principal line. Voice index 1 within the staff (track = staffIdx*VOICES + kCueVoice). +inline constexpr int kCueVoice = 1; + +// faceValue low nibble: 1=whole, 2=half ... 8=256th; 0 and 9..15 are invalid. +bool isValidFaceValue(quint8 faceValue); +void applyConcertPitch(mu::engraving::Note* n, int semitone); + +struct MeasEmitCtx { + mu::engraving::Measure* measure = nullptr; + const EncMeasure* encMeas = nullptr; + mu::engraving::Fraction measTick; + int measIdx = 0; + int nLineStaves = 0; + const std::vector* lineStaffInstrIdx = nullptr; + const std::vector* lineStaffWithin = nullptr; + const std::array* lineSlotByRawByte = nullptr; + + std::set validTupletGroupMember; + std::set partialEndGroup; + std::vector nestedInfos; + // All notes that belong to an INNER group (the notes inside the nested sub-tuplet). + std::set innerGroupMembers; + // Override (actualN, normalN) for notes detected as uniform-fill groups (e.g. 15→[15:8]). + std::map > overrideGroupRatios; + // Lookup: first elem of inner group → NestedTupletInfo* + std::map nestedByInnerFirst; + // Lookup: last elem of inner group → NestedTupletInfo* + std::map nestedByInnerLast; + // key={si,v,tick}, value=sourcePosition from EncTie (+14); -1 means all notes at that tick + std::multimap, int8_t> tieStartSet; + std::set noteTicks; + std::set voice4NoteTicks; + // (staffIdx, voice, tick) of every NOTE. A plain (non-tuplet) REST at a position that also holds + // a note in the same voice is a redundant placeholder Encore writes for the voice; it must be + // dropped so the note keeps the beat instead of being pushed after the rest. + std::set > noteStaffVoiceTicks; + // Staves (raw index) carrying a real voice-0..3 note in this measure. A whole-measure + // rest that arrives on Encore's "voice 4" (the silent-voice placeholder) is redundant on + // such a staff and would corrupt the bar if merged into voice 0; it is skipped. + std::set stavesWithRealNote; + std::map v0NoteCountAtTick; + std::map ornFingCountAtTick; + int maxVoice0Tick = -1; + std::set > filteredTieSenderPitches; + // True when at least one note in the measure has au in 0x39..0x40 (scale string anchors). + // When set, notes with options bit 0 and no other artic byte also show string numbers. + bool hasScaleStringAnchors { false }; + + // notePosition=-1 matches any note (used for bypass checks); otherwise only matches the given position. + bool isTieStartAt(int si, int v, int tick, int notePosition = -1) const; + void closeTupletWithFill(BuildCtx& ctx, TupletTracker& tt, std::pair trackKey); +}; + +// Per-element context: computed in the main element loop before dispatch. +struct NoteElemCtx { + const EncMeasureElem* e = nullptr; + EncElemType et {}; + int staffIdx = 0; + int voice = 0; + int msVoice = 0; + mu::engraving::track_idx_t track = 0; + std::pair trackKey; + std::pair encVoiceKey; + bool isChordExt = false; + bool isNoteOrRest = false; + mu::engraving::Fraction elemTick; + int savedPrevMidiTick = -1; + bool hadLastChordPos = false; + mu::engraving::Fraction savedLastChordPos; +}; + +void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); +// Returns true if note was a grace note (caller must return). (emitters-note-grace.cpp) +bool tryHandleGraceNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec, const EncNote* en); +// Apply articulations, ornaments, tremolos, string numbers to a note/chord. (emitters-note-artic.cpp) +void applyNoteArticulations(BuildCtx& ctx, mu::engraving::Note* note, mu::engraving::Chord* chord, const EncNote* en, + mu::engraving::track_idx_t track, const MeasEmitCtx& mc); +void handleRest(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); +void handleOrnament(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); +void handleChordSym(BuildCtx& ctx, const MeasEmitCtx& mc, const NoteElemCtx& ec); + +// Queue one LYRIC element into ctx.pendingLyrics. (emitters-lyrics.cpp) +void enqueueLyric(BuildCtx& ctx, const EncLyric* el, mu::engraving::track_idx_t track); +// Attach queued lyrics to the nearest chords in the measure. (emitters-lyrics.cpp) +void attachPendingLyrics(BuildCtx& ctx, const MeasEmitCtx& mc); + +// Resolved MuseScore destination for an element: staff/voice and the derived track + lookup keys. +struct RoutedTrack { + int staffIdx { 0 }; + int voice { 0 }; + int msVoice { 0 }; + mu::engraving::track_idx_t track { 0 }; + std::pair trackKey; // (staffIdx, msVoice) + std::pair encVoiceKey; // (staffIdx, voice) +}; + +// Route an element's raw (staffIdx, voice, staffWithin) to a MuseScore (staffIdx, voice, track). +// Returns nullopt if the element should be skipped. (emitters.cpp) +std::optional routeElementStaffVoice( + const EncMeasureElem* e, bool isNoteOrRest, const std::array& lineSlotByRawByte, const MeasEmitCtx& mc, const BuildCtx& ctx); + +// Case-B pickup adjustment: shorten measure 0 if loop placed less than its nominal length. (emitters-fill.cpp) +void adjustPickupMeasure(BuildCtx& ctx, mu::engraving::Measure* measure, int measIdx); +// Pre-fill trailing silence with invisible gap rests. (emitters-fill.cpp) +void fillTrailingGaps(BuildCtx& ctx, mu::engraving::Measure* measure, mu::engraving::Fraction measTick); +// End-of-score handling of grace chords that never found a principal chord: re-place them as small +// audible cue notes in the spare cue voice of their own bar (flush to the barline) instead of +// dropping them. Clears ctx.scratch.pendingGraces. (emitters-fill.cpp) +void handleDanglingGraces(BuildCtx& ctx); +// Fix over/undershoots up to 1/24. (emitters-fill.cpp) +void correctMeasureLength(BuildCtx& ctx, mu::engraving::Measure* measure); +// Extend the measure to the max voice content (IrregularMeasure / Stretch fallback). (emitters-fill.cpp) +void extendMeasureIrregular(BuildCtx& ctx, mu::engraving::Measure* measure); +// Nuclear hard-cap: remove trailing elements and fill deficit. (emitters-fill.cpp) +void capMeasureLength(BuildCtx& ctx, mu::engraving::Measure* measure); +// Resolve overfull voices per the overfill strategy (Remove / Stretch / Irregular). (emitters-overfill.cpp) +void fitOverfullMeasure(BuildCtx& ctx, mu::engraving::Measure* measure); + +// Collect the ordered ChordRests of one track in a measure; returns their total actual ticks. (emitters-overfill.cpp) +mu::engraving::Fraction collectVoice(mu::engraving::Measure* measure, mu::engraving::track_idx_t tr, + std::vector& out); + +// Dissolve a tuplet whole: detach every member (revert to plain face value), remove the empty +// tuplet from its parent and delete it. A tuplet is atomic, never leave a partial one. (emitters-overfill.cpp) +void dissolveTuplet(mu::engraving::Tuplet* t); + +// Apply per-measure BPM marks as TempoText elements. (emitters-tempo.cpp) +void applyMeasureBpmMarks(BuildCtx& ctx); + +// Render tempo text (beatTicks in display ticks: 240=quarter, 360=dotted-quarter, ...). (emitters-tempo.cpp) +mu::engraving::String tempoXmlText(int displayBpm, int beatTicks); + +// Decode the ORN tempo beat unit from the `noto` byte to display ticks, or 0 if unset. (emitters-tempo.cpp) +int notoToBeatTicks(quint8 noto); +} // namespace mu::iex::enc + +#endif // MU_IEX_ENCORE_NOTELOOP_INTERNAL_H diff --git a/src/importexport/encore/internal/importer/emitters-lyrics.cpp b/src/importexport/encore/internal/importer/emitters-lyrics.cpp new file mode 100644 index 0000000000000..c42f476f3fadc --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-lyrics.cpp @@ -0,0 +1,344 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Queue Encore LYRIC elements and attach syllables to the nearest chords in a measure. + +#include "emitters-internal.h" + +#include "../parser/ticks.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/chordrest.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" + +namespace mu::iex::enc { +// Queue a LYRIC element. Hyphen separators ("-") update the hyphen flags on the +// preceding syllable and set a carry-forward flag for the next one. +void enqueueLyric(BuildCtx& ctx, const EncLyric* el, track_idx_t track) +{ + const String text(el->text); + auto& queue = ctx.scratch.pendingLyrics[track]; + if (text == u"-") { + if (!queue.empty()) { + queue.back().hyphenAfter = true; + } else { + // The preceding syllable was attached in an earlier measure; promote it so the + // hyphen renders across the bar (SINGLE -> BEGIN, END -> MIDDLE). + auto it = ctx.scratch.lastAttachedLyric.find(track); + if (it != ctx.scratch.lastAttachedLyric.end() && it->second) { + mu::engraving::Lyrics* prev = it->second; + if (prev->syllabic() == mu::engraving::LyricsSyllabic::SINGLE) { + prev->setSyllabic(mu::engraving::LyricsSyllabic::BEGIN); + } else if (prev->syllabic() == mu::engraving::LyricsSyllabic::END) { + prev->setSyllabic(mu::engraving::LyricsSyllabic::MIDDLE); + } + } + } + ctx.scratch.nextLyricHyphenBefore[track] = true; + } else if (text.isEmpty()) { + ctx.scratch.nextLyricHyphenBefore[track] = false; + } else { + PendingLyric pl; + pl.encTick = static_cast(el->tick); + pl.xoffset = static_cast(el->kie); // horizontal anchor; see attach pass + pl.text = text; + auto it = ctx.scratch.nextLyricHyphenBefore.find(track); + pl.hyphenBefore = (it != ctx.scratch.nextLyricHyphenBefore.end()) && it->second; + pl.hyphenAfter = false; + ctx.scratch.nextLyricHyphenBefore[track] = false; + queue.push_back(std::move(pl)); + } +} + +// Build the Encore NOTE ticks of each MuseScore staff's voice-0 notes, so lyric matching uses +// the real Encore tick of each note rather than a cumTick-to-encTick conversion (unreliable: the +// note loop accumulates durations, not Encore ticks, so the relationship is not proportional). +// Notes are routed with the SAME logic as the note loop (routeElementStaffVoice); keying by raw +// encStaff instead put grand-staff notes on the wrong staff and reversed the syllables. +static std::map > buildEncNoteTicksByStaff( + BuildCtx& ctx, const MeasEmitCtx& mc, const EncMeasure& encMeas) +{ + std::map > encNoteTicksByStaff; + for (const auto& elem : encMeas.elements) { + const EncMeasureElem* e = elem.get(); + if (e->type != static_cast(EncElemType::NOTE)) { + continue; + } + if (!mc.lineSlotByRawByte) { + continue; + } + std::optional routed + = routeElementStaffVoice(e, /*isNoteOrRest*/ true, *mc.lineSlotByRawByte, mc, ctx); + if (!routed || routed->msVoice != 0) { + continue; // skip unroutable; lyrics anchor to voice-0 chords + } + auto& tickList = encNoteTicksByStaff[routed->staffIdx]; + const int t = static_cast(e->tick); + if (tickList.empty() || tickList.back() != t) { + tickList.push_back(t); + } + } + return encNoteTicksByStaff; +} + +// Pair each ChordRest on chordTrack with an Encore tick. The kth chord takes the kth Encore note +// tick (positional assignment), which is more accurate than a cumTick conversion (see +// buildEncNoteTicksByStaff); rests do not consume a note tick. +static std::vector > buildCrTickPairs( + Measure* measure, const Fraction& measTick, const EncMeasure& encMeas, + track_idx_t chordTrack, const std::vector* noteTickList) +{ + std::vector > crTickPairs; + size_t noteTickIdx = 0; + for (Segment* s = measure->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(chordTrack); + if (!el || !el->isChordRest()) { + continue; + } + int segEncTick; + ChordRest* cr = toChordRest(el); + if (cr->isChord() && noteTickList && noteTickIdx < noteTickList->size()) { + segEncTick = (*noteTickList)[noteTickIdx++]; + } else { + // Rest, or note list exhausted: estimate from the measure's beat grid. + const Fraction relTick = s->tick() - measTick; + const int durTicks = encMeas.durTicks ? static_cast(encMeas.durTicks) : kEncWholeTicks; + segEncTick = (relTick.numerator() * durTicks) + / std::max(1, relTick.denominator()); + } + crTickPairs.emplace_back(segEncTick, cr); + } + return crTickPairs; +} + +// Find the index of the best unconsumed ChordRest for a lyric at encTick. +// wantChord selects chords (true) or rests (false); maxDelta caps the distance. +// When preferNotAfter is set, notes at/before encTick win over later notes regardless of +// distance, and ties break to the closest (the threshold pass); otherwise the closest by +// absolute distance wins (the rest/last-resort fallback passes). +static int findBestCr(const std::vector >& pairs, + const std::vector& consumed, int encTick, + bool wantChord, int maxDelta, bool preferNotAfter) +{ + int bestIdx = -1; + int bestDelta = INT_MAX; + bool bestIsAfter = false; // true if best match has note_tick > encTick + for (size_t ni = 0; ni < pairs.size(); ++ni) { + if (consumed[ni]) { + continue; + } + const bool ok = wantChord ? pairs[ni].second->isChord() : pairs[ni].second->isRest(); + if (!ok) { + continue; + } + const int delta = std::abs(pairs[ni].first - encTick); + if (delta > maxDelta) { + continue; + } + const bool isAfter = (pairs[ni].first > encTick); + if (bestIdx < 0 + || (preferNotAfter && !isAfter && bestIsAfter) + || ((!preferNotAfter || isAfter == bestIsAfter) && delta < bestDelta)) { + bestDelta = delta; + bestIdx = static_cast(ni); + bestIsAfter = isAfter; + } + } + return bestIdx; +} + +// Attach queued lyrics to the nearest chord in the measure. Greedy "lyrics-first" assignment: +// each syllable in tick order claims the nearest available note within the threshold, so later +// syllables cannot steal a note from an earlier one. +void attachPendingLyrics(BuildCtx& ctx, const MeasEmitCtx& mc) +{ + Measure* measure = mc.measure; + const EncMeasure& encMeas = *mc.encMeas; + const Fraction measTick = mc.measTick; + + std::map > encNoteTicksByStaff = buildEncNoteTicksByStaff(ctx, mc, encMeas); + + // matchThreshold: half a beat in Encore ticks. + const int beatTicksVal = encMeas.beatTicks ? static_cast(encMeas.beatTicks) : 240; + const int matchThreshold = beatTicksVal / 2; + + // Encore stores the second and later verses with tick=0 on every syllable; the real horizontal + // position lives only in the xoffset (kie). Build a per-staff xoffset->tick reference from the + // verses whose ticks are reliable (they span more than one value); a collapsed verse is remapped + // by nearest xoffset below so all verses align on the same notes. + std::map > > xoffTickRefByStaff; // staff -> [(xoffset, encTick)] + for (const auto& [refTrack, refEntries] : ctx.scratch.pendingLyrics) { + if (refEntries.size() < 2) { + continue; + } + bool spans = false; + for (const auto& e : refEntries) { + if (e.encTick != refEntries.front().encTick) { + spans = true; + break; + } + } + if (!spans) { + continue; + } + auto& ref = xoffTickRefByStaff[static_cast(refTrack) / VOICES]; + for (const auto& e : refEntries) { + ref.emplace_back(e.xoffset, e.encTick); + } + } + + // Fallback for measures where NO verse has reliable (spanning) ticks: a lone melisma word can be + // stored at its end note in one verse and at tick 0 in another, so tick matching would split the + // verses across notes. Position purely by xoffset: syllables whose xoffset nearly coincides are + // the same held word and resolve to the same (earliest) note. Only staves absent from the spanning + // reference are touched, so normal multi-syllable verses are left unchanged. + { + std::map > > noSpanByStaff; // staff -> [(xoffset, encTick)] + for (const auto& [t, es] : ctx.scratch.pendingLyrics) { + const int staff = static_cast(t) / VOICES; + if (xoffTickRefByStaff.count(staff)) { + continue; // has a reliable spanning reference; handled below + } + auto& v = noSpanByStaff[staff]; + for (const auto& e : es) { + v.emplace_back(e.xoffset, e.encTick); + } + } + // xoffset units; adjacent notes differ by far more, cross-verse syllables by much less. + const int kieThreshold = 25; + for (auto& [t, es] : ctx.scratch.pendingLyrics) { + auto sit = noSpanByStaff.find(static_cast(t) / VOICES); + if (sit == noSpanByStaff.end()) { + continue; + } + for (auto& e : es) { + for (const auto& [kx, kt] : sit->second) { + if (std::abs(kx - e.xoffset) <= kieThreshold && kt < e.encTick) { + e.encTick = kt; + } + } + } + } + } + + for (auto& [lyTrack, entries] : ctx.scratch.pendingLyrics) { + if (entries.empty()) { + continue; + } + // Encore multi-verse maps to voice: verse 1=voice 0, verse 2=voice 1. MuseScore anchors + // all verses to the voice-0 chord via setVerse(). + const int lyStaffIdx = static_cast(lyTrack) / VOICES; + const int lyVerseNo = static_cast(lyTrack) % VOICES; + const track_idx_t chordTrack = static_cast(lyStaffIdx) * VOICES; + + // A collapsed verse (every syllable at the same tick) but with distinct xoffsets is the + // tick=0 verse-2 case: remap each syllable's tick from its xoffset via the staff reference. + { + bool collapsed = entries.size() > 1; + bool xoffDistinct = false; + for (const auto& e : entries) { + if (e.encTick != entries.front().encTick) { + collapsed = false; + } + if (e.xoffset != entries.front().xoffset) { + xoffDistinct = true; + } + } + auto rit = xoffTickRefByStaff.find(lyStaffIdx); + if (collapsed && xoffDistinct && rit != xoffTickRefByStaff.end()) { + for (auto& e : entries) { + int bestTick = -1; + int bestDelta = INT_MAX; + for (const auto& [rx, rt] : rit->second) { + const int d = std::abs(rx - e.xoffset); + if (d < bestDelta) { + bestDelta = d; + bestTick = rt; + } + } + if (bestTick >= 0) { + e.encTick = bestTick; + } + } + } + } + + const std::vector* noteTickList = nullptr; + { + auto it = encNoteTicksByStaff.find(lyStaffIdx); + if (it != encNoteTicksByStaff.end() && !it->second.empty()) { + noteTickList = &it->second; + } + } + std::vector > crTickPairs + = buildCrTickPairs(measure, measTick, encMeas, chordTrack, noteTickList); + + std::vector crConsumed(crTickPairs.size(), false); + for (const auto& pl : entries) { + // Pass 1: nearest chord within the threshold, preferring notes at/before the lyric + // tick so a slightly-misaligned lyric does not grab a later note just for proximity. + int bestIdx = findBestCr(crTickPairs, crConsumed, pl.encTick, + /*wantChord*/ true, matchThreshold, /*preferNotAfter*/ true); + // Pass 2: nearest rest. + if (bestIdx < 0) { + bestIdx = findBestCr(crTickPairs, crConsumed, pl.encTick, + /*wantChord*/ false, INT_MAX, /*preferNotAfter*/ false); + } + // Pass 3: a sung syllable always belongs to a note, so rather than drop one whose + // stored tick sits far between notes, attach to the nearest chord at any distance. + if (bestIdx < 0) { + bestIdx = findBestCr(crTickPairs, crConsumed, pl.encTick, + /*wantChord*/ true, INT_MAX, /*preferNotAfter*/ false); + } + if (bestIdx < 0) { + continue; + } + crConsumed[bestIdx] = true; + ChordRest* c = crTickPairs[bestIdx].second; + Lyrics* ly = Factory::createLyrics(c); + ly->setTrack(chordTrack); + ly->setVerse(lyVerseNo); + ly->setXmlText(pl.text); + LyricsSyllabic syll = LyricsSyllabic::SINGLE; + if (pl.hyphenBefore && pl.hyphenAfter) { + syll = LyricsSyllabic::MIDDLE; + } else if (pl.hyphenBefore) { + syll = LyricsSyllabic::END; + } else if (pl.hyphenAfter) { + syll = LyricsSyllabic::BEGIN; + } + ly->setSyllabic(syll); + c->add(ly); + ctx.scratch.lastAttachedLyric[lyTrack] = ly; + } + // Lyric ticks are measure-relative; unmatched leftovers cannot anchor in a + // later measure, so discard them. + entries.clear(); + } + // ctx.scratch.nextLyricHyphenBefore survives barlines so a trailing hyphen (e.g. "RO -") + // carries into the next measure's first syllable. +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-note-artic.cpp b/src/importexport/encore/internal/importer/emitters-note-artic.cpp new file mode 100644 index 0000000000000..cd92070ee3561 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-note-artic.cpp @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Apply articulations, ornaments, tremolos and string numbers to an emitted note/chord. + +#include "emitters-internal.h" +#include "mappers.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/note.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/tremolosinglechord.h" + +namespace mu::iex::enc { +using namespace mu::engraving; + +// See ENCORE_IMPORTER.md §Articulations, technical markings, tremolos. +void applyNoteArticulations(BuildCtx& ctx, Note* note, Chord* chord, const EncNote* en, + track_idx_t track, const MeasEmitCtx& mc) +{ + auto isOrnamentSymId = [](SymId s) { + return s == SymId::ornamentTrill + || s == SymId::ornamentShortTrill + || s == SymId::ornamentTremblement + || s == SymId::ornamentMordent + || s == SymId::ornamentTurn; + }; + auto isFermataSymId = [](SymId s) { + return s == SymId::fermataAbove + || s == SymId::fermataBelow + || s == SymId::fermataShortAbove + || s == SymId::fermataShortBelow + || s == SymId::fermataLongAbove + || s == SymId::fermataLongBelow; + }; + + // Artic bytes that Encore uses for marks with no MuseScore equivalent. + static const std::map WARN_BYTES = { + { 0x01, "flat mark" }, { 0x02, "sharp/natural mark" }, + { 0x09, "wave mark" }, + { 0x47, "stick technique" }, + { 0x48, "brush" }, { 0x49, "soft mallet" }, { 0x4A, "hard mallet" }, + }; + Segment* chordSegForText = chord->segment(); + for (int slot = 0; slot < 2; ++slot) { + const quint8 check = slot == 0 ? en->articulationUp : en->articulationDown; + auto it = WARN_BYTES.find(check); + if (it == WARN_BYTES.end()) { + continue; + } + if (ctx.opts.importUnsupportedArticulationsAsText) { + StaffText* st = Factory::createStaffText(chordSegForText); + st->setTrack(track); + st->setXmlText(String::fromAscii(it->second)); + chordSegForText->add(st); + } else { + LOGW() << QString("Encore: artic byte 0x%1 (%2) not imported" + " (measure %3 staff %4 tick %5)") + .arg(check, 2, 16, QChar('0')) + .arg(it->second) + .arg(mc.measIdx).arg(static_cast(en->staffIdx)) + .arg(static_cast(en->tick)); + } + } + + Segment* chordSeg = chord->segment(); + for (int slot = 0; slot < 2; ++slot) { + const quint8 ab = slot == 0 ? en->articulationUp : en->articulationDown; + const bool isAbove = slot == 0; + for (SymId sid : encArticulation2SymIds(ab)) { + if (sid == SymId::noSym) { + continue; + } + // Fermatas anchor on the segment, not the chord. + if (isFermataSymId(sid) && chordSeg) { + // 0x20/0x21 doubles as a "tuplet bracket placement" flag on the last + // note of a tuplet group; never export as a in that context. + if ((ab == 0x20 || ab == 0x21) && en->tuplet != 0) { + continue; + } + Fermata* ferm = Factory::createFermata(chordSeg); + ferm->setTrack(track); + SymId resolved = sid; + if (sid == SymId::fermataAbove || sid == SymId::fermataBelow) { + resolved = isAbove ? SymId::fermataAbove : SymId::fermataBelow; + } else if (sid == SymId::fermataShortAbove || sid == SymId::fermataShortBelow) { + resolved = isAbove ? SymId::fermataShortAbove : SymId::fermataShortBelow; + } + ferm->setSymId(resolved); + ferm->setPlacement(isAbove ? PlacementV::ABOVE : PlacementV::BELOW); + ferm->setPropertyFlags(Pid::PLACEMENT, PropertyFlags::UNSTYLED); + chordSeg->add(ferm); + continue; + } + // Ornaments need an Ornament wrapper for MusicXML . + // Dedup: skip if the chord already has this symbol. + if (isOrnamentSymId(sid)) { + bool alreadyHas = false; + for (Articulation* a : chord->articulations()) { + if (a->isOrnament() && toOrnament(a)->symId() == sid) { + alreadyHas = true; + break; + } + } + if (!alreadyHas) { + Ornament* orn = Factory::createOrnament(chord); + orn->setSymId(sid); + if (sid == SymId::ornamentTrill) { + const auto interval = encArticByteToTrillInterval(ab); + if (interval.type != IntervalType::AUTO) { + orn->setIntervalAbove(interval); + } + } + chord->add(orn); + } + } else { + bool alreadyHas = false; + for (Articulation* a : chord->articulations()) { + if (a->symId() == sid) { + alreadyHas = true; + break; + } + } + if (!alreadyHas) { + Articulation* art = Factory::createArticulation(chord); + art->setSymId(sid); + chord->add(art); + } + } + } + } + + // Single-note tremolos: stroke count encoded in artic byte low nibble. + // 0x41/0x42/0x43 = 1/2/3 strokes; 0x03 also = 3 strokes. + auto tremoloStrokeFromByte = [](quint8 b) -> int { + if (b == 0x41 || b == 0x42 || b == 0x43) { + return b & 0x0F; + } + if (b == 0x03) { + return 3; + } + return 0; + }; + int strokes = std::max(tremoloStrokeFromByte(en->articulationUp), + tremoloStrokeFromByte(en->articulationDown)); + if (strokes > 0 && !chord->tremoloSingleChord()) { + TremoloSingleChord* trem = Factory::createTremoloSingleChord(chord); + TremoloType type = TremoloType::R8; + switch (strokes) { + case 1: type = TremoloType::R8; + break; + case 2: type = TremoloType::R16; + break; + case 3: type = TremoloType::R32; + break; + case 4: type = TremoloType::R64; + break; + default: break; + } + trem->setTremoloType(type); + chord->add(trem); + } + + // Scale string numbers (0x39..0x40 = string 1..8, "au" articulation byte). + // Fallback: options bit 0 + position field when the measure has at least one + // explicit anchor (mc.hasScaleStringAnchors). + const int scaleSn = encArticByteToScaleStringNumber(en->articulationUp); + if (scaleSn > 0) { + Fingering* fg = Factory::createFingering(note, TextStyleType::STRING_NUMBER); + fg->setTrack(track); + fg->setXmlText(String::number(scaleSn)); + note->add(fg); + } else if (mc.hasScaleStringAnchors + && (en->options & 0x01) + && en->position >= 0 && en->position <= 7 + && en->articulationUp == 0 + && en->articulationDown == 0) { + Fingering* fg = Factory::createFingering(note, TextStyleType::STRING_NUMBER); + fg->setTrack(track); + fg->setXmlText(String::number(static_cast(en->position) + 1)); + note->add(fg); + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-note-grace.cpp b/src/importexport/encore/internal/importer/emitters-note-grace.cpp new file mode 100644 index 0000000000000..4826319338469 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-note-grace.cpp @@ -0,0 +1,225 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Emit grace-note chords and attach them to their principal chord. + +#include "emitters-internal.h" +#include "mappers.h" +#include "../parser/ticks.h" +#include "durations.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/note.h" +#include "engraving/dom/segment.h" + +namespace mu::iex::enc { +using namespace mu::engraving; + +// Create a detached grace Chord for the EncNote and queue or attach it; returns true if handled +// as a grace (caller must return). Grace chords must be parented under a Chord, not a Segment, or +// pagePos crashes. See ENCORE_IMPORTER.md §Grace and cue notes. +bool tryHandleGraceNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec, + const EncNote* en) +{ + if (!isValidFaceValue(en->faceValue)) { + return false; + } + if (en->graceType() == EncGraceType::NORMAL && !en->isInnerGrace) { + return false; + } + // An explicitly-flagged grace (acciaccatura/appoggiatura) may carry any written value: Encore + // lets you turn a quarter or half note into a grace. Only the v0xA6 inferred-grace path + // (isInnerGrace, graceType NORMAL) keeps the historical "shorter than a quarter" guard. + if (en->graceType() == EncGraceType::NORMAL && (en->faceValue & 0x0F) < 4) { + return false; + } + + const auto trackKey = ec.trackKey; + + // Roll back per-track tick state so the next note is not detected as a chord extension of this grace. + if (ec.savedPrevMidiTick >= 0) { + ctx.scratch.prevMidiTick[trackKey] = ec.savedPrevMidiTick; + } else { + ctx.scratch.prevMidiTick.erase(trackKey); + } + if (ec.hadLastChordPos) { + ctx.scratch.lastChordPos[trackKey] = ec.savedLastChordPos; + } else { + ctx.scratch.lastChordPos.erase(trackKey); + } + + // Each grace chord member is a separate note at the same tick, but the grace path rolls + // prevMidiTick back so isChordExt never fires for the second member. Merge it into this + // track's last grace chord, else a 2-note grace chord splits into two single-note graces. + { + auto gcIt = ctx.scratch.lastGraceChord.find(trackKey); + auto tkIt = ctx.scratch.lastGraceTick.find(trackKey); + if (gcIt != ctx.scratch.lastGraceChord.end() && gcIt->second && !gcIt->second->notes().empty() + && tkIt != ctx.scratch.lastGraceTick.end() && tkIt->second == static_cast(en->tick)) { + Note* member = Factory::createNote(gcIt->second); + applyConcertPitch(member, en->semiTonePitch + ctx.staffPitchOffset[ec.staffIdx]); + if (en->isMuted()) { + member->setPlay(false); + } + gcIt->second->add(member); + return true; + } + } + + const bool appoggiatura = (en->graceType() == EncGraceType::APPOGGIATURA); + const bool beamedGroup = (en->grace1 & 0x10); + + // Classify the grace against the principal notes of its own voice/measure: + // - principalAtOrAfter: a principal note at or after the grace -> grace-before, ornaments it. + // - contiguousNoteBefore: a principal note whose written span reaches the grace tick with no + // silence between -> grace-after, belongs to that preceding note. + // A grace preceded by silence with nothing at/after it is neither: it falls through to the + // grace-before path and, via the cross-barline pending carry, ornaments the next bar's downbeat. + bool principalAtOrAfter = false; + bool contiguousNoteBefore = false; + if (mc.encMeas) { + const int graceTick = static_cast(en->tick); + for (const auto& elp : mc.encMeas->elements) { + if (static_cast(elp->type) != EncElemType::NOTE + || elp->staffIdx != ec.staffIdx || elp->voice != ec.voice) { + continue; + } + const EncNote* n = static_cast(elp.get()); + if (n->graceType() != EncGraceType::NORMAL) { + continue; // only principal notes bound the decision + } + if (static_cast(n->tick) >= graceTick) { + principalAtOrAfter = true; + } else if (static_cast(n->tick) + faceValue2ticks(n->faceValue & 0x0F) >= graceTick) { + contiguousNoteBefore = true; + } + } + } + + // A no-slash small note (appoggiatura) with no principal note to ornament is a cue note, not a + // grace: hand it back to the normal path, which keeps its full value and draws it small. Only an + // appoggiatura adjacent to a principal is a real grace; acciaccaturas (slash) are always graces. + if (appoggiatura && !principalAtOrAfter && !contiguousNoteBefore) { + return false; + } + + // grace-after only when a contiguous principal note precedes and nothing sits at/after the grace. + Chord* precedingChord = nullptr; + if (!appoggiatura && !ec.isChordExt && !principalAtOrAfter && contiguousNoteBefore) { + for (Segment* s = mc.measure->last(SegmentType::ChordRest); s; s = s->prev(SegmentType::ChordRest)) { + if (s->tick() >= ec.elemTick) { + continue; + } + EngravingItem* el = s->element(ec.track); + if (el && el->isChord() && !toChord(el)->isGrace()) { + precedingChord = toChord(el); + break; + } + } + } + const bool afterMode = (precedingChord != nullptr); + + // Grace figure: appoggiatura keeps its written type; a lone acciaccatura is the slashed eighth; + // a beamed group keeps its written figure unslashed (Encore does not slash beamed groups). + // Grace-after uses the *_AFTER variants. + NoteType graceNoteType; + if (appoggiatura) { + graceNoteType = NoteType::APPOGGIATURA; + } else if (afterMode) { + switch (en->faceValue & 0x0F) { + case 6: graceNoteType = NoteType::GRACE32_AFTER; + break; + case 5: graceNoteType = NoteType::GRACE16_AFTER; + break; + default: graceNoteType = beamedGroup ? NoteType::GRACE16_AFTER : NoteType::GRACE8_AFTER; + break; + } + } else if (beamedGroup) { + switch (en->faceValue & 0x0F) { + case 3: graceNoteType = NoteType::GRACE4; + break; + case 6: graceNoteType = NoteType::GRACE32; + break; + default: graceNoteType = NoteType::GRACE16; + break; + } + } else { + graceNoteType = NoteType::ACCIACCATURA; + } + + // A lone slashed acciaccatura is drawn as an eighth regardless of stored value, else a small + // stored value (e.g. a 128th) renders as a many-flagged glyph. + const bool eighthGlyph = (graceNoteType == NoteType::ACCIACCATURA + || graceNoteType == NoteType::GRACE8_AFTER); + DurationType graceDt = eighthGlyph ? DurationType::V_EIGHTH + : realDuration2DurationType(en->realDuration, en->faceValue); + Chord* gc = Factory::createChord(ctx.score->dummy()->segment()); + gc->setTrack(ec.track); + TDuration gdur(graceDt); + gc->setDurationType(gdur); + gc->setTicks(gdur.fraction()); + gc->setDots(0); + gc->setNoteType(graceNoteType); + + Note* gnote = Factory::createNote(gc); + applyConcertPitch(gnote, en->semiTonePitch + ctx.staffPitchOffset[ec.staffIdx]); + if (en->isMuted()) { + gnote->setPlay(false); // Encore per-note mute flag + } + gc->add(gnote); + + ctx.scratch.lastGraceChord[trackKey] = gc; + ctx.scratch.lastGraceTick[trackKey] = static_cast(en->tick); + + // Articulations are applied after attachment: a detached grace chord only sees the dummy + // segment, where fermatas cannot anchor. + + // Grace-after: attach immediately to the preceding principal chord (stays in this bar). + if (afterMode) { + gc->setGraceIndex(precedingChord->graceNotes().size()); + precedingChord->add(gc); + applyNoteArticulations(ctx, gnote, gc, en, ec.track, mc); + return true; + } + + // Retroactive attachment: main note already placed at elemTick (isChordExt=TRUE). + if (ec.isChordExt) { + Segment* existingSeg = mc.measure->getSegment(SegmentType::ChordRest, ec.elemTick); + if (existingSeg) { + EngravingItem* existingEl = existingSeg->element(ec.track); + if (existingEl && existingEl->isChord()) { + gc->setGraceIndex(0); + toChord(existingEl)->add(gc); + applyNoteArticulations(ctx, gnote, gc, en, ec.track, mc); + ctx.scratch.graceStolenTicks[trackKey] += faceValue2ticks(en->faceValue & 0x0F); + return true; + } + } + } + + // Grace-before: queue for the next principal chord. + ctx.scratch.pendingGraces[trackKey].push_back({ gc, en, mc.measure }); + ctx.scratch.graceStolenTicks[trackKey] += faceValue2ticks(en->faceValue & 0x0F); + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-note.cpp b/src/importexport/encore/internal/importer/emitters-note.cpp new file mode 100644 index 0000000000000..98e02860f305f --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -0,0 +1,771 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Emit notes: duration/tuplet resolution, ties, MIDI-artifact filtering and fingerings. + +#include "emitters-internal.h" +#include "mappers.h" +#include "../parser/ticks.h" +#include "durations.h" +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/note.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/drumset.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/tie.h" +#include "log.h" +#include +#include + +namespace mu::iex::enc { +using namespace mu::engraving; + +// Tuplet ratio and resolved duration for one note, threaded through resolveNoteDuration, +// attachChordToTuplet and advanceCumulativeTick. dt/dots are in/out (attach may shrink them +// for an isolated-explicit fill); dtFace is the pre-cap duration for that fill check. +struct TupletDecision { + int actualN { 0 }; + int normalN { 0 }; + DurationType dt { DurationType::V_INVALID }; + int dots { 0 }; + DurationType dtFace { DurationType::V_INVALID }; +}; + +// A short realDuration (< 15) is usually a MIDI tie-continuation artifact, not a real note. +static bool isMidiArtifact(const EncNote* en, + const NoteElemCtx& ec, + const MeasEmitCtx& mc, + std::set >& filteredSenders, + int savedPrevMidiTick, + bool isChordExt) +{ + if (en->realDuration == 0 || en->realDuration >= 15) { + return false; + } + const quint8 safeFv = fvLow(en->faceValue); + int fvBase = faceValue2ticks(safeFv); + if (fvBase <= 15) { + bool bypass = mc.isTieStartAt(ec.staffIdx, ec.voice, (int)ec.e->tick) + || isChordExt; + if (!bypass) { + if ((en->grace1 & 0x0F) == 1) { + filteredSenders.insert({ ec.staffIdx, ec.voice, (int)en->semiTonePitch }); + } + return true; + } + } else { + if (en->realDuration > CHORD_CLUSTER_THRESHOLD + && !mc.validTupletGroupMember.count(ec.e) + && !isChordExt + && savedPrevMidiTick >= 0) { + return true; + } + } + return false; +} + +// The tie-receiver of an already-filtered artifact (grace1 low nibble == 2) must be filtered too. +static bool isCascadeFilteredTieReceiver(const EncNote* en, + const NoteElemCtx& ec, + std::set >& filteredSenders) +{ + if ((en->grace1 & 0x0F) != 2) { + return false; + } + auto cascKey = std::make_tuple(ec.staffIdx, ec.voice, (int)en->semiTonePitch); + if (filteredSenders.count(cascKey)) { + filteredSenders.erase(cascKey); + return true; + } + return false; +} + +static void attachPendingGracesToChord(BuildCtx& ctx, + const std::pair& trackKey, + Chord* chord, + const MeasEmitCtx& mc) +{ + auto& pg = ctx.scratch.pendingGraces[trackKey]; + for (PendingGrace& g : pg) { + g.gc->setGraceIndex(chord->graceNotes().size()); + chord->add(g.gc); + // Now that the grace is parented under a chord, gc->segment() resolves to the parent + // segment, so the full articulation/ornament/fermata handling can run. + if (!g.gc->notes().empty()) { + applyNoteArticulations(ctx, g.gc->notes().front(), g.gc, g.en, g.gc->track(), mc); + } + } + pg.clear(); + // Do not erase graceStolenTicks yet: the snap guard for the next regular note reads it. +} + +static void applyFingeringsFromArtic(const NoteElemCtx& ec, + Note* note, + const EncNote* en) +{ + track_idx_t track = ec.track; + for (quint8 ab : { en->articulationUp, en->articulationDown }) { + int n = encArticByteToFingerNumber(ab); + if (n > 0) { + Fingering* fg = Factory::createFingering(note); + fg->setTrack(track); + fg->setXmlText(String::number(n)); + note->add(fg); + break; + } + if (encArticByteIsOpenString(ab)) { + Fingering* fg = Factory::createFingering(note); // "0" not circled STRING_NUMBER + fg->setTrack(track); + fg->setXmlText(u"0"); + note->add(fg); + break; + } + } +} + +static void completePendingTie(BuildCtx& ctx, + const NoteElemCtx& ec, + const EncNote* en, + Note* note) +{ + auto tieKey = std::make_tuple(ec.staffIdx, ec.voice, (int)en->semiTonePitch); + auto it = ctx.scratch.pendingTieNote.find(tieKey); + if (it != ctx.scratch.pendingTieNote.end()) { + Note* startNote = it->second; + // The format has no tie-end, so a tie-start is matched to a later note by (staff, voice, + // pitch); accept only when the receiver is the first chord after the start on that track + // (intervening chords void the tie, rests are skipped), else it jumps across measures to + // the next same-pitch note. See ENCORE_IMPORTER.md §TIE element handling. + bool consecutive = true; + Chord* startChord = startNote->chord(); + if (startChord && startChord->segment() && note->chord()) { + consecutive = false; + const track_idx_t trk = startNote->track(); + for (Segment* s = startChord->segment()->next1(SegmentType::ChordRest); + s; s = s->next1(SegmentType::ChordRest)) { + EngravingItem* el = s->element(trk); + if (el && el->isChord()) { + consecutive = (toChord(el) == note->chord()); + break; + } + } + } + if (consecutive) { + Tie* tie = Factory::createTie(startNote); + tie->setStartNote(startNote); + tie->setEndNote(note); + tie->setTrack(startNote->track()); + startNote->add(tie); + } + ctx.scratch.pendingTieNote.erase(it); + } +} + +static void registerTieStartIfApplicable(BuildCtx& ctx, + const NoteElemCtx& ec, + const MeasEmitCtx& mc, + const EncNote* en, + Note* note) +{ + bool hasTieStart = mc.isTieStartAt(ec.staffIdx, ec.voice, (int)ec.e->tick, (int)en->position) + || en->isTieSender; + if (hasTieStart) { + ctx.scratch.pendingTieNote[{ ec.staffIdx, ec.voice, (int)en->semiTonePitch }] = note; + } +} + +// Returns V_INVALID if rdur does not match the beat-relative pattern. +static DurationType resolveBeatRelativeFaceValue( + const EncNote* en, + const EncMeasure* encMeas, + int preACheck, + int preNCheck) +{ + if (en->realDuration == 0 || preACheck <= 0 || preNCheck <= 0 + || !encMeas || encMeas->beatTicks == 0) { + return DurationType::V_INVALID; + } + const int bt = static_cast(encMeas->beatTicks); + const int expectedBeatAdv = (bt * preNCheck + preACheck / 2) / preACheck; + if (static_cast(en->realDuration) != expectedBeatAdv) { + return DurationType::V_INVALID; + } + // Beat-relative fv (e.g. 8/8 where fv=Q means one beat): derive written note from rdur x ratio. + const int faceTicks = (static_cast(en->realDuration) * preACheck + + preNCheck / 2) / preNCheck; + // Choose fv to avoid the "realDur < faceValue2ticks(fv)" fallback in realDuration2DurationType. + quint8 computedFv = en->faceValue; + for (int f = 1; f <= 8; ++f) { + if (faceValue2ticks(static_cast(f)) <= faceTicks) { + computedFv = static_cast(f); + break; + } + } + return realDuration2DurationType(static_cast(faceTicks), computedFv); +} + +static void attachChordToTuplet( + BuildCtx& ctx, + MeasEmitCtx& mc, + const NoteElemCtx& ec, + const EncNote* en, + Chord* chord, + TupletDecision& dec) +{ + DurationType& dt = dec.dt; + int& dots = dec.dots; + const DurationType dtFace = dec.dtFace; + const int preACheck = dec.actualN; + const int preNCheck = dec.normalN; + const bool isInnerMember = mc.innerGroupMembers.count(ec.e) > 0; + const bool isInnerFirst = mc.nestedByInnerFirst.count(ec.e) > 0; + const bool isInnerLast = mc.nestedByInnerLast.count(ec.e) > 0; + Measure* measure = mc.measure; + const std::set& validTupletGroupMember = mc.validTupletGroupMember; + const std::set& partialEndGroup = mc.partialEndGroup; + const EncMeasureElem* e = ec.e; + const auto& trackKey = ec.trackKey; + track_idx_t track = ec.track; + Fraction elemTick = ec.elemTick; + bool isStandardExplicit = isStandardExplicitTuplet(preACheck, preNCheck); + auto closeTupletWithFill = [&](TupletTracker& tt, std::pair key) { + mc.closeTupletWithFill(ctx, tt, key); + }; + + auto& tt = ctx.scratch.tuplets[trackKey]; + int actualN = isStandardExplicit ? preACheck : 0; + int normalN = isStandardExplicit ? preNCheck : 0; + // Implied tuplet (pre-validated: isImpliedTupletMember set by parser for v0xC2 only). + if (actualN == 0 && (fvLow(en->faceValue)) >= 4 && validTupletGroupMember.count(e)) { + actualN = detectImpliedTuplet(en->realDuration, en->faceValue, normalN); + } + // Sandwich orphan (tup=0 surrounded by tup=N:M notes): use active ratio to stay in bracket. + if (actualN == 0 && tt.inTuplet() && !tt.groupFull() && validTupletGroupMember.count(e)) { + actualN = tt.actualN; + normalN = tt.normalN; + } + + if (actualN > 0 && normalN > 0) { + // Close full group before starting a new one. + if (tt.groupFull()) { + closeTupletWithFill(tt, trackKey); + } + if (!tt.inTuplet()) { + if (isStandardExplicit && !validTupletGroupMember.count(e)) { + // Isolated explicit note: start partial tuplet only when it exactly fills remaining space. + Fraction tupAdv = TDuration(dtFace).fraction() + * Fraction(normalN, actualN); + Fraction remaining = measure->ticks() - ctx.scratch.cumTick[trackKey]; + if (tupAdv == remaining) { + dt = dtFace; + dots = 0; + TDuration faceD(dtFace); + chord->setDurationType(faceD); + chord->setTicks(faceD.fraction()); + chord->setDots(0); + tt.startTuplet(measure, elemTick, actualN, normalN, dt, track); + } else { + actualN = 0; + normalN = 0; // treat as plain note + } + } else { + // Partial measure-end groups: derive baseLen from remaining/normalN (e.g. rem=1/8, normalN=2 -> baseLen=1/16). + DurationType baseLenDt = dt; + if (partialEndGroup.count(e)) { + Fraction rem3 = measure->ticks() - ctx.scratch.cumTick[trackKey]; + Fraction fullAdv = TDuration(dt).fraction() * Fraction(normalN, 1); + if (fullAdv > rem3 && rem3 > Fraction(0, 1)) { + Fraction baseFrac = Fraction(rem3.numerator(), + rem3.denominator() * normalN).reduced(); + TDuration baseLenDur(baseFrac, true /*truncate*/); + if (baseLenDur.isValid() && baseLenDur.fraction() == baseFrac) { + baseLenDt = baseLenDur.type(); + } + } + } + tt.startTuplet(measure, elemTick, actualN, normalN, baseLenDt, track); + } + } + } + if (actualN > 0 && normalN > 0) { + // Nested-tuplet: inner notes go into innerTt; outer advances via cumTick (doubly-nested block below). + auto& innerTt = ctx.scratch.innerTuplets[trackKey]; + if (isInnerMember) { + if (isInnerFirst) { + const NestedTupletInfo& ni = *mc.nestedByInnerFirst.at(e); + innerTt.closeTuplet(); + innerTt.startTuplet(measure, elemTick, ni.innerActualN, ni.innerNormalN, dt, track); + if (tt.inTuplet() && tt.currentTuplet && innerTt.currentTuplet) { + innerTt.currentTuplet->setTuplet(tt.currentTuplet); + tt.currentTuplet->add(innerTt.currentTuplet); + } + } + if (innerTt.inTuplet()) { + chord->setTuplet(innerTt.currentTuplet); + innerTt.currentTuplet->add(chord); + innerTt.faceTicks += TDuration(dt).fraction(); + } + if (isInnerLast && innerTt.inTuplet()) { + innerTt.closeTuplet(); + // Credit outer TupletTracker with one outer-slot face value. + if (tt.inTuplet() && tt.currentTuplet) { + tt.faceTicks += TDuration(tt.currentTuplet->baseLen()).fraction(); + } + } + } else { + chord->setTuplet(tt.currentTuplet); + tt.currentTuplet->add(chord); + + // No-downdate: only lower fullFaceSum when smaller fv arrives and current tally still fits the new threshold. + // {Q,E}/3:2: before E, faceTicks=Q≤3E → update; {Q,Q,8,8}/3:2: faceTicks=2Q>3E → skip. + if (tt.actualN > 0 && tt.fullFaceSum > Fraction(0, 1)) { + const Fraction thisFace = TDuration(dt).fraction(); + const Fraction currentBaseLen = tt.fullFaceSum / tt.actualN; + if (thisFace > Fraction(0, 1) && thisFace < currentBaseLen) { + const Fraction newThreshold = thisFace * tt.actualN; + if (tt.faceTicks <= newThreshold) { + tt.fullFaceSum = newThreshold; + } + } + } + tt.faceTicks += TDuration(dt).fraction(); + } + } else { + auto& innerTt2 = ctx.scratch.innerTuplets[trackKey]; + if (innerTt2.inTuplet()) { + innerTt2.closeTuplet(); + } + if (tt.groupFull()) { + closeTupletWithFill(tt, trackKey); + } + if (tt.inTuplet()) { + closeTupletWithFill(tt, trackKey); // non-tuplet note exits group + } + } +} + +// Returns false if chord was discarded (zero-tick residual). +static bool advanceCumulativeTick( + BuildCtx& ctx, + const NoteElemCtx& ec, + const MeasEmitCtx& mc, + Chord*& chord, + TupletDecision& dec) +{ + DurationType& dt = dec.dt; + int& dots = dec.dots; + const int preACheck = dec.actualN; + const int preNCheck = dec.normalN; + const bool isInnerMember = mc.innerGroupMembers.count(ec.e) > 0; + const auto& trackKey = ec.trackKey; + + auto& tt = ctx.scratch.tuplets[trackKey]; + auto& innerTtAdv = ctx.scratch.innerTuplets[trackKey]; + + // Doubly-nested advance: apply both inner and outer ratios so cumTick over the inner group + // equals one outer slot (3 inner 16ths would otherwise sum to 1/8 > the 1/12 outer slot). + Fraction advance; + if (isInnerMember) { + // Apply inner ratio AND outer ratio. Use saved NI ratios when innerLast has already closed the group. + const NestedTupletInfo* niAdv = nullptr; + if (mc.nestedByInnerFirst.count(ec.e)) { + niAdv = mc.nestedByInnerFirst.at(ec.e); + } else if (mc.nestedByInnerLast.count(ec.e)) { + niAdv = mc.nestedByInnerLast.at(ec.e); + } else if (!mc.nestedInfos.empty()) { + niAdv = &mc.nestedInfos.front(); // middle inner note; only one nested group per measure in known files + } + const int innerAN = niAdv ? niAdv->innerActualN : (innerTtAdv.inTuplet() ? innerTtAdv.actualN : preACheck); + const int innerNN = niAdv ? niAdv->innerNormalN : (innerTtAdv.inTuplet() ? innerTtAdv.normalN : preNCheck); + Fraction innerAdv = TDuration(dt).fraction() + * Fraction(innerNN, innerAN); + if (tt.inTuplet()) { + advance = innerAdv * Fraction(tt.normalN, tt.actualN); + } else { + advance = innerAdv; + } + } else if (tt.inTuplet()) { + advance = TDuration(dt).fraction() * Fraction(tt.normalN, tt.actualN); + } else { + advance = dottedAdvance(dt, dots); + } + + // Tuplet-remaining cap: fv > baseLen (e.g. 8th inside 3:2 with baseLen=16th) would produce non-TDuration-aligned Tuplet.ticks and crash layout. + if (tt.inTuplet() && chord) { + const Fraction tupExpected = TDuration(tt.currentTuplet->baseLen()).fraction() + * tt.normalN; + const Fraction tupRemaining = tupExpected - tt.placedTicks; + if (tupRemaining > Fraction(0, 1) && advance > tupRemaining) { + const Fraction neededFace = tupRemaining * Fraction(tt.actualN, tt.normalN); + TDuration cappedFace(neededFace, true /*truncate*/); + if (cappedFace.isValid() && cappedFace.fraction().numerator() > 0) { + advance = cappedFace.fraction() * Fraction(tt.normalN, tt.actualN); + chord->setDurationType(cappedFace); + chord->setTicks(cappedFace.fraction()); + chord->setDots(0); + // Re-sync faceTicks: the original dt may have been larger. + tt.faceTicks -= TDuration(dt).fraction(); + tt.faceTicks += cappedFace.fraction(); + } + } + } + + // A plain note may overrun the barline here on purpose; the overfull post-pass + // (fitOverfullMeasure) recuts it into a tied chain. A tuplet is atomic, so its members are + // likewise never cut here. + ctx.scratch.cumTick[trackKey] += advance; + if (tt.inTuplet()) { + tt.placedTicks += advance; + } + // Inner-group notes: advance innerTt.placedTicks by the singly-nested advance so closeTuplet() sees the correct inner span. + auto& innerTtFin = ctx.scratch.innerTuplets[trackKey]; + if (isInnerMember && innerTtFin.inTuplet()) { + const Fraction innerOnlyAdv = TDuration(dt).fraction() + * Fraction(innerTtFin.normalN, innerTtFin.actualN); + innerTtFin.placedTicks += innerOnlyAdv; + } + return true; +} + +// Returns false if the note should be skipped (implied-tuplet or zero-tick residual). +// Otherwise sets dt, dots, and dtFace (dt before capping, for isolated-explicit fill check). +static bool resolveNoteDuration( + BuildCtx& ctx, + const NoteElemCtx& ec, + const MeasEmitCtx& mc, + const EncNote* en, + bool isStandardExplicit, + TupletDecision& dec) +{ + const int preACheck = dec.actualN; + const int preNCheck = dec.normalN; + const bool isChordExt = ec.isChordExt; + const int savedPrevMidiTick = ec.savedPrevMidiTick; + DurationType& dt = dec.dt; + int& dots = dec.dots; + DurationType& dtFace = dec.dtFace; + Measure* measure = mc.measure; + const std::set& validTupletGroupMember = mc.validTupletGroupMember; + const std::set& partialEndGroup = mc.partialEndGroup; + const EncMeasureElem* e = ec.e; + const auto& trackKey = ec.trackKey; + + // Skip this note (MIDI artifact or zero-tick residual), rolling back prevMidiTick. + auto bailOut = [&]() -> bool { + if (savedPrevMidiTick >= 0) { + ctx.scratch.prevMidiTick[trackKey] = savedPrevMidiTick; + } else { + ctx.scratch.prevMidiTick.erase(trackKey); + } + return false; + }; + + if (isStandardExplicit) { + // In files where the face-value byte encodes "beats" rather than absolute note + // values (e.g. 8/8 where fv=Q means one eighth beat), rdur equals exactly + // beatTicks x (normalN/actualN). Use rdur in that case; otherwise trust fv. + // This distinguishes beat-relative face values (rdur=beatTicks x ratio) from + // truncated rdur (last note in a measure, rdur shortened by a following rest). + dt = faceValue2DurationType(en->faceValue); + { + const DurationType dtBeat = resolveBeatRelativeFaceValue(en, mc.encMeas, preACheck, preNCheck); + if (dtBeat != DurationType::V_INVALID) { + dt = dtBeat; + } + } + dots = 0; + // Partial measure-end groups: reduce dt when the tuplet advance overshoots remaining space. + if (partialEndGroup.count(e)) { + const auto& ttX = ctx.scratch.tuplets[trackKey]; + if (ttX.inTuplet() && dt != DurationType::V_INVALID) { + Fraction adv = TDuration(dt).fraction() + * Fraction(ttX.normalN, ttX.actualN); + Fraction rem = measure->ticks() - ctx.scratch.cumTick[trackKey]; + while (adv > rem && rem > Fraction(0, 1) + && dt < DurationType::V_128TH) { + dt = static_cast(static_cast(dt) + 1); + adv = TDuration(dt).fraction() + * Fraction(ttX.normalN, ttX.actualN); + } + } + } + } else { + dt = realDuration2DurationType(en->realDuration, en->faceValue); + if (en->forceDotted) { + // Explicitly marked dotted by the parser (v0xC2 dotted-eighth tick-pattern fix). + // Bypass computeDotCount to avoid the bit-0 fallback, which may fire on raw + // binary dotControl values that coincidentally have bit 0 set. + dots = 1; + } else if (en->dotControl > 0) { + // dotControl bit 0 = dotted flag; computeDotCount tries tick-value interpretation first, falls back to bit 0 on MIDI drift. + dots = computeDotCount(en->dotControl, en->realDuration, en->faceValue, + true /*useBit0Fallback*/); + } else { + dots = calcDotsSnap(en->realDuration, en->faceValue); + } + } + dtFace = dt; // before capping; used for isolated-explicit fill check + + { + const auto& ttPre = ctx.scratch.tuplets[trackKey]; + int preA = isStandardExplicit ? preACheck : 0; + int preN = isStandardExplicit ? preNCheck : 0; + if (!isStandardExplicit) { + if ((fvLow(en->faceValue)) >= 4 && validTupletGroupMember.count(e)) { + preA = detectImpliedTuplet(en->realDuration, en->faceValue, preN); + } + } + + // Implied-tuplet guard: skip if full group advance doesn't fit (partial triplet leaves 1/3072 residual). + if (!isStandardExplicit && !ttPre.inTuplet() + && !isChordExt && preA > 0 && preN > 0) { + Fraction singleAdv = TDuration(faceValue2DurationType(fvLow(en->faceValue))).fraction() + * Fraction(preN, preA); + Fraction fullGroupAdv = singleAdv * Fraction(preA, 1); + Fraction mRemaining = measure->ticks() - ctx.scratch.cumTick[trackKey]; + if (fullGroupAdv > mRemaining) { + return bailOut(); + } + } + + bool willBeExplicit = isStandardExplicit && validTupletGroupMember.count(e); + bool willBeTuplet = (preA > 0 && preN > 0 && (willBeExplicit || !isStandardExplicit)) + || (ttPre.inTuplet() && !ttPre.groupFull()); + if (!willBeTuplet) { + Fraction remaining = measure->ticks() - ctx.scratch.cumTick[trackKey]; + TDuration fullDur(dt); // must include dots; TDuration(dt) alone misses the dotted extension + fullDur.setDots(dots); + if (remaining > Fraction(0, 1) && fullDur.fraction() > remaining + && ctx.opts.overfillMeasureStrategy != OverfillStrategy::IrregularMeasure) { + TDuration capped(remaining, true); + // 1/3072-type residual: no representable duration fits (the note begins a hair + // before the barline). A zero-tick chord breaks sanityCheck, so drop the note. + if (capped.fraction().numerator() == 0) { + return bailOut(); + } + // Otherwise keep the note's full value and let it overrun; fitOverfullMeasure + // recuts it into a tied chain (collapsing here would strand the remainder as a rest). + } + } + } + return true; +} + +// Mark the note fixed so layoutDrumset() cannot override its headGroup: a later note on the same +// pitch updates the shared drumset entry, which would otherwise overwrite this note's headGroup. +static void fixNoteHeadImmune(Note* note, const EncNote* en, Drumset* ds) +{ + const int drumLine = (ds && ds->isValid(note->pitch())) + ? ds->line(note->pitch()) + : std::max(-4, 10 - static_cast(en->position)); + note->setFixed(true); + note->setFixedLine(drumLine); +} + +static void configureNoteHeadForDrumset(Note* note, const EncNote* en) +{ + Drumset* ds = note->part()->instrument()->drumset(); + const int nibble = fvHigh(en->faceValue) & 0xF; + + // faceValue high nibble=7: slash notehead in Encore's rhythm-staff notation. + if (nibble == 7) { + note->setHeadGroup(NoteHeadGroup::HEAD_SLASH); + if (ds) { + if (!ds->isValid(note->pitch())) { + DrumInstrument di; + di.name = String::number(note->pitch()); + di.line = std::max(-4, 10 - static_cast(en->position)); + di.stemDirection = DirectionV::UP; + ds->setDrum(note->pitch(), di); + } + ds->drum(note->pitch()).notehead = NoteHeadGroup::HEAD_SLASH; + } + fixNoteHeadImmune(note, en, ds); + return; + } + // faceValue high nibble=3: square notehead (Encore bass drum notation). + if (nibble == 3) { + note->setHeadGroup(NoteHeadGroup::HEAD_CUSTOM); + if (ds) { + if (!ds->isValid(note->pitch())) { + DrumInstrument di; + di.name = u"drum"; + di.notehead = NoteHeadGroup::HEAD_CUSTOM; + // Half/whole durations use the open (hollow) square; + // quarter and shorter use the filled square. + di.noteheads[int(NoteHeadType::HEAD_WHOLE)] = SymId::noteheadSquareWhite; + di.noteheads[int(NoteHeadType::HEAD_HALF)] = SymId::noteheadSquareWhite; + di.noteheads[int(NoteHeadType::HEAD_QUARTER)] = SymId::noteheadSquareBlack; + di.noteheads[int(NoteHeadType::HEAD_BREVIS)] = SymId::noteheadSquareBlack; + di.line = 7; + di.stemDirection = DirectionV::DOWN; + ds->setDrum(note->pitch(), di); + } else { + ds->drum(note->pitch()).notehead = NoteHeadGroup::HEAD_CUSTOM; + } + } + fixNoteHeadImmune(note, en, ds); + return; + } + { + // 5-line PERC staff: line derived from Encore position byte. + // faceValue high nibble encodes the notehead type (all 10 values confirmed): + // 0=normal, 1=diamond, 2=triangle-up, 4=cross, 5=xcircle, + // 6=plus, 8=large-diamond(soft), 9=invisible(no head) + static const NoteHeadGroup nibble2head[] = { + NoteHeadGroup::HEAD_NORMAL, // 0 + NoteHeadGroup::HEAD_DIAMOND, // 1 rombo + NoteHeadGroup::HEAD_TRIANGLE_UP, // 2 triangulo + NoteHeadGroup::HEAD_NORMAL, // 3 (square, handled above) + NoteHeadGroup::HEAD_CROSS, // 4 equis + NoteHeadGroup::HEAD_XCIRCLE, // 5 equis con circulo + NoteHeadGroup::HEAD_PLUS, // 6 mas (+) + NoteHeadGroup::HEAD_SLASH, // 7 slash (handled above) + NoteHeadGroup::HEAD_LARGE_DIAMOND, // 8 rombo blando + NoteHeadGroup::HEAD_NORMAL, // 9 sin_cabeza (note made invisible below) + }; + const NoteHeadGroup nhg = (nibble < 10) ? nibble2head[nibble] : NoteHeadGroup::HEAD_NORMAL; + if (ds) { + if (!ds->isValid(note->pitch())) { + DrumInstrument di; + di.name = String::number(note->pitch()); + di.line = std::max(-4, 10 - static_cast(en->position)); + di.stemDirection = DirectionV::UP; + ds->setDrum(note->pitch(), di); + } + // Set the drumset default notehead (used when the note is not fixed). + ds->drum(note->pitch()).notehead = nhg; + note->setHeadGroup(nhg); + } + if (nibble != 0) { + fixNoteHeadImmune(note, en, ds); + } + // nibble 9 = sin_cabeza (no notehead): make the note invisible. + if (nibble == 9) { + note->setVisible(false); + } + } +} + +void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) +{ + Measure* measure = mc.measure; + std::set >& filteredTieSenderPitches = mc.filteredTieSenderPitches; + const EncMeasureElem* e = ec.e; + int staffIdx = ec.staffIdx; + track_idx_t track = ec.track; + auto trackKey = ec.trackKey; + bool isChordExt = ec.isChordExt; + Fraction elemTick = ec.elemTick; + int savedPrevMidiTick = ec.savedPrevMidiTick; + + const EncNote* en = static_cast(e); + + if (tryHandleGraceNote(ctx, mc, ec, en)) { + return; + } + + if (!isValidFaceValue(en->faceValue)) { + return; + } + + // Pass pre-computed isChordExt: after the prevMidiTick update delta==0 would falsely bypass. + if (isMidiArtifact(en, ec, mc, filteredTieSenderPitches, savedPrevMidiTick, isChordExt)) { + return; + } + + if (isCascadeFilteredTieReceiver(en, ec, filteredTieSenderPitches)) { + return; + } + + TupletDecision dec; + dec.actualN = en->actualNotes(); + dec.normalN = en->normalNotes(); + { + auto orit = mc.overrideGroupRatios.find(e); + if (orit != mc.overrideGroupRatios.end()) { + dec.actualN = orit->second.first; + dec.normalN = orit->second.second; + } + } + bool isStandardExplicit = isStandardExplicitTuplet(dec.actualN, dec.normalN); + + if (!resolveNoteDuration(ctx, ec, mc, en, isStandardExplicit, dec)) { + return; + } + + Segment* seg = measure->getSegment(SegmentType::ChordRest, elemTick); + Chord* chord = nullptr; + if (seg->element(track) && seg->element(track)->isChord()) { + chord = toChord(seg->element(track)); + } else { + chord = Factory::createChord(seg); + chord->setTrack(track); + TDuration dur(dec.dt); + dur.setDots(dec.dots); + chord->setDurationType(dur); + chord->setTicks(dur.fraction()); + chord->setDots(dec.dots); + seg->add(chord); + + attachChordToTuplet(ctx, mc, ec, en, chord, dec); + + if (!advanceCumulativeTick(ctx, ec, mc, chord, dec)) { + return; + } + } + + attachPendingGracesToChord(ctx, trackKey, chord, mc); + + const int concertPitch = en->semiTonePitch + ctx.staffPitchOffset[staffIdx]; + if (chord->findNote(concertPitch)) { + // Some files encode the same pitch twice (duplicate NOTE or chord-extension copy); drop it. + return; + } + Note* note = Factory::createNote(chord); + applyConcertPitch(note, concertPitch); + chord->add(note); + + // A small note reaching this normal path is a cue note (full value, drawn small); graces never + // reach here. See ENCORE_IMPORTER.md §Grace and cue notes. + if (en->isSmall()) { + note->setSmall(true); + } + // Encore per-note mute flag: applies to any note (normal, cue, or grace), independent of size. + if (en->isMuted()) { + note->setPlay(false); + } + + configureNoteHeadForDrumset(note, en); + applyFingeringsFromArtic(ec, note, en); + completePendingTie(ctx, ec, en, note); + applyNoteArticulations(ctx, note, chord, en, track, mc); + registerTieStartIfApplicable(ctx, ec, mc, en, note); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-orn.cpp b/src/importexport/encore/internal/importer/emitters-orn.cpp new file mode 100644 index 0000000000000..9dc2a87f71e84 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-orn.cpp @@ -0,0 +1,631 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Dispatch Encore ORNAMENT elements to dynamics, wedges, tempo, slurs, trills, markers and more. + +#include "emitters-internal.h" +#include "coords.h" +#include "durations.h" +#include "../parser/ticks.h" +#include "mappers.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" + +namespace mu::iex::enc { +using namespace mu::engraving; + +static void handleDynamicOrnament(BuildCtx& /*ctx*/, MeasEmitCtx& mc, + NoteElemCtx& ec, const EncOrnament* eo, + Measure* measure, MasterScore* /*score*/) +{ + const EncMeasure& encMeas = *mc.encMeas; + const Fraction measTick = mc.measTick; + const EncMeasureElem* e = ec.e; + int& staffIdx = ec.staffIdx; + int msVoice = ec.msVoice; + track_idx_t& track = ec.track; + + // yoffset > 0 means the user dragged it onto the staff above; reroute to staffIdx-1. + if (eo->yoffset > 0 && staffIdx > 0) { + staffIdx -= 1; + track = static_cast(staffIdx * VOICES + msVoice); + } + const DynamicType dt = encOrnType2DynamicType(eo->ornType()); + // Use the enc tick as base: voice-0 cumTick may be 0 when notes live in other voices. + const Fraction dynBase = measTick + Fraction(static_cast(e->tick), kEncWholeTicks); + Fraction placeTick = snapStartTickByXoffset(dynBase, encMeas, staffIdx, + static_cast(eo->xoffset), measTick); + // Section-end dynamics are stored at measureDurTicks; clamp back to the last ChordRest. + if (placeTick >= measTick + measure->ticks()) { + Segment* last = measure->last(SegmentType::ChordRest); + placeTick = last ? last->tick() : measTick; + } + Segment* seg = measure->getSegment(SegmentType::ChordRest, placeTick); + if (!seg) { + seg = measure->getSegment(SegmentType::ChordRest, measTick); + } + // A ChordRest carries at most one dynamic per track, but Encore can stack two dynamic ORNs on + // one beat (a duplicate pair, or a score-view/part-view pair differing only in placement). + // Encore renders one; keep the first emitted and drop any later dynamic on this track/segment. + bool dupDyn = false; + for (EngravingItem* ann : seg->annotations()) { + if (ann && ann->isDynamic() && ann->track() == track) { + dupDyn = true; + break; + } + } + if (dupDyn) { + return; + } + Dynamic* dyn = Factory::createDynamic(seg); + dyn->setTrack(track); + dyn->setDynamicType(dt); + dyn->setXmlText(Dynamic::dynamicText(dt)); + if (eo->yoffset < 0) { + dyn->setPlacement(mu::engraving::PlacementV::BELOW); + dyn->setPropertyFlags(mu::engraving::Pid::PLACEMENT, mu::engraving::PropertyFlags::UNSTYLED); + } + seg->add(dyn); +} + +static void handleStaffTextOrnament(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncOrnament* eo, + Measure* measure, MasterScore* score) +{ + const EncRoot& enc = ctx.enc; + const Fraction measTick = mc.measTick; + const Fraction elemTick = ec.elemTick; + const track_idx_t track = ec.track; + + const int textIdx = static_cast(eo->tind); + if (textIdx < 0 + || textIdx >= static_cast(enc.textBlock.entries.size())) { + return; + } + QString text = enc.textBlock.entries[textIdx]; + if (text.isEmpty()) { + return; + } + Fraction placeTick = elemTick; + if (placeTick >= measTick + measure->ticks()) { + Segment* last = measure->last(SegmentType::ChordRest); + placeTick = last ? last->tick() : measTick; + } + Segment* seg = measure->getSegment(SegmentType::ChordRest, placeTick); + if (!seg) { + seg = measure->getSegment(SegmentType::ChordRest, measTick); + } + // Encore y-offset is Cartesian (positive = up); negative maps to PlacementV::BELOW. + const bool placeBelow = (eo->yoffset < 0); + + // Promote Italian tempo terms to TempoText so MuseScore tracks them in the tempo map. + // Skipped when importTempoTextSemantic is off: the text stays as StaffText. + const double tempoBps = ctx.opts.importTempoTextSemantic ? encTextToTempoBps(text) : -1.0; + if (tempoBps >= 0.0) { + TempoText* tt2 = Factory::createTempoText(seg); + tt2->setTrack(track); + tt2->setXmlText(String(text)); + if (tempoBps > 0.0) { + tt2->setTempo(BeatsPerSecond(tempoBps)); + tt2->setFollowText(true); + score->setTempo(elemTick, BeatsPerSecond(tempoBps)); + } + if (placeBelow) { + tt2->setPlacement(mu::engraving::PlacementV::BELOW); + tt2->setPropertyFlags(mu::engraving::Pid::PLACEMENT, mu::engraving::PropertyFlags::UNSTYLED); + } + seg->add(tt2); + return; + } + + StaffText* st = Factory::createStaffText(seg); + st->setTrack(track); + st->setXmlText(String(text)); + if (placeBelow) { + st->setPlacement(mu::engraving::PlacementV::BELOW); + st->setPropertyFlags(mu::engraving::Pid::PLACEMENT, mu::engraving::PropertyFlags::UNSTYLED); + } + seg->add(st); +} + +static void handleTempoOrnament(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncOrnament* eo, + MasterScore* score) +{ + const EncRoot& enc = ctx.enc; + const EncMeasure& encMeas = *mc.encMeas; + const Fraction measTick = mc.measTick; + const Fraction elemTick = ec.elemTick; + const track_idx_t track = ec.track; + Measure* measure = mc.measure; + + if (eo->tempo > 0) { + // When importTempoTextSemantic is off, visual tempo marks (ORN TEMPO) are suppressed + // just like Italian text terms; only the MEAS header BPM creates a TempoText. + if (!ctx.opts.importTempoTextSemantic) { + return; + } + // Use nominal timesig so a pickup measure inherits the main sig's beat classification. + const bool cmpd = isCompoundBeat(encMeas.beatTicks, measure->timesig()); + // The MEAS header BPM is the authoritative tempo position (applyMeasureBpmMarks places a + // TempoText at the measure start, which registers in the tempo map). The ORN TEMPO is only + // a visual mark whose stored tick is often off (end of a measure, or a system early). So + // suppress the ORN whenever a header BPM equals it, and keep the ORN only when NO header + // BPM matches (a genuine standalone mark). + if (static_cast(eo->tempo) == encMeas.bpm) { + return; // redundant with this measure's header + } + { + for (size_t mi = mc.measIdx + 1; mi < enc.measures.size(); ++mi) { + if (enc.measures[mi].bpm == static_cast(eo->tempo)) { + return; // misplaced: a later measure's header BPM will place it + } + } + } + + // Encore anchors the mark to a note's tick but may draw the glyph left of it, over an + // earlier downbeat rest. Snap to the chord-rest whose xoffset matches the drawn position + // so the tempo lands on the rest it visually governs. + Fraction placeTick = snapStartTickByXoffset(elemTick, encMeas, ec.staffIdx, + static_cast(eo->xoffset), measTick); + Segment* seg = measure->getSegment(SegmentType::ChordRest, placeTick); + if (!seg) { + seg = measure->getSegment(SegmentType::ChordRest, measTick); + } + TempoText* tt2 = Factory::createTempoText(seg); + tt2->setTrack(track); + + // The tempo value is expressed in the mark's beat unit. Prefer the unit Encore stored + // explicitly on the mark (`noto`); a compound meter is often beaten in dotted quarters, + // but the composer may pick a plain quarter (e.g. quarter=198 in 6/8), and only `noto` + // records that choice. Fall back to the meter heuristic when `noto` is unset. + const int notoTicks = notoToBeatTicks(eo->noto); + const int displayBeatTicks = notoTicks ? notoTicks : (cmpd ? 360 : 240); + const double beatInQuarters = displayBeatTicks / 240.0; + const double bps = eo->tempo * beatInQuarters / 60.0; + tt2->setTempo(BeatsPerSecond(bps)); + tt2->setXmlText(tempoXmlText(static_cast(eo->tempo), displayBeatTicks)); + tt2->setFollowText(true); + seg->add(tt2); + score->setTempo(seg->tick(), BeatsPerSecond(bps)); + } +} + +static void handleWedgeStart(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncOrnament* eo) +{ + const EncMeasure& encMeas = *mc.encMeas; + const Fraction measTick = mc.measTick; + const int staffIdx = ec.staffIdx; + const track_idx_t track = ec.track; + const int voice = ec.voice; + const int measIdx = mc.measIdx; + const EncMeasureElem* e = ec.e; + + // On grand staves (staffWithin > 0) the cumTick-based elemTick is wrong: the WEDGESTART ORN + // is always voice=0 but the notes may be voice=1+, a different trackKey. Compute the tick from + // the raw Encore element tick instead. Single-staff (staffWithin == 0) cumTick is correct. + const int wholeTicks2 = (e->staffWithin > 0) ? encWholeNoteTicks(encMeas) : 0; + const Fraction rawElemTick = (wholeTicks2 > 0) + ? measTick + Fraction(static_cast(e->tick), wholeTicks2).reduced() + : ec.elemTick; + + // No WEDGESTOP in .enc; alMezuro = forward measure count (upper bound). Precise tick2 resolved in post-pass. + int endIdx = measIdx + static_cast(eo->alMezuro); + if (endIdx < 0 || endIdx >= static_cast(ctx.measuresByIdx.size())) { + endIdx = measIdx; + } + Measure* endMeas = ctx.measuresByIdx[endIdx]; + Fraction maxEnd = endMeas->tick() + endMeas->ticks(); + const Fraction snappedStart = snapStartTickByXoffset(rawElemTick, encMeas, staffIdx, + static_cast(eo->xoffset), measTick); + if (maxEnd <= snappedStart) { + return; + } + // Encore 5 sets bit 1 too (0x02=cresc, 0x03=dim); test bit 0 only. + const HairpinType hpType + = ((eo->speguleco & 0x01) == 0) + ? HairpinType::CRESC_HAIRPIN + : HairpinType::DIM_HAIRPIN; + // On grand staves (staffWithin > 0) the WEDGESTART ORN is voice=0 but the notes may be a + // different Encore voice. Find the voice of the first note on the same sub-staff so the hairpin + // lands on the track it spans, not the measure-rest-only voice 0 (which cannot be positioned). + track_idx_t resolvedTrack = track; + int resolvedEncVoice = voice; + if (e->staffWithin > 0) { + for (const auto& elem : encMeas.elements) { + const EncMeasureElem* em = elem.get(); + if (em->type != static_cast(EncElemType::NOTE) + && em->type != static_cast(EncElemType::REST)) { + continue; + } + if (static_cast(em->staffIdx) != static_cast(e->staffIdx) + || static_cast(em->staffWithin) != static_cast(e->staffWithin)) { + continue; + } + const int emEncVoice = static_cast(em->voice); + const int vBase = static_cast(e->staffWithin) * (static_cast(VOICES) / 2); + const int emMsVoice = (emEncVoice >= vBase) ? emEncVoice - vBase : emEncVoice; + resolvedTrack = static_cast(staffIdx * VOICES + emMsVoice); + resolvedEncVoice = emEncVoice; + break; + } + } + + PendingHairpin ph; + ph.startTick = snappedStart; + ph.maxEndTick = maxEnd; + ph.track = resolvedTrack; + ph.type = hpType; + ph.endMeasIdx = endIdx; + ph.hairpinXoffset2 = static_cast(eo->xoffset2); + // Raw Encore staffIdx (rawStaff & 0x3F), not the MuseScore-mapped slot: resolveHairpinEndByXoffset + // compares against em->staffIdx on note elements which also use rawStaff & 0x3F. + ph.staffIdx = static_cast(e->staffIdx); + ph.encVoice = resolvedEncVoice; + ctx.pendingHairpins.push_back(ph); +} + +static void handleTrillOrnament(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncOrnament* eo) +{ + const EncMeasure& encMeas = *mc.encMeas; + const Fraction measTick = mc.measTick; + const Fraction elemTick = ec.elemTick; + const int staffIdx = ec.staffIdx; + const int voice = ec.voice; + const track_idx_t track = ec.track; + const int measIdx = mc.measIdx; + const EncMeasureElem* e = ec.e; + + // TRILL_START: spanner when TRILL_END or alMezuro marks the end; TRILL_ALT: always Ornament glyph; + // TRILL_TR/TRILL_SHORT: standalone glyph only, never a spanner. Chord deferred to post-pass. + PendingTrill pt; + pt.isAlt = (eo->ornType() != EncOrnamentType::TRILL_START); + pt.isSimple = (eo->ornType() == EncOrnamentType::TRILL_TR + || eo->ornType() == EncOrnamentType::TRILL_SHORT); + if (pt.isSimple) { + // Snap to visual position; 20px threshold ignores small alignment nudges. + const int ornXoff = static_cast(eo->xoffset); + int crXoffAtTick = -1; + for (const auto& elem : encMeas.elements) { + const EncMeasureElem* em = elem.get(); + if (static_cast(em->tick) != static_cast(e->tick)) { + continue; + } + if (em->staffIdx != staffIdx || em->voice != voice) { + continue; + } + if (em->type == static_cast(EncElemType::NOTE)) { + crXoffAtTick = static_cast(static_cast(em)->xoffset); + break; + } else if (em->type == static_cast(EncElemType::REST)) { + crXoffAtTick = static_cast(static_cast(em)->xoffset); + break; + } + } + constexpr int TRILL_SNAP_THRESHOLD = 20; + if (crXoffAtTick >= 0) { + if (ornXoff < crXoffAtTick - TRILL_SNAP_THRESHOLD) { + pt.tick = snapStartTickByXoffset(elemTick, encMeas, staffIdx, + static_cast(eo->xoffset), measTick); + } else { + pt.tick = elemTick; + } + } else { + // No note on the ORN's own tick: elemTick can overshoot to a later note. Anchor from + // the raw Encore tick and snap to the note it visually sits on, so a "TR" between two + // notes lands on the preceding one. + const int wt = encWholeNoteTicks(encMeas); + const Fraction rawTick = measTick + + Fraction(static_cast(e->tick), wt).reduced(); + pt.tick = snapStartTickByXoffset(rawTick, encMeas, staffIdx, + static_cast(eo->xoffset), measTick); + } + pt.simpleSymId = (eo->ornType() == EncOrnamentType::TRILL_SHORT) + ? SymId::ornamentShortTrill + : SymId::ornamentTrill; + } else { + pt.tick = elemTick; + } + pt.track = track; + if (!pt.isAlt) { + pt.alMezuro = static_cast(eo->alMezuro); + pt.measIdx = static_cast(measIdx); + pt.xoffset2 = static_cast(eo->xoffset2); + } + ctx.pendingTrills.push_back(pt); +} + +static void handleStringNumberOrnament(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncOrnament* eo) +{ + const Fraction elemTick = ec.elemTick; + const track_idx_t track = ec.track; + const int measIdx = mc.measIdx; + + const int sn = static_cast(eo->ornType()) + - static_cast(EncOrnamentType::STRING_NUMBER_2) + 2; + ctx.pendingOrnFingerings.push_back({ elemTick, track, sn, measIdx, false, false, true }); +} + +static void handleFingerOrnament(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncOrnament* eo) +{ + const EncMeasureElem* e = ec.e; + const Fraction elemTick = ec.elemTick; + const track_idx_t track = ec.track; + const int measIdx = mc.measIdx; + const std::set& voice4NoteTicks = mc.voice4NoteTicks; + const std::map& v0NoteCountAtTick = mc.v0NoteCountAtTick; + const std::map& ornFingCountAtTick = mc.ornFingCountAtTick; + const int maxVoice0Tick = mc.maxVoice0Tick; + + const int n = static_cast(eo->ornType()) + - static_cast(EncOrnamentType::FINGER_1) + 1; + const int orn_tick = static_cast(e->tick); + // cm: ORN at last voice=0 tick with no voice=4 note there; belongs to first chord of next measure. + const bool cm = !voice4NoteTicks.empty() + && !voice4NoteTicks.count(orn_tick) + && orn_tick == maxVoice0Tick; + // ps: excess FINGER ORNs beyond voice=0 note count target the voice=4 (2nd staff) chord. + const bool ps = !cm + && voice4NoteTicks.count(orn_tick) + && ornFingCountAtTick.count(orn_tick) + && v0NoteCountAtTick.count(orn_tick) + && ornFingCountAtTick.at(orn_tick) + > v0NoteCountAtTick.at(orn_tick); + ctx.pendingOrnFingerings.push_back({ elemTick, track, n, measIdx, cm, ps }); +} + +void handleOrnament(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) +{ + MasterScore* score = ctx.score; + Measure* measure = mc.measure; + const EncMeasure& encMeas = *mc.encMeas; + const Fraction measTick = mc.measTick; + const int measIdx = mc.measIdx; + const std::set& noteTicks = mc.noteTicks; + const EncMeasureElem* e = ec.e; + int& staffIdx = ec.staffIdx; // mutable ref (dynamic rerouting) + int voice = ec.voice; + int msVoice = ec.msVoice; + track_idx_t& track = ec.track; // mutable ref (dynamic rerouting) + Fraction elemTick = ec.elemTick; + + const EncOrnament* eo = static_cast(e); + + // Register a bowing/articulation ORN in pendingBowings. + auto pushBowing = [&](SymId sid) { + const bool cm = !noteTicks.count(static_cast(e->tick)); + const Fraction bt = measTick + Fraction(static_cast(e->tick), kEncWholeTicks); + ctx.pendingBowings.push_back({ bt, track, sid, measIdx, cm, + static_cast(eo->xoffset), static_cast(e->tick) }); + }; + + switch (eo->ornType()) { + case EncOrnamentType::SLURSTART: { + // No SLURSTOP in .enc; alMezuro = forward measure count. Endpoint resolved in post-pass. + // Skip slurs within one 16th of the bar boundary; Encore silently omits these. + if (encMeas.beatTicks > 0 + && static_cast(eo->tick) + static_cast(encMeas.beatTicks) / 4 + > static_cast(encMeas.durTicks)) { + break; + } + int endIdx = measIdx + static_cast(eo->alMezuro); + if (endIdx < 0 || endIdx >= static_cast(ctx.measuresByIdx.size())) { + endIdx = measIdx; + } + PendingSlur ps; + // Use raw eo->tick: elemTick is cumTick-based and wrong when voice 0 is empty. + { + const int wt = encWholeNoteTicks(encMeas); + ps.startTick = measTick + + Fraction(static_cast(eo->tick), wt).reduced(); + } + ps.track = track; + ps.startMeasIdx = measIdx; + ps.endMeasIdx = endIdx; + ps.alMezuro = static_cast(eo->alMezuro); + ps.alMezuroValid = eo->alMezuroValid; + // xoffset is a pixel position that wraps at 256; cast to quint8 to get the true positive value. + ps.slurXoffset = static_cast(static_cast(eo->xoffset)); + ps.slurXoffset2 = static_cast(eo->xoffset2); // already quint8 + ps.staffIdx = staffIdx; + ps.encVoice = voice; + ctx.pendingSlurs.push_back(ps); + break; + } + case EncOrnamentType::SLURSTOP: + break; + case EncOrnamentType::WEDGESTART: + handleWedgeStart(ctx, mc, ec, eo); + break; + case EncOrnamentType::WEDGESTOP: + break; + case EncOrnamentType::TEMPO: + handleTempoOrnament(ctx, mc, ec, eo, score); + break; + case EncOrnamentType::ARPEGGIO: { + ctx.pendingArpeggios.push_back({ elemTick, track }); + break; + } + case EncOrnamentType::TREMOLO_32: + case EncOrnamentType::TREMOLO_32B: { + // Post-pass searches backwards when no chord is found at the exact tick (tied passages). + PendingOrnTremolo pt; + pt.tick = elemTick; + pt.measTick = measTick; + pt.staffIdx = staffIdx; + pt.msVoice = msVoice; + pt.tremType = TremoloType::R32; + ctx.pendingOrnTremolos.push_back(pt); + break; + } + case EncOrnamentType::TRILL_START: + case EncOrnamentType::TRILL_ALT: + case EncOrnamentType::TRILL_TR: + case EncOrnamentType::TRILL_SHORT: + handleTrillOrnament(ctx, mc, ec, eo); + break; + case EncOrnamentType::TRILL_END: + ctx.pendingTrillEnds[track].push_back(elemTick); + break; + case EncOrnamentType::SEGNO: + case EncOrnamentType::TO_CODA: + case EncOrnamentType::CODA: { + MarkerType mt = MarkerType::CODA; + if (eo->ornType() == EncOrnamentType::SEGNO) { + mt = MarkerType::SEGNO; + } else if (eo->ornType() == EncOrnamentType::TO_CODA) { + mt = MarkerType::TOCODA; + } + ctx.pendingMarkers.push_back({ elemTick, mt }); + break; + } + case EncOrnamentType::STACCATO: { + ctx.pendingStaccatos.push_back({ elemTick, track }); + break; + } + case EncOrnamentType::FERMATA_ABOVE: + case EncOrnamentType::FERMATA_BELOW: + ctx.pendingFermatas.push_back({ elemTick, track, + eo->ornType() == EncOrnamentType::FERMATA_ABOVE + ? SymId::fermataAbove : SymId::fermataBelow }); + break; + case EncOrnamentType::REPEAT_MEASURE: { + bool already = false; + for (const auto& pmr : ctx.pendingMeasureRepeats) { + if (pmr.staffIdx == ec.staffIdx && pmr.measTick == measTick) { + already = true; + break; + } + } + if (!already) { + ctx.pendingMeasureRepeats.push_back({ measTick, ec.staffIdx }); + } + break; + } + case EncOrnamentType::CAESURA: + case EncOrnamentType::BREATH_COMMA: + ctx.pendingBreaths.push_back({ elemTick, track, + eo->ornType() == EncOrnamentType::CAESURA + ? SymId::caesura : SymId::breathMarkComma }); + break; + case EncOrnamentType::ACCENT: pushBowing(SymId::articAccentAbove); + break; + case EncOrnamentType::DOWNBOW: pushBowing(SymId::stringsDownBow); + break; + case EncOrnamentType::UPBOW: pushBowing(SymId::stringsUpBow); + break; + case EncOrnamentType::MARCATO: pushBowing(SymId::articMarcatoAbove); + break; + case EncOrnamentType::MARCATO_BELOW: pushBowing(SymId::articMarcatoBelow); + break; + case EncOrnamentType::MARCATO_STACCATO_BELOW: pushBowing(SymId::articMarcatoStaccatoBelow); + break; + case EncOrnamentType::TENUTO: pushBowing(SymId::articTenutoAbove); + break; + case EncOrnamentType::GUITAR_BEND: + case EncOrnamentType::GUITAR_BEND_2: + case EncOrnamentType::GUITAR_PREBEND: + case EncOrnamentType::GUITAR_PREBEND_RELEASE: + case EncOrnamentType::GUITAR_BEND_V: + LOGW() << QString("Encore: guitar bend 0x%1 not yet imported (measure %2 staff %3 tick %4)") + .arg(eo->tipo, 2, 16, QChar('0')) + .arg(measIdx) + .arg(staffIdx) + .arg(static_cast(e->tick)); + break; + case EncOrnamentType::DOUBLE_MORDENT: pushBowing(SymId::ornamentPrallMordent); + break; + case EncOrnamentType::TREMOLO_16: { + PendingOrnTremolo pt; + pt.tick = elemTick; + pt.measTick = measTick; + pt.staffIdx = staffIdx; + pt.msVoice = msVoice; + pt.tremType = TremoloType::R16; + ctx.pendingOrnTremolos.push_back(pt); + break; + } + case EncOrnamentType::STRING_NUMBER_2: + case EncOrnamentType::STRING_NUMBER_3: + case EncOrnamentType::STRING_NUMBER_4: + case EncOrnamentType::STRING_NUMBER_5: + case EncOrnamentType::STRING_NUMBER_6: + handleStringNumberOrnament(ctx, mc, ec, eo); + break; + case EncOrnamentType::OTTAVA_ALTA: + case EncOrnamentType::OTTAVA_BASSA: { + const OttavaType ot = (eo->ornType() == EncOrnamentType::OTTAVA_ALTA) + ? OttavaType::OTTAVA_8VA + : OttavaType::OTTAVA_8VB; + ctx.pendingOttavas.push_back({ elemTick, track, staffIdx, ot }); + break; + } + case EncOrnamentType::GRAPHIC_LINE: + break; + case EncOrnamentType::FINGER_1: + case EncOrnamentType::FINGER_2: + case EncOrnamentType::FINGER_3: + case EncOrnamentType::FINGER_4: + case EncOrnamentType::FINGER_5: + handleFingerOrnament(ctx, mc, ec, eo); + break; + case EncOrnamentType::DYN_PPP: + case EncOrnamentType::DYN_PP: + case EncOrnamentType::DYN_P: + case EncOrnamentType::DYN_MP: + case EncOrnamentType::DYN_MF: + case EncOrnamentType::DYN_F: + case EncOrnamentType::DYN_FF: + case EncOrnamentType::DYN_FFF: + case EncOrnamentType::DYN_SFZ: + case EncOrnamentType::DYN_SFFZ: + case EncOrnamentType::DYN_FP: + case EncOrnamentType::DYN_FZ: + case EncOrnamentType::DYN_SF: + handleDynamicOrnament(ctx, mc, ec, eo, measure, score); + break; + case EncOrnamentType::STAFFTEXT: + handleStaffTextOrnament(ctx, mc, ec, eo, measure, score); + break; + default: + LOGW() << QString("Encore: unknown ornament type 0x%1 at measure %2 staff %3 tick %4") + .arg(eo->tipo, 2, 16, QChar('0')) + .arg(measIdx) + .arg(staffIdx) + .arg(static_cast(e->tick)); + break; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-overfill.cpp b/src/importexport/encore/internal/importer/emitters-overfill.cpp new file mode 100644 index 0000000000000..3fcd6d2a7836c --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-overfill.cpp @@ -0,0 +1,552 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Overfull-measure resolution. When a voice's content sums to more than the measure +// length, each overfill strategy resolves it here, in the post-pass, so a tuplet is +// always handled atomically (never left as an invalid partial tuplet): +// - Truncate ("Remove extra notes"): dissolve any cut tuplet, drop trailing notes, +// dot the last survivor, fill the remainder with an exact rest. Destructive. +// - StretchLastNote ("Stretch last notes"): preserve the notes by reclaiming preceding rests +// (tier 1) / compressing the tuplet bracket (tier 2); fall back to irregular. +// - IrregularMeasure: extend the measure to fit (handled by capMeasureLength). + +#include "emitters-internal.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/durationtype.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/score.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tuplet.h" + +#include + +using namespace mu::engraving; + +namespace mu::iex::enc { +static void addFillRest(Measure* measure, track_idx_t tr, const Fraction& startTick, const Fraction& totalLen); + +// Largest representable note value (with up to maxDots dots) whose fraction is <= f. +// Returns 0/1 if nothing fits. +static Fraction largestDottedLE(const Fraction& f, int maxDots) +{ + if (f.numerator() <= 0) { + return Fraction(0, 1); + } + TDuration d(f, true /*truncate*/, maxDots); + if (!d.isValid()) { + return Fraction(0, 1); + } + return d.fraction(); +} + +// Dissolve a tuplet: detach every member so it reverts to its plain face value, then +// remove the (now empty) tuplet from its parent and delete it. A tuplet is atomic, so it +// is dissolved whole rather than leaving a partial tuplet behind. +void dissolveTuplet(Tuplet* t) +{ + if (!t) { + return; + } + std::vector members(t->elements().begin(), t->elements().end()); + for (DurationElement* de : members) { + if (de->isTuplet()) { + dissolveTuplet(toTuplet(de)); + } else { + de->setTuplet(nullptr); + } + } + if (EngravingItem* parent = t->parentItem()) { + parent->remove(t); + } + delete t; +} + +// Remove any spanner (slur, hairpin, ottava, ...) anchored to this ChordRest before the CR +// is removed or moved. Segment::remove() would otherwise call score()->undo() to null the +// spanner's start/end, leaving a dangling spanner that crashes layout. Done up front here so +// no such undo fires. (Ties live on notes, not in the spanner map, so they are unaffected.) +static void detachSpannersAt(ChordRest* cr) +{ + Score* score = cr->score(); + std::vector toRemove; + for (const auto& [tick, s] : score->spanner()) { + if (s->startElement() == cr || s->endElement() == cr) { + toRemove.push_back(s); + } + } + for (Spanner* s : toRemove) { + score->removeSpanner(s); + delete s; + } +} + +// Collect the ordered ChordRests of one track in a measure and their total actual ticks. +Fraction collectVoice(Measure* measure, track_idx_t tr, std::vector& out) +{ + out.clear(); + Fraction sum(0, 1); + for (Segment* seg = measure->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(tr); + if (!el || !el->isChordRest()) { + continue; + } + ChordRest* cr = toChordRest(el); + out.push_back(cr); + sum += cr->actualTicks(); + } + return sum; +} + +// Recut a trailing chord/rest that crosses the barline so it ends exactly at the barline, +// keeping its notes. The fitting duration (`room`) is expressed as the minimal chain of +// tied figures rather than collapsed to a single smaller value: e.g. a dotted half stranded +// in a 5/8 bar becomes a half tied to an eighth, preserving the full sounding value up to +// the barline. Rests are refigured the same way (no tie). Returns nothing; the crossing +// element is replaced in place. +static void recutCrossingCR(Measure* measure, track_idx_t tr, ChordRest* cr, const Fraction& room) +{ + const Fraction measTick = measure->tick(); + const Fraction startOff = cr->tick() - measTick; + const bool isChord = cr->isChord(); + + // Snapshot the chord's pitches/spellings so the tied chain reproduces them. + struct NoteSpec { + int pitch; + int tpc1; + int tpc2; + }; + std::vector notes; + if (isChord) { + for (Note* n : toChord(cr)->notes()) { + notes.push_back({ n->pitch(), n->tpc1(), n->tpc2() }); + } + } + detachSpannersAt(cr); + cr->segment()->remove(cr); + delete cr; + + Fraction pos = startOff; + Chord* prevChord = nullptr; + for (const TDuration& d : toDurationList(room, true /*useDots*/, 3 /*maxDots*/, false)) { + if (!d.isValid()) { + break; + } + Segment* seg = measure->getSegment(SegmentType::ChordRest, measTick + pos); + if (isChord) { + Chord* c = Factory::createChord(seg); + c->setTrack(tr); + c->setDurationType(d); + c->setTicks(d.fraction()); + c->setDots(d.dots()); + seg->add(c); + for (size_t i = 0; i < notes.size(); ++i) { + Note* n = Factory::createNote(c); + n->setTrack(tr); + n->setPitch(notes[i].pitch, notes[i].tpc1, notes[i].tpc2); + c->add(n); + if (prevChord && i < prevChord->notes().size()) { + Note* prev = prevChord->notes()[i]; + Tie* tie = Factory::createTie(prev); + tie->setStartNote(prev); + tie->setEndNote(n); + tie->setTrack(tr); + prev->add(tie); + } + } + prevChord = c; + } else { + Rest* r = Factory::createRest(seg, d); + r->setTicks(d.fraction()); + r->setTrack(tr); + r->setGap(false); + seg->add(r); + } + pos += d.fraction(); + } +} + +// "Remove extra notes" (Truncate). A trailing tuplet is dissolved and its notes shrunk from the +// right to keep as many as possible: each trailing note is halved (down to a quarter of its value) +// and removed only if even a quarter still overflows, stopping once the content fits. The last +// survivor is then dotted (up to 3 dots) to reach the barline and the remainder is filled with an +// exact rest. A plain trailing note that crosses the barline is recut to it (as a tied chain) +// rather than dropped. See ENCORE_IMPORTER.md §Overfull measures. +static void removeExtraNotes(Measure* measure, track_idx_t tr) +{ + const Fraction mLen = measure->ticks(); + const Fraction measTick = measure->tick(); + + std::vector crs; + + // 1. Resolve trailing plain overflow from the right until the voice fits or a tuplet surfaces: + // a note crossing the barline is recut to it, a note starting at/after it is removed. + while (true) { + Fraction sum = collectVoice(measure, tr, crs); + if (crs.empty() || sum <= mLen || crs.back()->tuplet()) { + break; + } + ChordRest* last = crs.back(); + const Fraction lastStart = last->tick() - measTick; + if (lastStart < mLen) { + recutCrossingCR(measure, tr, last, mLen - lastStart); + break; + } + detachSpannersAt(last); + last->segment()->remove(last); + delete last; + } + + ChordRest* lastSurvivor = nullptr; + Fraction contentEnd(0, 1); // offset from measTick where surviving content ends + + Fraction sum = collectVoice(measure, tr, crs); + if (!crs.empty() && sum > mLen && crs.back()->tuplet()) { + // 2. Trailing tuplet: dissolve, then shrink its members from the right. + Tuplet* t = crs.back()->tuplet(); + std::vector members; + Fraction preContent(0, 1); + for (ChordRest* cr : crs) { + if (cr->tuplet() == t) { + members.push_back(cr); + } else if (members.empty()) { + preContent += cr->actualTicks(); + } + } + dissolveTuplet(t); // members now plain at face value (segment positions are stale) + + const Fraction available = mLen - preContent; + std::vector durs; + std::vector halvings(members.size(), 0); + for (ChordRest* m : members) { + durs.push_back(m->actualTicks()); + } + auto durSum = [&]() { + Fraction s(0, 1); + for (const Fraction& d : durs) { + s += d; + } + return s; + }; + // Halve each trailing note (max twice -> down to 1/4 of its value); if even a quarter + // of it still overflows, remove it and move left. Stop as soon as the content fits. + int idx = static_cast(members.size()) - 1; + while (durSum() > available && idx >= 0) { + if (durs[idx] <= Fraction(0, 1)) { + --idx; + continue; + } + const Fraction halved = durs[idx] * Fraction(1, 2); + if (halvings[idx] < 2 && fitsTDuration(halved)) { + durs[idx] = halved; + ++halvings[idx]; + } else { + durs[idx] = Fraction(0, 1); + --idx; + } + } + + // Detach every member from its stale segment, then re-place the survivors + // sequentially from preContent (deleting the removed ones). + for (ChordRest* m : members) { + detachSpannersAt(m); + m->segment()->remove(m); + } + Fraction pos = preContent; + for (size_t j = 0; j < members.size(); ++j) { + if (durs[j] <= Fraction(0, 1)) { + delete members[j]; + continue; + } + const TDuration td(durs[j]); + members[j]->setDurationType(td); + members[j]->setTicks(td.fraction()); + members[j]->setDots(td.dots()); + members[j]->setTrack(tr); + Segment* seg = measure->getSegment(SegmentType::ChordRest, measTick + pos); + seg->add(members[j]); + pos += durs[j]; + lastSurvivor = members[j]; + } + contentEnd = pos; + } else { + contentEnd = sum; + lastSurvivor = (!crs.empty() && crs.back()->isChord() && !crs.back()->tuplet()) ? crs.back() : nullptr; + } + + // 3. Dot the last surviving note (up to 3 dots) to absorb the remaining gap, then fill the + // residual with an exact rest. + Fraction gap = mLen - contentEnd; + if (gap <= Fraction(0, 1)) { + return; + } + if (lastSurvivor && lastSurvivor->isChord()) { + const Fraction cur = lastSurvivor->actualTicks(); + const Fraction target = largestDottedLE(cur + gap, 3 /*maxDots*/); + if (target > cur) { + const TDuration td(target); + lastSurvivor->setDurationType(td); + lastSurvivor->setTicks(td.fraction()); + lastSurvivor->setDots(td.dots()); + contentEnd += (target - cur); + gap = mLen - contentEnd; + } + } + addFillRest(measure, tr, measTick + contentEnd, gap); +} + +// Fill `totalLen` from `startTick` with visible rests, split into individually notatable +// figures (up to 3 dots) via toDurationList so layout keeps them as-is. A single rest with an +// odd total (e.g. a dotted 16th) is fine; larger residues split into a tied-rest sequence. +static void addFillRest(Measure* measure, track_idx_t tr, const Fraction& startTick, const Fraction& totalLen) +{ + if (totalLen <= Fraction(0, 1)) { + return; + } + Fraction pos = startTick; + for (const TDuration& d : toDurationList(totalLen, true /*useDots*/, 3 /*maxDots*/, false)) { + if (!d.isValid()) { + break; + } + Segment* fillSeg = measure->getSegment(SegmentType::ChordRest, pos); + if (!fillSeg->element(tr)) { + Rest* r = Factory::createRest(fillSeg, d); + r->setTicks(d.fraction()); + r->setTrack(tr); + r->setGap(false); + fillSeg->add(r); + } + pos += d.fraction(); + } +} + +// Stretch tier 1: reclaim space from preceding rests so all notes fit within the nominal measure +// length. Non-destructive: only shortens/removes rests, never alters note values. The overflow is +// reclaimed from the latest rests first, so the least content shifts (typically only the trailing +// notes move earlier onto the shortened rest). Returns true if the voice was made to fit; false if +// there is not enough reclaimable rest (the caller falls through to tier 2/3), or if the voice holds +// a tuplet (moving tuplet members individually is unsafe here; tier 2 compresses the bracket). +static bool robRestsToFit(Measure* measure, track_idx_t tr) +{ + const Fraction mLen = measure->ticks(); + const Fraction measTick = measure->tick(); + + std::vector crs; + const Fraction voiceSum = collectVoice(measure, tr, crs); + const Fraction overflow = voiceSum - mLen; + if (overflow <= Fraction(0, 1) || crs.empty()) { + return true; // already fits + } + // Sum the reclaimable rest, and decline on any tuplet member (handled by bracket compression). + Fraction reclaimable(0, 1); + for (ChordRest* cr : crs) { + if (cr->tuplet()) { + return false; + } + if (cr->isRest()) { + reclaimable += cr->actualTicks(); + } + } + if (reclaimable < overflow) { + return false; // not enough rest to absorb the overflow; caller falls back + } + + // Decide each element's kept duration: chords unchanged; reclaim `overflow` from the latest + // rests first (a rest reclaimed in full is dropped, a partially reclaimed one is re-split). + std::vector keepDur(crs.size()); + Fraction need = overflow; + for (size_t i = crs.size(); i-- > 0;) { + if (crs[i]->isRest()) { + const Fraction cur = crs[i]->actualTicks(); + const Fraction take = (cur < need) ? cur : need; + keepDur[i] = cur - take; + need -= take; + } else { + keepDur[i] = crs[i]->actualTicks(); + } + } + + // Detach everything from its (now stale) segment, then re-place sequentially from measTick. + // Chords are moved intact (notes/ties preserved); rests are re-created at their reduced length + // via addFillRest so an odd remainder splits into exact figures; fully-reclaimed rests vanish. + for (ChordRest* cr : crs) { + detachSpannersAt(cr); + cr->segment()->remove(cr); + } + Fraction pos(0, 1); + for (size_t i = 0; i < crs.size(); ++i) { + if (crs[i]->isRest()) { + delete crs[i]; + if (keepDur[i] > Fraction(0, 1)) { + addFillRest(measure, tr, measTick + pos, keepDur[i]); + pos += keepDur[i]; + } + } else { + Segment* seg = measure->getSegment(SegmentType::ChordRest, measTick + pos); + crs[i]->setTrack(tr); + seg->add(crs[i]); + pos += crs[i]->actualTicks(); + } + } + return true; +} + +// "Stretch last notes" for one overfull voice. Preserves all notes by, in order: reclaiming space +// from preceding rests (tier 1), compressing the trailing tuplet's bracket (tier 2), or, for a lone +// trailing note, recutting it to the barline; fills the remainder with an exact rest. Returns false +// (declining) when the result would be too small to be musical (tuplet bracket < half its natural +// span, or no space): the caller then falls back to extending the measure (tier 3, IrregularMeasure). +static bool stretchOverfullVoice(Measure* measure, track_idx_t tr) +{ + const Fraction mLen = measure->ticks(); + const Fraction measTick = measure->tick(); + + std::vector crs; + const Fraction voiceSum = collectVoice(measure, tr, crs); + if (crs.empty() || voiceSum <= mLen) { + return true; + } + + // Tier 1: reclaim space from preceding rests so every note keeps its full value at the + // nominal measure length. Only when there is enough rest (and no tuplet to preserve). + if (robRestsToFit(measure, tr)) { + return true; + } + + ChordRest* last = crs.back(); + if (last->tuplet()) { + Tuplet* t = last->tuplet(); + // Position where the tuplet starts = total actual ticks of the notes before its first member. + Fraction preContent(0, 1); + std::vector members; + for (ChordRest* cr : crs) { + if (cr->tuplet() == t) { + members.push_back(cr); + } else if (members.empty()) { + preContent += cr->actualTicks(); + } + } + const Fraction available = mLen - preContent; + if (available <= Fraction(0, 1) || members.empty()) { + return false; + } + const int aN = t->ratio().numerator(); + const int nN = t->ratio().denominator(); + if (aN <= 0 || nN <= 0) { + return false; + } + const Fraction naturalBracket = TDuration(t->baseLen()).fraction() * nN; + // Compress the bracket to the largest base that fits (up to 3 dots); the exact + // remainder is filled with a rest below. + const Fraction newBaseLen = largestDottedLE(available / nN, 3 /*maxDots*/); + if (newBaseLen <= Fraction(0, 1)) { + return false; + } + const Fraction newBracket = newBaseLen * nN; + // Too small to be musical: the largest bracket that fits is < half the natural span. + if (newBracket * Fraction(2, 1) < naturalBracket) { + return false; + } + // Resize and reposition each member within the compressed bracket. + const TDuration baseTd(newBaseLen); + const Fraction memberActual = newBaseLen * Fraction(nN, aN); + Fraction pos = preContent; + for (ChordRest* m : members) { + m->setDurationType(baseTd); + m->setTicks(baseTd.fraction()); + m->setDots(baseTd.dots()); + const Fraction newTick = measTick + pos; + if (m->segment()->tick() != newTick) { + detachSpannersAt(m); + Segment* oldSeg = m->segment(); + oldSeg->remove(m); + Segment* ns = measure->getSegment(SegmentType::ChordRest, newTick); + ns->add(m); + } + pos += memberActual; + } + t->setBaseLen(baseTd); + t->setTicks(newBracket); + t->setTick(measTick + preContent); + addFillRest(measure, tr, measTick + preContent + newBracket, mLen - (preContent + newBracket)); + return true; + } + + // Lone trailing note (no tuplet): recut it to end exactly at the barline, keeping its full + // value up to that point as a tied chain (e.g. a dotted half in 5/8 -> half tied to eighth) + // rather than collapsing it to the largest single figure and padding with a rest. + const Fraction preContent = voiceSum - last->actualTicks(); + const Fraction available = mLen - preContent; + if (available <= Fraction(0, 1)) { + return false; + } + recutCrossingCR(measure, tr, last, available); + return true; +} + +// Post-pass entry point: resolve any overfull voice according to the overfill strategy. +void fitOverfullMeasure(BuildCtx& ctx, Measure* measure) +{ + // IrregularMeasure extends the measure to hold all content. + if (ctx.opts.overfillMeasureStrategy == OverfillStrategy::IrregularMeasure) { + capMeasureLength(ctx, measure); + return; + } + + const Fraction mLen = measure->ticks(); + std::vector crs; + bool needIrregularFallback = false; + for (int si = 0; si < ctx.totalStaves; ++si) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + const track_idx_t tr = static_cast(si * VOICES + v); + const Fraction sum = collectVoice(measure, tr, crs); + if (crs.empty() || sum <= mLen) { + continue; + } + switch (ctx.opts.overfillMeasureStrategy) { + case OverfillStrategy::Truncate: + removeExtraNotes(measure, tr); + break; + case OverfillStrategy::StretchLastNote: + // Tier 1 (reclaim preceding rests) / tier 2 (compress bracket) / lone-note recut; + // decline -> tier 3 (irregular). Tier 1 robs value only from rests (non-destructive), + // so a stretch that still cannot resolve degrades to IrregularMeasure output rather + // than a standard-length bar. See ENCORE_IMPORTER.md §Overfull measures. + if (!stretchOverfullVoice(measure, tr)) { + needIrregularFallback = true; + } + break; + default: + break; + } + } + } + if (needIrregularFallback) { + extendMeasureIrregular(ctx, measure); + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-rest.cpp b/src/importexport/encore/internal/importer/emitters-rest.cpp new file mode 100644 index 0000000000000..15b8023455cac --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-rest.cpp @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Emit rests, including multi-measure rests, from Encore REST elements. + +#include "emitters-internal.h" +#include "mappers.h" +#include "../parser/ticks.h" +#include "durations.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/tuplet.h" + +namespace mu::iex::enc { +using namespace mu::engraving; + +void handleRest(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) +{ + Measure* measure = mc.measure; + const EncMeasureElem* e = ec.e; + track_idx_t track = ec.track; + auto trackKey = ec.trackKey; + Fraction elemTick = ec.elemTick; + int savedPrevMidiTick = ec.savedPrevMidiTick; + const std::set& validTupletGroupMember = mc.validTupletGroupMember; + const std::set& impliedGroupMember = mc.validTupletGroupMember; + auto closeTupletWithFill = [&](TupletTracker& tt, std::pair key) { + mc.closeTupletWithFill(ctx, tt, key); + }; + + const EncRest* er = static_cast(e); + if (!isValidFaceValue(er->faceValue)) { + return; + } + if (er->realDuration > 0 && er->realDuration < 15) { + // A short rdur is a MIDI ghost rest unless the face value shows a real duration (rdur was + // shortened by the next note's MIDI start). 64th or smaller face value: drop; else keep. + const int faceTicks = faceValue2ticks(er->faceValue); + if (faceTicks <= 0 || faceTicks < 30) { + return; + } + } + DurationType dt = realDuration2DurationType(er->realDuration, er->faceValue); + int dots = computeDotCount(er->dotControl, er->realDuration, er->faceValue); + // Cap rest duration to remaining space; a full tuplet group is closed before this rest, so it + // counts as plain and is capped too. + { + const auto& ttPre = ctx.scratch.tuplets[trackKey]; + if (!ttPre.inTuplet() || ttPre.groupFull()) { + Fraction remaining = measure->ticks() - ctx.scratch.cumTick[trackKey]; + TDuration fullDur(dt); + fullDur.setDots(dots); + if (remaining > Fraction(0, 1) && fullDur.fraction() > remaining) { + TDuration capped(remaining, true); + dt = capped.type(); + dots = capped.dots(); + } + } + } + + Segment* seg = measure->getSegment(SegmentType::ChordRest, elemTick); + if (!seg->element(track)) { + TDuration dur(dt); + dur.setDots(dots); + Rest* rest = Factory::createRest(seg, dur); + rest->setTrack(track); + rest->setTicks(dur.fraction()); + rest->setDots(dots); + seg->add(rest); + + auto& tt = ctx.scratch.tuplets[trackKey]; + int actualNr = er->actualNotes(); + int normalNr = er->normalNotes(); + const bool isStdExplicitR = isStandardExplicitTuplet(actualNr, normalNr); + if (!isStdExplicitR) { + actualNr = 0; + normalNr = 0; + } + if (actualNr == 0 && (er->faceValue & 0x0F) >= 4 && impliedGroupMember.count(e)) { + actualNr = detectImpliedTuplet(er->realDuration, er->faceValue, normalNr); + } + if (actualNr > 0 && normalNr > 0) { + if (tt.groupFull()) { + closeTupletWithFill(tt, trackKey); + } + if (!tt.inTuplet()) { + if (isStdExplicitR && !validTupletGroupMember.count(e)) { + Fraction tupAdv = TDuration(dt).fraction() + * Fraction(normalNr, actualNr); + Fraction remaining = measure->ticks() - ctx.scratch.cumTick[trackKey]; + if (tupAdv == remaining) { + tt.startTuplet(measure, elemTick, actualNr, normalNr, dt, track); + } else { + actualNr = 0; + normalNr = 0; + } + } else { + tt.startTuplet(measure, elemTick, actualNr, normalNr, dt, track); + } + } + } + if (actualNr > 0 && normalNr > 0) { + rest->setTuplet(tt.currentTuplet); + tt.currentTuplet->add(rest); + + tt.faceTicks += TDuration(dt).fraction(); + } else { + if (tt.groupFull()) { + closeTupletWithFill(tt, trackKey); + } + if (tt.inTuplet()) { + closeTupletWithFill(tt, trackKey); + } + } + + // When capped, also update the rest's ticks so actualTicks() matches the cumTick advance + // (avoids sanityCheck overshoot). + Fraction advance = tt.inTuplet() + ? TDuration(dt).fraction() * Fraction(tt.normalN, tt.actualN) + : dottedAdvance(dt, dots); + // Mirror the note path (advanceCumulativeTick): never cut a tuplet member here (a tuplet is + // atomic, resolved whole in fitOverfullMeasure) and skip the cap for IrregularMeasure so + // capMeasureLength can extend the bar. + Fraction remaining = measure->ticks() - ctx.scratch.cumTick[trackKey]; + if (advance > remaining && remaining > Fraction(0, 1) + && ctx.opts.overfillMeasureStrategy != OverfillStrategy::IrregularMeasure + && !rest->tuplet()) { + advance = TDuration(remaining, true).fraction(); + if (advance.numerator() == 0) { + // Remaining too small to fit any standard duration: drop the + // rest we just placed rather than leave a zero-tick element. + if (tt.inTuplet()) { + rest->setTuplet(nullptr); + tt.currentTuplet->remove(rest); + tt.faceTicks -= TDuration(dt).fraction(); + } + seg->remove(rest); + delete rest; + if (savedPrevMidiTick >= 0) { + ctx.scratch.prevMidiTick[trackKey] = savedPrevMidiTick; + } else { + ctx.scratch.prevMidiTick.erase(trackKey); + } + return; + } + if (tt.inTuplet()) { + rest->setTuplet(nullptr); + tt.currentTuplet->remove(rest); + tt.faceTicks -= TDuration(dt).fraction(); + } + TDuration cappedDur(advance); + rest->setDurationType(cappedDur); + rest->setTicks(cappedDur.fraction()); + rest->setDots(0); + } + ctx.scratch.cumTick[trackKey] += advance; + if (tt.inTuplet()) { + tt.placedTicks += advance; + } + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-tempo.cpp b/src/importexport/encore/internal/importer/emitters-tempo.cpp new file mode 100644 index 0000000000000..ef7bbef0b98c7 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-tempo.cpp @@ -0,0 +1,172 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Apply per-measure BPM marks as TempoText and render tempo-text strings. + +#include "emitters-internal.h" + +#include + +#include "durations.h" +#include "../parser/ticks.h" + +#include "engraving/dom/factory.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/tempotext.h" + +namespace mu::iex::enc { +// Decode the tempo mark's beat unit from the ORN `noto` field into display ticks (quarter=240). +// Returns 0 when `noto` is unset or unrecognised (older formats store unrelated bytes here) so +// the caller can fall back to the meter heuristic. See ENCORE_FORMAT.md §Ornament element. +int notoToBeatTicks(quint8 noto) +{ + if (noto == 0) { + return 0; + } + const bool dotted = (noto & 0x80) != 0; + const int baseVal = noto & 0x7F; // 0-indexed note value + if (baseVal > 6) { + return 0; // out of range: misparsed old-format byte + } + int ticks = faceValue2ticks(static_cast(baseVal + 1)); // 1=whole .. 7=64th + if (ticks == 0) { + return 0; + } + if (dotted) { + ticks = ticks * 3 / 2; + } + return ticks; +} + +// Render tempo text. displayBpm is the beat-unit BPM that Encore shows the user. +// beatTicks is the beat duration in display ticks (quarter=240, dotted-quarter=360, +// half=480, eighth=120, ...); a value that is a base note times 3/2 renders dotted. +String tempoXmlText(int displayBpm, int beatTicks) +{ + bool dotted = false; + int base = beatTicks; + if (beatTicks % 3 == 0) { + const int b = beatTicks * 2 / 3; + if (b == 960 || b == 480 || b == 240 || b == 120 || b == 60) { + dotted = true; + base = b; + } + } + String sym; + switch (base) { + case 960: sym = u"metNoteWhole"; + break; + case 480: sym = u"metNoteHalfUp"; + break; + case 240: sym = u"metNoteQuarterUp"; + break; + case 120: sym = u"metNote8thUp"; + break; + case 60: sym = u"metNote16thUp"; + break; + default: // unknown: fall back to quarter / dotted-quarter + sym = u"metNoteQuarterUp"; + dotted = (beatTicks == 360); + break; + } + if (dotted) { + return String(u"%1spacemetAugmentationDot = %2").arg(sym).arg(displayBpm); + } + return String(u"%1 = %2").arg(sym).arg(displayBpm); +} + +// Apply per-measure BPM from MEAS headers as TempoText elements. +// Emits a TempoText only when BPM changes, and skips measures that already +// have an ORN TEMPO or STAFFTEXT tempo mark to avoid duplicates. +void applyMeasureBpmMarks(BuildCtx& ctx) +{ + const EncRoot& enc = ctx.enc; + MasterScore* score = ctx.score; + + quint16 lastBpm = 0; + for (size_t mi = 0; mi < enc.measures.size(); ++mi) { + const quint16 bpm = enc.measures[mi].bpm; + if (bpm == 0) { + continue; + } + if (mi > 0 && bpm == lastBpm) { + continue; + } + const size_t msI = (mi < ctx.encToMsIdx.size()) ? ctx.encToMsIdx[mi] : mi; + if (msI >= ctx.measuresByIdx.size()) { + continue; + } + Measure* m = ctx.measuresByIdx[msI]; + const Fraction measTick = m->tick(); + Segment* seg = m->getSegment(SegmentType::ChordRest, measTick); + if (!seg) { + continue; + } + bool hasExisting = false; + for (Segment* s = m->first(SegmentType::ChordRest); s && !hasExisting; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + hasExisting = true; + break; + } + } + } + if (!hasExisting) { + // The header BPM is a quarter-note BPM. Choose the display beat unit: prefer the + // explicit unit on this measure's ORN tempo mark (`noto`) so a quarter=198 mark in 6/8 + // stays "quarter=198" rather than the compound default "dotted-quarter=132"; otherwise + // fall back to the meter heuristic (compound meters display in dotted quarters). + int displayBeatTicks = 0; + for (const auto& el : enc.measures[mi].elements) { + const EncOrnament* orn = dynamic_cast(el.get()); + if (orn && orn->ornType() == EncOrnamentType::TEMPO && orn->tempo > 0) { + displayBeatTicks = notoToBeatTicks(orn->noto); + if (displayBeatTicks != 0) { + break; + } + } + } + const double bps = bpm / 60.0; + int displayBpm; + if (displayBeatTicks != 0) { + // Re-express the quarter-note BPM in the chosen beat unit. + displayBpm = static_cast(std::lround(bpm * 240.0 / displayBeatTicks)); + } else { + const bool cmpd = isCompoundBeat(enc.measures[mi].beatTicks, m->timesig()); + displayBeatTicks = cmpd ? 360 : 240; + displayBpm = cmpd ? (bpm * 2 + 1) / 3 : static_cast(bpm); + } + TempoText* tt = Factory::createTempoText(seg); + tt->setTrack(0); + tt->setTempo(BeatsPerSecond(bps)); + tt->setXmlText(tempoXmlText(displayBpm, displayBeatTicks)); + tt->setFollowText(true); + seg->add(tt); + score->setTempo(measTick, BeatsPerSecond(bps)); + } + lastBpm = bpm; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-tuplets-groups.cpp b/src/importexport/encore/internal/importer/emitters-tuplets-groups.cpp new file mode 100644 index 0000000000000..17c47b43c2e46 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-tuplets-groups.cpp @@ -0,0 +1,530 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Detect implied and explicit tuplet groupings across a measure's note runs. + +#include "emitters-tuplets.h" + +#include +#include +#include + +#include "../parser/ticks.h" +#include "durations.h" +#include "engraving/dom/mscore.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Explicit tuplet ratio from the tup byte (high nibble = actual, low nibble = normal). +static void getExplicit(const std::vector& grp, + int& outActual, int& outNormal) +{ + outActual = 0; + outNormal = 0; + if (grp.empty()) { + return; + } + const EncMeasureElem* e = grp[0]; + const quint8 tup = e->tupletByte(); + int a = tup >> 4, n = tup & 0x0F; + if (isStandardExplicitTuplet(a, n)) { + outActual = a; + outNormal = n; + } +} + +// Implied tuplet ratio from realDuration vs faceValue. Only fires when the first element was +// pre-marked by the parser (calculateRealDurations Phase 3b), so v0xC4 notes with incidental +// MIDI drift are never misidentified as implied triplets. +static void getImplied(const std::vector& grp, + int& outActual, int& outNormal) +{ + outActual = 0; + outNormal = 0; + if (grp.empty()) { + return; + } + const EncMeasureElem* e = grp[0]; + if (!e->impliedTupletMember()) { + return; + } + const quint8 fv = fvLow(e->faceValueByte()); + const qint16 rdur = e->realDuration; + if (fv >= 4) { + outActual = detectImpliedTuplet(rdur, fv, outNormal); + } +} + +// Face value as Fraction for the first element of a chord group. +static Fraction getFaceValue(const std::vector& grp) +{ + if (grp.empty()) { + return Fraction(0, 1); + } + const quint8 fv = fvLow(grp[0]->faceValueByte()); + return faceValue2DurationType(fv) == DurationType::V_INVALID ? Fraction(0, 1) + : TDuration(faceValue2DurationType(fv)).fraction(); +} + +// Actual Encore-tick duration of chord at index k. +// = fv_ticks x (nn/an) for explicit tup, = fv_ticks for plain note. +static int actualDurEnc(int k, + const std::vector >& chords, + int n) +{ + if (k < 0 || k >= n || chords[k].empty()) { + return 0; + } + const EncMeasureElem* ch = chords[k][0]; + const quint8 fv = fvLow(ch->faceValueByte()); + const quint8 tupByte = ch->tupletByte(); + const int fvt = faceValue2ticks(fv); + const int an2 = tupByte >> 4, nn2 = tupByte & 0x0F; + if (an2 > 0 && nn2 > 0) { + return (fvt * nn2 + an2 / 2) / an2; // tuplet-scaled, rounded + } + return fvt; +} + +// Segment-override pre-pass: when N notes share a tup byte but N is not a multiple of actualN, +// reinterpret as [N:m] where m = round(available / fv_ticks). +// available = durTicks - leading_dur - trailing_dur. +static void processSegmentOverrides( + const std::vector >& chords, + int n, + const EncMeasure& encMeas, + std::map >* overrideRatios, + std::set& result) +{ + int j = 0; + while (j < n) { + int a0 = 0, n0 = 0; + getExplicit(chords[j], a0, n0); + if (a0 <= 0) { + ++j; + continue; + } + + // Find end of same-tup run. + int segStart = j; + while (j < n) { + int aj = 0, nj = 0; + getExplicit(chords[j], aj, nj); + if (aj != a0 || nj != n0) { + break; + } + ++j; + } + int N = j - segStart; + + // Override only when N exceeds one group and is not a clean multiple. + if (N <= a0 || (N % a0) == 0) { + continue; + } + + // Require uniform face value and NOTE type throughout the segment. + const Fraction fv0 = getFaceValue(chords[segStart]); + if (fv0 <= Fraction(0, 1)) { + continue; + } + bool allOk = !chords[segStart].empty() + && (static_cast(chords[segStart][0]->type) == EncElemType::NOTE); + for (int k = segStart + 1; k < j && allOk; ++k) { + allOk = !chords[k].empty() + && (static_cast(chords[k][0]->type) == EncElemType::NOTE) + && (getFaceValue(chords[k]) == fv0); + } + if (!allOk) { + continue; + } + + // Leading/trailing durations to compute available space for the segment. + int leadingDur = 0; + for (int k = 0; k < segStart; ++k) { + leadingDur += actualDurEnc(k, chords, n); + } + int trailingDur = 0; + for (int k = j; k < n; ++k) { + trailingDur += actualDurEnc(k, chords, n); + } + + const int available = static_cast(encMeas.durTicks) - leadingDur - trailingDur; + if (available <= 0) { + continue; + } + + // fv_enc = fv0 x durTicks (fv0 is whole-note-relative; durTicks spans one measure). + const int fvEnc = static_cast( + static_cast(fv0.numerator()) * encMeas.durTicks / fv0.denominator()); + if (fvEnc <= 0) { + continue; + } + + // m = round(available / fvEnc) + const int m = (available + fvEnc / 2) / fvEnc; + if (m <= 0) { + continue; + } + + // Tolerance: error must be < 10% of available. + if (std::abs(m * fvEnc - available) * 10 > available) { + continue; + } + + const Fraction tupTicks = fv0 * m; + if (!fitsTDuration(tupTicks) || !isStandardExplicitTuplet(N, m)) { + continue; + } + + // Skip override when the non-override interpretation (complete groups + orphan plain notes) fits the measure. + // Example: 4 notes tup=3:2 + 1 plain Q: non-override total=960 <= 960 -> leave alone. + { + const int completeTicks = (N / a0) * (fvEnc * n0); + const int orphanTicks = (N % a0) * fvEnc; + const int noOverrideTotal = leadingDur + completeTicks + orphanTicks + trailingDur; + if (noOverrideTotal <= static_cast(encMeas.durTicks)) { + continue; // non-override interpretation fits: skip override + } + } + + // Mark segment and record override ratio. + for (int k = segStart; k < j; ++k) { + for (const EncMeasureElem* e2 : chords[k]) { + result.insert(e2); + (*overrideRatios)[e2] = { N, m }; + } + } + } +} + +// Sandwich heuristic: a note whose tup byte is missing/mismatched still belongs to the current +// bracket when the NEXT note matches the ratio, the orphan's face value equals baseLen, and it sits +// at the expected advance tick after the previous member (v0xC4 live recording occasionally drops +// the byte). i must be inside an open group (faceSum > 0) with a valid previous and next chord. +static bool isSandwichOrphan( + const std::vector >& chords, int i, int n, + int actualN, int normalN, Fraction baseLen, Fraction faceSum) +{ + if (!(faceSum > Fraction(0, 1) && i + 1 < n + && !chords[i].empty() && !chords[i - 1].empty())) { + return false; + } + int a3 = 0, n3 = 0; + getExplicit(chords[i + 1], a3, n3); + const Fraction fvOrphan = getFaceValue(chords[i]); + if (a3 != actualN || n3 != normalN || fvOrphan != baseLen) { + return false; + } + // Orphan must land at the expected advance tick after the previous member. + const int advNum = baseLen.numerator() * normalN; + const int advDen = baseLen.denominator() * actualN; + const int advTicks = (advNum * kEncWholeTicks + advDen / 2) / advDen; + const int tol = std::max(4, advTicks / 4); + const int lastTick = static_cast(chords[i - 1][0]->tick); + const int orphanTick = static_cast(chords[i][0]->tick); + return std::abs(orphanTick - lastTick - advTicks) <= tol; +} + +// Implied (v0xC2): require exactly actualN consecutive matching groups. +static void processImpliedTupletGroup( + int& i, int actualN, int normalN, + const std::vector >& chords, + int n, + std::set& result) +{ + if (i + actualN > n) { + ++i; + return; + } + bool allMatch = true; + for (int j = 1; j < actualN && allMatch; ++j) { + int a2 = 0, n2 = 0; + getImplied(chords[i + j], a2, n2); + allMatch = (a2 == actualN && n2 == normalN); + } + if (allMatch) { + for (int j = 0; j < actualN; ++j) { + for (const EncMeasureElem* e : chords[i + j]) { + result.insert(e); + } + } + i += actualN; + } else { + ++i; + } +} + +// Nested-tuplet: the current group closed via a no-downdate reduction, and the next +// (actualN - 1) notes also share innerBaseLen -> record NestedTupletInfo for the emitters +// and pull the peeked-ahead notes into the result set. +static void detectNestedTuplet( + std::vector* nestedInfos, + int innerGroupStartIdx, Fraction innerBaseLen, Fraction originalBaseLen, + int actualN, int normalN, int i, int n, + const std::vector >& chords, + std::set& result) +{ + if (!(nestedInfos && innerGroupStartIdx >= 2 + && innerBaseLen > Fraction(0, 1) + && innerBaseLen < originalBaseLen)) { + return; + } + int peekAhead = actualN - (i - innerGroupStartIdx); // remaining notes needed to complete inner group + bool innerOk = (peekAhead >= 0); + int innerEndIdx = i - 1; // last note of inner group so far (= end of current group) + if (innerOk && peekAhead > 0) { + innerOk = false; + if (i + peekAhead <= n) { + innerOk = true; + for (int p = 0; p < peekAhead && innerOk; ++p) { + int a3 = 0, n3 = 0; + getExplicit(chords[i + p], a3, n3); + if (a3 != actualN || n3 != normalN) { + innerOk = false; + } else { + const Fraction fvp = getFaceValue(chords[i + p]); + if (fvp != innerBaseLen) { + innerOk = false; + } + } + } + if (innerOk) { + innerEndIdx = i + peekAhead - 1; + } + } + } + if (innerOk && innerEndIdx >= innerGroupStartIdx) { + NestedTupletInfo ni; + ni.outerActualN = actualN; + ni.outerNormalN = normalN; + ni.innerActualN = actualN; + ni.innerNormalN = normalN; + if (!chords[innerGroupStartIdx].empty()) { + ni.innerFirst = chords[innerGroupStartIdx][0]; + } + if (!chords[innerEndIdx].empty()) { + ni.innerLast = chords[innerEndIdx][0]; + } + if (ni.innerFirst && ni.innerLast) { + nestedInfos->push_back(ni); + // Include peeked-ahead notes in result. + for (int p = 0; p < peekAhead; ++p) { + for (const EncMeasureElem* e2 : chords[i + p]) { + result.insert(e2); + } + } + } + } +} + +// Partial end group: when realDuration fills to the measure end while the face-value sum would +// overflow without tuplet scaling, mark [groupStart, i) as an incomplete implied tuplet. +static void detectPartialEndGroup( + int groupStart, int i, + const std::vector >& chords, + const EncMeasure& encMeas, bool seenCompleteGroup, + std::set* partialEndGroup, + std::set& result) +{ + if (i <= groupStart) { + return; + } + int startTick = 0; + if (!chords[groupStart].empty()) { + startTick = static_cast(chords[groupStart][0]->tick); + } + int rdurSum = 0; + int faceTickSum = 0; + for (int j = groupStart; j < i; ++j) { + if (!chords[j].empty()) { + const EncMeasureElem* ch = chords[j][0]; + rdurSum += std::max(0, static_cast(ch->realDuration)); + EncElemType cht = static_cast(ch->type); + quint8 fv = 0; + if (cht == EncElemType::NOTE) { + fv = static_cast(ch)->faceValue & 0x0F; + } else if (cht == EncElemType::REST) { + fv = static_cast(ch)->faceValue & 0x0F; + } + faceTickSum += faceValue2ticks(fv); + } + } + const bool rdurFillsMeasure = (rdurSum > 0) + && (startTick + rdurSum == static_cast(encMeas.durTicks)); + const bool faceWouldOverflow = (startTick + faceTickSum + > static_cast(encMeas.durTicks)); + // Guard: skip when a complete group was already found (tail MIDI notes after + // a full group can accidentally satisfy rdurFillsMeasure). + if (rdurFillsMeasure && faceWouldOverflow && !seenCompleteGroup) { + for (int j = groupStart; j < i; ++j) { + for (const EncMeasureElem* e2 : chords[j]) { + result.insert(e2); + if (partialEndGroup) { + partialEndGroup->insert(e2); + } + } + } + } +} + +std::set computeImpliedTupletMembers( + const MeasureElemRefVec& sortedElems, + const EncMeasure& encMeas, + int totalStaves, + std::set* partialEndGroup, + std::vector* nestedInfos, + std::map >* overrideRatios) +{ + std::set result; + + // Group by (staffIdx, voice): collapse same-tick notes into chord groups. + std::map, std::vector > > voiceChords; + for (const EncMeasureElem* e : sortedElems) { + EncElemType et = static_cast(e->type); + if (et != EncElemType::NOTE && et != EncElemType::REST) { + continue; + } + if (e->tick >= encMeas.durTicks) { + continue; + } + int si = static_cast(e->staffIdx); + int v = static_cast(e->voice); + if (si >= totalStaves || v >= static_cast(VOICES)) { + continue; + } + auto key = std::make_pair(si, v); + auto& chords = voiceChords[key]; + if (!chords.empty() && chords.back()[0]->tick == e->tick) { + chords.back().push_back(e); // same chord group + } else { + chords.push_back({ e }); // new chord group + } + } + + for (auto& [key, chords] : voiceChords) { + int n = static_cast(chords.size()); + + if (overrideRatios && n >= 2 && encMeas.durTicks > 0) { + processSegmentOverrides(chords, n, encMeas, overrideRatios, result); + } + + int i = 0; + while (i < n) { + // Already handled by segment-override pre-pass. + if (overrideRatios && !chords[i].empty() + && overrideRatios->count(chords[i][0])) { + ++i; + continue; + } + + // Try explicit tup byte first, then implied. + int actualN = 0, normalN = 0; + bool isExplicit = false; + getExplicit(chords[i], actualN, normalN); + if (actualN > 0) { + isExplicit = true; + } else { + getImplied(chords[i], actualN, normalN); + } + if (actualN < 2 || normalN < 1) { + ++i; + continue; + } + + if (isExplicit) { + // Explicit: accumulate faceSum; close when faceSum >= threshold. + // No-downdate rule: baseLen only shrinks when faceSum still fits the new threshold, + // allowing mixed-duration brackets like {Q,E}/3:2 or {Q,Q,8,8}/3:2. + Fraction baseLen = getFaceValue(chords[i]); + if (baseLen <= Fraction(0, 1)) { + ++i; + continue; + } + Fraction threshold = baseLen * actualN; + Fraction faceSum(0, 1); + int groupStart = i; + Fraction originalBaseLen = baseLen; // before any no-downdate + int innerGroupStartIdx = -1; // index of inner group's first chord + Fraction innerBaseLen(0, 1); + bool seenCompleteGroup = false; // at least one full group closed + while (i < n) { + int a2 = 0, n2 = 0; + getExplicit(chords[i], a2, n2); + if (a2 != actualN || n2 != normalN) { + if (isSandwichOrphan(chords, i, n, actualN, normalN, baseLen, faceSum)) { + a2 = actualN; + n2 = normalN; + } else { + break; + } + } + // No-downdate: shrink baseLen only when faceSum still fits the new (smaller) threshold. + const Fraction fv_i = getFaceValue(chords[i]); + if (fv_i > Fraction(0, 1) && fv_i < baseLen) { + const Fraction newThreshold = fv_i * actualN; + if (faceSum <= newThreshold) { + // Record where the inner group starts (= the downdating note). + innerGroupStartIdx = i; + innerBaseLen = fv_i; + baseLen = fv_i; + threshold = newThreshold; + } + } + faceSum += fv_i; + ++i; + if (faceSum < threshold) { + continue; + } + // Complete group: mark [groupStart, i-1] + for (int j = groupStart; j < i; ++j) { + for (const EncMeasureElem* e : chords[j]) { + result.insert(e); + } + } + + detectNestedTuplet(nestedInfos, innerGroupStartIdx, innerBaseLen, + originalBaseLen, actualN, normalN, i, n, chords, result); + + faceSum = Fraction(0, 1); + groupStart = i; + originalBaseLen = Fraction(0, 1); + innerGroupStartIdx = -1; + innerBaseLen = Fraction(0, 1); + seenCompleteGroup = true; + // Reset for next group. + if (i < n) { + baseLen = getFaceValue(chords[i]); + threshold = baseLen * actualN; + originalBaseLen = baseLen; + } + } + detectPartialEndGroup(groupStart, i, chords, encMeas, seenCompleteGroup, + partialEndGroup, result); + } else { + processImpliedTupletGroup(i, actualN, normalN, chords, n, result); + } + } + } + return result; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-tuplets.cpp b/src/importexport/encore/internal/importer/emitters-tuplets.cpp new file mode 100644 index 0000000000000..6b7cca93e71cd --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-tuplets.cpp @@ -0,0 +1,119 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// TupletTracker: build and close MuseScore tuplets as notes are emitted. + +#include "emitters-tuplets.h" + +#include "engraving/dom/factory.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/mscore.h" +#include "../parser/ticks.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +bool TupletTracker::groupFull() const +{ + // Close when accumulated face values reach actualN × baseLen. + // >= handles both standard groups ({8,8,8}: exact) and mixed-duration groups ({16,16,Q}: exceeds threshold). + return inTuplet() && fullFaceSum > Fraction(0, 1) && faceTicks >= fullFaceSum; +} + +// True when a Fraction fits exactly in a TDuration (power-of-two, up to 4 dots). +// Guards tuplet setTicks: beam layout calls TDuration(ticks, truncate=false) and asserts on non-fitting fractions. +bool fitsTDuration(const Fraction& f) +{ + if (f.numerator() <= 0) { + return false; + } + TDuration snap(f, true /*truncate*/); + return snap.isValid() && snap.fraction() == f; +} + +// Finish the current tuplet group and reset the per-group accumulators for the next one. +void TupletTracker::closeTuplet() +{ + if (currentTuplet && placedTicks > Fraction(0, 1)) { + const Fraction expected = TDuration(currentTuplet->baseLen()).fraction() + * currentTuplet->ratio().denominator(); + if (currentTuplet->ticks() == Fraction(0, 1)) { + // Beam layout builds TDuration(tuplet->ticks()) and asserts on a fraction that is + // not a real duration, so never store a placed length that does not fit a TDuration: + // snap a non-fitting partial length down to the nearest representable duration. + const Fraction t = fitsTDuration(placedTicks) + ? placedTicks + : TDuration(placedTicks, true /*truncate*/).fraction(); + if (t > Fraction(0, 1)) { + currentTuplet->setTicks(t); + } + } else { + const bool mixedValueOvershoot = (placedTicks > expected) + && (faceTicks > fullFaceSum); + const bool willSet = (placedTicks < expected || mixedValueOvershoot) + && fitsTDuration(placedTicks); + if (willSet) { + currentTuplet->setTicks(placedTicks); + } + } + } + currentTuplet = nullptr; + actualN = 0; + normalN = 0; + placedTicks = Fraction(0, 1); + faceTicks = Fraction(0, 1); + fullFaceSum = Fraction(0, 1); +} + +// Create a MuseScore Tuplet for an [aN:normalN_] group at tick and begin tracking it. +Tuplet* TupletTracker::startTuplet(Measure* measure, Fraction tick, + int aN, int normalN_, DurationType baseType, track_idx_t track_) +{ + closeTuplet(); + currentTuplet = Factory::createTuplet(measure); + currentTuplet->setRatio(Fraction(aN, normalN_)); + currentTuplet->setBaseLen(TDuration(baseType)); + currentTuplet->setTick(tick); + currentTuplet->setTrack(track_); + Fraction tupletDuration = TDuration(baseType).fraction() * normalN_; + if (fitsTDuration(tupletDuration)) { + currentTuplet->setTicks(tupletDuration); + } + measure->add(currentTuplet); + actualN = aN; + normalN = normalN_; + placedTicks = Fraction(0, 1); + faceTicks = Fraction(0, 1); + fullFaceSum = TDuration(baseType).fraction() * aN; + return currentTuplet; +} + +// Tick advance contributed by one tuplet member of the given base duration (scaled by the ratio). +Fraction TupletTracker::noteAdvance(DurationType baseType) const +{ + if (!inTuplet()) { + return TDuration(baseType).fraction(); + } + return TDuration(baseType).fraction() * Fraction(normalN, actualN); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/emitters-tuplets.h b/src/importexport/encore/internal/importer/emitters-tuplets.h new file mode 100644 index 0000000000000..11bc275a0714b --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-tuplets.h @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Tuplet detection and construction: the per-voice TupletTracker, nested-tuplet annotations, and +// the pass that finds implied (v0xC2) and explicit tuplet group members in a measure. + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_TUPLETS_H +#define MU_IMPORTEXPORT_ENC_IMPORT_TUPLETS_H + +#include + +#include "engraving/dom/durationtype.h" +#include "engraving/types/fraction.h" +#include "engraving/types/types.h" + +#include "../parser/elem.h" + +namespace mu::engraving { +class Measure; +class Tuplet; +} + +namespace mu::iex::enc { +// Tuplet state per staff+voice. Group closes when face-value sum reaches actualN * baseLen. +// This handles mixed-duration brackets (e.g. 3:2 triplet with 8th+8th+16th+16th). +struct TupletTracker { + mu::engraving::Tuplet* currentTuplet { nullptr }; + int actualN { 0 }; // ratio numerator (e.g. 3 for 3:2) + int normalN { 0 }; // ratio denominator (e.g. 2 for 3:2) + mu::engraving::Fraction placedTicks { 0, 1 };// cumulative cumTick advances while in this group + mu::engraving::Fraction faceTicks { 0, 1 };// cumulative FACE-VALUE sum of notes in this group + mu::engraving::Fraction fullFaceSum { 0, 1 };// target = baseLen * actualN (group closes when reached) + + bool inTuplet() const { return currentTuplet != nullptr; } + bool groupFull() const; + + void closeTuplet(); + + mu::engraving::Tuplet* startTuplet(mu::engraving::Measure* measure, mu::engraving::Fraction tick, int aN, int normalN_, + mu::engraving::DurationType baseType, mu::engraving::track_idx_t track_); + + // Duration advance per note within this tuplet group + mu::engraving::Fraction noteAdvance(mu::engraving::DurationType baseType) const; +}; + +// Nested-tuplet annotation: an inner group of actualN same-ratio notes whose combined +// face-value equals one outer-group slot. Created when a flat group closes via the +// no-downdate rule and the ending smaller-fv notes form a valid inner group. +struct NestedTupletInfo { + const EncMeasureElem* innerFirst { nullptr }; // first note of the inner group + const EncMeasureElem* innerLast { nullptr }; // last note of the inner group + int innerActualN { 0 }; + int innerNormalN { 0 }; + // The outer group spans: [outerGroupStart … innerLast, then one or more outer + // continuation notes]. outerActualN/NormalN = same as the flat group's ratio. + int outerActualN { 0 }; + int outerNormalN { 0 }; +}; + +// True when a Fraction fits exactly in a TDuration (power-of-two, up to 4 dots). +bool fitsTDuration(const mu::engraving::Fraction& f); + +// Find all elements belonging to complete tuplet groups (implied v0xC2 or explicit). Isolated notes with matching rdur are MIDI swing drift. +// partialEndGroup: if non-null, receives measure-end partial groups (rdur fills measure AND face-value would overflow without scaling). +// nestedInfos: if non-null, receives nested-tuplet annotations for detected inner groups. +// overrideRatios: if non-null, receives {actualN, normalN} overrides for notes that triggered +// uniform-fill detection (e.g. 15 equal notes → [15:8] instead of the tup-byte ratio [9:5]). +std::set computeImpliedTupletMembers( + const MeasureElemRefVec& sortedElems, const EncMeasure& encMeas, int totalStaves, + std::set* partialEndGroup = nullptr, std::vector* nestedInfos = nullptr, + std::map >* overrideRatios = nullptr); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_TUPLETS_H diff --git a/src/importexport/encore/internal/importer/emitters.cpp b/src/importexport/encore/internal/importer/emitters.cpp new file mode 100644 index 0000000000000..e0f769a3f85e5 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -0,0 +1,1086 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Emit driver: walk each measure, route every element to its (staff, voice, tick), dispatch to the +// type handler, and run the per-measure tuplet-fill, key/clef/volta and overfull passes. + +#include "ctx.h" +#include "import.h" +#include "emitters-internal.h" +#include "../parser/elem.h" +#include "../parser/readers.h" +#include "mappers.h" +#include "../parser/ticks.h" +#include "emitters-tuplets.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/box.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/clef.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/key.h" +#include "engraving/dom/keysig.h" +#include "engraving/editing/transpose.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/drumset.h" +#include "engraving/dom/note.h" +#include "engraving/dom/instrtemplate.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/slur.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/text.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/volta.h" +#include "engraving/engravingerrors.h" +#include "log.h" + +namespace mu::iex::enc { +bool MeasEmitCtx::isTieStartAt(int si, int v, int tick, int notePosition) const +{ + auto checkAt = [&](int t) { + auto range = tieStartSet.equal_range({ si, v, t }); + for (auto it = range.first; it != range.second; ++it) { + // notePosition<0 = match any; sourcePosition<0 = any note in chord + if (notePosition < 0 || it->second < 0 || it->second == static_cast(notePosition)) { + return true; + } + } + return false; + }; + for (int dt = 0; dt < CHORD_CLUSTER_THRESHOLD; ++dt) { + if (checkAt(tick - dt)) { + return true; + } + if (dt > 0 && checkAt(tick + dt)) { + return true; + } + } + return false; +} + +void MeasEmitCtx::closeTupletWithFill(BuildCtx& ctx, TupletTracker& tt, + std::pair trackKey) +{ + if (!tt.inTuplet() || tt.placedTicks <= Fraction(0, 1)) { + tt.closeTuplet(); + return; + } + const Fraction expectedTup = TDuration(tt.currentTuplet->baseLen()).fraction() + * tt.currentTuplet->ratio().denominator(); + TDuration snap(tt.placedTicks, true /*truncate*/); + const bool fitsTD = snap.isValid() && snap.fraction() == tt.placedTicks; + if (tt.placedTicks < expectedTup && !fitsTD) { + const bool countShort = (static_cast(tt.currentTuplet->elements().size()) < tt.actualN); + // Mixed-duration group: element count reaches actualN but the face-value sum is short. + const bool faceShort = (tt.fullFaceSum > Fraction(0, 1) && tt.faceTicks < tt.fullFaceSum); + if (countShort || faceShort) { + track_idx_t trk = static_cast(trackKey.first) * VOICES + + trackKey.second; + DurationType baseLen = tt.currentTuplet->baseLen().type(); + Fraction perNote = TDuration(baseLen).fraction() + * Fraction(tt.normalN, tt.actualN); + DurationType fillDurType = baseLen; + // Mixed-duration fill: derive from remaining face value rather than baseLen. + if (faceShort && !countShort) { + const Fraction remFace = tt.fullFaceSum - tt.faceTicks; + TDuration remDur(remFace, true /*truncate*/); + if (remDur.isValid() && remDur.fraction() == remFace) { + fillDurType = remDur.type(); + perNote = remFace * Fraction(tt.normalN, tt.actualN); + } + } + int safety = tt.actualN + 1; + while (tt.placedTicks < expectedTup && safety-- > 0 + && (static_cast(tt.currentTuplet->elements().size()) < tt.actualN + || (faceShort && tt.faceTicks < tt.fullFaceSum)) + && ctx.scratch.cumTick[trackKey] + perNote <= measure->ticks()) { + Fraction restTick = measure->tick() + ctx.scratch.cumTick[trackKey]; + Segment* seg = measure->getSegment(SegmentType::ChordRest, restTick); + if (!seg) { + break; + } + if (seg->element(trk)) { + break; + } + TDuration dur(fillDurType); + Rest* rest = Factory::createRest(seg, dur); + rest->setTrack(trk); + rest->setTicks(dur.fraction()); + rest->setVisible(false); + rest->setTuplet(tt.currentTuplet); + tt.currentTuplet->add(rest); + seg->add(rest); + tt.placedTicks += perNote; + ctx.scratch.cumTick[trackKey] += perNote; + } + } + } + tt.closeTuplet(); +} + +struct DeferredKeySig { + Key writtenKey; + Key concertKey; + int staffIdx { 0 }; +}; + +static bool hasPitchedNotes(const EncMeasure& m) +{ + for (const auto& elem : m.elements) { + if (static_cast(elem->type) == EncElemType::NOTE) { + return true; + } + } + return false; +} + +static bool hasMultiRest(const EncMeasure& m) +{ + if (m.elements.empty()) { + return false; + } + for (const auto& ep : m.elements) { + if (static_cast(ep->type) != EncElemType::REST) { + return false; + } + } + return static_cast(m.elements[0].get())->mrestCount > 1; +} + +static int measDisplayCount(const EncMeasure& m, const EncMeasure* prev) +{ + if (m.elements.empty()) { + return 1; + } + for (const auto& ep : m.elements) { + if (static_cast(ep->type) != EncElemType::REST) { + return 1; + } + } + const int cnt = static_cast(static_cast(m.elements[0].get())->mrestCount); + if (cnt <= 1) { + return 1; + } + if (prev && hasMultiRest(*prev)) { + return 1; + } + return cnt; +} + +static void sortMeasureElements(const EncMeasure& encMeas, MeasureElemRefVec& sortedElems) +{ + sortedElems.reserve(encMeas.elements.size()); + for (const auto& elem : encMeas.elements) { + sortedElems.push_back(elem.get()); + } + std::stable_sort(sortedElems.begin(), sortedElems.end(), + [](const EncMeasureElem* a, const EncMeasureElem* b) { + if (a->tick != b->tick) { + return a->tick < b->tick; + } + bool aIsNote = (static_cast(a->type) == EncElemType::NOTE + || static_cast(a->type) == EncElemType::REST); + bool bIsNote = (static_cast(b->type) == EncElemType::NOTE + || static_cast(b->type) == EncElemType::REST); + if (aIsNote != bIsNote) { + return !aIsNote; // non-notes before notes + } + if (!aIsNote) { + return false; // both non-notes: preserve stable order + } + // Among notes at same tick: tuplet notes before non-tuplet notes + bool aTuplet = a->tupletByte() != 0; + bool bTuplet = b->tupletByte() != 0; + if (aTuplet != bTuplet) { + return aTuplet; // tuplet note first + } + // Shortest faceValue first: chord root drives cumTick by minimum step. + const quint8 aFv = fvLow(a->faceValueByte()); + const quint8 bFv = fvLow(b->faceValueByte()); + if (aFv != bFv) { + return aFv > bFv; // higher number = shorter duration = comes first + } + return false; // stable for equal keys + }); +} + +static void collectTieStartPositions(const MeasureElemRefVec& sortedElems, + const std::array& lineSlotByRawByte, + int totalStaves, MeasEmitCtx& mc) +{ + for (const EncMeasureElem* e : sortedElems) { + if (static_cast(e->type) == EncElemType::TIE) { + const EncTie* et = static_cast(e); + if (et->isTieStart) { + int si = static_cast(e->staffIdx); + int v = static_cast(e->voice); + const quint8 tiRawByte = e->rawStaffByte(); + const int tiOrigSW = static_cast(e->staffWithin); + bool tiResolved = false; + if (lineSlotByRawByte[tiRawByte] >= 0) { + si = lineSlotByRawByte[tiRawByte]; + tiResolved = true; + if (tiOrigSW > 0 && v < static_cast(VOICES)) { + const int vBase = tiOrigSW * (static_cast(VOICES) / 2); + if (v >= vBase) { + v -= vBase; + } + } + } + if (v >= static_cast(VOICES)) { + v = 0; + } else if (!tiResolved && tiOrigSW > 0) { + const int sw = tiOrigSW; + const int vBase = sw * (static_cast(VOICES) / 2); + if (v >= vBase && si + sw < totalStaves) { + si += sw; + v -= vBase; + } + } + mc.tieStartSet.insert({ { si, v, (int)e->tick }, et->sourcePosition }); + } + } + } +} + +static void scanMeasureMetadata(const MeasureElemRefVec& sortedElems, MeasEmitCtx& mc) +{ + for (const EncMeasureElem* em : sortedElems) { + const EncElemType et2 = static_cast(em->type); + if (et2 == EncElemType::NOTE) { + mc.noteTicks.insert(static_cast(em->tick)); + mc.noteStaffVoiceTicks.insert({ static_cast(em->staffIdx), + static_cast(em->voice), static_cast(em->tick) }); + if (em->voice >= static_cast(VOICES)) { + mc.voice4NoteTicks.insert(static_cast(em->tick)); + } else { + mc.v0NoteCountAtTick[static_cast(em->tick)]++; + mc.maxVoice0Tick = std::max(mc.maxVoice0Tick, static_cast(em->tick)); + mc.stavesWithRealNote.insert(static_cast(em->staffIdx)); + } + // Detect scale string number anchors (au in 0x39..0x40) + const EncNote* enPre = static_cast(em); + if (encArticByteToScaleStringNumber(enPre->articulationUp) > 0 + || encArticByteToScaleStringNumber(enPre->articulationDown) > 0) { + mc.hasScaleStringAnchors = true; + } + } else if (et2 == EncElemType::ORNAMENT) { + const EncOrnament* eo2 = static_cast(em); + const EncOrnamentType ot2 = eo2->ornType(); + if (ot2 >= EncOrnamentType::FINGER_1 && ot2 <= EncOrnamentType::FINGER_5) { + mc.ornFingCountAtTick[static_cast(em->tick)]++; + } + } + } +} + +// Returns false if the element should be skipped entirely (tick out of range). +static bool shouldIncludeElement(const EncMeasureElem* e, const EncMeasure& encMeas) +{ + const EncElemType et = static_cast(e->type); + // Notes/rests at or beyond measure end are dropped; ORNs at durTicks are end-of-measure markers + // (e.g. hairpin on barline) and must not be dropped. + if ((et == EncElemType::NOTE || et == EncElemType::REST) + && e->tick >= encMeas.durTicks) { + return false; + } + if (et == EncElemType::ORNAMENT && e->tick > encMeas.durTicks) { + // Past-durTicks dynamics/STAFFTEXT: let through and clamp to the last segment. + const EncOrnament* eoFilt = static_cast(e); + const EncOrnamentType ot = eoFilt->ornType(); + const bool isDyn = (ot >= EncOrnamentType::DYN_PPP + && ot <= EncOrnamentType::DYN_FP) + || ot == EncOrnamentType::DYN_FZ + || ot == EncOrnamentType::DYN_SF; + const bool isText = (ot == EncOrnamentType::STAFFTEXT); + if (!isDyn && !isText) { + return false; + } + } + return true; +} + +// Returns false if the element should be skipped (staffIdx out of range or voice invalid). +// On success, fills staffIdx, voice, msVoice, track, trackKey, encVoiceKey. +std::optional routeElementStaffVoice( + const EncMeasureElem* e, + bool isNoteOrRest, + const std::array& lineSlotByRawByte, + const MeasEmitCtx& mc, + const BuildCtx& ctx) +{ + const EncRoot& enc = ctx.enc; + const int nLineStaves = mc.nLineStaves; + const std::vector& lineStaffInstrIdx = *mc.lineStaffInstrIdx; + const std::vector& lineStaffWithin = *mc.lineStaffWithin; + + int staffIdx = static_cast(e->staffIdx); + int voice = static_cast(e->voice); + + // Translate rawStaff byte (staffWithin<<6)|instrIdx to LINE slot; apply case-B voice remap when origStaffWithin > 0. + const quint8 rawNoteStaff = e->rawStaffByte(); + const int origStaffWithin = static_cast(e->staffWithin); + bool rawByteResolved = false; + if (lineSlotByRawByte[rawNoteStaff] >= 0) { + staffIdx = lineSlotByRawByte[rawNoteStaff]; + rawByteResolved = true; + if (isNoteOrRest && origStaffWithin > 0 && voice < static_cast(VOICES)) { + const int vBase = origStaffWithin * (static_cast(VOICES) / 2); + if (voice >= vBase) { + voice -= vBase; + } + } + } + + if (staffIdx >= ctx.totalStaves) { + // The element references a staff the score does not have. This is almost always orphan + // data from a staff deleted in Encore (its index is not reused, and Encore does not show + // it). Count it; emitMeasures reports the total once instead of one line per element. + ++ctx.scratch.droppedByMissingStaff[staffIdx]; + return std::nullopt; + } + // Multi-staff routing: + // (A) voice == VOICES (the canonical staff-2 / silent-voice marker): route to staffIdx+1, voice=0. + // (A') voice > VOICES (5..7): a genuine extra voice on the SAME staff, mapped into 0..3. + // (B) staffWithin > 0: route to staffIdx+sw, remap voice down by sw*(VOICES/2). + if (voice == static_cast(VOICES)) { + bool routedToSecondStaff = false; + bool singleStaffInstr = false; + if (staffIdx < nLineStaves) { + const int instrIdx = lineStaffInstrIdx[staffIdx]; + if (instrIdx >= 0 && instrIdx < static_cast(enc.instruments.size())) { + singleStaffInstr = (enc.instruments[instrIdx].nstaves <= 1); + if (enc.instruments[instrIdx].nstaves > 1 + && lineStaffWithin[staffIdx] + 1 < enc.instruments[instrIdx].nstaves + && staffIdx + 1 < ctx.totalStaves) { + staffIdx += 1; + routedToSecondStaff = true; + } + } + } + // Grand-staff: voice 4 is the second staff's silent voice -> voice 0 there. On a single-staff + // instrument whose voice 0 already has a line, voice 4 is a genuine second melodic voice -> + // MuseScore voice 1 (kept separate; mergeNonOverlappingVoices may fold it back). If voice 0 + // is empty (an SATB line stored only as voice 4), keep it on voice 0. + const bool hasOwnVoice0 = mc.stavesWithRealNote.count(static_cast(e->staffIdx)) > 0; + voice = (singleStaffInstr && !routedToSecondStaff && hasOwnVoice0) ? 1 : 0; + } else if (voice > static_cast(VOICES)) { + // Voices 5..7 are extra voices on this SAME staff, not a staff-2 marker: collapse to voice 0 + // without changing the staff. + voice = 0; + } else if (!rawByteResolved && origStaffWithin > 0) { + // Standalone ORNs always have voice=0 and route by staffWithin alone. + const int sw = origStaffWithin; + const int vBase = sw * (static_cast(VOICES) / 2); + const bool voiceInRange = !isNoteOrRest || voice >= vBase; + if (voiceInRange && staffIdx < nLineStaves) { + const int instrIdx = lineStaffInstrIdx[staffIdx]; + if (instrIdx >= 0 + && instrIdx < static_cast(enc.instruments.size()) + && enc.instruments[instrIdx].nstaves > sw + && staffIdx + sw < ctx.totalStaves) { + staffIdx += sw; + if (isNoteOrRest) { + voice -= vBase; + } + } + } + } + + const int msVoice = voice; + if (msVoice >= static_cast(VOICES)) { + ++ctx.scratch.droppedByBadVoice; + return std::nullopt; // voice out of range + } + RoutedTrack r; + r.staffIdx = staffIdx; + r.voice = voice; + r.msVoice = msVoice; + r.track = static_cast(staffIdx * VOICES + msVoice); + r.trackKey = std::make_pair(staffIdx, msVoice); + r.encVoiceKey = std::make_pair(staffIdx, voice); + return r; +} + +// Returns the MuseScore tick where this element should be placed. +// Has side effects on ctx: updates prevMidiTick, lastChordPos, prevRestTick, noteXoffByMeasStaff. +static Fraction computeElementTick( + const EncMeasureElem* e, + bool isNoteOrRest, + bool isChordExt, + int voice, + int staffIdx, + std::pair trackKey, + const Measure* measure, + Fraction measTick, + BuildCtx& ctx, + const MeasEmitCtx& mc) +{ + constexpr int CHORD_MIDI_THRESHOLD = 2 * CHORD_CLUSTER_THRESHOLD; // = 8 + const EncElemType et = static_cast(e->type); + if (isChordExt) { + return ctx.scratch.lastChordPos.count(trackKey) ? ctx.scratch.lastChordPos.at(trackKey) : measTick; + } + + // Gap-snap: when the binary tick is on the face grid and gap > CHORD_MIDI_THRESHOLD, advance + // cumTick to reveal implicit rests; sub-grid live-recorded ticks never trigger it, preserving + // multi-stream placement. See ENCORE_IMPORTER.md §Implicit-silence gap snap. + if (isNoteOrRest) { + quint8 elemFv = 0; + if (et == EncElemType::NOTE) { + elemFv = static_cast(e)->faceValue; + } else if (et == EncElemType::REST) { + elemFv = static_cast(e)->faceValue; + } + const int faceTicks = faceValue2ticks(elemFv); + const bool onFaceGrid = faceTicks > 0 + && ((int)e->tick % faceTicks) == 0; + // Suppress gap-snap: (a) grace pending (v0xA6 ticks on face grid but no real time), + // (b) inside active tuplet (apparent gap is tuplet-internal timing artifact), + // (c) gap equals stolen grace ticks (grace-displaced note must not fire spurious rest). + const bool gracePending = !ctx.scratch.pendingGraces[trackKey].empty(); + const bool inActiveTuplet = ctx.scratch.tuplets.count(trackKey) + && ctx.scratch.tuplets.at(trackKey).inTuplet(); + const int stolenTicks = ctx.scratch.graceStolenTicks.count(trackKey) + ? ctx.scratch.graceStolenTicks.at(trackKey) : 0; + if (onFaceGrid && !gracePending && !inActiveTuplet) { + // Use kEncWholeTicks: the beatTicks*timeSigDen formula breaks for + // non-standard beatTicks (e.g. 2/2 with beatTicks=240 gives 480). + const Fraction encTickFrac((int)e->tick, kEncWholeTicks); + if (encTickFrac > ctx.scratch.cumTick[trackKey]) { + const Fraction gap = encTickFrac - ctx.scratch.cumTick[trackKey]; + const int gapEncTicks + = (gap.numerator() * kEncWholeTicks) + / std::max(1, gap.denominator()); + const bool gapIsGraceArtifact + = (stolenTicks > 0 && gapEncTicks <= stolenTicks); + if (gapEncTicks > CHORD_MIDI_THRESHOLD && !gapIsGraceArtifact + && encTickFrac < measure->ticks()) { + ctx.scratch.cumTick[trackKey] = encTickFrac; + } + } + } + } + + const Fraction elemTick = measTick + ctx.scratch.cumTick[trackKey]; + if (isNoteOrRest) { + ctx.scratch.lastChordPos[trackKey] = elemTick; + } + // Rests don't set prevMidiTick: a note after a rest is a fresh cluster. + if (et == EncElemType::NOTE) { + ctx.scratch.prevMidiTick[trackKey] = e->tick; + ctx.scratch.prevEncVoice[trackKey] = voice; + ctx.scratch.prevXoffset[trackKey] = static_cast(e->xoffset); + // Record note xoffset for bowing-mark cluster resolution. + const auto* en = static_cast(e); + auto& vec = ctx.noteXoffByMeasStaff[{ mc.measIdx, staffIdx }]; + const int encTick = static_cast(e->tick); + const int xoff = static_cast(en->xoffset); + bool already = false; + for (const auto& p : vec) { + if (p.first == encTick) { + already = true; + break; + } + } + if (!already) { + vec.push_back({ encTick, xoff }); + } + } else if (et == EncElemType::REST) { + ctx.scratch.prevRestTick[trackKey] = static_cast(e->tick); + } + return elemTick; +} + +static void initLineStaffMappings( + const EncRoot& enc, + int nLineStaves, + std::vector& lineStaffInstrIdx, + std::vector& lineStaffWithin, + std::array& lineSlotByRawByte) +{ + lineStaffInstrIdx.assign(nLineStaves, -1); + lineStaffWithin.assign(nLineStaves, 0); + for (int s = 0; s < nLineStaves; ++s) { + lineStaffInstrIdx[s] = static_cast(enc.lines[0].staffData[s].instrumentIndex()); + lineStaffWithin[s] = static_cast(enc.lines[0].staffData[s].staffIndex()); + } + + buildLineSlotByRawByte(enc, lineSlotByRawByte); +} + +static void fillExpandedMrestMeasure(Measure* vm, int totalStaves) +{ + const Fraction vmTick = vm->tick(); + const Fraction vmLen = vm->ticks(); + for (int si = 0; si < totalStaves; ++si) { + const track_idx_t tr = static_cast(si) * VOICES; + Segment* seg = vm->getSegment(SegmentType::ChordRest, vmTick); + Rest* r = Factory::createRest(seg, TDuration(DurationType::V_MEASURE)); + r->setTicks(vmLen); + r->setTrack(tr); + seg->add(r); + } +} + +static void coalesceVolta(BuildCtx& ctx, Measure* measure, + const EncMeasure& encMeas, Fraction measTick) +{ + // A volta is closed (down-turning end hook) only when its last measure has a repeat-end + // barline; a terminal ending has none and must be drawn open. See ENCORE_IMPORTER.md + // §Volta coalescing and numbered text. + const bool voltaClosed = (encMeas.endBarline() == EncBarlineType::REPEATEND); + if (encMeas.repeatAlternative != 0) { + if (ctx.activeVolta && ctx.activeVoltaBits == encMeas.repeatAlternative) { + ctx.activeVolta->setTick2(measTick + measure->ticks()); + // The volta now ends at this later measure; its hook follows that measure's barline. + ctx.activeVolta->setVoltaType(voltaClosed ? Volta::Type::CLOSED : Volta::Type::OPEN); + } else { + // Accumulate the bits of the bracket being closed so the next bracket can filter out + // already-labelled endings (e.g. after "1.-3.", raw bits {2,4} show only "4."). + if (ctx.activeVolta) { + ctx.usedVoltaBits |= ctx.activeVoltaBits; + } + // Visible endings = new bits not already covered by earlier brackets. + const quint8 rawBits = encMeas.repeatAlternative; + const quint8 newBits = rawBits & ~ctx.usedVoltaBits; + const quint8 displayBits = (newBits != 0) ? newBits : rawBits; + + std::vector endings; + for (int b = 0; b < 8; ++b) { + if (displayBits & (1 << b)) { + endings.push_back(b + 1); + } + } + Volta* volta = Factory::createVolta(ctx.score->dummy()); + volta->setVoltaType(voltaClosed ? Volta::Type::CLOSED : Volta::Type::OPEN); + volta->setTrack(0); + volta->setTrack2(0); + volta->setTick(measTick); + volta->setTick2(measTick + measure->ticks()); + volta->setEndings(endings); + // setText required: setEndings alone leaves the bracket blank. + String voltaText; + for (int number : endings) { + if (!voltaText.empty()) { + voltaText += u", "; + } + voltaText += String::number(number); + } + voltaText += u"."; + volta->setText(voltaText); + ctx.score->addElement(volta); + ctx.activeVolta = volta; + ctx.activeVoltaBits = rawBits; + } + } else { + ctx.activeVolta = nullptr; + ctx.activeVoltaBits = 0; + ctx.usedVoltaBits = 0; // Reset for next repeat block + } +} + +static void resetPerMeasureState(BuildCtx& ctx) +{ + for (auto& [key, tt] : ctx.scratch.tuplets) { + if (tt.inTuplet()) { + tt.closeTuplet(); + } + } + ctx.scratch.tuplets.clear(); + for (auto& [key, tt] : ctx.scratch.innerTuplets) { + if (tt.inTuplet()) { + tt.closeTuplet(); + } + } + ctx.scratch.innerTuplets.clear(); + ctx.scratch.cumTick.clear(); + ctx.scratch.prevMidiTick.clear(); + ctx.scratch.prevEncVoice.clear(); + ctx.scratch.lastChordPos.clear(); + ctx.scratch.prevRestTick.clear(); + ctx.scratch.graceStolenTicks.clear(); + ctx.scratch.lastGraceChord.clear(); + ctx.scratch.lastGraceTick.clear(); + + // pendingGraces are deliberately NOT cleared: end-of-measure graces ornament the first note of + // the next measure, so they carry across the barline. Any left over become cue notes via + // handleDanglingGraces() at end of score. +} + +static void buildNestedTupletMaps(MeasEmitCtx& mc, + const MeasureElemRefVec& sortedElems) +{ + mc.nestedByInnerFirst.clear(); + mc.nestedByInnerLast.clear(); + mc.innerGroupMembers.clear(); + for (const NestedTupletInfo& ni : mc.nestedInfos) { + if (ni.innerFirst) { + mc.nestedByInnerFirst[ni.innerFirst] = ∋ + } + if (ni.innerLast) { + mc.nestedByInnerLast[ni.innerLast] = ∋ + } + // Collect all sorted elements between innerFirst and innerLast (inclusive). + if (ni.innerFirst && ni.innerLast) { + bool inInner = false; + for (const EncMeasureElem* em2 : sortedElems) { + if (em2 == ni.innerFirst) { + inInner = true; + } + if (inInner && em2->staffIdx == ni.innerFirst->staffIdx) { + mc.innerGroupMembers.insert(em2); + } + if (em2 == ni.innerLast) { + break; + } + } + } + } +} + +static void handleClefChange(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncMeasureElem* e) +{ + const EncClefChange* ecc = static_cast(e); + if (ec.staffIdx < 0 || ec.staffIdx >= ctx.totalStaves) { + return; + } + const ClefType ct = encClef2MuseScore(ecc->clefType); + const track_idx_t track = static_cast(ec.staffIdx) * VOICES; + + // A clef change applies before the note it physically precedes, not at its own stored tick, so + // anchor it to the first note/rest on the same staff after this clef element. A trailing clef + // (no following note/rest) is cautionary and belongs on the next measure's downbeat. + const EncMeasureElem* nextCr = nullptr; + if (mc.encMeas) { + bool seenSelf = false; + for (const auto& up : mc.encMeas->elements) { + const EncMeasureElem* el = up.get(); + if (el == e) { + seenSelf = true; + continue; + } + if (!seenSelf) { + continue; + } + const EncElemType t = static_cast(el->type); + if ((t == EncElemType::NOTE || t == EncElemType::REST) + && el->staffIdx == e->staffIdx && el->staffWithin == e->staffWithin) { + nextCr = el; + break; + } + } + } + + Measure* target = mc.measure; + Fraction segTick; + if (nextCr) { + segTick = mc.measure->tick() + Fraction(static_cast(nextCr->tick), kEncWholeTicks); + } else if (Measure* next = mc.measure->nextMeasure()) { + target = next; + segTick = next->tick(); + } else { + segTick = mc.measure->tick() + mc.measure->ticks(); + } + + Segment* seg = target->getSegment(SegmentType::Clef, segTick); + Clef* clef = Factory::createClef(seg); + clef->setTrack(track); + clef->setClefType(ct); + seg->add(clef); +} + +// Set the staff key and add a KeySig segment element. Shared by the immediate key change +// and the deferred flush (a key change in a rest-only measure is held until the next measure +// with notes, so it does not break MMRest condensation). +static void placeKeySig(MasterScore* score, Measure* measure, Fraction tick, + int staffIdx, track_idx_t track, Key concertKey, Key writtenKey) +{ + Staff* staff = score->staff(staffIdx); + if (!staff) { + return; + } + KeySigEvent ke; + ke.setConcertKey(concertKey); + ke.setKey(writtenKey); + staff->setKey(tick, ke); + Segment* seg = measure->getSegment(SegmentType::KeySig, tick); + KeySig* ks = Factory::createKeySig(seg); + ks->setTrack(track); + ks->setKey(concertKey, writtenKey); + seg->add(ks); +} + +static void handleKeyChange(BuildCtx& ctx, const MeasEmitCtx& mc, + const NoteElemCtx& ec, const EncMeasureElem* e, + std::vector& pendingKeySigs) +{ + MasterScore* score = ctx.score; + const EncKeyChange* ekc = static_cast(e); + Staff* staff = score->staff(ec.staffIdx); + if (!staff) { + return; + } + Key writtenKey = Key(encKeyToFifths(ekc->tipo)); + Interval v = Interval(ctx.staffPitchOffset[ec.staffIdx]); + Key concertKey = v.isZero() ? writtenKey : Transpose::transposeKey(writtenKey, v); + + if (!hasPitchedNotes(*mc.encMeas)) { + // Placing a KeySig in a rest-only measure breaks MuseScore's MMRest + // condensation. Defer to the next measure that contains notes. + pendingKeySigs.push_back({ writtenKey, concertKey, ec.staffIdx }); + return; + } + + placeKeySig(score, mc.measure, ec.elemTick, ec.staffIdx, ec.track, concertKey, writtenKey); +} + +// Runs at the end of each MEAS block after all elements are placed. +// Closes open tuplets, attaches lyrics, adjusts pickup, fills gaps, validates, +// and advances the measure-skip counter for multi-measure rest expansion. +static void finalizeMeasureAfterNoteLoop(BuildCtx& ctx, MeasEmitCtx& mc, + Measure* measure, const EncMeasure& encMeas, + const Fraction& measTick, int measIdx, + int& measSkip, size_t& msIdxCounter, + const EncRoot& enc) +{ + for (auto& [key, tt] : ctx.scratch.tuplets) { + mc.closeTupletWithFill(ctx, tt, key); + } + attachPendingLyrics(ctx, mc); + adjustPickupMeasure(ctx, measure, measIdx); + fillTrailingGaps(ctx, measure, measTick); + for (int si = 0; si < ctx.totalStaves; ++si) { + measure->checkMeasure(static_cast(si)); + } + correctMeasureLength(ctx, measure); + fitOverfullMeasure(ctx, measure); + const EncMeasure* prevMeas = (measIdx > 0) ? &enc.measures[measIdx - 1] : nullptr; + measSkip = measDisplayCount(encMeas, prevMeas) - 1; + ++msIdxCounter; +} + +// Flush key changes that were deferred from rest-only measures onto the first later measure +// that has notes (placing a KeySig in an MMRest-eligible measure breaks condensation). +static void flushPendingKeySigs(MasterScore* score, Measure* measure, Fraction measTick, + const EncMeasure& encMeas, + std::vector& pendingKeySigs) +{ + if (pendingKeySigs.empty() || !hasPitchedNotes(encMeas)) { + return; + } + for (const DeferredKeySig& dks : pendingKeySigs) { + placeKeySig(score, measure, measTick, dks.staffIdx, + dks.staffIdx * VOICES, dks.concertKey, dks.writtenKey); + } + pendingKeySigs.clear(); +} + +// Sort the measure's elements and compute the per-measure tuplet/tie/metadata state the +// element emit loop reads from mc. +static void prepareMeasureContext(BuildCtx& ctx, MeasEmitCtx& mc, const EncMeasure& encMeas, + MeasureElemRefVec& sortedElems) +{ + // Sort: tick asc, ORNs before notes, tuplet notes before non-tuplet (ensures tup note sets duration at shared tick). + sortMeasureElements(encMeas, sortedElems); + // Collect TIE-START positions using routed (staffIdx, voice) so bit6-encoded second-staff notes resolve correctly. + collectTieStartPositions(sortedElems, *mc.lineSlotByRawByte, ctx.totalStaves, mc); + mc.overrideGroupRatios.clear(); + mc.validTupletGroupMember + = computeImpliedTupletMembers(sortedElems, encMeas, ctx.totalStaves, + &mc.partialEndGroup, &mc.nestedInfos, + &mc.overrideGroupRatios); + buildNestedTupletMaps(mc, sortedElems); + scanMeasureMetadata(sortedElems, mc); +} + +// Route one measure element to its (staff, voice, tick) and dispatch it to the type handler. +static void emitMeasureElement(BuildCtx& ctx, MeasEmitCtx& mc, const EncMeasureElem* e, + std::vector& pendingKeySigs) +{ + Measure* measure = mc.measure; + const Fraction measTick = mc.measTick; + const EncMeasure& encMeas = *mc.encMeas; + const std::array& lineSlotByRawByte = *mc.lineSlotByRawByte; + + const EncElemType et = static_cast(e->type); + const bool isNoteOrRest = (et == EncElemType::NOTE || et == EncElemType::REST); + + // Let notes/rests past durTicks through for every overfill strategy so an + // overshooting tuplet's later members still arrive; non-tuplet overflow is + // re-dropped below (the "voice full" guard), and the post-pass resolves the + // rest. (Only note/rest are let through; other element gating is unchanged.) + if (!shouldIncludeElement(e, encMeas) && !isNoteOrRest) { + return; + } + + std::optional routed = routeElementStaffVoice(e, isNoteOrRest, lineSlotByRawByte, mc, ctx); + if (!routed) { + return; + } + const int staffIdx = routed->staffIdx; + const int voice = routed->voice; + const int msVoice = routed->msVoice; + const track_idx_t track = routed->track; + const std::pair trackKey = routed->trackKey; + const std::pair encVoiceKey = routed->encVoiceKey; + + // Encore's "voice 4" is a silent-voice placeholder that routing folds into voice 0. A voice-4 + // rest on a staff that already carries a real note is redundant: merged into voice 0 it collides + // with the notes and pushes content past the barline. Drop it here. + if (et == EncElemType::REST && e->voice >= static_cast(VOICES) + && mc.stavesWithRealNote.count(staffIdx)) { + return; + } + + // Near-simultaneous notes (< CHORD_MIDI_THRESHOLD) extend the chord; same Encore voice required. + constexpr int CHORD_MIDI_THRESHOLD = 2 * CHORD_CLUSTER_THRESHOLD; // = 8 + // Two notes close in time but in different notated columns (xoffset) are sequential events, not + // one chord: a chord's members share a column (a few pixels of notehead offset at most), so only + // a gap >= COLUMN_SEPARATION_MIN marks a genuine column change. Only for formats that store the + // column (see EncFormatReader::clustersChordsByXoffset). + constexpr int COLUMN_SEPARATION_MIN = 8; + const bool columnAware = ctx.enc.fmt && ctx.enc.fmt->clustersChordsByXoffset(); + const bool differentColumn = columnAware && e->xoffset != 0 + && ctx.scratch.prevXoffset.count(trackKey) + && ctx.scratch.prevXoffset.at(trackKey) != 0 + && std::abs(ctx.scratch.prevXoffset.at(trackKey) - static_cast(e->xoffset)) + >= COLUMN_SEPARATION_MIN; + bool isChordExt = isNoteOrRest && !differentColumn + && ctx.scratch.prevMidiTick.count(trackKey) + && ctx.scratch.prevEncVoice.count(trackKey) + && ctx.scratch.prevEncVoice.at(trackKey) == voice + && (int)e->tick - (int)ctx.scratch.prevMidiTick.at(trackKey) >= 0 + && (int)e->tick - (int)ctx.scratch.prevMidiTick.at(trackKey) + < CHORD_MIDI_THRESHOLD; + // REST-REST dedup: two Encore voices routing to the same MuseScore voice at the same tick; the + // second REST would double-advance cumTick. + if (!isChordExt && et == EncElemType::REST + && ctx.scratch.prevRestTick.count(trackKey) + && ctx.scratch.prevRestTick.at(trackKey) == static_cast(e->tick)) { + return; + } + // A REST before its voice's already-filled position is redundant (a chord or earlier rest covers + // that beat); placing it would push later content forward and inflate the bar. Drop it. + if (!isChordExt && et == EncElemType::REST + && ctx.scratch.cumTick.count(trackKey) + && Fraction(static_cast(e->tick), kEncWholeTicks) < ctx.scratch.cumTick.at(trackKey)) { + return; + } + // Same when the coincident rest is serialized before its voice's note (cumTick not yet advanced): + // a plain REST sharing (staff, voice, tick) with a NOTE is a redundant placeholder; drop it so + // the note keeps the beat. Tuplet rests are exempt (a same-tick tuplet rest + note are sequential). + if (et == EncElemType::REST && e->tupletByte() == 0 + && mc.noteStaffVoiceTicks.count({ static_cast(e->staffIdx), + static_cast(e->voice), static_cast(e->tick) })) { + return; + } + + // Drop overflow notes when the voice is full (MIDI artifacts must not spill to the next voice), + // but only under Truncate: the other strategies keep them for the post-pass to resolve. An open + // tuplet is never dropped here so the whole tuplet lands intact. See ENCORE_IMPORTER.md + // §Overfull measures. + const bool inOpenTuplet = ctx.scratch.tuplets.count(trackKey) && ctx.scratch.tuplets.at(trackKey).inTuplet(); + if (isNoteOrRest && !isChordExt && ctx.scratch.cumTick[trackKey] >= measure->ticks() + && ctx.opts.overfillMeasureStrategy == OverfillStrategy::Truncate + && !inOpenTuplet) { + return; + } + + const int savedPrevMidiTick = ctx.scratch.prevMidiTick.count(trackKey) + ? ctx.scratch.prevMidiTick.at(trackKey) : -1; + const bool hadLastChordPos = ctx.scratch.lastChordPos.count(trackKey); + const Fraction savedLastChordPos = hadLastChordPos + ? ctx.scratch.lastChordPos.at(trackKey) : Fraction(-1, 1); + + const Fraction elemTick = computeElementTick(e, isNoteOrRest, isChordExt, voice, + staffIdx, trackKey, measure, measTick, + ctx, mc); + + NoteElemCtx ec; + ec.e = e; + ec.et = et; + ec.staffIdx = staffIdx; + ec.voice = voice; + ec.msVoice = msVoice; + ec.track = track; + ec.trackKey = trackKey; + ec.encVoiceKey = encVoiceKey; + ec.isChordExt = isChordExt; + ec.isNoteOrRest = isNoteOrRest; + ec.elemTick = elemTick; + ec.savedPrevMidiTick = savedPrevMidiTick; + ec.hadLastChordPos = hadLastChordPos; + ec.savedLastChordPos = savedLastChordPos; + + switch (et) { + case EncElemType::NOTE: handleNote(ctx, mc, ec); + break; + case EncElemType::REST: handleRest(ctx, mc, ec); + break; + case EncElemType::CHORD: handleChordSym(ctx, mc, ec); + break; + case EncElemType::LYRIC: enqueueLyric(ctx, static_cast(e), track); + break; + case EncElemType::ORNAMENT: handleOrnament(ctx, mc, ec); + break; + case EncElemType::KEYCHANGE: handleKeyChange(ctx, mc, ec, e, pendingKeySigs); + break; + case EncElemType::CLEF: handleClefChange(ctx, mc, ec, e); + break; + default: break; + } +} + +// Walk every parsed measure and emit its elements. The resolver post-pass (spanners, etc.) runs +// afterwards from buildScore. +void emitMeasures(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + + const int nLineStaves = (!enc.lines.empty()) + ? static_cast(enc.lines[0].staffData.size()) : 0; + std::vector lineStaffInstrIdx; + std::vector lineStaffWithin; + std::array lineSlotByRawByte; + initLineStaffMappings(enc, nLineStaves, lineStaffInstrIdx, lineStaffWithin, lineSlotByRawByte); + + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (mb->isMeasure()) { + ctx.measuresByIdx.push_back(toMeasure(mb)); + } + } + + // Slurs resolved after the pass: .enc has no SLURSTOP; end anchored at last ChordRest in the alMezuro target measure. + + std::vector pendingKeySigs; + + // measSkip tracks the expansion of multi-measure rests (1 enc MEAS to N MuseScore measures). + int measSkip = 0; + size_t msIdxCounter = 0; + + int measIdx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + + if (measSkip > 0) { + --measSkip; + ++msIdxCounter; + fillExpandedMrestMeasure(toMeasure(mb), ctx.totalStaves); + continue; + } + + if (measIdx >= static_cast(enc.measures.size())) { + break; + } + Measure* measure = toMeasure(mb); + const EncMeasure& encMeas = enc.measures[measIdx]; + const Fraction measTick = measure->tick(); + + MeasEmitCtx mc; + mc.measure = measure; + mc.encMeas = &encMeas; + mc.measTick = measTick; + mc.measIdx = measIdx; + mc.nLineStaves = nLineStaves; + mc.lineStaffInstrIdx = &lineStaffInstrIdx; + mc.lineStaffWithin = &lineStaffWithin; + mc.lineSlotByRawByte = &lineSlotByRawByte; + + resetPerMeasureState(ctx); + + EncRepeatType rt = encMeas.repeatMark(); + if (rt != EncRepeatType::NONE) { + addRepeatMark(score, measure, rt); + } + + // Consecutive measures with equal repeatAlternative bitmask coalesce into one Volta. + coalesceVolta(ctx, measure, encMeas, measTick); + + flushPendingKeySigs(score, measure, measTick, encMeas, pendingKeySigs); + + MeasureElemRefVec sortedElems; + prepareMeasureContext(ctx, mc, encMeas, sortedElems); + + for (const EncMeasureElem* e : sortedElems) { + emitMeasureElement(ctx, mc, e, pendingKeySigs); + } + + finalizeMeasureAfterNoteLoop(ctx, mc, measure, encMeas, measTick, measIdx, + measSkip, msIdxCounter, enc); + ++measIdx; + } + + applyMeasureBpmMarks(ctx); + handleDanglingGraces(ctx); + + // One-line summary of elements that could not be placed (they reference a staff/voice the + // score does not have), instead of a debug line per dropped element. + if (!ctx.scratch.droppedByMissingStaff.empty() || ctx.scratch.droppedByBadVoice > 0) { + int total = ctx.scratch.droppedByBadVoice; + std::string byStaff; + for (const auto& [staffIdx, count] : ctx.scratch.droppedByMissingStaff) { + total += count; + if (!byStaff.empty()) { + byStaff += ", "; + } + byStaff += "staff " + std::to_string(staffIdx) + ": " + std::to_string(count); + } + LOGD() << "Encore import: dropped " << total << " element(s) the score has no place for" + << " (" << ctx.totalStaves << " staves built" + << (byStaff.empty() ? std::string() : "; missing-staff refs: " + byStaff) + << (ctx.scratch.droppedByBadVoice ? "; out-of-range voice: " + + std::to_string(ctx.scratch.droppedByBadVoice) : std::string()) + << "). These usually come from staves deleted in Encore (not shown there)."; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/import-options.h b/src/importexport/encore/internal/importer/import-options.h new file mode 100644 index 0000000000000..0c1101960583c --- /dev/null +++ b/src/importexport/encore/internal/importer/import-options.h @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// User-facing import options: the under/overfill and instrument-search strategy enums and the +// EncImportOptions struct whose in-code defaults are the test fallback (GUI defaults differ). + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_OPTIONS_H +#define MU_IMPORTEXPORT_ENC_IMPORT_OPTIONS_H + +namespace mu { +namespace iex { +namespace enc { +enum class UnderfillStrategy { + InvisibleRests, // gap rests (invisible) + VisibleRests, // normal visible rests + IrregularMeasure, // set actual measure duration to match content (shipped default) +}; + +enum class OverfillStrategy { + Truncate, // remove trailing notes/rests + StretchLastNote, // compress the trailing tuplet / notes to fit + IrregularMeasure, // set actual measure duration to match content (shipped default) +}; + +enum class InstrumentSearchMode { + NameAndMidi, // name matching + MIDI fallback (shipped default) + MidiOnly, // skip name matching, use only MIDI program + Piano, // assign Grand Piano to all instruments +}; + +struct EncImportOptions { + // Layout group + bool importPageLayout = true; // apply page size and margins from the Encore file + bool importPageBreaks = true; // apply page breaks derived from the Encore LINE blocks + bool importStaffSize = true; // apply staff size scaling from the Encore file + + // Layout group (continued) + bool importSystemLocks = true; // lock each system to Encore's line measure count + + // Text / content group + bool importTempoTextSemantic = true; // map Italian tempo terms to BPM values + bool importUnsupportedArticulationsAsText = false; // emit unknown artic bytes as staff text + + // Instrument search + InstrumentSearchMode instrumentSearchMode = InstrumentSearchMode::NameAndMidi; + + // Measure correction group. These struct values are the in-code fallback used by tests; + // the shipped (GUI) default for both is IrregularMeasure (see enc-importconfiguration.cpp). + UnderfillStrategy underfillMeasureStrategy = UnderfillStrategy::InvisibleRests; + OverfillStrategy overfillMeasureStrategy = OverfillStrategy::Truncate; + bool firstMeasureIsPickup = true; // shorten first measure as pickup; false = pad with rests + + // Voice consolidation: collapse a staff's non-overlapping voices back into voice 1, all or + // nothing (a staff with genuinely overlapping voices is left untouched). In-code fallback is + // off so existing fixtures keep their voices; the GUI default is true. + bool mergeVoices = false; +}; +} // namespace enc +} // namespace iex +} // namespace mu + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_OPTIONS_H diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp new file mode 100644 index 0000000000000..37aeac13d00d3 --- /dev/null +++ b/src/importexport/encore/internal/importer/import.cpp @@ -0,0 +1,481 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Top-level Encore (.enc) import: read the file, build the score, and run whole-score fix-up passes. +// Binary format reverse-engineered by Leon Vinken (Enc2MusicXML, GPL v3+) building on enc2ly by Felipe Castro. + +#include "ctx.h" +#include "builders.h" +#include "resolvers.h" +#include "page-layout.h" +#include "debug-dump.h" + +#include "import.h" + +#include "../parser/elem.h" +#include "mappers.h" +#include "../parser/ticks.h" +#include "emitters-tuplets.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/box.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/clef.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/key.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/instrtemplate.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/slur.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/text.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/system.h" +#include "engraving/dom/volta.h" +#include "engraving/dom/mscore.h" +#include "engraving/types/spatium.h" +#include "engraving/engravingerrors.h" + +#include "engraving/editing/editenharmonicspelling.h" +#include "engraving/editing/implodeexplode.h" +#include "engraving/editing/editvoice.h" +#include "engraving/editing/transaction/transaction.h" + +#include "log.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// faceValue low nibble: 1=whole, 2=half ... 8=256th; 0 and 9..15 are invalid. +// High nibble carries unrelated flags. +bool isValidFaceValue(quint8 faceValue) +{ + const quint8 fv = faceValue & 0x0F; + return fv > 0 && fv <= 8; +} + +void applyConcertPitch(Note* n, int semitone) +{ + // A transposed or garbage Encore semitone can land outside MIDI's [0,127]. Note::setPitch + // only asserts the range (no clamp), and downstream drumset lookups index a 128-entry table + // by pitch, so an out-of-range value is undefined behaviour. Clamp once, here, at the single + // choke point both the main and grace note paths go through. + n->setPitch(std::clamp(semitone, 0, 127)); + n->setTpcFromPitch(); +} + +// score->spell() re-spells the whole score with a context heuristic that can spell transposed +// pitches with double-flats instead of the plain note the key wants. Re-derive the TPC of notes on +// transposing staves from pitch + concert key + transposition (pitch unchanged); leave others as is. +// TODO: format-agnostic, reads no Encore data; candidate to promote to a shared importexport util. +static void respellTransposingStaves(MasterScore* score) +{ + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (track_idx_t t = 0; t < score->ntracks(); ++t) { + EngravingItem* e = s->element(t); + if (!e || !e->isChord()) { + continue; + } + Chord* chord = toChord(e); + if (!chord->staff() || chord->staff()->transpose(chord->tick()).isZero()) { + continue; // non-transposing staff: keep spell()'s spelling + } + for (Chord* gc : chord->graceNotes()) { + for (Note* n : gc->notes()) { + n->setTpcFromPitch(); + } + } + for (Note* n : chord->notes()) { + n->setTpcFromPitch(); + } + } + } + } +} + +// Map Encore score-size (1 to 4) to MuseScore Staff Properties Scale (Pid::MAG): 1=60%, 2=75%, +// 3=100%, 4=130%. Global spatium is not changed. +static void applyStaffScale(MasterScore* score, const EncRoot& enc) +{ + static const double kScaleBySize[4] = { 0.60, 0.75, 1.00, 1.30 }; + staff_idx_t msStaffIdx = 0; + for (size_t instrIdx = 0; instrIdx < enc.instruments.size(); ++instrIdx) { + const int sz = staffDisplaySize(enc, static_cast(instrIdx)); + const double scale = kScaleBySize[sz - 1]; + const int ns = enc.instruments[instrIdx].nstaves > 0 ? enc.instruments[instrIdx].nstaves : 1; + for (int s = 0; s < ns && msStaffIdx < score->staves().size(); ++s, ++msStaffIdx) { + score->staves()[msStaffIdx]->setProperty(Pid::MAG, PropertyValue(scale)); + } + } +} + +// Collapse a staff's voices back into voice 1 when they never sound at the same time (the +// engraving equivalent of "move to voice 1" + Tools > Implode). All-or-nothing per staff: +// a staff is collapsible only if every voice fits into voice 1 with no timing change (notes may +// merge into a chord only at identical onset+duration), so the music is never altered. +// TODO: format-agnostic, reads no Encore data; candidate to promote to a shared importexport util. +static void mergeNonOverlappingVoices(MasterScore* score) +{ + // Pass 1: find the staves that carry notes in more than voice 0 and whose voices + // can be flattened without a timing conflict. + std::vector candidates; + for (staff_idx_t si = 0; si < score->nstaves(); ++si) { + const track_idx_t base = si * VOICES; + bool hasUpperVoiceNotes = false; + std::set > intervals; // distinct (startTick, endTick) of chords + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + EngravingItem* e = s->element(base + v); + if (!e || !e->isChord()) { + continue; + } + if (v != 0) { + hasUpperVoiceNotes = true; + } + const Chord* c = toChord(e); + const int start = c->tick().ticks(); + const int end = (c->tick() + c->actualTicks()).ticks(); + intervals.insert({ start, end }); + } + } + } + if (!hasUpperVoiceNotes) { + continue; // already a single voice, nothing to do + } + // The distinct intervals (identical ones, i.e. chord candidates, are deduped + // by the set) must not overlap. Sweep in start order: an interval that begins + // before the furthest end seen so far overlaps a different one => conflict. + bool collapsible = true; + int maxEnd = -1; + for (const std::pair& iv : intervals) { // std::set is ordered by (start, end) + if (iv.first < maxEnd) { + collapsible = false; + break; + } + maxEnd = std::max(maxEnd, iv.second); + } + if (collapsible) { + candidates.push_back(si); + } + } + + Measure* first = score->firstMeasure(); + Measure* last = score->lastMeasure(); + if (!first || !last) { + return; + } + + // Pass 2: collapse each candidate staff. No undo transaction is opened, so the + // editing commands below execute immediately and free themselves (see + // UndoStack::pushAndPerform); the surrounding ScoreLoad keeps that path quiet. + // (May be empty; the stale-rest cleanup below still runs.) + for (staff_idx_t si : candidates) { + const track_idx_t base = si * VOICES; + + // The voice change below rebuilds the destination chord from scratch; it carries + // articulations, lyrics and slurs across but not a single-chord tremolo, so a + // tremolo on a moved upper-voice chord would be lost. Snapshot every tremolo on + // the staff (keyed by onset tick) and re-attach it after the collapse. + std::map tremolosByTick; + for (Measure* m = first; m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + EngravingItem* e = s->element(base + v); + if (e && e->isChord()) { + if (TremoloSingleChord* trem = toChord(e)->tremoloSingleChord()) { + tremolosByTick[s->tick().ticks()] = trem->tremoloType(); + } + } + } + } + } + + // Move every note on the staff into voice 1, filling its rests and merging + // simultaneous same-duration notes into chords. + score->deselectAll(); + score->select(first, SelectType::RANGE, si); + score->select(last, SelectType::RANGE, si); + EditVoice::changeSelectedElementsVoice(score->transactionManager()->currentOrDummyTransaction(), score, 0); + + // Drop the now-empty upper voices (their leftover rests) by imploding the + // single staff onto voice 1. + score->deselectAll(); + score->select(first, SelectType::RANGE, si); + score->select(last, SelectType::RANGE, si); + ImplodeExplode::implode(score); + + // Re-attach any tremolo whose chord was moved into voice 1 (and so lost it). + for (const auto& [tick, type] : tremolosByTick) { + const Fraction f = Fraction::fromTicks(tick); + Measure* m = score->tick2measure(f); + if (!m) { + continue; + } + Segment* s = m->findSegment(SegmentType::ChordRest, f); + if (!s) { + continue; + } + for (voice_idx_t v = 0; v < VOICES; ++v) { + EngravingItem* e = s->element(base + v); + if (e && e->isChord() && !toChord(e)->tremoloSingleChord()) { + Chord* c = toChord(e); + TremoloSingleChord* trem = Factory::createTremoloSingleChord(c); + trem->setTremoloType(type); + c->add(trem); + break; + } + } + } + } + + // Final pass: drop redundant upper-voice rests. An upper voice (index >= 1) holding only rests + // in a measure is not a real second voice (voice 0 already fills the bar after the collapse); + // it would show as a spurious extra voice and can inflate the measure length. An upper voice + // still carrying a chord is a genuine overlapping voice and is left untouched. + for (staff_idx_t si = 0; si < score->nstaves(); ++si) { + const track_idx_t base = si * VOICES; + std::vector staleRests; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (voice_idx_t v = 1; v < VOICES; ++v) { + bool voiceHasChord = false; + std::vector voiceRests; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(base + v); + if (!e) { + continue; + } + if (e->isChord()) { + voiceHasChord = true; + break; + } + if (e->isRest()) { + voiceRests.push_back(toRest(e)); + } + } + if (!voiceHasChord) { + staleRests.insert(staleRests.end(), voiceRests.begin(), voiceRests.end()); + } + } + } + for (Rest* r : staleRests) { + score->removeElement(r); + } + } + score->deselectAll(); +} + +static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOptions& opts) +{ + ScoreLoad sl; // import edits run outside any undo transaction; see mergeNonOverlappingVoices + + score->style().set(Sid::chordsXmlFile, true); + score->chordList()->read(u"chords.xml"); + + // Enable multi-measure rests only when the file uses them (any REST with mrestCount > 1); + // otherwise show individual whole rests. + const bool hasMMRest = std::any_of(enc.measures.begin(), enc.measures.end(), + [](const EncMeasure& m) { + if (m.elements.empty()) { + return false; + } + for (const auto& ep : m.elements) { + if (static_cast(ep->type) != EncElemType::REST) { + return false; + } + } + return static_cast(m.elements[0].get())->mrestCount > 1; + }); + score->style().set(Sid::createMultiMeasureRests, hasMMRest); + + // Encore positions tuplet brackets/numbers flush against note heads and stems + // with no extra vertical gap, and never pushes them outside the staff. + score->style().set(Sid::tupletOutOfStaff, false); + score->style().set(Sid::tupletVHeadDistance, 0.0); + score->style().set(Sid::tupletVStemDistance, 0.0); + + // Encore does not stretch systems and staves to fill the page: it lays them out at fixed + // distances from the top. Keep vertical justification enabled but allow it no extra room + // (max system/staff spread = 0), so the imported spacing matches Encore instead of being + // spread to fill the page. + score->style().set(Sid::enableVerticalSpread, true); + score->style().set(Sid::maxSystemSpread, Spatium(0.0)); + score->style().set(Sid::maxStaffSpread, Spatium(0.0)); + + BuildCtx ctx{ score, enc, opts }; + buildParts(ctx); + buildMeasures(ctx); + buildInitialSignatures(ctx); + emitMeasures(ctx); + + applyPageSetup(ctx); + if (ctx.opts.importStaffSize) { + applyStaffScale(score, enc); + } + + resolveAll(ctx); + + EditEnharmonicSpelling::spell(score); + respellTransposingStaves(score); + addTitleFrame(score, enc.titleBlock); + // Assign MIDI ports/channels to every part. The file read path does this on load, + // but a direct import builds the score in memory without it, leaving each channel + // at -1; that makes Part::midiPort() index m_midiMapping[-1] and crash on a + // straight-to-MusicXML export. + score->rebuildMidiMapping(); + score->setUpTempoMap(); + score->doLayout(); + + if (ctx.opts.mergeVoices) { + mergeNonOverlappingVoices(score); + score->doLayout(); + } + + // With imported page breaks, a first-page system may have spilled onto the next page at the + // default staff space; shrink it just enough (<= 0.022 inch) to pull that system back. + fitFirstPageStaffSpace(ctx); + + // doLayout computes and caches the repeat list; at that point voltas may not yet be + // anchored, so the cached expansion ignores 1st/2nd endings and replays the 1st + // ending on every pass. The file read path invalidates the repeat list after load + // for the same reason; do the same here so playback right after import is correct. + score->masterScore()->invalidateRepeatList(); +} + +muse::String encoreLoadErrorMessage(const QString& path) +{ + QByteArray head; + QFile file(path); + if (file.open(QIODevice::ReadOnly)) { + head = file.read(5); + } + const QByteArray magic = head.left(4); + const muse::String name = muse::String::fromQString(QFileInfo(path).fileName()); + + // Older encrypted Encore container (ZBOT/ZBOP/ZBO6). + if (magic == "ZBOT" || magic == "ZBOP" || magic == "ZBO6") { + return muse::mtrc("engraving", + "“%1” is in an older, encrypted Encore format (%2) that this importer cannot read. " + "Open it in Encore and save it again, then import the saved file.") + .arg(name).arg(muse::String::fromQString(QString::fromLatin1(magic))); + } + // Recognizable Encore header, but the file could not be parsed: unsupported variant, damaged, + // or empty. + if (magic == "SCOW" || magic == "SCO5") { + const QString ver = QStringLiteral("%1").arg( + head.size() >= 5 ? static_cast(head[4]) : 0, 2, 16, QChar('0')); + return muse::mtrc("engraving", + "“%1” could not be read as an Encore file (format version 0x%2). It may be damaged " + "or use an unsupported variant. Try opening it in Encore and saving it again, then " + "import the saved file.") + .arg(name).arg(muse::String::fromQString(ver)); + } + // No recognizable Encore header at all. + return muse::mtrc("engraving", + "“%1” is not a recognized Encore file. Its header does not match any known Encore " + "format, so it may be corrupted or a different type of file.") + .arg(name); +} + +Err importEncore(MasterScore* score, const QString& path, const EncImportOptions& opts) +{ + if (!QFileInfo::exists(path)) { + return Err::FileNotFound; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return Err::FileOpenError; + } + + // ZBOT/ZBOP/ZBO6 are older encrypted Encore containers (only the first 42 bytes decrypt with + // a known XOR key; see ENCORE_FORMAT.md). Not supported here. See MuseScore#24341. + { + QByteArray magic4 = file.read(4); + file.seek(0); + if (magic4 == "ZBOT" || magic4 == "ZBOP" || magic4 == "ZBO6") { + LOGW() << "Encore: encrypted format (" << magic4.toStdString() + << ") is not supported; re-save the file in Encore as an unencrypted file first."; + return Err::FileBadFormat; + } + } + + QDataStream ds(&file); + ds.setByteOrder(QDataStream::LittleEndian); + + EncRoot enc; + if (!enc.read(ds)) { + return Err::FileBadFormat; + } + + if (enc.instruments.empty() || enc.measures.empty()) { + return Err::FileBadFormat; + } + + logEncRootInfo(enc); + buildScore(score, enc, opts); + + muse::Ret integrity = score->sanityCheck(); + if (!integrity) { + LOGW() << "Encore import: score corruption detected:\n" << integrity.text(); + } + + return Err::NoError; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/import.h b/src/importexport/encore/internal/importer/import.h new file mode 100644 index 0000000000000..ceb150771e627 --- /dev/null +++ b/src/importexport/encore/internal/importer/import.h @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Public entry point of the Encore (.enc) importer: parse a file into a MasterScore, plus the +// helper that turns a rejected file into a user-facing error message. +// +// The binary format was reverse-engineered by Leon Vinken (Enc2MusicXML project, +// https://github.com/lvinken/Enc2MusicXML, GPL v3+) building on enc2ly by Felipe Castro. +// This importer is based on that work. + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_IMPORT_H +#define MU_IMPORTEXPORT_ENC_IMPORT_IMPORT_H + +#include "engraving/engravingerrors.h" +#include "import-options.h" + +namespace mu::engraving { +class MasterScore; +} + +namespace mu::iex::enc { +mu::engraving::Err importEncore(mu::engraving::MasterScore* score, const QString& path, const EncImportOptions& opts = EncImportOptions {}); + +// Build a user-facing message for a file importEncore rejected with FileBadFormat, by +// re-reading its header: an older encrypted Encore container (ZBOT/ZBOP/ZBO6), a SCOW/SCO5 +// file that could not be parsed (unsupported variant, damaged, or empty), or a file with no +// recognizable Encore header at all. +muse::String encoreLoadErrorMessage(const QString& path); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_IMPORT_H diff --git a/src/importexport/encore/internal/importer/mappers-articulations.cpp b/src/importexport/encore/internal/importer/mappers-articulations.cpp new file mode 100644 index 0000000000000..e7e4699639a98 --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers-articulations.cpp @@ -0,0 +1,160 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Map Encore articulation/ornament bytes to MuseScore SymIds, finger numbers and string numbers. + +#include "mappers.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +std::vector encArticulation2SymIds(quint8 articByte) +{ + using mu::engraving::SymId; + // Byte encodes one or two glyphs (e.g. 0x24=tenuto+staccato). See ENCORE_FORMAT.md §Articulation bytes. + switch (articByte) { + // Trill/mordent: 0x04..0x07=trill, 0x08=turn, 0x09=wave(no MS equivalent), + // 0x0A=inv-mordent(short), + // 0x0C=inv-mordent(long/double)=tremblement, 0x0B=mordent(simple), 0x2F=mordent(double/long)=prallMordent. + case 0x04: + case 0x05: + case 0x06: + case 0x07: return { SymId::ornamentTrill }; + case 0x08: return { SymId::ornamentTurn }; + case 0x09: return {}; // wave mark, no MuseScore equivalent, skip + case 0x0A: return { SymId::ornamentShortTrill }; // + case 0x0C: return { SymId::ornamentTremblement }; // + case 0x0B: return { SymId::ornamentMordent }; // simple lower mordent + case 0x2F: return { SymId::ornamentPrallMordent }; // double/long lower mordent (parallel to 0x0C = ornamentTremblement for inverted) + case 0x2E: return { SymId::ornamentTurnInverted }; // inverted turn + case 0x12: return { SymId::articAccentAbove }; + case 0x13: return { SymId::articMarcatoAbove }; + case 0x14: return { SymId::articMarcatoStaccatoBelow }; // staccato + heavy accent (∨) as single glyph + case 0x15: return { SymId::articMarcatoStaccatoAbove }; // marcato + staccato as single glyph + case 0x16: return { SymId::articAccentAbove, SymId::articStaccatissimoAbove }; + case 0x17: return { SymId::articAccentStaccatoAbove }; // accent + staccato as single glyph + case 0x18: return { SymId::stringsUpBow }; + case 0x19: return { SymId::stringsDownBow }; + case 0x1A: return { SymId::articMarcatoAbove }; + case 0x1C: return { SymId::articTenutoAbove }; + case 0x1D: return { SymId::articStaccatoAbove }; + case 0x20: + case 0x21: return { SymId::fermataAbove }; + // Combined-articulation bytes come in (below, above) pairs, one pair per glyph. + // Placement is taken from the note articulation slot; subtype() normalises Below->Above. + case 0x22: // tenuto + accent (below) + case 0x23: return { SymId::articTenutoAccentAbove }; // tenuto + accent (above) + case 0x24: // tenuto + staccato (portato, below) + case 0x25: return { SymId::articTenutoStaccatoAbove }; // tenuto + staccato (portato, above) + case 0x26: return { SymId::articMarcatoTenutoBelow }; // tenuto + heavy accent (∨) below + case 0x27: return { SymId::articMarcatoTenutoAbove }; // tenuto + heavy accent (above) + case 0x28: + case 0x29: return { SymId::articStaccatissimoAbove }; + case 0x2A: // heavy accent (∨) + staccatissimo (below) + case 0x2B: return { SymId::articMarcatoAbove, SymId::articStaccatissimoAbove }; // heavy accent + staccatissimo (above) + case 0x2C: // tenuto + staccatissimo (below) + case 0x2D: return { SymId::articTenutoAbove, SymId::articStaccatissimoAbove }; // tenuto + staccatissimo (above) + case 0x1B: return { SymId::brassMuteClosed }; // technical/stopped (+) + case 0x30: return { SymId::brassMuteHalfClosed }; // technical/stopped (tick/half stopped) + // String markings (m3, m4, m18): 0x1E/0x1F=harmonic, 0x44/0x45=thumb-position. + // 0x46=open-string: handled in encArticByteIsOpenString() (no SymId; uses Fingering "0"). + // 0x47=stick (drumstick technique): not a string number, left unmapped. + case 0x1E: + case 0x1F: return { SymId::stringsHarmonic }; + case 0x44: + case 0x45: return { SymId::stringsThumbPosition }; + default: + return {}; + } +} + +int encArticByteToFingerNumber(quint8 articByte) +{ + // Finger 1..5 map to bytes 0x0D..0x11. + switch (articByte) { + case 0x0D: return 1; + case 0x0E: return 2; + case 0x0F: return 3; + case 0x10: return 4; + case 0x11: return 5; + default: return 0; + } +} + +bool encArticByteIsOpenString(quint8 articByte) +{ + // 0x46=open-string; emitted as Fingering "0" (STRING_NUMBER style). + return articByte == 0x46; +} + +// Scale string-number artic bytes: 0x39-0x40 encode string numbers 1-8 as (byte - kStringArticBase). +static constexpr quint8 kStringArticBase = 0x38; +static constexpr quint8 kStringArticMin = 0x39; // string 1 +static constexpr quint8 kStringArticMax = 0x40; // string 8 + +int encArticByteToScaleStringNumber(quint8 articByte) +{ + // These appear as explicit anchors in scale exercises; their presence in a + // measure enables options-bit-0 string number display on all other notes. + if (articByte >= kStringArticMin && articByte <= kStringArticMax) { + return static_cast(articByte) - static_cast(kStringArticBase); + } + return 0; +} + +mu::engraving::OrnamentInterval encArticByteToTrillInterval(quint8 articByte) +{ + using mu::engraving::IntervalStep; + using mu::engraving::IntervalType; + // Trill artic bytes 0x04..0x07 share the trill glyph but carry an accidental: + // 0x04: no accidental (AUTO = use key context) + // 0x05: flat → minor second above (MINOR) + // 0x06: sharp → augmented second above (AUGMENTED) + // 0x07: natural → major second above (MAJOR) + switch (articByte) { + case 0x05: return { IntervalStep::SECOND, IntervalType::MINOR }; + case 0x06: return { IntervalStep::SECOND, IntervalType::AUGMENTED }; + case 0x07: return { IntervalStep::SECOND, IntervalType::MAJOR }; + default: return {}; // AUTO (default) + } +} + +DynamicType encOrnType2DynamicType(EncOrnamentType ot) +{ + switch (ot) { + case EncOrnamentType::DYN_PPP: return DynamicType::PPP; + case EncOrnamentType::DYN_PP: return DynamicType::PP; + case EncOrnamentType::DYN_P: return DynamicType::P; + case EncOrnamentType::DYN_MP: return DynamicType::MP; + case EncOrnamentType::DYN_MF: return DynamicType::MF; + case EncOrnamentType::DYN_F: return DynamicType::F; + case EncOrnamentType::DYN_FF: return DynamicType::FF; + case EncOrnamentType::DYN_FFF: return DynamicType::FFF; + case EncOrnamentType::DYN_SFZ: return DynamicType::SFZ; + case EncOrnamentType::DYN_SFFZ: return DynamicType::SFFZ; + case EncOrnamentType::DYN_FP: return DynamicType::FP; + case EncOrnamentType::DYN_FZ: return DynamicType::FZ; + case EncOrnamentType::DYN_SF: return DynamicType::SF; + default: return DynamicType::OTHER; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/mappers-clefs.cpp b/src/importexport/encore/internal/importer/mappers-clefs.cpp new file mode 100644 index 0000000000000..c1eb3e8887626 --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers-clefs.cpp @@ -0,0 +1,231 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Map Encore clef and key-signature bytes to MuseScore clefs/keys, applying octave-clef rules. + +#include "mappers.h" + +#include "engraving/dom/clef.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/key.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/part.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/timesig.h" +#include "engraving/editing/transpose.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +ClefType encClef2MuseScore(EncClefType ct) +{ + switch (ct) { + case EncClefType::G: return ClefType::G; + case EncClefType::F: return ClefType::F; + case EncClefType::C3L: return ClefType::C3; + case EncClefType::C4L: return ClefType::C4; + case EncClefType::G8P: return ClefType::G8_VA; + case EncClefType::G8M: return ClefType::G8_VB; + case EncClefType::F8M: return ClefType::F8_VB; + case EncClefType::PERC: return ClefType::PERC; + case EncClefType::TAB: return ClefType::TAB; + default: return ClefType::G; + } +} + +// Encore key byte is an index 0..14 into { C, F, Bb, Eb, Ab, Db, Gb, Cb, G, D, A, E, B, F#, C# } +// mapping to fifths {0,-1,-2,-3,-4,-5,-6,-7,1,2,3,4,5,6,7}. See ENCORE_FORMAT.md §Key encoding. +int encKeyToFifths(quint8 key) +{ + static const int table[] = { 0, -1, -2, -3, -4, -5, -6, -7, 1, 2, 3, 4, 5, 6, 7 }; + if (key < 15) { + return table[key]; + } + return 0; +} + +void addInitialKeySig(MasterScore* score, int staffIdx, quint8 encKey) +{ + int fifths = encKeyToFifths(encKey); + Staff* staff = score->staff(staffIdx); + if (!staff) { + return; + } + Key writtenKey = Key(fifths); + // Encore's key field is the written key for the instrument. Convert to concert + // key for the staff timeline; the written key is stored explicitly for display. + Interval v = staff->part()->instrument()->transpose(); + Key concertKey = v.isZero() ? writtenKey : Transpose::transposeKey(writtenKey, v); + // Prefer sharp enharmonics (F# over Gb) for extreme flat keys: an augmented-4th transposing + // instrument yields Gb (-6), which makes pitch2tpc spell notes with double flats. F# spells them right. + if (static_cast(concertKey) <= -6) { + concertKey = Key(static_cast(concertKey) + 12); + } + // Store the concert key even for C major so Staff::concertKey() returns the normalized value + // rather than recomputing it from the transposition (which may pick the flat enharmonic). + Fraction tick = Fraction(0, 1); + KeySigEvent ke; + ke.setConcertKey(concertKey); + ke.setKey(writtenKey); + staff->setKey(tick, ke); + // For C major written key, no visible key signature is needed in the score. + if (fifths == 0) { + return; + } + + Measure* m = score->tick2measure(tick); + if (!m) { + return; + } + Segment* seg = m->getSegment(SegmentType::KeySig, tick); + KeySig* ks = Factory::createKeySig(seg); + ks->setTrack(staffIdx * VOICES); + ks->setKey(concertKey, writtenKey); + seg->add(ks); +} + +void addInitialTimeSig(MasterScore* score, int nstaves, Fraction ts, TimeSigType tsType) +{ + Measure* m = score->tick2measure(Fraction(0, 1)); + if (!m) { + return; + } + for (int staffIdx = 0; staffIdx < nstaves; ++staffIdx) { + Segment* seg = m->getSegment(SegmentType::TimeSig, Fraction(0, 1)); + TimeSig* tsig = Factory::createTimeSig(seg); + tsig->setTrack(staffIdx * VOICES); + tsig->setSig(ts, tsType); + seg->add(tsig); + } +} + +// Common time is glyph 0x43 ('C') or 0x63 ('c'). Cut time has no confirmed glyph value yet, +// so it falls through to NORMAL (numeric). See ENCORE_FORMAT.md §Time-signature glyph. +TimeSigType encTimeSigGlyph2Type(quint8 glyph, Fraction ts) +{ + if ((glyph == 0x43 || glyph == 0x63) && ts == Fraction(4, 4)) { + return TimeSigType::FOUR_FOUR; + } + return TimeSigType::NORMAL; +} + +static int clefOctaveOffset(ClefType ct) +{ + switch (ct) { + case ClefType::G8_VB: + case ClefType::G8_VB_O: + case ClefType::G8_VB_P: + case ClefType::G8_VB_C: + case ClefType::F8_VB: + case ClefType::C4_8VB: + return -12; + case ClefType::G8_VA: + case ClefType::F_8VA: + return 12; + case ClefType::G15_MB: + case ClefType::F15_MB: + return -24; + case ClefType::G15_MA: + case ClefType::F_15MA: + return 24; + default: + return 0; + } +} + +static int clefGlyphFamily(ClefType ct) +{ + switch (ct) { + case ClefType::G: + case ClefType::G_1: + case ClefType::G8_VB: + case ClefType::G8_VA: + case ClefType::G15_MA: + case ClefType::G15_MB: + case ClefType::G8_VB_O: + case ClefType::G8_VB_P: + case ClefType::G8_VB_C: + return 1; // G family + case ClefType::F: + case ClefType::F_B: + case ClefType::F_C: + case ClefType::F_F18C: + case ClefType::F_19C: + case ClefType::F15_MB: + case ClefType::F8_VB: + case ClefType::F_8VA: + case ClefType::F_15MA: + return 2; // F family + default: + return 0; + } +} + +// A negative octave offset returns the matching octave-down clef in the same glyph family, so +// instruments sounding 8vb are written with an 8vb clef. A positive offset keeps the plain clef +// (octave-up clefs are rare; the octave is a playback transposition, see builders-parts.cpp). +ClefType applyOctaveToClef(ClefType base, int keyOffsetSemitones) +{ + if (keyOffsetSemitones >= 0 || clefGlyphFamily(base) == 0) { + return base; + } + static const std::array kCandidates = { + ClefType::G8_VB, ClefType::G8_VA, + ClefType::G15_MB, ClefType::G15_MA, + ClefType::F8_VB, ClefType::F_8VA, + ClefType::F15_MB, ClefType::F_15MA, + }; + const int family = clefGlyphFamily(base); + for (ClefType c : kCandidates) { + if (clefGlyphFamily(c) == family && clefOctaveOffset(c) == keyOffsetSemitones) { + return c; + } + } + return base; +} + +ClefType pickStaffClef(EncClefType encClef, int keyOffsetSemitones) +{ + return applyOctaveToClef(encClef2MuseScore(encClef), keyOffsetSemitones); +} + +void addInitialClef(MasterScore* score, int staffIdx, EncClefType ct) +{ + addInitialClef(score, staffIdx, encClef2MuseScore(ct)); +} + +void addInitialClef(MasterScore* score, int staffIdx, ClefType ct) +{ + Measure* m = score->tick2measure(Fraction(0, 1)); + if (!m) { + return; + } + Segment* seg = m->getSegment(SegmentType::HeaderClef, Fraction(0, 1)); + Clef* clef = Factory::createClef(seg); + clef->setTrack(staffIdx * VOICES); + clef->setClefType(ct); + seg->add(clef); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/mappers-instruments.cpp b/src/importexport/encore/internal/importer/mappers-instruments.cpp new file mode 100644 index 0000000000000..4a0ba4dd825a4 --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers-instruments.cpp @@ -0,0 +1,534 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Match Encore instrument names and MIDI programs to MuseScore instrument templates. + +#include "mappers.h" + +#include + +#include + +#include "global/stringutils.h" + +#include "engraving/dom/instrtemplate.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/part.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Strip trailing ordinal numbers from instrument names ("Bandurria 1ª" -> "Bandurria"; standalone "ª"/"º" also removed). +QString normalizeEncoreInstrName(const QString& name) +{ + QString s = name.trimmed(); + // Remove: optional separator (space, - _ .) + digits + optional ordinal (ª º °). + // The separator is optional so an attached part number is also stripped: + // "Voz 1", "Voz-1", "Voz.1", "Bandurria 2ª", "Bandurria1", "Violin2" -> base name. + static const QRegularExpression trailingNum(QStringLiteral("[\\s\\-_.]*\\d+[\xaa\xb0\xba]*$")); + // Remove: trailing ordinals with no preceding digit + static const QRegularExpression trailingOrd(QStringLiteral("[\xaa\xb0\xba]+$")); + // Only strip when a non-empty stem remains (so "1234" or "2ª" are left untouched). + const QRegularExpressionMatch numMatch = trailingNum.match(s); + if (numMatch.hasMatch() && numMatch.capturedStart() > 0) { + s = s.left(numMatch.capturedStart()).trimmed(); + } + s.remove(trailingOrd); + return s.trimmed(); +} + +// Lowercase + accent-strip so "Laúd" matches "Laud" and "Percusión" matches "Percusion". +static QString normalizeForCompare(const QString& s) +{ + const QString d = s.normalized(QString::NormalizationForm_D); + QString out; + out.reserve(d.size()); + for (const QChar& ch : d) { + if (ch.category() != QChar::Mark_NonSpacing) { + out.append(ch.toLower()); + } + } + return out; +} + +// Transposition compatibility: octave-only (chromatic%12==0) always passes (handled by pickStaffClef); +// non-octave requires matching mod-12 with encKey; rejects when Encore says C-instrument (encKey%12==0). +static bool transpCompatibleWith(int tmplChromatic, int encKeySemitones) +{ + if (tmplChromatic % 12 == 0) { + return true; + } + if (encKeySemitones % 12 == 0) { + return false; + } + const auto mod12 = [](int x) { return ((x % 12) + 12) % 12; }; + return mod12(tmplChromatic) == mod12(encKeySemitones); +} + +// Minimum instrument name length for template search. Single-letter SATB labels +// ("S","A","T","B") match too broadly with substring scoring; skip them. +static constexpr int kMinInstrNameLen = 4; + +// Instrument template matching scores (see findEncoreInstrumentTemplate). +static constexpr int kScoreTrackExact = 4; // trackName == needle +static constexpr int kScoreTrackContains = 2; // trackName contains needle +static constexpr int kScoreLongExact = 2; // longName == needle +static constexpr int kScoreLongContains = 1; // longName contains needle +static constexpr int kScoreShortExact = 1; // shortName == needle +static constexpr int kScoreMidiMatch = 6; // MIDI program matches +static constexpr int kScoreCommonGenre = 1; // "common" genre tag + +// Find best non-drumset template by name+MIDI score (weights above). With the encKeySemitones filter, +// prefers a transposition-compatible match; falls back to best name+MIDI match when none is compatible. +const InstrumentTemplate* findEncoreInstrumentTemplate(const QString& encName, int encMidiProgram, + int encKeySemitones, bool* outExactName, + bool* outUniqueName) +{ + if (outExactName) { + *outExactName = false; + } + if (outUniqueName) { + *outUniqueName = false; + } + if (encName.isEmpty()) { + return nullptr; + } + + if (encName.trimmed().size() < static_cast(kMinInstrNameLen)) { + return nullptr; + } + + const QString norm = normalizeEncoreInstrName(encName); + + QStringList needles; + auto addNeedle = [&](const QString& s) { + const QString n = normalizeForCompare(s); + if (!n.isEmpty() && !needles.contains(n)) { + needles << n; + } + }; + // Add a needle plus its Romance singular stems (-s and -es); template matching picks the one + // that exists, so a stem matching no template is simply never scored. Language-agnostic. + auto addNeedleWithStems = [&](const QString& s) { + addNeedle(s); + const QString n = normalizeForCompare(s); + if (n.endsWith(u's') && n.length() >= 5) { + addNeedle(n.left(n.length() - 1)); + } + if (n.endsWith(QStringLiteral("es")) && n.length() >= 6) { + addNeedle(n.left(n.length() - 2)); + } + }; + addNeedle(encName); + addNeedleWithStems(norm); + // Split words on any non-alphanumeric separator (space, - _ . and punctuation), keeping + // accented letters within words. So "Voz-1"/"Vln.2"/"Sax_tenor" tokenize correctly. + static const QRegularExpression wordSplit(QStringLiteral("[^\\p{L}\\p{N}]+")); + for (const QString& word : norm.split(wordSplit, Qt::SkipEmptyParts)) { + if (word.length() >= 4) { + addNeedleWithStems(word); + } + } + if (needles.isEmpty()) { + return nullptr; + } + + const bool filterTransp = (encKeySemitones != ENC_KEY_NO_FILTER); + const InstrumentTemplate* best = nullptr; + const InstrumentTemplate* bestCompatible = nullptr; + int bestScore = 0; + int bestCompatibleScore = 0; + int bestNameStrength = 0; + int bestCompatibleNameStrength = 0; + bool bestExact = false; + bool bestCompatibleExact = false; + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (it->useDrumset) { + continue; + } + const QString nt = normalizeForCompare(it->trackName.toQString()); + const QString nl = normalizeForCompare(it->instrumentName.longName().toQString()); + const QString ns = normalizeForCompare(it->instrumentName.shortName().toQString()); + int nameStrength = 0; + bool exactHit = false; + for (const QString& needle : needles) { + int s = 0; + if (nt == needle) { + s += kScoreTrackExact; + exactHit = true; + } else if (nt.contains(needle)) { + s += kScoreTrackContains; + } + if (nl == needle) { + s += kScoreLongExact; + exactHit = true; + } else if (nl.contains(needle)) { + s += kScoreLongContains; + } + if (ns == needle) { + s += kScoreShortExact; + exactHit = true; + } + if (s > nameStrength) { + nameStrength = s; + } + } + if (nameStrength == 0) { + continue; + } + int midiBonus = 0; + if (encMidiProgram >= 0) { + for (const InstrChannel& ch : it->channel) { + if (ch.program() == encMidiProgram) { + midiBonus = kScoreMidiMatch; + break; + } + } + } + // "common" genre tag breaks ties between same-score templates (e.g. guitar-nylon vs soprano-guitar). + int commonBonus = 0; + for (const InstrumentGenre* gen : it->genres) { + if (gen && gen->id == "common") { + commonBonus = kScoreCommonGenre; + break; + } + } + const int score = nameStrength + midiBonus + commonBonus; + if (score > bestScore + || (score == bestScore && nameStrength > bestNameStrength)) { + bestScore = score; + bestNameStrength = nameStrength; + best = it; + bestExact = exactHit; + } + if (filterTransp && transpCompatibleWith(it->transpose.chromatic, encKeySemitones)) { + if (score > bestCompatibleScore + || (score == bestCompatibleScore && nameStrength > bestCompatibleNameStrength)) { + bestCompatibleScore = score; + bestCompatibleNameStrength = nameStrength; + bestCompatible = it; + bestCompatibleExact = exactHit; + } + } + } + } + // Last resort when neither exact nor substring matched: an edit-distance (Levenshtein) match + // for typos and close inflections (>= 75% similar, <= 2 edits, both words >= 5 chars). Catches + // near roots but not distant cross-language roots. A fuzzy hit unique to one template is as + // distinctive as a unique substring (reported unique below so MIDI won't override); a shared + // fuzzy hit stays non-unique and defers to MIDI. + bool fuzzyUnique = false; + if (!best) { + static const QRegularExpression fuzzyWordSplit(QStringLiteral("[^\\p{L}\\p{N}]+")); + constexpr double kFuzzyMinSim = 0.75; + constexpr int kFuzzyMaxDist = 2; + constexpr int kFuzzyMinLen = 5; + double bestSim = 0.0; + int fuzzyHits = 0; // distinct templates reaching kFuzzyMinSim + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (it->useDrumset) { + continue; + } + const QString nt = normalizeForCompare(it->trackName.toQString()); + const QString nl = normalizeForCompare(it->instrumentName.longName().toQString()); + double sim = 0.0; + for (const QString& needle : needles) { + if (needle.length() < kFuzzyMinLen) { + continue; + } + for (const QString& field : { nt, nl }) { + for (const QString& w : field.split(fuzzyWordSplit, Qt::SkipEmptyParts)) { + if (w.length() < kFuzzyMinLen) { + continue; + } + const int dist = static_cast( + muse::strings::levenshteinDistance(needle.toStdString(), w.toStdString())); + if (dist < 1 || dist > kFuzzyMaxDist) { + continue; + } + const double s = 1.0 - static_cast(dist) + / std::max(needle.length(), w.length()); + sim = std::max(sim, s); + } + } + } + if (sim >= kFuzzyMinSim) { + ++fuzzyHits; + } + if (sim > bestSim) { + bestSim = sim; + best = it; + bestExact = false; + } + } + } + if (bestSim < kFuzzyMinSim) { + best = nullptr; + } else { + fuzzyUnique = (fuzzyHits == 1); + } + } + + const bool useCompatible = filterTransp && bestCompatible; + if (outExactName) { + *outExactName = useCompatible ? bestCompatibleExact : bestExact; + } + + const InstrumentTemplate* result = useCompatible ? bestCompatible : best; + + // A fuzzy match unique above the similarity threshold is as distinctive as a unique substring. + if (outUniqueName && fuzzyUnique && result == best) { + *outUniqueName = true; + } + + // A contains-only match is weak in general, but a needle matched by exactly one template is + // as confident as an exact match; report it so callers do not let MIDI override it. Ambiguous + // needles that hit many templates stay weak and still defer to MIDI. + if (outUniqueName && result) { + auto needleMatches = [](const QString& needle, const InstrumentTemplate* it) { + const QString nt = normalizeForCompare(it->trackName.toQString()); + const QString nl = normalizeForCompare(it->instrumentName.longName().toQString()); + const QString ns = normalizeForCompare(it->instrumentName.shortName().toQString()); + return nt == needle || nt.contains(needle) + || nl == needle || nl.contains(needle) + || ns == needle; + }; + for (const QString& needle : needles) { + if (!needleMatches(needle, result)) { + continue; + } + int freq = 0; + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (!it->useDrumset && needleMatches(needle, it) && ++freq > 1) { + break; + } + } + if (freq > 1) { + break; + } + } + if (freq == 1) { + *outUniqueName = true; + break; + } + } + } + + return result; +} + +// Find best drumset template by name score (exact match only, no substring). +const InstrumentTemplate* findDrumsetTemplate(const QString& encName) +{ + if (encName.trimmed().size() < static_cast(kMinInstrNameLen)) { + return nullptr; + } + + const QString norm = normalizeEncoreInstrName(encName); + QStringList needles; + auto addNeedle = [&](const QString& s) { + const QString n = normalizeForCompare(s); + if (!n.isEmpty() && !needles.contains(n)) { + needles << n; + } + }; + addNeedle(encName); + addNeedle(norm); + static const QRegularExpression wordSplit(QStringLiteral("[^\\p{L}\\p{N}]+")); + for (const QString& word : norm.split(wordSplit, Qt::SkipEmptyParts)) { + if (word.length() >= 4) { + addNeedle(word); + } + } + if (needles.isEmpty()) { + return nullptr; + } + + const InstrumentTemplate* best = nullptr; + int bestScore = 0; + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (!it->useDrumset) { + continue; + } + const QString nt = normalizeForCompare(it->trackName.toQString()); + const QString nl = normalizeForCompare( + it->instrumentName.longName().toQString()); + const QString ns = normalizeForCompare( + it->instrumentName.shortName().toQString()); + int score = 0; + for (const QString& needle : needles) { + if (nt == needle) { + score += 4; + } + if (nl == needle) { + score += 2; + } + if (ns == needle) { + score += 1; + } + } + if (score > bestScore) { + bestScore = score; + best = it; + } + } + } + return best; +} + +// Strip a trailing parenthetical variant suffix: "Classical Guitar (tablature)" -> "Classical Guitar". +static QString stripVariantSuffix(const QString& trackName) +{ + QString s = trackName.trimmed(); + const int paren = s.lastIndexOf(u'('); + if (paren > 0) { + s = s.left(paren).trimmed(); + } + return normalizeForCompare(s); +} + +const InstrumentTemplate* findInstrumentVariant(const InstrumentTemplate* base, bool wantTab) +{ + if (!base) { + return nullptr; + } + const bool baseIsTab = (base->staffGroup == StaffGroup::TAB); + if (baseIsTab == wantTab) { + return base; // already the requested kind + } + const String baseXmlId = base->musicXmlId; + const QString baseName = stripVariantSuffix(base->trackName.toQString()); + + const InstrumentTemplate* best = nullptr; + bool bestIsCommon = false; + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (it->useDrumset) { + continue; + } + if ((it->staffGroup == StaffGroup::TAB) != wantTab) { + continue; + } + const bool sameByXmlId = !baseXmlId.isEmpty() && it->musicXmlId == baseXmlId; + const bool sameByName = !baseName.isEmpty() + && stripVariantSuffix(it->trackName.toQString()) == baseName; + if (!sameByXmlId && !sameByName) { + continue; + } + bool isCommon = false; + for (const InstrumentGenre* gen : it->genres) { + if (gen && gen->id == "common") { + isCommon = true; + break; + } + } + if (!best || (isCommon && !bestIsCommon)) { + best = it; + bestIsCommon = isCommon; + } + } + } + return best; +} + +const InstrumentTemplate* findTemplateByMidi(int encMidiProgram0indexed) +{ + if (encMidiProgram0indexed < 0) { + return nullptr; + } + const InstrumentTemplate* best = nullptr; + bool bestIsCommon = false; + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (it->useDrumset || it->channel.empty()) { + continue; + } + // Match only the first channel of each instrument. The first channel is the + // instrument's primary sound; additional channels (tremolo, pizzicato, mute…) + // are articulation variants that share programs across many instruments and + // would produce false matches if included. + if (it->channel.front().program() != encMidiProgram0indexed) { + continue; + } + bool isCommon = false; + for (const InstrumentGenre* gen : it->genres) { + if (gen && gen->id == "common") { + isCommon = true; + break; + } + } + if (!best || (isCommon && !bestIsCommon)) { + best = it; + bestIsCommon = isCommon; + } + } + } + return best; +} + +const InstrumentTemplate* findTemplateByMidiFamily(int encMidiProgram0indexed) +{ + if (encMidiProgram0indexed < 0) { + return nullptr; + } + // General MIDI groups its 128 programs into 16 families of 8. When no template has the exact + // program as its primary sound, fall back to the nearest template within the same family so the + // part keeps its instrument category instead of collapsing to Grand Piano. + const int family = encMidiProgram0indexed / 8; + const int familyFirst = family * 8; + const int familyLast = familyFirst + 7; + const InstrumentTemplate* best = nullptr; + int bestDist = 1000; + bool bestIsCommon = false; + for (const InstrumentGroup* g : instrumentGroups) { + for (const InstrumentTemplate* it : g->instrumentTemplates) { + if (it->useDrumset || it->channel.empty()) { + continue; + } + const int prog = it->channel.front().program(); + if (prog < familyFirst || prog > familyLast) { + continue; + } + bool isCommon = false; + for (const InstrumentGenre* gen : it->genres) { + if (gen && gen->id == "common") { + isCommon = true; + break; + } + } + const int dist = prog > encMidiProgram0indexed + ? prog - encMidiProgram0indexed : encMidiProgram0indexed - prog; + // Prefer the nearest program; break ties toward the "common" genre. + if (!best || dist < bestDist || (dist == bestDist && isCommon && !bestIsCommon)) { + best = it; + bestDist = dist; + bestIsCommon = isCommon; + } + } + } + return best; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/mappers-tempo.cpp b/src/importexport/encore/internal/importer/mappers-tempo.cpp new file mode 100644 index 0000000000000..76fab681a0efa --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers-tempo.cpp @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Map Encore tempo text and BPM values to MuseScore tempo semantics. + +#include "mappers.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +double encTextToTempoBps(const QString& text) +{ + const QString t = text.trimmed().toLower(); + if (t.isEmpty()) { + return -1.0; + } + // Italian tempo terms; BPM values match MuseScore's tempo palette (palettecreator.cpp) for consistent playback. + struct Entry { + const char* word; + double bps; + }; + static const Entry kAbsolute[] = { + { "grave", 35.0 / 60.0 }, + { "largo", 50.0 / 60.0 }, + { "lento", 52.5 / 60.0 }, + { "larghetto", 63.0 / 60.0 }, + { "adagio", 71.0 / 60.0 }, + { "andante", 92.0 / 60.0 }, + { "andantino", 94.0 / 60.0 }, + { "moderato", 114.0 / 60.0 }, + { "allegretto", 116.0 / 60.0 }, + { "allegro", 144.0 / 60.0 }, + { "vivace", 172.0 / 60.0 }, + { "presto", 187.0 / 60.0 }, + { "prestissimo", 200.0 / 60.0 }, + }; + for (const Entry& e : kAbsolute) { + if (t == QString::fromLatin1(e.word)) { + return e.bps; + } + } + // Relative markings: become TempoText with BPS=0 (falls back to previous tempo). + static const char* kRelative[] = { + "a tempo", + "tempo i", + "tempo 1", + "tempo primo", + }; + for (const char* r : kRelative) { + if (t == QString::fromLatin1(r)) { + return 0.0; + } + } + return -1.0; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/mappers-title.cpp b/src/importexport/encore/internal/importer/mappers-title.cpp new file mode 100644 index 0000000000000..dbf52ffa5459f --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers-title.cpp @@ -0,0 +1,255 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Map Encore title/subtitle/author/copyright blocks to a MuseScore title frame. + +#include "mappers.h" + +#include + +#include "engraving/style/style.h" +#include "engraving/dom/box.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/text.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Translate Encore page tokens (#P, #D, #T) to MuseScore macros ($P, $D, $m). +static String translateHeaderFooterTokens(const String& s) +{ + String out = s; + out.replace(u"#P", u"$P"); + out.replace(u"#D", u"$D"); + out.replace(u"#T", u"$m"); + return out; +} + +void addTitleFrame(MasterScore* score, const EncTitle& titleBlock) +{ + // TITL stores multi-line content as separate slots (subtitle1..2, author1..4, etc.). + // Join non-empty slots with newline, same as Encore's MusicXML exporter does. + auto joinSlots = [](const std::vector& items) -> QString { + QStringList nonEmpty; + for (const QString& s : items) { + if (!s.isEmpty()) { + nonEmpty.append(s); + } + } + return nonEmpty.join(QChar('\n')); + }; + // Promote the first non-empty subtitle to title when the title slot is blank (TITL fixed-line layout quirk). + QString effectiveTitle = titleBlock.title; + std::vector subtitleSlots = titleBlock.subtitle; + if (effectiveTitle.isEmpty()) { + for (QString& slot : subtitleSlots) { + if (!slot.isEmpty()) { + effectiveTitle = slot; + slot = QString(); + break; + } + } + } + + const QString joinedSubtitle = joinSlots(subtitleSlots); + const QString joinedInstruction = joinSlots(titleBlock.instruction); + const QString joinedAuthor = joinSlots(titleBlock.author); + const QString joinedCopyright = joinSlots(titleBlock.copyright); + + const bool hasSubtitle = !joinedSubtitle.isEmpty(); + const bool hasInstruction = !joinedInstruction.isEmpty(); + const bool hasAuthor = !joinedAuthor.isEmpty(); + const bool hasCopyright = !joinedCopyright.isEmpty(); + + if (!effectiveTitle.isEmpty()) { + score->setMetaTag(u"workTitle", String(effectiveTitle)); + } + if (hasSubtitle) { + score->setMetaTag(u"subtitle", String(joinedSubtitle)); + } + if (hasInstruction) { + score->setMetaTag(u"lyricist", String(joinedInstruction)); + } + if (hasAuthor) { + score->setMetaTag(u"composer", String(joinedAuthor)); + } + if (hasCopyright) { + score->setMetaTag(u"copyright", String(joinedCopyright)); + } + + if (effectiveTitle.isEmpty() && !hasSubtitle && !hasAuthor && !hasInstruction) { + return; + } + + VBox* vbox = Factory::createTitleVBox(score->dummy()->system()); + vbox->setNext(score->first()); + score->measures()->add(vbox); + + if (!effectiveTitle.isEmpty()) { + Text* t = Factory::createText(vbox, TextStyleType::TITLE); + t->setPlainText(String(effectiveTitle)); + vbox->add(t); + } + if (hasSubtitle) { + Text* t = Factory::createText(vbox, TextStyleType::SUBTITLE); + t->setPlainText(String(joinedSubtitle)); + vbox->add(t); + } + if (hasInstruction) { + Text* t = Factory::createText(vbox, TextStyleType::LYRICIST); + t->setPlainText(String(joinedInstruction)); + vbox->add(t); + } + if (hasAuthor) { + Text* t = Factory::createText(vbox, TextStyleType::COMPOSER); + t->setPlainText(String(joinedAuthor)); + vbox->add(t); + } + + // Header/footer: map alignment to odd/even Sid slots. Same-alignment slots joined with newline. + auto applyHFGroup = [score](const std::vector& items, + mu::engraving::Sid sidL, + mu::engraving::Sid sidC, + mu::engraving::Sid sidR, + mu::engraving::Sid sidEvenL, + mu::engraving::Sid sidEvenC, + mu::engraving::Sid sidEvenR) { + std::map grouped; + for (const EncHeaderFooter& hf : items) { + if (hf.text.isEmpty()) { + continue; + } + grouped[hf.align].append(hf.text); + } + for (const auto& [align, lines] : grouped) { + mu::engraving::Sid sid = sidL; + mu::engraving::Sid sidEven = sidEvenL; + if (align == EncTextAlign::CENTER) { + sid = sidC; + sidEven = sidEvenC; + } else if (align == EncTextAlign::RIGHT) { + sid = sidR; + sidEven = sidEvenR; + } + const String text = translateHeaderFooterTokens( + String(lines.join(QChar('\n')))); + score->style().set(sid, text); + score->style().set(sidEven, text); + } + }; + applyHFGroup(titleBlock.header, + mu::engraving::Sid::oddHeaderL, mu::engraving::Sid::oddHeaderC, mu::engraving::Sid::oddHeaderR, + mu::engraving::Sid::evenHeaderL, mu::engraving::Sid::evenHeaderC, mu::engraving::Sid::evenHeaderR); + applyHFGroup(titleBlock.footer, + mu::engraving::Sid::oddFooterL, mu::engraving::Sid::oddFooterC, mu::engraving::Sid::oddFooterR, + mu::engraving::Sid::evenFooterL, mu::engraving::Sid::evenFooterC, mu::engraving::Sid::evenFooterR); +} + +void addRepeatMark(Score* /*score*/, Measure* measure, EncRepeatType rt) +{ + switch (rt) { + case EncRepeatType::SEGNO: { + Marker* m = Factory::createMarker(measure); + m->setMarkerType(MarkerType::SEGNO); + m->setTrack(0); + measure->add(m); + break; + } + case EncRepeatType::CODA1: { + // CODA1=0x85 is "To Coda" (jump source); CODA2=0x89 is the Coda destination. + Marker* m = Factory::createMarker(measure); + m->setMarkerType(MarkerType::TOCODA); + m->setTrack(0); + measure->add(m); + break; + } + case EncRepeatType::CODA2: { + Marker* m = Factory::createMarker(measure); + m->setMarkerType(MarkerType::CODA); + m->setTrack(0); + measure->add(m); + break; + } + case EncRepeatType::FINE: { + Marker* m = Factory::createMarker(measure); + m->setMarkerType(MarkerType::FINE); + m->setTrack(0); + measure->add(m); + break; + } + case EncRepeatType::DC: { + Jump* j = Factory::createJump(measure); + j->setJumpType(JumpType::DC); + j->setPlayRepeats(true); + j->setTrack(0); + measure->add(j); + break; + } + case EncRepeatType::DS: { + Jump* j = Factory::createJump(measure); + j->setJumpType(JumpType::DS); + j->setPlayRepeats(true); + j->setTrack(0); + measure->add(j); + break; + } + case EncRepeatType::DCALFINE: { + Jump* j = Factory::createJump(measure); + j->setJumpType(JumpType::DC_AL_FINE); + j->setPlayRepeats(true); + j->setTrack(0); + measure->add(j); + break; + } + case EncRepeatType::DSALFINE: { + Jump* j = Factory::createJump(measure); + j->setJumpType(JumpType::DS_AL_FINE); + j->setPlayRepeats(true); + j->setTrack(0); + measure->add(j); + break; + } + case EncRepeatType::DCALCODA: { + Jump* j = Factory::createJump(measure); + j->setJumpType(JumpType::DC_AL_CODA); + j->setPlayRepeats(true); + j->setTrack(0); + measure->add(j); + break; + } + case EncRepeatType::DSALCODA: { + Jump* j = Factory::createJump(measure); + j->setJumpType(JumpType::DS_AL_CODA); + j->setPlayRepeats(true); + j->setTrack(0); + measure->add(j); + break; + } + default: + break; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/mappers.h b/src/importexport/encore/internal/importer/mappers.h new file mode 100644 index 0000000000000..6ab944ee69bd4 --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers.h @@ -0,0 +1,138 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Pure lookups from Encore values to MuseScore types: clefs, key/time signatures, dynamics, +// articulations, tempo terms, instrument-template matching, and the frame/signature builders. + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_MAPPING_H +#define MU_IMPORTEXPORT_ENC_IMPORT_MAPPING_H + +#include +#include + +#include "engraving/dom/clef.h" +#include "engraving/dom/timesig.h" + +#include "engraving/types/symid.h" +#include "engraving/types/types.h" +#include "../parser/elem.h" + +namespace mu::engraving { +class MasterScore; +class Measure; +class Score; +class InstrumentTemplate; +} + +namespace mu::iex::enc { +mu::engraving::ClefType encClef2MuseScore(EncClefType ct); + +// Encore time-signature glyph byte to MuseScore TimeSigType. Common time (0x43 'C' / 0x63 'c') +// in 4/4 maps to FOUR_FOUR; everything else is NORMAL (numeric). Cut time (alla breve) uses a +// glyph value that is not yet confirmed in the format, so it currently shows as numeric 2/2. +mu::engraving::TimeSigType encTimeSigGlyph2Type(quint8 glyph, mu::engraving::Fraction ts); + +// Encore dynamic ornament subtype (DYN_*) to MuseScore DynamicType. Non-dynamic subtypes +// return DynamicType::OTHER. +mu::engraving::DynamicType encOrnType2DynamicType(EncOrnamentType ot); + +// Pick octave-decorated clef when Encore's plain G/F plus a NEGATIVE octave Key implies one +// (e.g. keyOffset=-12 -> G8_VB/F8_VB; -24 -> G15_MB/F15_MB). Positive octave Keys keep the plain +// clef (the octave is carried as a playback transposition; see builders-parts.cpp). +mu::engraving::ClefType pickStaffClef(EncClefType encClef, int keyOffsetSemitones); + +// Return the octave-down variant of an already-resolved MuseScore clef for a NEGATIVE octave Key +// (-12/-24). Returns the input clef for non-octave or positive offsets, or when the clef has no +// octave variant. +mu::engraving::ClefType applyOctaveToClef(mu::engraving::ClefType base, int keyOffsetSemitones); + +int encKeyToFifths(quint8 key); + +void addTitleFrame(mu::engraving::MasterScore* score, const EncTitle& titleBlock); +void addInitialKeySig(mu::engraving::MasterScore* score, int staffIdx, quint8 encKey); +void addInitialTimeSig(mu::engraving::MasterScore* score, int nstaves, mu::engraving::Fraction ts, + mu::engraving::TimeSigType tsType = mu::engraving::TimeSigType::NORMAL); +void addInitialClef(mu::engraving::MasterScore* score, int staffIdx, EncClefType ct); +void addInitialClef(mu::engraving::MasterScore* score, int staffIdx, mu::engraving::ClefType clef); +void addRepeatMark(mu::engraving::Score* score, mu::engraving::Measure* measure, EncRepeatType rt); + +QString normalizeEncoreInstrName(const QString& name); + +// Sentinel for findEncoreInstrumentTemplate: skip the transposition compatibility filter. +// Valid Encore key offsets are in [-33, +24]; 0x7FFFFFFF is outside that range. +constexpr int ENC_KEY_NO_FILTER = 0x7FFFFFFF; + +// Find best non-drumset template by name+MIDI score; applies transposition filter when encKeySemitones != ENC_KEY_NO_FILTER. +// When outExactName is non-null, it is set to true if the returned template matched the instrument +// name exactly (track/long/short name equality) rather than only via a substring ("contains"). +// When outUniqueName is non-null, it is set to true if the returned template matched via a +// "distinctive" needle, i.e. a word no other template's name contains (e.g. "Dulzaina" hits only +// "Castilian Dulzaina"). Such a contains-match is as trustworthy as an exact match. +const mu::engraving::InstrumentTemplate* findEncoreInstrumentTemplate( + const QString& encName, int encMidiProgram = -1, int encKeySemitones = ENC_KEY_NO_FILTER, bool* outExactName = nullptr, + bool* outUniqueName = nullptr); + +// Same as findEncoreInstrumentTemplate but restricted to useDrumset templates. +const mu::engraving::InstrumentTemplate* findDrumsetTemplate(const QString& encName); + +// MIDI-only lookup among non-drumset templates; prefers "common" genre when multiple share the same program. +const mu::engraving::InstrumentTemplate* findTemplateByMidi(int encMidiProgram0indexed); + +// Fallback when findTemplateByMidi finds no exact program match: returns the nearest template +// within the same General MIDI family (16 families of 8 programs), preferring the "common" +// genre on ties. Keeps the instrument's category (Strings, Brass, Bass, …) instead of falling +// back to Grand Piano for programs no template carries as its primary sound (Pizzicato/Tremolo +// Strings, Muted Trumpet, Synth Bass, Voice Oohs, …). +const mu::engraving::InstrumentTemplate* findTemplateByMidiFamily(int encMidiProgram0indexed); + +// Given a matched template, return its standard-notation or tablature sibling (e.g. +// "Classical Guitar" <-> "Classical Guitar (tablature)"). Returns the input if it already +// matches wantTab, or nullptr when no sibling exists. Siblings are matched by shared +// musicXmlId, then by track name with any trailing "(...)" variant suffix removed. +const mu::engraving::InstrumentTemplate* findInstrumentVariant( + const mu::engraving::InstrumentTemplate* base, bool wantTab); + +// Return BPS if text is a standard Italian tempo term (Allegro, Andante, ...). +// Return 0 for relative marks (a tempo, Tempo I). Return -1 if not a tempo mark. +double encTextToTempoBps(const QString& text); + +// Map articulation byte to MuseScore SymIds. One byte can encode multiple glyphs +// (e.g. 0x24=tenuto+staccato). Empty vector means no known articulation mapping. +std::vector encArticulation2SymIds(quint8 articByte); + +// Map articulation byte to fingering number 1..5, or 0 if not a fingering. +int encArticByteToFingerNumber(quint8 articByte); + +// True when the articulation byte is an open-string marker (importer emits Fingering "0"). +bool encArticByteIsOpenString(quint8 articByte); + +// String number (1..8) for the explicit string-number anchor bytes 0x39..0x40 (= byte - 0x38), +// else 0. When a measure holds at least one, its notes with options bit 0 and no other artic byte +// also show string numbers. +int encArticByteToScaleStringNumber(quint8 articByte); + +// Returns the trill upper-neighbor interval for artic bytes 0x05/0x06/0x07 (flat/sharp/natural). +// Returns {SECOND, AUTO} for bytes with no accidental modifier. +mu::engraving::OrnamentInterval encArticByteToTrillInterval(quint8 articByte); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_MAPPING_H diff --git a/src/importexport/encore/internal/importer/page-layout.cpp b/src/importexport/encore/internal/importer/page-layout.cpp new file mode 100644 index 0000000000000..4864c0381124f --- /dev/null +++ b/src/importexport/encore/internal/importer/page-layout.cpp @@ -0,0 +1,563 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Page and printer geometry for the Encore importer (see page-layout.h). + +#include "page-layout.h" + +#include +#include +#include + +#include + +#include "ctx.h" +#include "../parser/elem.h" +#include "../parser/readers.h" + +#include "engraving/dom/factory.h" +#include "engraving/dom/layoutbreak.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/mscore.h" +#include "engraving/dom/page.h" +#include "engraving/dom/system.h" +#include "engraving/dom/systemlock.h" +#include "engraving/style/style.h" + +#include "log.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Find the smallest standard page (in pts) whose printable area contains the WINI edges. +static bool detectPtsPageSize(qint32 rightEdge, qint32 bottomEdge, + double& outWidthIn, double& outHeightIn) +{ + static constexpr double kTol = 1.0; // pts tolerance for metric rounding + double bestArea = 1e18; + bool found = false; + for (int id = 0; id <= static_cast(QPageSize::LastPageSize); ++id) { + if (id == static_cast(QPageSize::Custom)) { + continue; + } + const QSizeF sz = QPageSize::size(static_cast(id), + QPageSize::Inch); + const double wPts = sz.width() * 72.0; + const double hPts = sz.height() * 72.0; + if (wPts + kTol < static_cast(rightEdge) + || hPts + kTol < static_cast(bottomEdge)) { + continue; + } + const double area = wPts * hPts; + if (area < bestArea) { + bestArea = area; + outWidthIn = sz.width(); + outHeightIn = sz.height(); + found = true; + } + } + return found; +} + +// Identify the paper size from WINI screen-pixel coordinates (pageWUnits = rightEdge + left, +// pageHUnits = bottomEdge + top) by matching the implied DPI ratio. Pass 1 tries the ISO A-series +// first: all AN sizes share the 1:sqrt(2) ratio, so a non-A format with an accidentally smaller +// delta must not win over the correct AN. Pass 2 tries every other standard size. Both keep the +// candidate with the smallest abs(dpiW - dpiH). Returns false when nothing matches (custom page). +static bool detectWiniPageSize(int pageWUnits, int pageHUnits, + double& outWidthIn, double& outHeightIn) +{ + static constexpr double kDpiMin = 60.0; // minimum plausible screen DPI + static constexpr double kDpiMax = 135.0; // maximum plausible screen DPI + static constexpr double kMaxDelta = 6.0; // max |dpiW - dpiH| + + // ISO A-series IDs in Qt's QPageSize enum (Qt 6). + static const QPageSize::PageSizeId kASeriesIds[] = { + QPageSize::A0, QPageSize::A1, QPageSize::A2, QPageSize::A3, + QPageSize::A4, QPageSize::A5, QPageSize::A6, QPageSize::A7, + QPageSize::A8, QPageSize::A9, QPageSize::A10, + }; + + auto tryCandidate = [&](QPageSize::PageSizeId id, + double& bestDelta, + double& bestW, double& bestH) -> bool { + const QSizeF sz = QPageSize::size(id, QPageSize::Inch); + const double w = sz.width(); + const double h = sz.height(); + if (w <= 0.0 || h <= 0.0) { + return false; + } + const double dpiW = pageWUnits / w; + const double dpiH = pageHUnits / h; + if (dpiW < kDpiMin || dpiW > kDpiMax || dpiH < kDpiMin || dpiH > kDpiMax) { + return false; + } + const double delta = std::abs(dpiW - dpiH); + if (delta < kMaxDelta && delta < bestDelta) { + bestDelta = delta; + bestW = w; + bestH = h; + return true; + } + return false; + }; + + // Build a set of A-series IDs for fast exclusion in pass 2. + std::set aSeriesSet; + for (const auto id : kASeriesIds) { + aSeriesSet.insert(static_cast(id)); + } + + // Pass 1: ISO A-series. + double bestDelta = kMaxDelta; + bool found = false; + for (const auto id : kASeriesIds) { + if (tryCandidate(id, bestDelta, outWidthIn, outHeightIn)) { + found = true; + } + } + if (found) { + return true; + } + + // Pass 2: all other standard sizes (Letter, Legal, B-series, etc.). + for (int id = 0; id <= static_cast(QPageSize::LastPageSize); ++id) { + if (id == static_cast(QPageSize::Custom)) { + continue; + } + if (aSeriesSet.count(id)) { + continue; // already tried in pass 1 + } + if (tryCandidate(static_cast(id), bestDelta, outWidthIn, outHeightIn)) { + found = true; + } + } + return found; +} + +// Map a Windows DEVMODE dmPaperSize (DMPAPER_*) to a Qt page size. Returns Custom for +// values without a standard mapping; the caller then falls back to dmPaperLength/Width or +// to the WINI geometry heuristic. +static QPageSize::PageSizeId dmPaperToQt(int dmPaper) +{ + switch (dmPaper) { + case 1: return QPageSize::Letter; + case 5: return QPageSize::Legal; + case 7: return QPageSize::Executive; + case 8: return QPageSize::A3; + case 9: return QPageSize::A4; + case 11: return QPageSize::A5; + case 12: return QPageSize::B4; // DMPAPER_B4 (JIS) + case 13: return QPageSize::B5; // DMPAPER_B5 (JIS) + default: return QPageSize::Custom; + } +} + +// Resolve the page size (inches) from the PREC (DEVMODE) block: dmPaperSize enum, falling back +// to dmPaperLength/Width (tenths of a millimetre) for custom sizes, with the landscape swap +// applied. Returns false when PREC has no usable size (caller falls back to WINI geometry). +bool precPageSizeInches(const EncPrintSetup& pr, double& wIn, double& hIn) +{ + if (!pr.hasData) { + return false; + } + const QPageSize::PageSizeId id = dmPaperToQt(pr.paperSize); + if (id != QPageSize::Custom) { + const QSizeF sz = QPageSize::size(id, QPageSize::Inch); + wIn = sz.width(); + hIn = sz.height(); + } else if (pr.paperLength > 0 && pr.paperWidth > 0) { + wIn = pr.paperWidth / 254.0; + hIn = pr.paperLength / 254.0; + } else { + return false; + } + if (pr.orientation == 2) { // DMORIENT_LANDSCAPE + std::swap(wIn, hIn); + } + return true; +} + +// Apply page size, orientation and notation scale from the PREC (DEVMODE) block. Returns +// true when the page size was set (so the WINI margin pass must not override it). PREC is +// present in almost every Encore file across all formats, while WINI (margins) exists only +// in v0xC4, so this is the primary source of the page size for v0xA6/v0xC2 and for the +// many v0xC4 files without a WINI block. +static bool applyPagePrintSetup(MasterScore* score, const EncPrintSetup& pr) +{ + double wIn = 0.0, hIn = 0.0; + if (!precPageSizeInches(pr, wIn, hIn)) { + return false; // unknown paper: let the WINI geometry heuristic decide + } + score->style().set(Sid::pageWidth, wIn); + score->style().set(Sid::pageHeight, hIn); + // TODO: dmScale (Encore's notation-size percent) is parsed and logged but not applied. + // MuseScore has no global percentage scale, and mapping it onto spatium would compound with + // the per-staff size from applyStaffScale (Pid::MAG); the reconciliation needs investigation. + LOGD() << " PREC: orientation=" << pr.orientation << " paperSize=" << pr.paperSize + << " paper=" << pr.paperWidth << "x" << pr.paperLength << "(0.1mm)" + << " scale(zoom)=" << pr.scale << "%" + << " -> " << QString::number(wIn, 'f', 2).toStdString() + << "x" << QString::number(hIn, 'f', 2).toStdString() << "in"; + return true; +} + +// Display size (1-4) for an instrument: per-instrument staffSizeHint from the LINE staff entry, +// falling back to the global header.scoreSize for files without LINE data. +// See ENCORE_FORMAT.md §System block (LINE). +int staffDisplaySize(const EncRoot& enc, int instrIdx) +{ + if (!enc.lines.empty()) { + for (const EncLineStaffData& lsd : enc.lines[0].staffData) { + if (static_cast(lsd.instrumentIndex()) == instrIdx) { + return std::clamp(static_cast(lsd.staffSizeHint) + 1, 1, 4); + } + } + } + return std::clamp(static_cast(enc.header.scoreSize), 1, 4); +} + +double winiUnitsPerInch(int rightEdge, int left, double pageWIn) +{ + if (pageWIn <= 0.0) { + return 72.0; + } + // (rightEdge + left) / pageWidth near 72 means the WINI is in typographic points; a clearly + // larger value (about 84) means screen pixels at the monitor DPI. Snap the near-72 case to + // exactly 72. The pixel estimate is exact only when left/right margins are symmetric; with + // asymmetric margins it reads about 2% low. + const double est = static_cast(rightEdge + left) / pageWIn; + return (est <= 76.0) ? 72.0 : est; +} + +static void applyPageMargins(MasterScore* score, const EncPageSetup& ps, bool pageSizeLocked) +{ + if (!ps.hasData) { + return; + } + // WINI fields are nominally typographic points (1/72 inch), but some Encore versions store + // them in screen pixels at the monitor DPI (about 84-85 PPI on older hardware); the tell is + // rightEdge/bottomEdge exceeding the page size in pts (e.g. 672 > A4 width 595). The pts case + // recovers the page via detectPtsPageSize, the pixel case via detectWiniPageSize (DPI ratio). + // See ENCORE_FORMAT.md §WINI block. + // Cap each margin to a fraction of the page so a misread WINI cannot produce an absurd margin, + // while still allowing legitimately large margins (2"+ are common on A3/landscape). + static constexpr double kMaxMarginFrac = 0.45; + + double pageHIn = score->style().styleD(Sid::pageHeight); + double pageWIn = score->style().styleD(Sid::pageWidth); + + // 1 pt tolerance mirrors detectPtsPageSize: metric page heights convert to fractional pts + // (A4 297mm = 841.89pt, stored as 842) so the integer WINI value can exceed floor(pageH*72) + // by 1 without being screen-pixels. + static constexpr double kPixelTol = 1.0; + const bool screenPixelFmt = (ps.rightEdge > static_cast(pageWIn * 72.0 + kPixelTol)) + || (ps.bottomEdge > static_cast(pageHIn * 72.0 + kPixelTol)); + double scaleUpi = 72.0; + if (pageSizeLocked) { + // The page size is known (from PREC), so derive the WINI unit directly from the printable + // extent rather than guessing whether it is points or screen pixels. + scaleUpi = winiUnitsPerInch(ps.rightEdge, ps.left, pageWIn); + } else if (screenPixelFmt) { + const int pageWUnits = ps.rightEdge + ps.left; + const int pageHUnits = ps.bottomEdge + ps.top; + double detectedW = 0.0, detectedH = 0.0; + if (detectWiniPageSize(pageWUnits, pageHUnits, detectedW, detectedH)) { + pageWIn = detectedW; + pageHIn = detectedH; + score->style().set(Sid::pageWidth, pageWIn); + score->style().set(Sid::pageHeight, pageHIn); + } + scaleUpi = static_cast(pageWUnits) / pageWIn; + } else { + double detectedW = 0.0, detectedH = 0.0; + if (detectPtsPageSize(ps.rightEdge, ps.bottomEdge, detectedW, detectedH)) { + pageWIn = detectedW; + pageHIn = detectedH; + score->style().set(Sid::pageWidth, pageWIn); + score->style().set(Sid::pageHeight, pageHIn); + } + // scaleUpi stays 72.0 (pts = 1/72 inch by definition) + } + + double topIn = ps.top / scaleUpi; + double leftIn = ps.left / scaleUpi; + double printW = (ps.rightEdge - ps.left) / scaleUpi; + double printH = (ps.bottomEdge - ps.top) / scaleUpi; + + LOGD() << " enc margins (in): T=" << QString::number(topIn, 'f', 3).toStdString() + << " L=" << QString::number(leftIn, 'f', 3).toStdString() + << " R=" << QString::number(pageWIn - leftIn - printW, 'f', 3).toStdString() + << " B=" << QString::number(pageHIn - topIn - printH, 'f', 3).toStdString() + << " paper=" << QString::number(pageWIn * 25.4, 'f', 1).toStdString() + << "x" << QString::number(pageHIn * 25.4, 'f', 1).toStdString() << "mm" + << (screenPixelFmt ? " [pixels]" : " [pts]"); + + topIn = std::clamp(topIn, 0.0, pageHIn * kMaxMarginFrac); + leftIn = std::clamp(leftIn, 0.0, pageWIn * kMaxMarginFrac); + + const double maxPrintW = pageWIn - leftIn; + if (printW > maxPrintW) { + printW = maxPrintW; + } + + double bottomIn = std::max(0.0, pageHIn - topIn - printH); + bottomIn = std::min(bottomIn, pageHIn * kMaxMarginFrac); + + LOGD() << " applied (in): T=" << QString::number(topIn, 'f', 3).toStdString() + << " L=" << QString::number(leftIn, 'f', 3).toStdString() + << " R=" << QString::number(pageWIn - leftIn - printW, 'f', 3).toStdString() + << " B=" << QString::number(bottomIn, 'f', 3).toStdString() + << " paper=" << QString::number(pageWIn * 25.4, 'f', 1).toStdString() + << "x" << QString::number(pageHIn * 25.4, 'f', 1).toStdString() << "mm"; + + score->style().set(Sid::pageOddTopMargin, topIn); + score->style().set(Sid::pageEvenTopMargin, topIn); + score->style().set(Sid::pageOddLeftMargin, leftIn); + score->style().set(Sid::pageEvenLeftMargin, leftIn); + score->style().set(Sid::pagePrintableWidth, printW); + score->style().set(Sid::pageOddBottomMargin, bottomIn); + score->style().set(Sid::pageEvenBottomMargin, bottomIn); +} + +// Inclusive [firstBlock, lastBlock] MEAS-block range covered by line[li]. Prefer the stored +// per-line measureCount; when it is absent (0, as in SCO5) fall back to the gap to the next +// line's start, or to totalBlocks for the last line. lastBlock < firstBlock when it spans nothing. +struct LineBlockSpan { + int firstBlock; + int lastBlock; +}; + +static LineBlockSpan lineSpanBlocks(const std::vector& lines, size_t li, int totalBlocks) +{ + const int firstBlock = static_cast(lines[li].start); + int span = static_cast(lines[li].measureCount); + if (span <= 0) { + const int nextStart = (li + 1 < lines.size()) + ? static_cast(lines[li + 1].start) + : totalBlocks; + span = nextStart - firstBlock; + } + return { firstBlock, firstBlock + span - 1 }; +} + +// SystemLocks enforce Encore's line layout as hard constraints so the engine compresses +// spacing within the system rather than redistributing measures across lines. +static void applySystemLocksFromLines(BuildCtx& ctx) +{ + const auto& lines = ctx.enc.lines; + const auto& enc2ms = ctx.encToMsIdx; + const int totalMeas = static_cast(ctx.measuresByIdx.size()); + + for (size_t li = 0; li < lines.size(); ++li) { + const auto [firstBlock, lastBlock] = lineSpanBlocks(lines, li, static_cast(enc2ms.size())); + + if (firstBlock < 0 || lastBlock < firstBlock + || firstBlock >= static_cast(enc2ms.size()) + || lastBlock >= static_cast(enc2ms.size())) { + continue; + } + + const int firstMsIdx = static_cast(enc2ms[static_cast(firstBlock)]); + // Last MuseScore measure = first MS index of the last MEAS block's range plus that block's + // span (the gap to the next block, or to the end). + const int nextBlockMs = (lastBlock + 1 < static_cast(enc2ms.size())) + ? static_cast(enc2ms[static_cast(lastBlock + 1)]) + : totalMeas; + const int lastMsIdx = nextBlockMs - 1; + + if (firstMsIdx < 0 || lastMsIdx < firstMsIdx + || firstMsIdx >= totalMeas || lastMsIdx >= totalMeas) { + continue; + } + + Measure* firstM = ctx.measuresByIdx[static_cast(firstMsIdx)]; + Measure* lastM = ctx.measuresByIdx[static_cast(lastMsIdx)]; + if (!firstM || !lastM) { + continue; + } + ctx.score->addSystemLock(new SystemLock(firstM, lastM)); + } +} + +// pageIdx in EncLineStaffData is the row-on-page counter (0-based, resets each page). +// A page break is placed at the end of line[i] whenever line[i+1].pageIdx <= line[i].pageIdx +// (the counter did not increment, meaning a new page started). +static void applyPageBreaksFromLines(BuildCtx& ctx) +{ + const auto& lines = ctx.enc.lines; + const auto& enc2ms = ctx.encToMsIdx; + const int totalMeas = static_cast(ctx.measuresByIdx.size()); + + for (size_t li = 1; li < lines.size(); ++li) { + const EncLine& prev = lines[li - 1]; + const EncLine& curr = lines[li]; + + if (prev.staffData.empty() || curr.staffData.empty()) { + continue; + } + if (curr.staffData[0].pageIdx > prev.staffData[0].pageIdx) { + continue; // same page: row counter incremented normally + } + + // Page break: add LayoutBreak to the last measure of line[li-1]. + const auto [firstBlock, lastBlock] = lineSpanBlocks(lines, li - 1, static_cast(enc2ms.size())); + if (firstBlock < 0 || lastBlock < firstBlock + || lastBlock >= static_cast(enc2ms.size())) { + continue; + } + const int nextBlockMs = (lastBlock + 1 < static_cast(enc2ms.size())) + ? static_cast(enc2ms[static_cast(lastBlock + 1)]) + : totalMeas; + const int lastMsIdx = nextBlockMs - 1; + if (lastMsIdx < 0 || lastMsIdx >= totalMeas) { + continue; + } + Measure* lastM = ctx.measuresByIdx[static_cast(lastMsIdx)]; + if (!lastM) { + continue; + } + bool alreadyHasPageBreak = false; + for (EngravingItem* e : lastM->el()) { + if (e && e->isLayoutBreak() && toLayoutBreak(e)->isPageBreak()) { + alreadyHasPageBreak = true; + break; + } + } + if (!alreadyHasPageBreak) { + LayoutBreak* lb = Factory::createLayoutBreak(lastM); + lb->setLayoutBreakType(LayoutBreakType::PAGE); + lb->setTrack(0); + lastM->add(lb); + } + } +} + +void applyPageSetup(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + + LOGD() << " importPageLayout=" << (ctx.opts.importPageLayout ? "true" : "false"); + if (ctx.opts.importPageLayout) { + const bool sizeFromPrec = applyPagePrintSetup(score, enc.printSetup); + applyPageMargins(score, enc.pageSetup, sizeFromPrec); + // SCO5 (macOS Encore 5) does not store document margins in any importable block: + // WINI holds only window state, the PREC plist holds only printer rects, and some + // files have no PREC at all. Apply a clean, symmetric 0.25" margin: forcing 0 looks + // cramped (edge to edge), and MuseScore's default margins are tuned for A4 so they + // come out asymmetric on Letter. A small uniform margin is the better default. + if (enc.fmt && enc.fmt->usesUniformPageMargins()) { + constexpr double kMacMarginIn = 0.25; + const double pageWIn = score->style().styleD(Sid::pageWidth); + score->style().set(Sid::pageOddTopMargin, kMacMarginIn); + score->style().set(Sid::pageEvenTopMargin, kMacMarginIn); + score->style().set(Sid::pageOddLeftMargin, kMacMarginIn); + score->style().set(Sid::pageEvenLeftMargin, kMacMarginIn); + score->style().set(Sid::pageOddBottomMargin, kMacMarginIn); + score->style().set(Sid::pageEvenBottomMargin, kMacMarginIn); + score->style().set(Sid::pagePrintableWidth, pageWIn - 2.0 * kMacMarginIn); + } else if (sizeFromPrec && !enc.pageSetup.hasData) { + // No WINI margins, but PREC set the page size (e.g. A4 landscape). MuseScore's default + // printable width is sized for the portrait page, so the extra landscape width becomes a + // lopsided right margin (~4" on A4 landscape). Keep the default margins but recompute the + // printable width so the right margin equals the left. On a portrait page whose size + // matches the default this is a no-op (printable already = width - 2*leftMargin). + const double pageWIn = score->style().styleD(Sid::pageWidth); + const double leftIn = score->style().styleD(Sid::pageOddLeftMargin); + score->style().set(Sid::pagePrintableWidth, pageWIn - 2.0 * leftIn); + } + } + + if (ctx.opts.importSystemLocks) { + applySystemLocksFromLines(ctx); + } + if (ctx.opts.importPageBreaks) { + applyPageBreaksFromLines(ctx); + } +} + +// Page index (0-based) that the first imported PAGE break's measure is laid out on, or -1 when +// there is no such break or its page cannot be resolved (caller then leaves the staff space +// untouched). +static int firstPageBreakPageIndex(MasterScore* score) +{ + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + bool hasPageBreak = false; + for (EngravingItem* e : mb->el()) { + if (e && e->isLayoutBreak() && toLayoutBreak(e)->isPageBreak()) { + hasPageBreak = true; + break; + } + } + if (!hasPageBreak) { + continue; + } + const System* sys = toMeasure(mb)->system(); + if (!sys || !sys->page()) { + return -1; + } + const std::vector& pages = score->pages(); + for (size_t i = 0; i < pages.size(); ++i) { + if (pages[i] == sys->page()) { + return static_cast(i); + } + } + return -1; + } + return -1; +} + +void fitFirstPageStaffSpace(BuildCtx& ctx) +{ + if (!ctx.opts.importPageBreaks) { + return; + } + MasterScore* score = ctx.score; + if (firstPageBreakPageIndex(score) <= 0) { + // -1: no importable page break. 0: the first page already holds all its systems. + return; + } + + const double sp0 = score->style().styleD(Sid::spatium); + constexpr double kStepInches = 0.002; // reduction granularity + constexpr int kMaxSteps = 11; // up to 0.022 inch total + + // Bubble up from the smallest reduction; the first that pulls the spilled system back onto + // the first page is the ideal (least change from Encore's staff size). + for (int k = 1; k <= kMaxSteps; ++k) { + score->style().set(Sid::spatium, sp0 - kStepInches * k * DPI); + score->doLayout(); + if (firstPageBreakPageIndex(score) == 0) { + return; + } + } + + // Even a 0.022 inch reduction was not enough: restore Encore's original staff size. + score->style().set(Sid::spatium, sp0); + score->doLayout(); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/page-layout.h b/src/importexport/encore/internal/importer/page-layout.h new file mode 100644 index 0000000000000..5d99adcef80d5 --- /dev/null +++ b/src/importexport/encore/internal/importer/page-layout.h @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Page and printer geometry for the Encore importer: paper size/orientation/scale from the +// PREC block, margins from the WINI block, the SCO5 uniform-margin default, and the +// system-lock / page-break line layout derived from LINE blocks. + +#pragma once + +namespace mu::iex::enc { +struct BuildCtx; +struct EncPrintSetup; +struct EncRoot; + +// Resolve the page size (inches) from the PREC (DEVMODE) block: dmPaperSize enum, falling back +// to dmPaperLength/Width for custom sizes, with the landscape swap applied. Returns false when +// PREC has no usable size. Exposed for the import debug summary; applyPageSetup uses it too. +bool precPageSizeInches(const EncPrintSetup& pr, double& wIn, double& hIn); + +// Derive the display size index (1-4) for an instrument: per-instrument LINE staff-size hint +// when present, otherwise the global header score-size fallback. Used by applyStaffScale and +// the import debug summary. +int staffDisplaySize(const EncRoot& enc, int instrIdx); + +// Resolve the WINI margin unit (units per inch) from the printable extent: (rightEdge + left) +// over the page width is ~72 when WINI is in typographic points and ~84 when it is screen +// pixels at the monitor DPI. The near-72 case snaps to exactly 72. +double winiUnitsPerInch(int rightEdge, int left, double pageWIn); + +// Apply Encore's page geometry to the score in buildScore's layout phase: page +// size/orientation/scale (PREC), margins (WINI), the SCO5 uniform-margin default, and the +// system-lock / page-break line layout. Each part is gated by its matching import option. +void applyPageSetup(BuildCtx& ctx); + +// When imported page breaks pushed a first-page system onto page 2, shrink the staff space +// (spatium) by the smallest step (up to 0.022 inch in 0.002 increments) that pulls the first +// break's measure back onto page 1. No-op unless page breaks were imported; run after full layout. +void fitFirstPageStaffSpace(BuildCtx& ctx); +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers-fingering.cpp b/src/importexport/encore/internal/importer/resolvers-fingering.cpp new file mode 100644 index 0000000000000..86ae8e4c0dcb2 --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers-fingering.cpp @@ -0,0 +1,321 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass: place pending fingerings and bowing/string marks on their chords. + +#include "resolvers.h" +#include "../parser/elem.h" +#include "../parser/ticks.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/note.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/articulation.h" + +#include +#include + +using namespace mu::engraving; + +namespace mu::iex::enc { +// First chord in m on preferTrack, else on fallbackTrack; *outTrack reports which was used. +static Chord* findFirstChordInMeasure(Measure* m, track_idx_t preferTrack, + track_idx_t fallbackTrack, + track_idx_t* outTrack) +{ + if (!m) { + return nullptr; + } + // Tracks are derived from untrusted file data; ignore any that fall outside the score. + const bool preferOk = validTrack(m->score(), preferTrack); + const bool fallbackOk = validTrack(m->score(), fallbackTrack); + Chord* preferred = nullptr; + Chord* fallback = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + if (!preferred && preferOk) { + EngravingItem* el = s->element(preferTrack); + if (el && el->isChord()) { + preferred = toChord(el); + } + } + if (!fallback && fallbackOk) { + EngravingItem* el = s->element(fallbackTrack); + if (el && el->isChord()) { + fallback = toChord(el); + } + } + if (preferred && fallback) { + break; + } + } + if (preferred) { + *outTrack = preferTrack; + return preferred; + } + if (fallback) { + *outTrack = fallbackTrack; + return fallback; + } + return nullptr; +} + +// True when the ORN's own staff has a note at its raw Encore tick. That tick is an explicit, +// reliable anchor, whereas the xoffset heuristic misfires because ornXoffset and note xoffset use +// different origins; so xoffset correction is only worthwhile when no note sits on the raw tick. +static bool bowingHasNoteAtRawTick(const PendingBowing& pb, const BuildCtx& ctx) +{ + const int staffIdx = static_cast(pb.track / VOICES); + auto it = ctx.noteXoffByMeasStaff.find({ pb.measIdx, staffIdx }); + if (it == ctx.noteXoffByMeasStaff.end()) { + return false; + } + for (const auto& np : it->second) { + if (np.first == pb.encTickRaw) { + return true; + } + } + return false; +} + +// Phase 1: borrow the tick of a same-measure bowing ORN whose xoffset clusters with this one. +static std::optional bowingTickFromMatchingOrn(const PendingBowing& pb, + const std::vector& allBowings) +{ + static constexpr int BOW_XOFF_CLUSTER = 6; + for (const PendingBowing& anchor : allBowings) { + if (&anchor == &pb || anchor.measIdx != pb.measIdx || anchor.encTickRaw == 0) { + continue; + } + if (std::abs(anchor.ornXoffset - pb.ornXoffset) <= BOW_XOFF_CLUSTER) { + return anchor.tick; + } + } + return std::nullopt; +} + +// Phase 2: snap to the note whose xoffset is the closest one at or left of the ORN's xoffset. +static std::optional bowingTickFromNoteXoffset(const PendingBowing& pb, const BuildCtx& ctx) +{ + const int staffIdx = static_cast(pb.track / VOICES); + auto it = ctx.noteXoffByMeasStaff.find({ pb.measIdx, staffIdx }); + if (it == ctx.noteXoffByMeasStaff.end()) { + return std::nullopt; + } + int bestTick = -1; + int bestDiff = INT_MAX; + for (const auto& p : it->second) { // p = { enc_tick, note.xoffset } + const int diff = pb.ornXoffset - p.second; + if (diff >= 0 && diff < bestDiff) { + bestDiff = diff; + bestTick = p.first; + } + } + if (bestTick < 0) { + return std::nullopt; + } + const Measure* m = (pb.measIdx >= 0 && pb.measIdx < static_cast(ctx.measuresByIdx.size())) + ? ctx.measuresByIdx[pb.measIdx] : nullptr; + if (!m) { + return std::nullopt; + } + return m->tick() + Fraction(bestTick, kEncWholeTicks); // bowing snaps to the 960-tick note grid +} + +static void correctBowingTickFromXoffset( + PendingBowing& pb, + const std::vector& allBowings, + const BuildCtx& ctx, + bool multiAtRawTick) +{ + // xoffset == 0 means no visual displacement: the mark is already at its correct note tick. + if (pb.ornXoffset == 0) { + return; + } + // Trust the raw tick when a note sits on the ORN's beat, unless several marks share that beat + // with distinct xoffsets: Encore stores such a run all at the downbeat and spreads it only by + // xoffset, so fall through to xoffset placement to land each on its own note. + if (!multiAtRawTick && bowingHasNoteAtRawTick(pb, ctx)) { + return; + } + if (std::optional t = bowingTickFromMatchingOrn(pb, allBowings)) { + pb.tick = *t; + return; + } + if (std::optional t = bowingTickFromNoteXoffset(pb, ctx)) { + pb.tick = *t; + } +} + +static void applyPendingBowings(BuildCtx& ctx, MasterScore* score) +{ + // Count marks sharing the same measure/staff/raw-tick so a run of articulations + // stored at one downbeat (distinguished only by xoffset) is spread across notes. + std::map, int> marksAtRawTick; + for (const PendingBowing& pb : ctx.pendingBowings) { + if (pb.crossMeasure) { + continue; + } + const int staffIdx = static_cast(pb.track / VOICES); + ++marksAtRawTick[{ pb.measIdx, staffIdx, pb.encTickRaw }]; + } + + // Tick correction: Encore sometimes stores ORN enc tick=0 when the mark visually + // falls on a later beat. Correct before attachment. + for (PendingBowing& pb : ctx.pendingBowings) { + if (pb.crossMeasure || pb.encTickRaw > 0) { + continue; + } + const int staffIdx = static_cast(pb.track / VOICES); + const bool multi = marksAtRawTick[{ pb.measIdx, staffIdx, pb.encTickRaw }] > 1; + correctBowingTickFromXoffset(pb, ctx.pendingBowings, ctx, multi); + } + + // Bowing marks: crossMeasure means Encore misplaced the ORN in the previous measure. + for (const PendingBowing& pb : ctx.pendingBowings) { + track_idx_t useTrack = pb.track; + Chord* c = nullptr; + + if (pb.crossMeasure) { + int nextIdx = pb.measIdx + 1; + if (nextIdx >= 0 && nextIdx < static_cast(ctx.measuresByIdx.size())) { + c = findFirstChordInMeasure(ctx.measuresByIdx[nextIdx], + pb.track + VOICES, pb.track, &useTrack); + } + } else { + Measure* m = score->tick2measure(pb.tick); + if (m) { + Segment* seg = m->findSegment(SegmentType::ChordRest, pb.tick); + if (seg) { + // ORN is always voice 0; scan all voices of own staff before sibling. + const int ownStaff = static_cast(pb.track / VOICES); + c = firstChordVoiceAt(score, seg, ownStaff, useTrack); + if (!c) { + c = firstChordVoiceAt(score, seg, ownStaff + 1, useTrack); + } + } + } + } + + if (!c) { + continue; + } + Articulation* art = Factory::createArticulation(c); + art->setTrack(useTrack); + art->setSymId(pb.symId); + c->add(art); + } +} + +static void applyPendingFingeringOrns(BuildCtx& ctx, MasterScore* score) +{ + // Fingering ORNs (0xB9..0xBD): multiple ORNs at the same tick attach low-to-high. + // crossMeasure: next-measure sibling chord. preferSibling: 2nd-staff chord at same tick. + std::map fingeringCount; + for (const PendingOrnFingering& pf : ctx.pendingOrnFingerings) { + track_idx_t useTrack = pf.track; + Chord* c = nullptr; + + if (pf.crossMeasure) { + int nextIdx = pf.measIdx + 1; + if (nextIdx >= 0 && nextIdx < static_cast(ctx.measuresByIdx.size())) { + c = findFirstChordInMeasure(ctx.measuresByIdx[nextIdx], + pf.track + VOICES, pf.track, &useTrack); + } + } else { + Measure* m = score->tick2measure(pf.tick); + if (m) { + Segment* seg = m->findSegment(SegmentType::ChordRest, pf.tick); + if (seg) { + // Both tracks come from file data; only consult the ones in range. + track_idx_t sibTrack = pf.track + VOICES; + EngravingItem* sibEl = validTrack(score, sibTrack) ? seg->element(sibTrack) : nullptr; + EngravingItem* ownEl = validTrack(score, pf.track) ? seg->element(pf.track) : nullptr; + if (pf.preferSibling) { + if (sibEl && sibEl->isChord()) { + c = toChord(sibEl); + useTrack = sibTrack; + } else if (ownEl && ownEl->isChord()) { + c = toChord(ownEl); + useTrack = pf.track; + } + } else { + if (ownEl && ownEl->isChord()) { + c = toChord(ownEl); + useTrack = pf.track; + } else if (sibEl && sibEl->isChord()) { + c = toChord(sibEl); + useTrack = sibTrack; + } + } + } + } + } + + if (!c) { + continue; + } + const auto& notes = c->notes(); + if (notes.empty()) { + continue; + } + if (pf.isStringNum) { + // String number ORN (0xE6..0xEA): circled number above the top note. + // The per-note artic "options bit 0 + hasScaleStringAnchors" path may have already + // placed a STRING_NUMBER on this note. Skip if one already exists (dedup). + Note* n = notes.back(); + bool alreadyHas = false; + for (EngravingItem* el : n->el()) { + if (el && el->isFingering() + && toFingering(el)->textStyleType() == TextStyleType::STRING_NUMBER) { + alreadyHas = true; + break; + } + } + if (!alreadyHas) { + Fingering* f = Factory::createFingering(n, TextStyleType::STRING_NUMBER); + f->setTrack(useTrack); + f->setXmlText(String::number(pf.fingerNum)); + n->add(f); + } + } else { + int& idx = fingeringCount[c]; + Note* n = notes[std::min(idx, static_cast(notes.size()) - 1)]; + ++idx; + Fingering* f = Factory::createFingering(n); + f->setTrack(useTrack); + f->setXmlText(String::number(pf.fingerNum)); + n->add(f); + } + } +} + +void resolveFingeringAndBowing(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + applyPendingBowings(ctx, score); + applyPendingFingeringOrns(ctx, score); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers-hairpin.cpp b/src/importexport/encore/internal/importer/resolvers-hairpin.cpp new file mode 100644 index 0000000000000..5e6935e932a28 --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers-hairpin.cpp @@ -0,0 +1,186 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass: resolve hairpin (crescendo/diminuendo) spanner endpoints. + +#include "resolvers.h" +#include "coords.h" +#include "../parser/elem.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// Snap the hairpin end to the note/rest nearest xoffset2, else return currentEndTick unchanged. +// targetMeasTick is the MuseScore start tick of enc measure ph.endMeasIdx. +static Fraction resolveHairpinEndByXoffset( + const PendingHairpin& ph, + const EncRoot& enc, + Fraction currentEndTick, + Fraction targetMeasTick) +{ + if (ph.endMeasIdx < 0 + || ph.endMeasIdx >= static_cast(enc.measures.size())) { + return currentEndTick; + } + const EncMeasure& endEncMeas = enc.measures[static_cast(ph.endMeasIdx)]; + const int wholeTicks = encWholeNoteTicks(endEncMeas); + const int xoff2 = ph.hairpinXoffset2; + int bestEncTick = -1; + int bestXoff = -1; + int anyPositiveXoff = -1; // sentinel: any note/rest with xoff > 0 exists + forEachStaffNoteXoff(endEncMeas, ph.staffIdx, /*includeRests*/ true, /*lineSlotByRawByte*/ nullptr, + [&](const EncMeasureElem* em, int xoff) { + if (xoff <= 0) { + return true; + } + anyPositiveXoff = xoff; + if (xoff <= xoff2 && xoff > bestXoff) { + bestXoff = xoff; + bestEncTick = static_cast(em->tick); + } + return true; + }); + if (bestEncTick >= 0) { + // Snap end to the note/rest whose xoffset best matches xoff2. + Fraction snapEnd = targetMeasTick + Fraction(bestEncTick, wholeTicks).reduced(); + return std::min(currentEndTick, snapEnd); + } else if (anyPositiveXoff >= 0) { + // xoff2 precedes all notes with positive xoffsets: end at the barline. + return std::min(currentEndTick, targetMeasTick); + } + // else: no notes with positive xoffsets (synthetic/empty data); keep maxEndTick. + return currentEndTick; +} + +void resolveHairpins(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + + // Pre-pass: detect same-measure CRESC+DIM swell pairs on the same track and split the measure + // at its midpoint so each hairpin covers a half. xoffset2 pixels do not map linearly to ticks + // when the measure has empty beats, so a midpoint split better matches Encore's visual. + const size_t n = ctx.pendingHairpins.size(); + std::vector startOverride(n, Fraction(-1, 1)); // -1 = no override + std::vector endOverride(n, Fraction(-1, 1)); + + for (size_t i = 0; i < n; ++i) { + const PendingHairpin& ph1 = ctx.pendingHairpins[i]; + if (ph1.type != HairpinType::CRESC_HAIRPIN) { + continue; + } + const Measure* m1 = score->tick2measure(ph1.startTick); + if (!m1) { + continue; + } + for (size_t j = i + 1; j < n; ++j) { + const PendingHairpin& ph2 = ctx.pendingHairpins[j]; + if (ph2.track != ph1.track) { + continue; + } + if (ph2.type != HairpinType::DIM_HAIRPIN) { + continue; + } + const Measure* m2 = score->tick2measure(ph2.startTick); + if (m2 != m1) { + continue; // different measures: not a same-measure swell pair + } + // Both CRESC and DIM start in the same measure: split at midpoint. + const Fraction midTick = m1->tick() + m1->ticks() / 2; + endOverride[i] = midTick; // CRESC ends at mid + startOverride[j] = midTick; // DIM starts at mid + endOverride[j] = m1->tick() + m1->ticks(); // DIM ends at barline + break; + } + } + + // Main pass: resolve each hairpin's endTick then create the Hairpin element. + for (size_t i = 0; i < n; ++i) { + const PendingHairpin& ph = ctx.pendingHairpins[i]; + + Fraction startTick = (startOverride[i].numerator() >= 0) ? startOverride[i] : ph.startTick; + Fraction endTick = ph.maxEndTick; + + if (endOverride[i].numerator() >= 0) { + // Swell-pair override: use the pre-computed midpoint or barline. + endTick = endOverride[i]; + } else { + // (1) Next Dynamic on track takes priority; handles mfmf chains. + bool foundNextDynamic = false; + for (Segment* s = score->firstSegment(SegmentType::ChordRest); s; + s = s->next1(SegmentType::ChordRest)) { + if (s->tick() <= ph.startTick) { + continue; + } + if (s->tick() > ph.maxEndTick) { + break; + } + bool stopHere = false; + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isDynamic() && ann->track() == ph.track) { + stopHere = true; + break; + } + } + if (stopHere) { + endTick = std::min(endTick, s->tick()); + foundNextDynamic = true; + break; + } + } + + // (2) xoffset2 snap: find the last note/rest in the target measure with xoffset <= xoff2. + if (!foundNextDynamic + && ph.hairpinXoffset2 > 0 + && ph.endMeasIdx >= 0 + && ph.endMeasIdx < static_cast(enc.measures.size()) + && ph.endMeasIdx < static_cast(ctx.measuresByIdx.size())) { + Fraction targetMeasTick + = ctx.measuresByIdx[static_cast(ph.endMeasIdx)]->tick(); + endTick = resolveHairpinEndByXoffset(ph, enc, endTick, targetMeasTick); + } + } + + if (endTick <= startTick) { + continue; + } + // ph.track is derived from a raw grand-staff voice byte; skip a hairpin whose track is out + // of range rather than hand an out-of-bounds track to the engraving DOM (matches ottava). + if (!validTrack(score, ph.track)) { + continue; + } + Hairpin* hp = Factory::createHairpin(score->dummy()->segment()); + hp->setTrack(ph.track); + hp->setTrack2(ph.track); + hp->setTick(startTick); + hp->setTick2(endTick); + hp->setHairpinType(ph.type); + score->addElement(hp); + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers-ornaments.cpp b/src/importexport/encore/internal/importer/resolvers-ornaments.cpp new file mode 100644 index 0000000000000..587482c5b45d0 --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers-ornaments.cpp @@ -0,0 +1,461 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass: place pending ornaments, fermatas, tremolos, trills, arpeggios and breaths. + +#include "resolvers.h" +#include "../parser/elem.h" +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/note.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/breath.h" +#include "engraving/dom/measurerepeat.h" +#include "engraving/dom/trill.h" +#include "engraving/editing/editmeasurerepeat.h" +#include "engraving/editing/transaction/transaction.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +static void resolveArpeggios(MasterScore* score, + const std::vector& pendingArpeggios) +{ + // ORN precedes chord in MEAS order, so deferred to resolve phase. + for (const PendingArpeggio& pa : pendingArpeggios) { + Chord* c = findChordAt(score, pa.tick, pa.track); + if (!c || c->arpeggio()) { + continue; + } + Arpeggio* arp = Factory::createArpeggio(c); + arp->setTrack(pa.track); + arp->setArpeggioType(ArpeggioType::NORMAL); + c->add(arp); + } +} + +// Locate the Chord a single-chord tremolo ORN belongs to. The ORN's stored tick/voice are +// unreliable (Encore may place it at durTicks or in voice 0 regardless of the note's real voice), +// so the tick->measure->voice fallbacks try, in order: the exact (tick, track) segment; the last +// chord on that track in the source measure; then any voice on that staff. Returns nullptr when no +// chord is found. See ENCORE_FORMAT.md §Ornament element. +static Chord* findChordForTremolo(MasterScore* score, const PendingOrnTremolo& pt) +{ + // staffIdx/msVoice come from the file; reject an out-of-range staff before deriving tracks. + if (!validTrack(score, static_cast(pt.staffIdx) * VOICES)) { + return nullptr; + } + const track_idx_t trTrack = static_cast(pt.staffIdx * VOICES + pt.msVoice); + Measure* m = score->tick2measure(pt.tick); + if (!m) { + m = score->tick2measure(pt.measTick); + } + if (!m) { + return nullptr; + } + Segment* seg = m->findSegment(SegmentType::ChordRest, pt.tick); + if (!seg || !seg->element(trTrack) || !seg->element(trTrack)->isChord()) { + Measure* srcMeas = score->tick2measure(pt.measTick); + if (!srcMeas) { + srcMeas = m; + } + seg = nullptr; + for (Segment* s = srcMeas->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + if (s->element(trTrack) && s->element(trTrack)->isChord()) { + seg = s; + } + } + } + track_idx_t resolvedTrack = trTrack; + if (!seg || !seg->element(resolvedTrack) || !seg->element(resolvedTrack)->isChord()) { + Measure* srcMeas = score->tick2measure(pt.measTick); + if (!srcMeas) { + srcMeas = m; + } + if (srcMeas) { + for (int v = 0; v < static_cast(VOICES) && !seg; ++v) { + const track_idx_t altTrack = static_cast(pt.staffIdx * VOICES + v); + for (Segment* s = srcMeas->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + if (s->element(altTrack) && s->element(altTrack)->isChord()) { + seg = s; + resolvedTrack = altTrack; + } + } + } + } + } + if (!seg || !seg->element(resolvedTrack)) { + return nullptr; + } + EngravingItem* el = seg->element(resolvedTrack); + if (!el || !el->isChord()) { + return nullptr; + } + return toChord(el); +} + +static void resolveSingleChordTremolos(MasterScore* score, + const std::vector& pendingOrnTremolos) +{ + for (const PendingOrnTremolo& pt : pendingOrnTremolos) { + Chord* c = findChordForTremolo(score, pt); + if (!c) { + continue; + } + // Encore places the tremolo ORN after the tied-from note; walk back to tie start. + if (!c->notes().empty() && c->notes().front()->tieBack()) { + Chord* prev = c->notes().front()->tieBack()->startNote()->chord(); + if (prev) { + c = prev; + } + } + if (c->tremoloSingleChord()) { + continue; + } + TremoloSingleChord* trem = Factory::createTremoloSingleChord(c); + trem->setTremoloType(pt.tremType); + c->add(trem); + } +} + +static void resolveMarkers(MasterScore* score, + const std::vector& pendingMarkers) +{ + for (const PendingMarker& pm : pendingMarkers) { + Measure* m = score->tick2measure(pm.tick); + if (!m) { + continue; + } + Marker* mk = Factory::createMarker(m); + mk->setMarkerType(pm.type); + mk->setTrack(0); + m->add(mk); + } +} + +// True when seg already carries a Fermata with this symId (a per-note artic byte may have +// produced the same glyph; dedup against it). +static bool segmentHasFermata(const Segment* seg, SymId symId) +{ + for (EngravingItem* e : seg->annotations()) { + if (e->isFermata() && toFermata(e)->symId() == symId) { + return true; + } + } + return false; +} + +// True when chord c already carries an Articulation with this symId (dedup against a +// per-note artic byte that produced the same glyph). +static bool chordHasArticulation(const Chord* c, SymId symId) +{ + for (Articulation* a : c->articulations()) { + if (a->symId() == symId) { + return true; + } + } + return false; +} + +static void resolveFermatas(MasterScore* score, + const std::vector& pendingFermatas) +{ + for (const PendingFermata& pf : pendingFermatas) { + Chord* c = findChordAt(score, pf.tick, pf.track); + if (!c) { + continue; + } + Segment* seg = c->segment(); + if (segmentHasFermata(seg, pf.symId)) { + continue; + } + const bool isBelow = (pf.symId == SymId::fermataBelow + || pf.symId == SymId::fermataShortBelow + || pf.symId == SymId::fermataLongBelow); + Fermata* fermata = Factory::createFermata(seg); + fermata->setTrack(pf.track); + fermata->setSymId(pf.symId); + fermata->setPlacement(isBelow ? PlacementV::BELOW : PlacementV::ABOVE); + fermata->setPropertyFlags(Pid::PLACEMENT, PropertyFlags::UNSTYLED); + seg->add(fermata); + } +} + +static void resolveStaccatos(MasterScore* score, + const std::vector& pendingStaccatos) +{ + // Dedup: artic byte 0x1D produces the same glyph. + for (const PendingStaccato& ps : pendingStaccatos) { + Chord* c = findChordAt(score, ps.tick, ps.track); + if (!c) { + continue; + } + if (chordHasArticulation(c, SymId::articStaccatoAbove) + || chordHasArticulation(c, SymId::articStaccatoBelow)) { + continue; + } + Articulation* art = Factory::createArticulation(c); + art->setTrack(ps.track); + art->setSymId(SymId::articStaccatoAbove); + c->add(art); + } +} + +// True when a non-alt trill on the same track starts before pt, so pt's alt glyph sits within +// that earlier trill's span (Ornament glyph only, no new spanner). +static bool hasEarlierTrillStart(const std::vector& pendingTrills, const PendingTrill& pt) +{ + for (const PendingTrill& other : pendingTrills) { + if (!other.isAlt && other.track == pt.track && other.tick < pt.tick) { + return true; + } + } + return false; +} + +static void resolveTrillsWithSpans(MasterScore* score, + const std::vector& pendingTrills, + std::map >& pendingTrillEnds, + const std::vector& measuresByIdx) +{ + // (A) TRILL_ALT within a prior TRILL_START span: Ornament glyph only. + // (B) TRILL_ALT standalone: spanner on note duration. + // (C) TRILL_START: spanner when endpoint found; glyph otherwise. + for (const PendingTrill& pt : pendingTrills) { + Fraction trillTick = pt.tick; + Chord* trillChord = findChordAt(score, trillTick, pt.track); + if (!trillChord) { + // TRILL_SIMPLE may land on a rest tick; snap forward to next chord in measure. + if (pt.isAlt) { + Measure* m = score->tick2measure(pt.tick); + if (m) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + if (s->tick() < pt.tick) { + continue; + } + EngravingItem* el = s->element(pt.track); + if (el && el->isChord()) { + trillChord = toChord(el); + trillTick = s->tick(); + break; + } + } + } + } + if (!trillChord) { + continue; + } + } + + const bool altWithinSpan = pt.isAlt && hasEarlierTrillStart(pendingTrills, pt); + const bool standaloneAlt = pt.isAlt && !altWithinSpan; + + Fraction endTick; + bool hasSpan = !altWithinSpan; + + if (hasSpan) { + hasSpan = false; + auto it = pendingTrillEnds.find(pt.track); + if (it != pendingTrillEnds.end()) { + auto& endVec = it->second; + for (auto eit = endVec.begin(); eit != endVec.end(); ++eit) { + if (*eit > trillTick) { + endTick = *eit; + hasSpan = true; + endVec.erase(eit); + break; + } + } + } + // Cross-measure span via alMezuro field. + if (!hasSpan && pt.alMezuro > 0) { + const size_t endMeasIdx = pt.measIdx + static_cast(pt.alMezuro); + if (endMeasIdx < measuresByIdx.size()) { + Measure* endMeas = measuresByIdx[endMeasIdx]; + if (endMeas) { + endTick = endMeas->endTick(); + hasSpan = true; + } + } + } + } + + if (pt.isSimple) { + hasSpan = false; + } else if (standaloneAlt && (!hasSpan || endTick <= trillTick)) { + const Fraction noteDuration = trillChord->actualTicks(); + if (!noteDuration.isZero()) { + endTick = trillChord->tick() + noteDuration; + hasSpan = true; + } + } + + if (hasSpan && endTick > trillTick) { + Trill* trill = Factory::createTrill(score->dummy()); + trill->setTrack(pt.track); + trill->setTrack2(pt.track); + trill->setTick(trillTick); + trill->setTick2(endTick); + trill->setTrillType(TrillType::TRILL_LINE); + score->addElement(trill); + } else { + const SymId sid = pt.isSimple ? pt.simpleSymId : SymId::ornamentTrill; + bool alreadyHas = false; + for (Articulation* a : trillChord->articulations()) { + if (a && a->isOrnament() && toOrnament(a)->symId() == sid) { + alreadyHas = true; + break; + } + } + if (!alreadyHas) { + Ornament* orn = Factory::createOrnament(trillChord); + orn->setTrack(pt.track); + orn->setSymId(sid); + trillChord->add(orn); + } + } + } +} + +static void resolveUnconsumedTrillEnds(MasterScore* score, + std::map >& pendingTrillEnds) +{ + // TRILL_END markers not consumed by TRILL_START: create a spanner on the note's duration. + for (auto& [trTrack, endTicks] : pendingTrillEnds) { + for (const Fraction& eTick : endTicks) { + Chord* c = findChordAt(score, eTick, trTrack); + if (!c) { + continue; + } + const Fraction noteDuration = c->actualTicks(); + if (noteDuration.isZero()) { + continue; + } + Trill* trill = Factory::createTrill(score->dummy()); + trill->setTrack(trTrack); + trill->setTrack2(trTrack); + trill->setTick(c->tick()); + trill->setTick2(c->tick() + noteDuration); + trill->setTrillType(TrillType::TRILL_LINE); + score->addElement(trill); + } + } + pendingTrillEnds.clear(); +} + +static void resolveBreaths(MasterScore* score, + const std::vector& pendingBreaths) +{ + // pb.tick is the following note; attach after the preceding chord. + // If pb.tick is at a measure boundary, that chord is in the prior measure. + for (const PendingBreath& pb : pendingBreaths) { + Measure* m = score->tick2measure(pb.tick); + if (m && m->tick() == pb.tick) { + MeasureBase* prevBase = m->prev(); + while (prevBase && !prevBase->isMeasure()) { + prevBase = prevBase->prev(); + } + if (prevBase) { + m = toMeasure(prevBase); + } + } + if (!m) { + continue; + } + Chord* prevChord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(pb.track); + if (el && el->isChord()) { + Chord* c = toChord(el); + if (c->tick() + c->actualTicks() <= pb.tick) { + prevChord = c; + } + } + } + const Fraction breathTick = prevChord + ? prevChord->tick() + prevChord->actualTicks() + : pb.tick; + Measure* breathMeasure = prevChord ? prevChord->measure() : m; + if (!breathMeasure) { + continue; + } + Segment* seg = breathMeasure->getSegment(SegmentType::Breath, breathTick); + Breath* breath = Factory::createBreath(seg); + breath->setTrack(pb.track); + breath->setSymId(pb.symId); + breath->setPlacement(PlacementV::ABOVE); + breath->setPropertyFlags(Pid::PLACEMENT, PropertyFlags::UNSTYLED); + seg->add(breath); + } +} + +static void resolveMeasureRepeats(MasterScore* score, + const std::vector& pendingMeasureRepeats) +{ + // Replace measure content with "%" symbol. + for (const PendingMeasureRepeat& pmr : pendingMeasureRepeats) { + Measure* m = score->tick2measure(pmr.measTick); + if (!m) { + continue; + } + const track_idx_t track = static_cast(pmr.staffIdx) * VOICES; + Segment* firstSeg = m->first(SegmentType::ChordRest); + if (!firstSeg) { + continue; + } + Staff* st = score->staff(static_cast(pmr.staffIdx)); + if (!st) { + continue; + } + score->makeGap(firstSeg, track, m->stretchedLen(st), nullptr); + EditMeasureRepeat::addMeasureRepeat(score->transactionManager()->currentOrDummyTransaction(), score, m->tick(), track, 1); + m->setMeasureRepeatCount(1, static_cast(pmr.staffIdx)); + } +} + +void resolveOrnaments(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + resolveArpeggios(score, ctx.pendingArpeggios); + resolveSingleChordTremolos(score, ctx.pendingOrnTremolos); + resolveMarkers(score, ctx.pendingMarkers); + resolveFermatas(score, ctx.pendingFermatas); + resolveStaccatos(score, ctx.pendingStaccatos); + resolveTrillsWithSpans(score, ctx.pendingTrills, ctx.pendingTrillEnds, ctx.measuresByIdx); + resolveUnconsumedTrillEnds(score, ctx.pendingTrillEnds); + resolveBreaths(score, ctx.pendingBreaths); + resolveMeasureRepeats(score, ctx.pendingMeasureRepeats); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers-ottava.cpp b/src/importexport/encore/internal/importer/resolvers-ottava.cpp new file mode 100644 index 0000000000000..55809a9edb682 --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers-ottava.cpp @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass: resolve ottava (8va/8vb) line spanner endpoints. + +#include "resolvers.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/ottava.h" + +#include + +using namespace mu::engraving; + +namespace mu::iex::enc { +void resolveOttavas(BuildCtx& ctx) +{ + if (ctx.pendingOttavas.empty()) { + return; + } + + MasterScore* score = ctx.score; + + // Sort by staffIdx asc, then startTick asc so the "next ottava" lookup is a simple i+1 check. + std::vector sorted = ctx.pendingOttavas; + std::sort(sorted.begin(), sorted.end(), [](const PendingOttava& a, const PendingOttava& b) { + if (a.staffIdx != b.staffIdx) { + return a.staffIdx < b.staffIdx; + } + return a.startTick < b.startTick; + }); + + Measure* lastMeasure = score->lastMeasure(); + const Fraction scoreEnd = lastMeasure ? lastMeasure->endTick() : Fraction(0, 1); + + for (size_t i = 0; i < sorted.size(); ++i) { + const PendingOttava& po = sorted[i]; + + // po.track is derived from file data; skip a spanner whose track is out of range + // rather than hand an out-of-bounds track to the engraving DOM. + if (!validTrack(score, po.track)) { + continue; + } + + // Endpoint: start of the next ottava on the same staff, or end of score. + Fraction endTick = scoreEnd; + if (i + 1 < sorted.size() && sorted[i + 1].staffIdx == po.staffIdx) { + endTick = sorted[i + 1].startTick; + } + + if (endTick <= po.startTick) { + continue; + } + + Ottava* ottava = Factory::createOttava(score->dummy()); + ottava->setAnchor(Spanner::Anchor::SEGMENT); + ottava->setTrack(po.track); + ottava->setTrack2(po.track); + ottava->setTick(po.startTick); + ottava->setTick2(endTick); + ottava->setOttavaType(po.ottavaType); + score->addElement(ottava); + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers-slur.cpp b/src/importexport/encore/internal/importer/resolvers-slur.cpp new file mode 100644 index 0000000000000..2e908216a4fa9 --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers-slur.cpp @@ -0,0 +1,552 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass: resolve slur spanner endpoints (Encore stores no explicit slur end). + +#include "resolvers.h" +#include "coords.h" +#include "../parser/elem.h" +#include +#include +#include +#include +#include "engraving/dom/chord.h" +#include "engraving/dom/slur.h" +#include "engraving/dom/factory.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/note.h" +#include "log.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// ps.track is captured before the note is emitted, so it may be stale after stream-overflow +// voice reassignment; find the voice that actually carries a chord at tick. +static track_idx_t resolveChordTrack(MasterScore* score, Fraction tick, int staffIdx, track_idx_t fallback) +{ + Segment* seg = score->tick2segment(tick, false, SegmentType::ChordRest); + track_idx_t t = fallback; + return firstChordVoiceAt(score, seg, staffIdx, t) ? t : fallback; +} + +static int getLineSlot(const EncMeasureElem* em, const std::array& lineSlotByRawByte) +{ + const quint8 raw = em->rawStaffByte(); + const int slot = lineSlotByRawByte[static_cast(raw)]; + return (slot >= 0) ? slot : static_cast(em->staffIdx); +} + +static void removeOrphanSlurs(MasterScore* score, const std::set& explicitSlurs) +{ + std::vector toRemove; + for (auto& [tick, spanner] : score->spannerMap().map()) { + if (spanner->isSlur()) { + // Slurs anchored to explicit chord elements (reliable measure-count path): keep as-is, + // recompute would null them out at bar boundaries. + if (explicitSlurs.count(spanner)) { + continue; + } + // computeStartElement() would resolve to the regular chord, not the explicitly set + // grace sub-chord (tick2segment cannot see it), so skip the recompute for grace starts. + const bool graceStart = spanner->startElement() + && spanner->startElement()->isChord() + && toChord(spanner->startElement())->isGrace(); + if (!graceStart) { + spanner->computeStartElement(); + } + // Grace-to-main (tick == tick2): computeEndElement() would fail (no segment at the + // same tick via the spanner lookup), so skip it. Grace-to-later resolves normally. + const bool graceToMain = graceStart + && (spanner->tick() == spanner->tick2()); + if (!graceToMain) { + spanner->computeEndElement(); + } + // An overfull measure can make a slur's start and end grips resolve to the same + // chord (zero-length arc); its layout takes atan of a zero span and asserts on a + // NaN Bezier control point, so drop it along with the orphans. + if (!spanner->startElement() || !spanner->endElement() + || spanner->startElement() == spanner->endElement()) { + toRemove.push_back(spanner); + } + } + } + for (Spanner* sp : toRemove) { + score->removeElement(sp); + } +} + +static void createGraceToMainSlur(const PendingSlur& ps, MasterScore* score, Fraction startTick) +{ + // Zero span (SLURSTART tick == parent chord tick): a grace note slurs to its own main note. + Segment* gSeg = score->tick2segment(startTick, true, SegmentType::ChordRest); + if (gSeg) { + const track_idx_t graceTrack = resolveChordTrack(score, startTick, ps.staffIdx, ps.track); + EngravingItem* el = gSeg->element(graceTrack); + if (el && el->isChord()) { + const std::vector graces = toChord(el)->graceNotesBefore(); + if (!graces.empty()) { + Slur* gSlur = Factory::createSlur(score->dummy()); + gSlur->setTrack(graceTrack); + gSlur->setTrack2(graceTrack); + gSlur->setTick(startTick); + // tick2 == tick signals graceToMain=true to the post-pass, preventing computeEndElement() + // from replacing the explicit end element with an end-of-measure chord. + gSlur->setTick2(startTick); + gSlur->setStartElement(graces.front()); + gSlur->setEndElement(el); + // false: keep the explicit grace endpoints instead of recomputing them. + score->addSpanner(gSlur, false); + } + } + } +} + +static void createNormalSlur(const PendingSlur& ps, track_idx_t startTrack, track_idx_t endTrack, + Fraction endTick, MasterScore* score) +{ + Slur* slur = Factory::createSlur(score->dummy()); + slur->setTrack(startTrack); + slur->setTrack2(endTrack); + slur->setTick(ps.startTick); + slur->setTick2(endTick); + // If the chord at/after startTick has grace notes, the SLURSTART belongs to the grace; anchor + // there. tick2rightSegment absorbs grace-note tick stealing (startTick can be just before it). + { + Segment* rSeg = score->tick2rightSegment(ps.startTick, false, SegmentType::ChordRest); + if (rSeg) { + EngravingItem* rEl = rSeg->element(startTrack); + if (rEl && rEl->isChord()) { + const std::vector graces = toChord(rEl)->graceNotesBefore(); + if (!graces.empty()) { + slur->setStartElement(graces.front()); + } + } + } + } + score->addElement(slur); +} + +// Fallback 1: xoffset2 directly comparable within target measure (xoffsets reset at barlines). +// Returns the best candidate endTick, or nullopt if no note found. +static std::optional resolveCrossMeasureXoffset( + const PendingSlur& ps, const EncMeasure& endEncMeas, + Measure* endMeas, const std::array& lineSlotByRawByte) +{ + const int wholeTicks = encWholeNoteTicks(endEncMeas); + int bestDist = std::numeric_limits::max(); + int bestEncTick = -1; + forEachStaffNoteXoff(endEncMeas, ps.staffIdx, /*includeRests*/ false, &lineSlotByRawByte, + [&](const EncMeasureElem* em, int xoff) { + const int dist = std::abs(xoff - ps.slurXoffset2); + if (dist <= bestDist) { + bestDist = dist; + bestEncTick = static_cast(em->tick); + } + return true; + }); + if (bestEncTick >= 0 && wholeTicks > 0) { + const Fraction candidate = endMeas->tick() + Fraction(bestEncTick, wholeTicks).reduced(); + if (candidate > ps.startTick) { + return candidate; + } + } + return std::nullopt; +} + +// Fallback 2: last chord/rest in the target measure on any voice of the same staff. +// Returns the tick or nullopt if the measure has no chords on that staff. +static std::optional resolveLastChordInMeasure(const PendingSlur& ps, Measure* endMeas) +{ + Segment* lastSeg = nullptr; + for (Segment* s = endMeas->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + track_idx_t t = static_cast(ps.staffIdx * VOICES + v); + if (s->element(t) && s->element(t)->isChord()) { + lastSeg = s; + break; + } + } + } + if (!lastSeg) { + return std::nullopt; + } + return lastSeg->tick(); +} + +// First chord at or after `from` on any voice of the staff within the measure. +// Sets outTrack to the voice that carries it. Returns nullptr if none. +// Iterates ChordRest segments directly because tick2segment is unreliable at bar boundaries. +static Chord* firstChordOnStaffFrom(const Score* score, Measure* m, int staffIdx, + const Fraction& from, track_idx_t& outTrack) +{ + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + if (s->tick() < from) { + continue; + } + if (Chord* c = firstChordVoiceAt(score, s, staffIdx, outTrack)) { + return c; + } + } + return nullptr; +} + +// The xoffset of the slur's start note: scan the start measure for the NOTE at startEncTick on +// this staff (any voice, since the slur ORN's encVoice is the arc position, not the note voice) +// and return its xoffset. A grace note at that tick wins over a regular one (v0xC4 serializes the +// regular note first, but the grace xoffset is the true arc-start reference). Returns -1 when no +// start note is found. See ENCORE_FORMAT.md §Slur. +static int findSlurStartNoteXoffset(const EncMeasure& startEncMeas, int staffIdx, int startEncTick, + const std::array& lineSlotByRawByte) +{ + int firstNoteXoff = -1; + int graceXoff = -1; + forEachStaffNoteXoff(startEncMeas, staffIdx, /*includeRests*/ false, &lineSlotByRawByte, + [&](const EncMeasureElem* em, int xoff) { + if (static_cast(em->tick) != startEncTick) { + return true; + } + const EncNote* en = static_cast(em); + if (en->graceType() != EncGraceType::NORMAL) { + graceXoff = xoff; // grace wins; stop searching + return false; + } + if (firstNoteXoff < 0) { + firstNoteXoff = xoff; // regular: tentative, keep searching + } + return true; + }); + return (graceXoff >= 0) ? graceXoff : firstNoteXoff; +} + +// Cross-measure endpoint search: when alMezuro is unreliable and the arc clearly runs past the +// start measure, scan the next one or two measures for the note whose xoffset best matches +// targetEndXoff. bestDist is in/out: only a strictly closer note updates it and yields a result. +// Returns the endpoint tick when a closer note is found, else nullopt (caller keeps its endpoint). +static std::optional extendSlurToLaterMeasures( + BuildCtx& ctx, const PendingSlur& ps, const std::array& lineSlotByRawByte, + int targetEndXoff, int& bestDist) +{ + const EncRoot& enc = ctx.enc; + std::optional result; + for (int nextMIdx = ps.startMeasIdx + 1; + nextMIdx <= ps.startMeasIdx + 2 + && nextMIdx < static_cast(enc.measures.size()) + && nextMIdx < static_cast(ctx.measuresByIdx.size()); + ++nextMIdx) { + const EncMeasure& nextEncMeas = enc.measures[nextMIdx]; + const int nextWt = encWholeNoteTicks(nextEncMeas); + Measure* nextMs = ctx.measuresByIdx[nextMIdx]; + forEachStaffNoteXoff(nextEncMeas, ps.staffIdx, /*includeRests*/ false, &lineSlotByRawByte, + [&](const EncMeasureElem* em, int xoff) { + const int dist = std::abs(xoff - targetEndXoff); + if (dist < bestDist) { + bestDist = dist; + const Fraction endRel(static_cast(em->tick), nextWt); + result = nextMs->tick() + endRel.reduced(); + } + return true; + }); + if (bestDist == 0) { + break; + } + } + return result; +} + +static std::optional resolveSameMeasureHeuristic( + BuildCtx& ctx, const PendingSlur& ps, const std::array& lineSlotByRawByte) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + const bool tryHeuristic = (!ps.alMezuroValid || ps.alMezuro == 0) + && ps.startMeasIdx >= 0 + && ps.startMeasIdx < static_cast(enc.measures.size()); + if (!tryHeuristic) { + return std::nullopt; + } + Fraction endTick; + bool resolved = false; + + const EncMeasure& startEncMeas = enc.measures[ps.startMeasIdx]; + const Fraction relStartTick = ps.startTick - ctx.measuresByIdx[ps.startMeasIdx]->tick(); + const int wt = encWholeNoteTicks(startEncMeas); + const int startEncTick = (relStartTick.numerator() * wt) + / std::max(1, relStartTick.denominator()); + const int firstNoteXoff = findSlurStartNoteXoffset(startEncMeas, ps.staffIdx, startEncTick, + lineSlotByRawByte); + if (firstNoteXoff >= 0) { + const int pixelSpan = ps.slurXoffset2 - ps.slurXoffset; + // Tiny pixelSpan (0-2) with note before arc start: firstNoteXoff+pixelSpan near 0 matches a decoy. + // Use slurXoffset2 directly as the arc-end target instead. + const bool usedTinyPixelSpan = (pixelSpan >= 0 && pixelSpan <= 2 + && firstNoteXoff < ps.slurXoffset); + // v0xC2 short slur: slurXoffset2 lives in a stale ornament-coordinate origin, so matching + // it over-extends the arc. pixelSpan is origin-independent but only distinguishes short + // from long, so a tiny span is treated as a note-to-next-note slur (anchored below). + const bool v0c2ShortSlur = enc.fmt->slurXoffset2Stale() + && (std::abs(pixelSpan) <= 2); + const int targetEndXoff = usedTinyPixelSpan + ? ps.slurXoffset2 + : firstNoteXoff + pixelSpan; + // One pass: pick the best later-note endpoint and detect a grace/regular co-location + // at the start (the grace-to-main shortcut below). + { + int bestDist = std::numeric_limits::max(); + int bestEncTick = -1; + int maxXoffInMeas = -1; + bool hasGraceAtStart = false; + int regularXoffAtStart = -1; + for (const auto& elem : startEncMeas.elements) { + const EncMeasureElem* em = elem.get(); + if (em->type != static_cast(EncElemType::NOTE)) { + continue; + } + if (getLineSlot(em, lineSlotByRawByte) != ps.staffIdx) { + continue; + } + const int xoff = static_cast(em->xoffset); + if (xoff > maxXoffInMeas) { + maxXoffInMeas = xoff; + } + if (static_cast(em->tick) == startEncTick) { + const EncNote* en = static_cast(em); + if (en->graceType() != EncGraceType::NORMAL) { + hasGraceAtStart = true; + } else { + // Gap notes often have xoffset=0; keep the best-matching regular note. + const int thisDist = std::abs(xoff - targetEndXoff); + if (regularXoffAtStart < 0 + || thisDist < std::abs(regularXoffAtStart - targetEndXoff)) { + regularXoffAtStart = xoff; + } + } + } + // Only notes strictly after the start can be endpoints. + if (static_cast(em->tick) <= startEncTick) { + continue; + } + if (v0c2ShortSlur) { + // Next-note rule: pick the earliest note after the start. + if (bestEncTick < 0 || static_cast(em->tick) < bestEncTick) { + bestEncTick = static_cast(em->tick); + bestDist = 0; + } + continue; + } + const int dist = std::abs(xoff - targetEndXoff); + if (dist < bestDist) { + bestDist = dist; + bestEncTick = static_cast(em->tick); + } + } + // Grace-to-main: grace + regular share startEncTick and regular is closest match: zero-span. + // If a later note is closer, resolve as grace-to-later instead. + // For a v0xC2 short slur the next-note rule sets bestDist=0, so the distance + // comparison can never pick grace-to-main; a grace at the start is the strong + // signal that the slur ornaments its own main note, so prefer zero-span there. + if (hasGraceAtStart && regularXoffAtStart >= 0) { + const int regularDist = std::abs(regularXoffAtStart - targetEndXoff); + if (v0c2ShortSlur || regularDist < bestDist) { + endTick = ps.startTick; + resolved = true; + } + } + if (!resolved && bestEncTick > startEncTick) { + const Fraction endRel(bestEncTick, wt); + const Fraction candidate = ctx.measuresByIdx[ps.startMeasIdx]->tick() + + endRel; + // Snap to chord segment: grace notes steal time, shifting cumTick earlier than proportional tick. + Measure* sMeas = ctx.measuresByIdx[ps.startMeasIdx]; + Segment* snappedSeg = score->tick2leftSegment( + candidate, false, SegmentType::ChordRest); + if (snappedSeg && snappedSeg->measure() == sMeas + && snappedSeg->tick() >= ps.startTick) { + bool hasChord = false; + for (int v = 0; v < static_cast(VOICES) && !hasChord; ++v) { + track_idx_t t = static_cast( + ps.staffIdx * VOICES + v); + if (snappedSeg->element(t) + && snappedSeg->element(t)->isChord()) { + hasChord = true; + } + } + endTick = hasChord ? snappedSeg->tick() : candidate; + } else { + endTick = candidate; + } + resolved = true; + } + // Cross-measure extension when alMezuro is unreliable and the arc endpoint clearly + // exceeds the start measure. Excluded for tiny-pixelspan slurs (ornament placed after + // first note) because their targetEndXoff is slurXoffset2, which may be in the next + // measure's coordinate space and would produce a false positive. Also excluded when + // the same-measure search already resolved to a zero-span (grace-to-main) endpoint. + if (!ps.alMezuroValid && !usedTinyPixelSpan && bestDist > 0 + && (targetEndXoff > maxXoffInMeas || bestEncTick < 0) + && !(resolved && endTick == ps.startTick)) { + if (auto ext = extendSlurToLaterMeasures(ctx, ps, lineSlotByRawByte, + targetEndXoff, bestDist)) { + endTick = *ext; + resolved = true; + } + } + } // integrated endpoint-search block + } + return resolved ? std::optional(endTick) : std::nullopt; +} + +void resolveSlurs(BuildCtx& ctx) +{ + MasterScore* score = ctx.score; + const EncRoot& enc = ctx.enc; + + // Raw-staff-byte to LINE-slot lookup (shared with the emitter's staff/voice routing). + std::array lineSlotByRawByte; + buildLineSlotByRawByte(enc, lineSlotByRawByte); + + // Slurs whose endpoints were set explicitly (reliable measure-count path); excluded from + // the recompute in removeOrphanSlurs. + std::set explicitSlurs; + + // The v0xC2 slur measure-count (element +16) is unreliable: some files store noise or a + // per-staff constant there rather than a per-slur forward count. Two tells mark the whole + // file's field as junk, so every slur then resolves by the xoffset heuristic: (1) any count + // pointing past the last measure, and (2) the same multi-measure count repeated at different + // start measures (a real span varies per slur). See ENCORE_FORMAT.md §Slur. + bool v0c2SlurCountUnreliable = false; + if (enc.fmt->slurXoffset2Stale()) { + const int measCount = static_cast(ctx.measuresByIdx.size()); + std::map > startMeasuresByCount; // alMezuro value -> distinct start measures + for (const PendingSlur& ps : ctx.pendingSlurs) { + if (!ps.alMezuroValid || ps.alMezuro <= 0) { + continue; + } + if (ps.startMeasIdx + ps.alMezuro >= measCount) { + v0c2SlurCountUnreliable = true; + break; + } + // A multi-measure span (>= 3) repeated at two or more different start measures is a + // constant, not a per-slur count. Small spans (1-2 measures) legitimately recur. + if (ps.alMezuro >= 3) { + startMeasuresByCount[ps.alMezuro].insert(ps.startMeasIdx); + if (startMeasuresByCount[ps.alMezuro].size() >= 2) { + v0c2SlurCountUnreliable = true; + break; + } + } + } + } + + // .enc has no SLURSTOP; endpoint derived from alMezuro (target measure) + xoffset heuristic. + for (PendingSlur ps : ctx.pendingSlurs) { + // File-level: +16 is noise here, so drop the count and let the heuristic anchor the arc. + if (v0c2SlurCountUnreliable) { + ps.alMezuroValid = false; + ps.alMezuro = 0; + ps.endMeasIdx = ps.startMeasIdx; + } + // When alMezuro is not a reliable measure count, clamp to the start measure + // so the same-measure xoffset heuristic handles it. + int clampedEndMeasIdx = ps.endMeasIdx; + if (!ps.alMezuroValid && ps.startMeasIdx >= 0 + && ps.startMeasIdx < static_cast(ctx.measuresByIdx.size())) { + clampedEndMeasIdx = ps.startMeasIdx; + } + if (clampedEndMeasIdx < 0 + || clampedEndMeasIdx >= static_cast(ctx.measuresByIdx.size())) { + continue; + } + Measure* endMeas = ctx.measuresByIdx[clampedEndMeasIdx]; + Fraction endTick; + bool resolved = false; + + // v0xC2 reliable forward measure-count (element +16): Encore draws these as + // note-1-to-note-1 arcs between bar starts; xoffset2 is stale in this format, so anchor + // explicitly to the downbeat chord of the target measure rather than guessing by + // coordinate. v0xC4/SCO5 keep the xoffset2 heuristic (reliable there). + // tick2segment is unreliable at bar boundaries (computeEndElement returns null there), + // so locate both endpoints by iterating ChordRest segments and set the slur elements + // explicitly; these are protected from recompute in removeOrphanSlurs. + if (enc.fmt->slurXoffset2Stale() && ps.alMezuroValid && ps.alMezuro > 0 + && ps.startMeasIdx >= 0 + && ps.startMeasIdx < static_cast(ctx.measuresByIdx.size())) { + Measure* startMeas = ctx.measuresByIdx[ps.startMeasIdx]; + track_idx_t st = 0, et = 0; + Chord* sc = firstChordOnStaffFrom(score, startMeas, ps.staffIdx, ps.startTick, st); + Chord* ec = firstChordOnStaffFrom(score, endMeas, ps.staffIdx, endMeas->tick(), et); + if (sc && ec && ec->tick() > sc->tick()) { + Slur* slur = Factory::createSlur(score->dummy()); + slur->setTrack(st); + slur->setTrack2(et); + slur->setTick(sc->tick()); + slur->setTick2(ec->tick()); + slur->setStartElement(sc); + slur->setEndElement(ec); + score->addSpanner(slur, false); // explicit endpoints: skip recompute + explicitSlurs.insert(slur); + continue; + } + } + + if (auto t = resolveSameMeasureHeuristic(ctx, ps, lineSlotByRawByte)) { + endTick = *t; + resolved = true; + } + + const track_idx_t startTrack = resolveChordTrack(score, ps.startTick, ps.staffIdx, ps.track); + + // Fallback 1: cross-measure xoffset2 matching. + if (!resolved && clampedEndMeasIdx < static_cast(enc.measures.size())) { + if (auto t = resolveCrossMeasureXoffset(ps, enc.measures[clampedEndMeasIdx], + endMeas, lineSlotByRawByte)) { + endTick = *t; + resolved = true; + } + } + + // Fallback 2: last chord in target measure. + if (!resolved) { + if (auto t = resolveLastChordInMeasure(ps, endMeas)) { + endTick = *t; + } else { + continue; // no chord on this staff: skip slur + } + } + + if (endTick < ps.startTick) { + continue; // negative span: always drop + } + if (endTick == ps.startTick) { + createGraceToMainSlur(ps, score, ps.startTick); + continue; // handled as grace-to-main or dropped (no grace notes) + } + const track_idx_t endTrack = resolveChordTrack(score, endTick, ps.staffIdx, startTrack); + createNormalSlur(ps, startTrack, endTrack, endTick, score); + } + + // Remove slurs with missing start/end note (corrupted files cause NaN in Bezier layout). + removeOrphanSlurs(score, explicitSlurs); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers.cpp b/src/importexport/encore/internal/importer/resolvers.cpp new file mode 100644 index 0000000000000..e3bb36431d9ea --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers.cpp @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass driver: run all deferred spanner/ornament resolvers in order after the score is built. + +#include "resolvers.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +void resolveAll(BuildCtx& ctx) +{ + resolveSlurs(ctx); + resolveHairpins(ctx); + resolveOrnaments(ctx); + resolveFingeringAndBowing(ctx); + resolveOttavas(ctx); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers.h b/src/importexport/encore/internal/importer/resolvers.h new file mode 100644 index 0000000000000..350ceb6cd520d --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers.h @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Post-pass resolvers that attach deferred spanners and ornaments (slurs, hairpins, ornaments, +// fingerings/bowings, ottavas) once every chord exists, plus shared track-validity and +// tick-to-chord lookup helpers. + +#ifndef MU_IMPORTEXPORT_ENC_IMPORT_RESOLVERS_H +#define MU_IMPORTEXPORT_ENC_IMPORT_RESOLVERS_H + +#include "ctx.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" + +namespace mu::iex::enc { +void resolveAll(BuildCtx& ctx); +void resolveSlurs(BuildCtx& ctx); +void resolveHairpins(BuildCtx& ctx); +void resolveOrnaments(BuildCtx& ctx); +void resolveFingeringAndBowing(BuildCtx& ctx); +void resolveOttavas(BuildCtx& ctx); + +// A track derived from untrusted Encore staff/voice bytes can exceed the score's track +// count; Segment::element(track) indexes a fixed-size vector, so an out-of-range track is +// out-of-bounds. Every resolver must check this before any element(track) / spanner-track use. +inline bool validTrack(const mu::engraving::Score* score, mu::engraving::track_idx_t track) +{ + return score && track < score->ntracks(); +} + +// Tick → measure → ChordRest segment → Chord lookup; shared by all resolver files. +inline mu::engraving::Chord* findChordAt(mu::engraving::MasterScore* score, + mu::engraving::Fraction tick, + mu::engraving::track_idx_t track) +{ + using namespace mu::engraving; + if (!validTrack(score, track)) { + return nullptr; + } + Measure* m = score->tick2measure(tick); + if (!m) { + return nullptr; + } + Segment* seg = m->findSegment(SegmentType::ChordRest, tick); + if (!seg) { + return nullptr; + } + EngravingItem* el = seg->element(track); + if (!el || !el->isChord()) { + return nullptr; + } + return toChord(el); +} + +// First chord on any voice of `staffIdx` within segment `seg`, scanning voices 0..VOICES-1 in order. +// Sets outTrack to the carrying voice's track and returns the chord, or nullptr if seg is null or +// holds no chord on that staff. validTrack is folded in so an out-of-range staff yields nullptr +// instead of an out-of-bounds element() access. +inline mu::engraving::Chord* firstChordVoiceAt(const mu::engraving::Score* score, + const mu::engraving::Segment* seg, int staffIdx, + mu::engraving::track_idx_t& outTrack) +{ + using namespace mu::engraving; + if (!seg) { + return nullptr; + } + const track_idx_t base = static_cast(staffIdx) * VOICES; + for (track_idx_t v = 0; v < VOICES; ++v) { + const track_idx_t t = base + v; + if (!validTrack(score, t)) { + break; + } + EngravingItem* el = seg->element(t); + if (el && el->isChord()) { + outTrack = t; + return toChord(el); + } + } + return nullptr; +} +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_RESOLVERS_H diff --git a/src/importexport/encore/internal/notationencreader.cpp b/src/importexport/encore/internal/notationencreader.cpp new file mode 100644 index 0000000000000..8c8394f635eed --- /dev/null +++ b/src/importexport/encore/internal/notationencreader.cpp @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Collects import options from the configuration, runs importEncore, and maps errors to messages. + +#include "notationencreader.h" + +#include "importer/import.h" + +#include "engraving/engravingerrors.h" + +using namespace mu::iex::enc; +using namespace mu::engraving; + +muse::Ret NotationEncoreReader::read(MasterScore* score, const muse::io::path_t& path, const Options&) +{ + EncImportOptions opts; + opts.importPageLayout = encoreConfiguration()->importPageLayout(); + opts.importPageBreaks = encoreConfiguration()->importPageBreaks(); + opts.importSystemLocks = encoreConfiguration()->importSystemLocks(); + opts.importStaffSize = encoreConfiguration()->importStaffSize(); + opts.importTempoTextSemantic = encoreConfiguration()->importTempoTextSemantic(); + opts.importUnsupportedArticulationsAsText = encoreConfiguration()->importUnsupportedArticulationsAsText(); + opts.instrumentSearchMode = encoreConfiguration()->instrumentSearchMode(); + opts.underfillMeasureStrategy = encoreConfiguration()->underfillMeasureStrategy(); + opts.overfillMeasureStrategy = encoreConfiguration()->overfillMeasureStrategy(); + opts.firstMeasureIsPickup = encoreConfiguration()->firstMeasureIsPickup(); + opts.mergeVoices = encoreConfiguration()->mergeVoices(); + Err err = importEncore(score, path.toQString(), opts); + if (err == Err::FileBadFormat) { + // Give a specific reason instead of the generic "Bad format" text. + return make_ret(err, encoreLoadErrorMessage(path.toQString())); + } + return make_ret(err, path); +} diff --git a/src/importexport/encore/internal/notationencreader.h b/src/importexport/encore/internal/notationencreader.h new file mode 100644 index 0000000000000..2857ba48adb01 --- /dev/null +++ b/src/importexport/encore/internal/notationencreader.h @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// INotationReader for .enc files: adapts the reader interface to the Encore importer. + +#ifndef MU_IMPORTEXPORT_NOTATIONENCOREREADER_H +#define MU_IMPORTEXPORT_NOTATIONENCOREREADER_H + +#include "modularity/ioc.h" +#include "project/inotationreader.h" +#include "../ienc-importconfiguration.h" + +namespace mu::iex::enc { +class NotationEncoreReader : public project::INotationReader +{ +public: + muse::Ret read(mu::engraving::MasterScore* score, const muse::io::path_t& path, const Options& options = Options()) override; + +private: + muse::GlobalInject encoreConfiguration; +}; +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_NOTATIONENCOREREADER_H diff --git a/src/importexport/encore/internal/parser/elem-enums.h b/src/importexport/encore/internal/parser/elem-enums.h new file mode 100644 index 0000000000000..e6c5ccd9d8613 --- /dev/null +++ b/src/importexport/encore/internal/parser/elem-enums.h @@ -0,0 +1,198 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Enumerations for Encore binary field values: format version, clef/staff/element/barline +// kinds, repeat marks, ornaments, accidentals, grace and text-alignment codes. + +#pragma once + +#include + +namespace mu::iex::enc { +// Encore file format versions (byte at file offset 4). +enum class EncFormatVersion : quint8 { + V2_X = 0xA6, // Encore 2.x (legacy) + V3_4_X = 0xC2, // Encore 3.x / 4.x + V5_X = 0xC4, // Encore 5.x (current) +}; + +// Text encoding width of an instrument's strings: 1-byte (ANSI) or 2-byte (UTF-16 LE). +enum class EncCharSize : char { + ONE_BYTE, + TWO_BYTES +}; + +// Clef shape stored per staff/clef-change. ALIA (-1) = inherit/unchanged. +enum class EncClefType : qint8 { + ALIA = -1, + G = 0, + F = 1, + C3L = 2, + C4L = 3, + G8P = 4, + G8M = 5, + F8M = 6, + PERC = 7, + TAB = 8 +}; + +// Staff notation kind: standard melody, guitar tablature, or rhythm-slash staff. +enum class EncStaffType : quint8 { + MELODY = 0, + TAB = 1, + RHYTHM = 2 +}; + +enum class EncElemType : quint8 { + NONE = 0, + CLEF = 1, + KEYCHANGE = 2, + TIE = 3, + BEAM = 4, + ORNAMENT = 5, + LYRIC = 6, + CHORD = 7, + REST = 8, + NOTE = 9, + UNKNOWN1 = 10, + MIDI_CC = 11 // inline MIDI Control Change (sustain/volume/modulation); playback only, no notation +}; + +enum class EncBarlineType : quint8 { + NORMAL = 0, + REPEATSTART = 2, + DOUBLEL = 3, + REPEATEND = 4, + FINAL = 5, + DOUBLER = 6, + DOTTED = 8 +}; + +enum class EncRepeatType : quint8 { + NONE = 0, + DCALCODA = 0x80, + DSALCODA = 0x81, + DCALFINE = 0x82, + DSALFINE = 0x83, + DS = 0x84, + CODA1 = 0x85, + FINE = 0x86, + DC = 0x87, + SEGNO = 0x88, + CODA2 = 0x89 +}; + +enum class EncOrnamentType : quint8 { + // Lines, spanners, text + NONE = 0, + OTTAVA_ALTA = 0x10, + OTTAVA_BASSA = 0x12, + GRAPHIC_LINE = 0x1C, + WEDGESTART = 0x1D, + STAFFTEXT = 0x1E, + SLURSTART = 0x21, + ARPEGGIO = 0x22, + + // Guitar bends, tempo + GUITAR_BEND = 0x28, + GUITAR_BEND_2 = 0x29, + GUITAR_PREBEND = 0x2A, + GUITAR_PREBEND_RELEASE = 0x2B, + GUITAR_BEND_V = 0x30, + TEMPO = 0x32, + + // Trills, slur/wedge stops + TRILL_END = 0x35, + TRILL_START = 0x36, + TRILL_ALT = 0x37, + SLURSTOP = 0x41, + WEDGESTOP = 0x4D, + + // Dynamics + DYN_PPP = 0x80, + DYN_PP = 0x81, + DYN_P = 0x82, + DYN_MP = 0x83, + DYN_MF = 0x84, + DYN_F = 0x85, + DYN_FF = 0x86, + DYN_FFF = 0x87, + DYN_SFZ = 0x88, + DYN_SFFZ = 0x89, + DYN_FP = 0x8A, + + // Navigation (segno/coda), breaths, more dynamics + SEGNO = 0xA2, + REPEAT_MEASURE = 0xA3, + TO_CODA = 0xA5, + CODA = 0xA6, + CAESURA = 0xA7, + BREATH_COMMA = 0xA8, + DYN_FZ = 0xAA, + DYN_SF = 0xAB, + + // Tremolo, trills, mordent, fingering, accents + TREMOLO_32 = 0xAF, + TRILL_TR = 0xB0, + TRILL_SHORT = 0xB6, + DOUBLE_MORDENT = 0xB8, + FINGER_1 = 0xB9, + FINGER_2 = 0xBA, + FINGER_3 = 0xBB, + FINGER_4 = 0xBC, + FINGER_5 = 0xBD, + ACCENT = 0xBE, + MARCATO = 0xBF, + MARCATO_STACCATO_BELOW = 0xC0, + + // Bows, marcato, tenuto, staccato, fermata + UPBOW = 0xC4, + DOWNBOW = 0xC5, + MARCATO_BELOW = 0xC6, + TENUTO = 0xC8, + STACCATO = 0xC9, + FERMATA_ABOVE = 0xCC, + FERMATA_BELOW = 0xCD, + + // String numbers, tremolo ladder + STRING_NUMBER_2 = 0xE6, + STRING_NUMBER_3 = 0xE7, + STRING_NUMBER_4 = 0xE8, + STRING_NUMBER_5 = 0xE9, + STRING_NUMBER_6 = 0xEA, + TREMOLO_16 = 0xEE, + TREMOLO_32B = 0xEF +}; + +enum class EncGraceType : char { + NORMAL = 0, + ACCIACCATURA = 1, + APPOGGIATURA = 2 +}; + +// See ENCORE_FORMAT.md §TITL block for header/footer alignment byte values. +enum class EncTextAlign : quint8 { + LEFT = 0x04, + CENTER = 0x06, + RIGHT = 0x02 +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/elem-measure.h b/src/importexport/encore/internal/parser/elem-measure.h new file mode 100644 index 0000000000000..d8c4d9d5073d1 --- /dev/null +++ b/src/importexport/encore/internal/parser/elem-measure.h @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// EncMeasure: one parsed measure (time signature, tempo, barlines, repeats) and its +// element list, with the API to read it and resolve real note durations. + +#pragma once + +#include + +#include "elem-note.h" // MeasureElemVec, EncMeasureElem +#include "elem-enums.h" // EncBarlineType, EncRepeatType + +namespace mu::iex::enc { +struct EncFormatReader; // defined in reader.h + +struct EncMeasure { + quint32 varsize { 0 }; + quint16 bpm { 0 }; + quint8 timeSigGlyph { 0 }; + quint16 beatTicks { 0 }; + quint16 durTicks { 0 }; + quint8 timeSigNum { 0 }; + quint8 timeSigDen { 0 }; + quint8 barTypeStart { 0 }; + quint8 barTypeEnd { 0 }; + quint8 repeatAlternative { 0 }; + quint32 coda { 0 }; + MeasureElemVec elements; + + EncMeasure() = default; + EncMeasure(const EncMeasure&) = delete; + EncMeasure& operator=(const EncMeasure&) = delete; + EncMeasure(EncMeasure&&) noexcept = default; + EncMeasure& operator=(EncMeasure&&) noexcept = default; + + ~EncMeasure() = default; + + EncBarlineType startBarline() const { return static_cast(barTypeStart); } + EncBarlineType endBarline() const { return static_cast(barTypeEnd); } + EncRepeatType repeatMark() const { return static_cast(coda & 0xFF); } + + bool read(QDataStream& ds, const quint32 vs, const struct EncFormatReader& fmt); + void calculateRealDurations(bool hasGraceTimeBorrowing, const struct EncFormatReader& fmt); + // Snap a note whose MIDI tick drifted back to the tick of its xoffset column. + void reconcileStaleNoteTicksByColumn(); +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/elem-note.h b/src/importexport/encore/internal/parser/elem-note.h new file mode 100644 index 0000000000000..f8ad562a44dd5 --- /dev/null +++ b/src/importexport/encore/internal/parser/elem-note.h @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Measure-element structs: the EncMeasureElem base plus note, rest, key/clef change, +// MIDI CC and generic elements, with the flags parsing derives (grace, tie, tuplet). + +#pragma once + +#include +#include + +#include + +#include "elem-enums.h" + +namespace mu::iex::enc { +// Notes within this many Encore ticks treated as simultaneous (MIDI timing drift). +inline constexpr int CHORD_CLUSTER_THRESHOLD = 4; // Encore ticks (~8ms at 120bpm) + +// faceValue byte accessors: low nibble = duration (1=whole..8=128th), high nibble = notehead type. +inline quint8 fvLow(quint8 fv) { return fv & 0x0F; } +inline quint8 fvHigh(quint8 fv) { return static_cast((fv >> 4) & 0x0F); } + +// Base class for all measure elements. +struct EncMeasureElem { + quint16 tick { 0 }; + quint8 type { 0 }; + quint8 voice { 0 }; + quint8 size { 0 }; + quint8 staffIdx { 0 }; // low 6 bits of raw staff byte: staff index in system + quint8 staffWithin { 0 }; // high 2 bits (>> 6): staff index within instrument (0=first, 1=second, ...) + quint8 xoffset { 0 }; + qint16 realDuration { -1 }; + + // Raw staff byte (staffWithin<<6)|staffIdx, identical to instrStaffIdx in the LINE block. + // Importers reverse-map it to a LINE slot (see buildLineSlotByRawByte). + quint8 rawStaffByte() const + { + return static_cast((static_cast(staffWithin) << 6) | static_cast(staffIdx)); + } + + // Nonzero = tuplet member; sort tuplet notes first at their tick so they create the chord. + virtual quint8 tupletByte() const { return 0; } + // Raw faceValue byte; 0 for elements without one. + virtual quint8 faceValueByte() const { return 0; } + virtual bool impliedTupletMember() const { return false; } + + EncMeasureElem() = default; + EncMeasureElem(quint16 t, quint8 tp, quint8 v) + : tick(t), type(tp), voice(v) {} + virtual ~EncMeasureElem() = default; + + virtual bool read(QDataStream& ds); +}; + +// A pitched note: face value (duration + notehead), MIDI pitch, articulations, tuplet ratio, +// and grace/tie flags derived during parsing. +struct EncNote : EncMeasureElem { + quint8 faceValue { 0 }; + quint8 grace1 { 0 }; + quint8 grace2 { 0 }; + qint8 position { 0 }; + quint8 tuplet { 0 }; + quint8 dotControl { 0 }; + quint8 semiTonePitch { 0 }; + // Parsed for format completeness and as a read-order cursor, not consumed by any emitter: Encore's + // playback (as-performed) duration; the importer takes rhythm from faceValue. Removing it shifts + // every subsequent field read in EncNote::read, so keep it (or an equivalent byte skip). + quint16 playbackDurTicks{ 0 }; + // Parsed for format completeness and as read-order cursors, not consumed by any emitter: velocity + // is Encore's per-note MIDI velocity and alterationGlyph its explicit-accidental glyph selector; + // MuseScore derives both from the score model. Removing either shifts subsequent field reads. + quint8 velocity { 0 }; + quint8 options { 0 }; + quint8 alterationGlyph { 0 }; + quint8 articulationUp { 0 }; + quint8 articulationDown{ 0 }; + // Set by calculateRealDurations() for v0xA6: note is a non-leading grace + // within a grace group (shorter duration than the leading grace). + bool isInnerGrace { false }; + // Set by postProcessElement() for formats where grace1 low nibble encodes tie-sender (v0xC2). + bool isTieSender { false }; + // Set by calculateRealDurations() Phase 4 for v0xC2: note belongs to an implied tuplet group + // (rdur/faceValue mismatch gives the ratio). Explicit flag so incidental MIDI timing drift + // in other formats is never misread as a tuplet. + bool isImpliedTupletMember { false }; + // Set by fixDottedEighthPattern() (v0xC2): forces dots=1 for the dotted-eighth in the + // dotted-eighth+sixteenth anomaly, bypassing the unreliable dotControl bit-0 fallback. + bool forceDotted { false }; + + using EncMeasureElem::EncMeasureElem; + + quint8 tupletByte() const override { return tuplet; } + quint8 faceValueByte() const override { return faceValue; } + bool impliedTupletMember() const override { return isImpliedTupletMember; } + int actualNotes() const { return tuplet >> 4; } + int normalNotes() const { return tuplet & 0x0F; } + + EncGraceType graceType() const; + // grace1 bit 0x20 = small note (a grace or a cue). grace2 bit 0x01 = muted (playback off), a + // per-note Encore flag independent of size; a cue is small and muted by default. + bool isSmall() const { return grace1 & 0x20; } + bool isMuted() const { return grace2 & 0x01; } + + bool read(QDataStream& ds) override; +}; + +// A rest; mrestCount > 1 marks an Encore multi-measure rest (v0xC4) shown as one symbol. +struct EncRest : EncMeasureElem { + quint8 faceValue { 0 }; + quint8 tuplet { 0 }; + quint8 dotControl { 0 }; + // Multi-measure rest display count (v0xC4): > 1 means this one MEAS block spans that + // many empty measures. Only meaningful when it is the block's sole REST. See ENCORE_FORMAT.md §REST element. + quint8 mrestCount { 1 }; + // Set by calculateRealDurations() Phase 4 for v0xC2 (same semantics as EncNote::isImpliedTupletMember). + bool isImpliedTupletMember { false }; + + using EncMeasureElem::EncMeasureElem; + + quint8 tupletByte() const override { return tuplet; } + quint8 faceValueByte() const override { return faceValue; } + bool impliedTupletMember() const override { return isImpliedTupletMember; } + int actualNotes() const { return tuplet >> 4; } + int normalNotes() const { return tuplet & 0x0F; } + + bool read(QDataStream& ds) override; +}; + +// Mid-measure key-signature change (tipo = Encore key index). +struct EncKeyChange : EncMeasureElem { + quint8 tipo { 0 }; + + using EncMeasureElem::EncMeasureElem; + + bool read(QDataStream& ds) override; +}; + +// Mid-measure clef change. +struct EncClefChange : EncMeasureElem { + EncClefType clefType { EncClefType::G }; + + using EncMeasureElem::EncMeasureElem; + + bool read(QDataStream& ds) override; +}; + +// Placeholder for element types the importer does not model; carried through but not emitted. +struct EncGenericElem : EncMeasureElem { + using EncMeasureElem::EncMeasureElem; + + bool read(QDataStream& ds) override; +}; + +// Inline MIDI Control Change (EncElemType::MIDI_CC). Playback only, no notation: the importer +// logs controller/value and drops it. See ENCORE_FORMAT.md §MIDI control change (type 11). +struct EncMidiCc : EncMeasureElem { + using EncMeasureElem::EncMeasureElem; + + quint8 controller { 0 }; // 64=sustain pedal, 7=volume, 1=modulation + quint8 value { 0 }; // 127=max/on, 0=off + + bool read(QDataStream& ds) override; +}; + +using MeasureElemVec = std::vector >; +using MeasureElemRefVec = std::vector; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/elem-ornament.h b/src/importexport/encore/internal/parser/elem-ornament.h new file mode 100644 index 0000000000000..62940932ba571 --- /dev/null +++ b/src/importexport/encore/internal/parser/elem-ornament.h @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// EncOrnament: articulations, dynamics, spanners (slur/wedge/ottava), trills, tempo and +// staff text, sharing one element struct whose fields vary by ornament type and format. + +#pragma once + +#include "elem-note.h" + +namespace mu::iex::enc { +struct EncOrnament : EncMeasureElem { + // Field names follow the Encore binary format notation used throughout the spec + quint8 tipo { 0 }; + qint16 yoffset { 0 }; // signed 16-bit Cartesian y (positive = upward in Encore) + quint8 altMezuro { 0 }; // v0xC2 spanning measure-count lives at element +16 (not +18) + quint8 alMezuro { 0 }; + bool alMezuroValid { true }; // false when format cannot guarantee measure-count semantics (v0xC2) + quint8 xoffset2 { 0 }; + quint8 speguleco { 0 }; + quint8 noto { 0 }; + quint8 tempo { 0 }; + quint8 tind { 0 }; + // Byte offset of tind from the type/voice byte, or -1 to read it inline by size. Set from + // EncFormatReader::staffTextTindOffset(); v0xA6's compact ornament stores tind at +26. + int tindOffset { -1 }; + // Byte offset of yoffset from the type/voice byte, or -1 to read it inline. Set from + // EncFormatReader::staffTextYoffsetOffset(); v0xA6's compact ornament stores it at +6. + int yoffOffset { -1 }; + + using EncMeasureElem::EncMeasureElem; + + EncOrnamentType ornType() const { return static_cast(tipo); } + void setOrnType(EncOrnamentType t) { tipo = static_cast(t); } + + bool read(QDataStream& ds) override; +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/elem-root.h b/src/importexport/encore/internal/parser/elem-root.h new file mode 100644 index 0000000000000..1082a871ab71d --- /dev/null +++ b/src/importexport/encore/internal/parser/elem-root.h @@ -0,0 +1,222 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Top-level document structs: EncRoot and its parts (instruments, system lines, title, +// header, text, page/print setup) plus the free functions that parse and stitch them. + +#pragma once + +#include +#include + +#include + +#include "elem-enums.h" +#include "elem-note.h" // MeasureElemVec, EncMeasureElem +#include "elem-measure.h" // EncMeasure (used in EncRoot::measures) +#include "elem-text.h" // EncLyric, EncTie, EncChordSym + +namespace mu::iex::enc { +struct EncFormatReader; // defined in reader.h + +// --------------------------------------------------------------------------- +// Instrument / part +// --------------------------------------------------------------------------- + +struct EncInstrument { + QString name; + quint32 offset { 0 }; + qint64 contentFilePos { -1 }; // byte offset of TK content start (after 8-byte header); -1 for compact + int nstaves { 0 }; + int midiProgram { 0 }; // 1-indexed GM program (0 = not configured) + // Signed chromatic offset from Encore's Staff Sheet "Key" field. + // 0=written, -12=octave lower, +12=octave higher. v0xC4 only. + qint8 keyTransposeSemitones { 0 }; + + EncCharSize charSize() const { return (offset > 250) ? EncCharSize::TWO_BYTES : EncCharSize::ONE_BYTE; } + + bool read(QDataStream& ds, quint32 vs, bool probeEncoding = false); +}; + +// --------------------------------------------------------------------------- +// Staff data within a system line +// --------------------------------------------------------------------------- + +struct EncLineStaffData { + EncClefType clef { EncClefType::G }; + quint8 key { 0 }; + quint8 pageIdx { 0 }; + EncStaffType staffType { EncStaffType::MELODY }; + quint8 instrStaffIdx { 0 }; + bool showStaff { true }; // byte +19 of LINE staff entry: 0x01 = visible, 0x00 = hidden. + // Staff display size: byte +13 of LINE staff entry, 0-indexed (0=Size1/60%, 1=Size2/70%, 2=Size3/75%, 3=Size4/100%). + quint8 staffSizeHint { 3 }; + + unsigned int instrumentIndex() const { return instrStaffIdx & 0x3F; } + unsigned int staffIndex() const { return instrStaffIdx >> 6; } + + bool read(QDataStream& ds); +}; + +struct EncLine { + quint32 offset { 0 }; + quint16 start { 0 }; + quint8 measureCount { 0 }; + std::vector staffData; + // v0xA6 only: per-staff written key index (Encore key index 0-14), parsed directly + // from the 22-byte staff entries because v0xA6's header staffPerSystem and LINE staff + // layout differ from v0xC2/C4, which leaves staffData empty. See parsers-root.cpp. + std::vector staffKeys; + + bool read(QDataStream& ds, quint32 vs, int staffPerSystem); +}; + +// --------------------------------------------------------------------------- +// Title block +// --------------------------------------------------------------------------- + +QString readTextItem(QDataStream& ds, EncCharSize cs, qint64 blockEnd); + +struct EncHeaderFooter { + QString text; + EncTextAlign align { EncTextAlign::LEFT }; +}; + +struct EncTitle { + QString title; + std::vector subtitle; + std::vector instruction; + std::vector author; + std::vector header; + std::vector footer; + std::vector copyright; + + bool read(QDataStream& ds, quint32 vs, EncCharSize cs); + + bool hasContent() const + { + if (!title.isEmpty()) { + return true; + } + auto anyNonEmpty = [](const std::vector& v) { + for (const auto& s : v) { + if (!s.isEmpty()) { + return true; + } + } + return false; + }; + auto anyHFNonEmpty = [](const std::vector& v) { + for (const auto& hf : v) { + if (!hf.text.isEmpty()) { + return true; + } + } + return false; + }; + return anyNonEmpty(subtitle) || anyNonEmpty(instruction) || anyNonEmpty(author) + || anyHFNonEmpty(header) || anyHFNonEmpty(footer) || anyNonEmpty(copyright); + } +}; + +// --------------------------------------------------------------------------- +// File header +// --------------------------------------------------------------------------- + +struct EncHeader { + QString magic; + quint8 chuMagio { 0 }; + quint16 chuVersio { 0 }; + // Parsed for format completeness and as read-order cursors, not consumed by the importer: + // reserved header words after the version. Removing them shifts every subsequent header read. + quint16 nekon1 { 0 }; + quint16 fiksa1 { 0 }; + qint16 lineCount { 0 }; + qint16 pageCount { 0 }; + qint8 instrumentCount{ 0 }; + qint8 staffPerSystem { 0 }; + qint16 measureCount { 0 }; + quint8 formatRev { 0 }; // format-revision byte at 0x3E: 1 = Encore 4.5, 4 = Encore 5.0 (v0xC4) + quint8 scoreSize { 4 }; // staff-size selector 1-4 at header offset 0x52; 4 = default + + bool readMagicAndVersion(QDataStream& ds); + bool read(QDataStream& ds, const EncFormatReader& fmt); +}; + +// --------------------------------------------------------------------------- +// EncRoot: top-level container +// --------------------------------------------------------------------------- + +bool isInstrumentMagic(const QString& magic); +bool isKnownMagic(const QString& magic); +QString findNextKnownMagic(QDataStream& ds); +void addSpannerEnds(std::vector& measures); + +// TEXT block: N-th entry referenced by ORN tind byte. textOffset (from EncFormatReader) is the +// per-entry text offset (14 for v0xC4/v0xC2, 0 for v0xA6). See ENCORE_FORMAT.md §TEXT block. +struct EncTextBlock { + std::vector entries; + + bool read(QDataStream& ds, quint32 varSize, int textOffset = 14, bool hasRunHeader = true); +}; + +// WINI block: margins in points (1/72 inch). See ENCORE_FORMAT.md §WINI block. +struct EncPageSetup { + bool hasData { false }; + qint32 top { 0 }; // top margin in pts + qint32 left { 0 }; // left margin in pts + qint32 bottomEdge { 0 }; // pageHeight_pts - bottomMargin_pts + qint32 rightEdge { 0 }; // pageWidth_pts - rightMargin_pts + + // Decode the WINI block margins in place; consumes the whole block (clamped to the stream). + void read(QDataStream& ds, quint32 varSize); +}; + +// PREC block: a Windows DEVMODE. Page size, orientation and notation scale. +// See ENCORE_FORMAT.md §PREC block. +struct EncPrintSetup { + bool hasData { false }; + int orientation { 0 }; // dmOrientation: 1=portrait, 2=landscape + int paperSize { 0 }; // dmPaperSize (DMPAPER_*): 1=Letter, 5=Legal, 8=A3, 9=A4, 11=A5, ... + int paperLength { 0 }; // dmPaperLength: tenths of a millimetre (custom sizes only) + int paperWidth { 0 }; // dmPaperWidth: tenths of a millimetre (custom sizes only) + int scale { 0 }; // dmScale: notation/print scale percent (100 = default) +}; + +// Parse the SCO5 (macOS Encore 5) NSPrintInfo XML plist found in the PREC block into +// orientation / paper size / scale. Returns false when the buffer is not a usable plist. +bool parsePrecPlist(const QByteArray& buf, EncPrintSetup& out); + +struct EncRoot { + EncHeader header; + std::vector instruments; + std::vector lines; + std::vector measures; + EncTitle titleBlock; + EncTextBlock textBlock; + EncPageSetup pageSetup; + EncPrintSetup printSetup; + std::unique_ptr fmt; // set during read() + + bool read(QDataStream& ds); +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/elem-text.h b/src/importexport/encore/internal/parser/elem-text.h new file mode 100644 index 0000000000000..faef12d462cf6 --- /dev/null +++ b/src/importexport/encore/internal/parser/elem-text.h @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Text-bearing measure elements: chord symbols, lyrics and ties, each with the format +// layout fields (anchor bytes, gaps, arc endpoints) their read() needs. + +#pragma once + +#include + +#include "elem-note.h" + +namespace mu::iex::enc { +struct EncChordSym : EncMeasureElem { + quint8 toniko { 0 }; + quint8 tipo { 0 }; + quint8 radiko { 0 }; + quint8 baso { 0 }; + bool hasFretDiagram { false }; // tipo bit 2 (0x04): Encore draws a guitar frame + QString teksto; + + using EncMeasureElem::EncMeasureElem; + + bool read(QDataStream& ds) override; + QString chordName() const; +}; + +struct EncLyric : EncMeasureElem { + QString text; + quint8 kie { 0 }; // location/anchor byte (similar to xoffset) + // Lyric layout, set from the EncFormatReader so read() stays format-agnostic. + quint8 preKieSkip { 5 }; // bytes from the post-header cursor to the kie byte + quint8 textGapAfterKie { 9 }; // bytes to skip after kie before text + quint8 spacingFactor { 1 }; // element slot = size * spacingFactor + + using EncMeasureElem::EncMeasureElem; + + bool read(QDataStream& ds) override; +}; + +// TIE element: dir byte (+5) and startFlag (+6) encode arc direction. See ENCORE_FORMAT.md §TIE element. +struct EncTie : EncMeasureElem { + bool isTieStart { false }; // true when dir byte has bit 7 or bit 1 set, or startFlag has bit 7 set + quint8 arcX1 { 0 }; // arc start x (element offset +10); only valid for size >= 18 + quint8 arcX2 { 0 }; // arc end x (element offset +12); only valid for size >= 18 + qint8 sourcePosition { -1 }; // staff position of source note (+14); -1 = all notes in chord + + using EncMeasureElem::EncMeasureElem; + + bool read(QDataStream& ds) override; +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/elem.h b/src/importexport/encore/internal/parser/elem.h new file mode 100644 index 0000000000000..4bc824ecf7cf9 --- /dev/null +++ b/src/importexport/encore/internal/parser/elem.h @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Umbrella header: pulls in every Encore parser element struct in one include. + +#ifndef MU_IMPORTEXPORT_ENC_PARSER_ELEMENTS_H +#define MU_IMPORTEXPORT_ENC_PARSER_ELEMENTS_H + +#include "elem-enums.h" +#include "elem-note.h" +#include "elem-ornament.h" +#include "elem-text.h" +#include "elem-measure.h" +#include "elem-root.h" + +#endif // MU_IMPORTEXPORT_ENC_PARSER_ELEMENTS_H diff --git a/src/importexport/encore/internal/parser/parsers-chord.cpp b/src/importexport/encore/internal/parser/parsers-chord.cpp new file mode 100644 index 0000000000000..f2e23775ae18b --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-chord.cpp @@ -0,0 +1,164 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read CHORD symbols and decode the numeric root/quality/bass encoding into a chord name. + +#include "elem-text.h" +#include "parsers-encoding.h" + +namespace mu::iex::enc { +// Chord quality suffixes indexed by toniko value (0-63): Encore's own chord palette in palette +// order. Encore typography is mapped to what the MuseScore chord parser expects: augmented-fifth +// "+5" becomes "#5", and "sus2,sus4" drops the comma. See ENCORE_FORMAT.md §CHORD symbol element. +static const char* const kChordQuality[] = { + "", // 0: major (no suffix) + "m", // 1: minor + "+", // 2: augmented + "dim", // 3: diminished + "dim7", // 4: diminished 7 + "5", // 5: power chord + "6", // 6: major 6 + "6/9", // 7 + "(add2)", // 8 + "(add9)", // 9 + "(omit3)", // 10 + "(omit5)", // 11 + "maj7", // 12 + "maj7(b5)", // 13 + "maj7(6/9)", // 14 + "maj7(#5)", // 15 (Encore "maj7(+5)") + "maj7(#11)", // 16 + "maj9", // 17 + "maj9(b5)", // 18 + "maj9(#5)", // 19 (Encore "maj9(+5)") + "maj9(#11)", // 20 + "maj13", // 21 + "maj13(b5)", // 22 + "maj13(#11)", // 23 + "7", // 24: dominant 7 + "7(b5)", // 25 + "7(b9)", // 26 + "7(#9)", // 27 + "7(#11)", // 28 + "7(b5,b9)", // 29 + "7(b5,#9)", // 30 + "7(b9,#9)", // 31 + "9", // 32 + "9(b5)", // 33 + "9(#11)", // 34 + "11", // 35 + "13", // 36 + "13(b5)", // 37 + "13(b9)", // 38 + "13(#9)", // 39 + "13(#11)", // 40 + "+7", // 41: augmented 7 + "+7(b9)", // 42 + "+7(#9)", // 43 + "+9", // 44 + "sus2", // 45 + "sus2sus4", // 46 (Encore "sus2,sus4"; comma removed for MuseScore parser) + "sus4", // 47 + "7sus4", // 48 + "9sus4", // 49 + "13sus4", // 50 + "m(add2)", // 51 + "m(add9)", // 52 + "m6", // 53 + "m6/9", // 54 + "m7", // 55 + "m(maj7)", // 56 + "m7(b5)", // 57 + "m7(add4)", // 58 + "m7(add11)", // 59 + "m9", // 60 + "m9(maj7)", // 61 + "m11", // 62 + "m13", // 63 +}; +static constexpr int kChordQualityCount = static_cast(sizeof(kChordQuality) / sizeof(kChordQuality[0])); + +// Note names for the lower nibble of radiko/baso (0=C, 1=D, 2=E, 3=F, 4=G, 5=A, 6=B). +static const char* const kNoteNames[] = { "C", "D", "E", "F", "G", "A", "B" }; +static constexpr int kNoteNameCount = static_cast(sizeof(kNoteNames) / sizeof(kNoteNames[0])); + +static QString encRootToString(quint8 field) +{ + const int noteIdx = field & 0x0F; + if (noteIdx >= kNoteNameCount) { + return {}; + } + QString root = QString::fromLatin1(kNoteNames[noteIdx]); + const int acc = (field & 0xF0) >> 4; + if (acc == 1) { + root += u'#'; + } else if (acc == 2) { + root += u'b'; + } + return root; +} + +bool EncChordSym::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + ds >> toniko >> tipo; + ds.skipRawData(3); + ds >> xoffset; + ds.skipRawData(1); + ds >> radiko >> baso; + // tipo bit 2 = draw a fretboard diagram above the symbol. See ENCORE_FORMAT.md §CHORD symbol element. + hasFretDiagram = (tipo & 0x04) != 0; + const bool hasText = (tipo & 1); + if (hasText) { + teksto = readEncodedStringFixed(ds, 36); // fixed 36-byte text slot + } + // No trailing skip: the element loop reseeks to the element end after read(). + return true; +} + +QString EncChordSym::chordName() const +{ + if (!teksto.isEmpty()) { + return teksto; + } + + const QString root = encRootToString(radiko); + if (root.isEmpty()) { + return {}; + } + + const QString quality = (toniko < kChordQualityCount) + ? QString::fromLatin1(kChordQuality[toniko]) + : QString{}; + + QString name = root + quality; + + if (tipo & 0x02) { + const QString bass = encRootToString(baso); + if (!bass.isEmpty()) { + name += QChar(u'/') + bass; + } + } + + return name; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-encoding.cpp b/src/importexport/encore/internal/parser/parsers-encoding.cpp new file mode 100644 index 0000000000000..4919383a2e42c --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-encoding.cpp @@ -0,0 +1,125 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Implements string decoding: probes the first bytes to pick UTF-16 LE or Latin-1, then reads. + +#include "parsers-encoding.h" + +namespace mu::iex::enc { +bool probeUtf16LE(quint8 b0, quint8 b1) +{ + return b0 >= 0x20 && b0 < 0x7F && b1 == 0x00; +} + +QString readEncodedStringRemaining(QDataStream& ds, int& remaining) +{ + if (remaining <= 0) { + return {}; + } + + // Peek at the first bytes to detect encoding; peek leaves the cursor in place and works + // on non-seekable devices. + quint8 b0 = 0, b1 = 0; + { + char buf[2] = { 0, 0 }; + const qint64 want = (remaining >= 2) ? 2 : 1; + const qint64 n = ds.device()->peek(buf, want); + if (n >= 1) { + b0 = static_cast(buf[0]); + } + if (n >= 2) { + b1 = static_cast(buf[1]); + } + } + const bool utf16 = probeUtf16LE(b0, b1); + + QString result; + while (remaining > 0) { + if (utf16) { + if (remaining < 2) { + break; + } + quint8 lo, hi; + ds >> lo >> hi; + remaining -= 2; + const QChar ch(char16_t((hi << 8) | lo)); + if (ch == u'\0') { + break; + } + result.append(ch); + } else { + quint8 b; + ds >> b; + remaining -= 1; + if (b == 0) { + break; + } + result.append(QChar(char16_t(b))); + } + } + return result; +} + +QString readEncodedStringFixed(QDataStream& ds, int fixedLen) +{ + if (fixedLen <= 0) { + return {}; + } + const QByteArray buf = ds.device()->read(fixedLen); + const int n = buf.size(); + if (n < 2) { + if (n == 1 && buf[0] != '\0') { + return QString(QChar(char16_t(static_cast(buf[0])))); + } + return {}; + } + const quint8 b0 = static_cast(buf[0]); + const quint8 b1 = static_cast(buf[1]); + const bool utf16 = probeUtf16LE(b0, b1); + + QString result; + int i = 0; + while (i < n) { + if (utf16) { + if (i + 1 >= n) { + break; + } + const quint8 lo = static_cast(buf[i]); + const quint8 hi = static_cast(buf[i + 1]); + const QChar ch(char16_t((hi << 8) | lo)); + i += 2; + if (ch == u'\0') { + break; + } + result.append(ch); + } else { + const quint8 b = static_cast(buf[i]); + ++i; + if (b == 0) { + break; + } + result.append(QChar(char16_t(b))); + } + } + return result; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-encoding.h b/src/importexport/encore/internal/parser/parsers-encoding.h new file mode 100644 index 0000000000000..93ce7a5aaf0a0 --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-encoding.h @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// String decoding for the Encore parser: UTF-16 LE vs Latin-1 auto-detection and readers. + +#pragma once + +#include +#include + +namespace mu::iex::enc { +// Returns true if b0/b1 look like the first two bytes of a UTF-16 LE string: +// b0 is a printable ASCII byte and b1 is zero (BMP character in 0x0020..0x007E range). +bool probeUtf16LE(quint8 b0, quint8 b1); + +// Reads a null-terminated string from ds, auto-detecting UTF-16 LE vs Latin-1 from the +// first two bytes. `remaining` is decremented by every byte consumed (including the +// null terminator). The caller is responsible for skipping any leftover bytes. +QString readEncodedStringRemaining(QDataStream& ds, int& remaining); + +// Reads exactly `fixedLen` bytes from ds, auto-detecting UTF-16 LE vs Latin-1, and +// returns the decoded string up to the first null character. All fixedLen bytes are +// consumed regardless of where the null terminator falls. +QString readEncodedStringFixed(QDataStream& ds, int fixedLen); +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-lyric-tie.cpp b/src/importexport/encore/internal/parser/parsers-lyric-tie.cpp new file mode 100644 index 0000000000000..62e21a53fede1 --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-lyric-tie.cpp @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read LYRIC (syllable text in its size slot) and TIE (arc span decides forward-tie direction). + +#include "elem-text.h" +#include "parsers-encoding.h" + +namespace mu::iex::enc { +bool EncLyric::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + + // Syllable text lives inside the size*spacingFactor slot at a format-supplied offset. + const int textOffset = 5 + static_cast(preKieSkip) + 1 + static_cast(textGapAfterKie); + const int slot = static_cast(size) * static_cast(spacingFactor); + int remaining = slot - textOffset; + if (remaining <= 0) { + return true; // too short to carry text; element loop reseeks past it + } + + ds.skipRawData(preKieSkip); + ds >> kie; + ds.skipRawData(textGapAfterKie); + + text = readEncodedStringRemaining(ds, remaining); + // No trailing skip: the element loop reseeks to the element end after read(). + return true; +} + +bool EncTie::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + quint8 dirByte = 0; + quint8 startFlag = 0; + if (size > 5) { + ds >> dirByte; // offset +5 + } + if (size > 6) { + ds >> startFlag; // offset +6 + } + // Dir/startFlag bit layout: see ENCORE_FORMAT.md §TIE element. + isTieStart = ((dirByte & 0x80) != 0) || ((startFlag & 0x80) != 0) || ((dirByte & 0x02) != 0); + + if (static_cast(size) >= 18) { + // 18-byte form: the arc x-span is the authoritative forward-tie signal, overriding the + // +5 curvature byte. arcX1> arcX1; + ds.skipRawData(1); // to offset +12 + ds >> arcX2; + if (arcX1 < arcX2) { + isTieStart = true; + } else if (arcX1 == arcX2 && (startFlag & 0x80) == 0) { + isTieStart = false; + } + ds.skipRawData(1); // to offset +14: staff position of source note + quint8 sp = 0; + ds >> sp; + sourcePosition = static_cast(sp); + } + // No trailing skip: the element loop reseeks to the element end after read(). + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-measure.cpp b/src/importexport/encore/internal/parser/parsers-measure.cpp new file mode 100644 index 0000000000000..43051e5b963bc --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-measure.cpp @@ -0,0 +1,410 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read a MEAS block and derive note durations from MIDI tick gaps, with chord-column and +// stale-tick reconciliation. + +#include "elem.h" + +#include "readers.h" +#include "ticks.h" +#include "log.h" + +namespace mu::iex::enc { +// --------------------------------------------------------------------------- +// Static helpers +// --------------------------------------------------------------------------- + +static std::unique_ptr createMeasureElement( + quint16 tick, quint8 tp, quint8 vo, + const EncFormatReader& fmt) +{ + switch (static_cast(tp)) { + case EncElemType::NOTE: + return std::make_unique(tick, tp, vo); + case EncElemType::REST: + return std::make_unique(tick, tp, vo); + case EncElemType::CHORD: + return std::make_unique(tick, tp, vo); + case EncElemType::ORNAMENT: + { + auto orn = std::make_unique(tick, tp, vo); + orn->tindOffset = fmt.staffTextTindOffset(); + orn->yoffOffset = fmt.staffTextYoffsetOffset(); + return orn; + } + case EncElemType::LYRIC: + { + auto lyr = std::make_unique(tick, tp, vo); + lyr->preKieSkip = fmt.lyricPreKieSkip(); + lyr->textGapAfterKie = fmt.lyricTextGapAfterKie(); + lyr->spacingFactor = static_cast(fmt.elemSpacing(1)); // element slot = size * factor + return lyr; + } + case EncElemType::KEYCHANGE: + return std::make_unique(tick, tp, vo); + case EncElemType::TIE: + return std::make_unique(tick, tp, vo); + case EncElemType::UNKNOWN1: + // Genuinely unknown element type; counted and reported once by logEncRootInfo. + return std::make_unique(tick, tp, vo); + case EncElemType::MIDI_CC: + // Inline MIDI Control Change (sustain/volume/modulation), playback only; captured for + // the diagnostic summary in logEncRootInfo and otherwise dropped (no notation). + return std::make_unique(tick, tp, vo); + case EncElemType::CLEF: + return std::make_unique(tick, tp, vo); + case EncElemType::NONE: + case EncElemType::BEAM: + // BEAM is intentionally not modeled: MuseScore auto-beams from note durations and time + // signature, so Encore's explicit beam groups are dropped. See ENCORE_FORMAT.md. + default: + return std::make_unique(tick, tp, vo); + } +} + +// Set realDuration from MIDI tick gaps (with optional grace time-borrowing). boundaryTicks are +// non-note ticks (CLEF, KEYCHANGE) that cap the gap even when no note follows, so a rest before a +// mid-measure clef change is not stretched to fill the whole measure. +void computeElementDurations( + std::vector& elems, + int durTicks, + bool hasGraceTimeBorrowing, + const std::vector& boundaryTicks) +{ + for (size_t i = 0; i < elems.size(); ++i) { + size_t j = i + 1; + // Skip same-tick chord members, then near-simultaneous cluster notes. + while (j < elems.size() && elems[j]->tick == elems[i]->tick) { + ++j; + } + while (j < elems.size() + && elems[j]->tick - elems[i]->tick < CHORD_CLUSTER_THRESHOLD) { + ++j; + } + qint16 nextTick = (j < elems.size()) ? elems[j]->tick : durTicks; + for (qint16 bt : boundaryTicks) { + if (bt > elems[i]->tick && bt < nextTick) { + nextTick = bt; + } + } + qint16 dur = nextTick - elems[i]->tick; + // v0xA6 grace time-borrowing: grace notes shorten next note's gap; see ENCORE_FORMAT.md §v0xA6 grace note time-borrowing. + const EncNote* enCur = dynamic_cast(elems[i]); + if (hasGraceTimeBorrowing && enCur && dur > 0) { + const qint16 faceTicks = faceValue2ticks(enCur->faceValue); + if (faceTicks > dur && faceTicks <= durTicks) { + qint16 totalGraceFace = 0; + for (size_t k = 0; k < i; ++k) { + const EncNote* en = dynamic_cast(elems[k]); + if (en && en->graceType() != EncGraceType::NORMAL) { + totalGraceFace += faceValue2ticks(en->faceValue); + } + } + if (totalGraceFace == faceTicks - dur) { + dur = faceTicks; + } else if (dur > faceTicks + && totalGraceFace > 0 + && (dur - faceTicks) <= totalGraceFace) { + dur = faceTicks; + } + } + } + // A following GRACE note has no rhythmic footprint, so it must not inflate the current + // principal note. Cap at the written face value, otherwise a note trailed by a grace whose + // gap happens to match a dotted ratio would be promoted to a dotted/longer note. + if (enCur && enCur->graceType() == EncGraceType::NORMAL && j < elems.size()) { + const EncNote* enNext = dynamic_cast(elems[j]); + const qint16 faceTicks = faceValue2ticks(enCur->faceValue); + if (enNext && enNext->graceType() != EncGraceType::NORMAL + && faceTicks > 0 && dur > faceTicks) { + dur = faceTicks; + } + } + if (dur > 0) { + elems[i]->realDuration = dur; + } + } +} + +// Encore staggers a chord's playback ticks ("strum" drift), but all its notes share one notated +// column in the xoffset byte. Collapse each run of consecutive notes with the same nonzero xoffset +// and face value to the run's earliest tick, so downstream sees chord members instead of a split +// chord. A run counts as one chord only within a small window of the anchor (capped at one notated +// duration and at CHORD_STRUM_MAX_SPAN) so a long note never absorbs a genuine later note reusing +// the column. Zero-xoffset notes are left untouched. See ENCORE_FORMAT.md §Chord column (xoffset). +static void normalizeChordColumnTicks(std::vector& elems) +{ + // Observed strum spans reach ~30 ticks; the tightest sequential subdivision stays well above, + // so 48 cleanly separates a chord column from a run of notes. + constexpr int CHORD_STRUM_MAX_SPAN = 48; + size_t i = 0; + while (i < elems.size()) { + EncNote* anchor = dynamic_cast(elems[i]); + if (!anchor || anchor->xoffset == 0) { + ++i; + continue; + } + const int faceTicks = faceValue2ticks(anchor->faceValue); + const int window = std::min(faceTicks, CHORD_STRUM_MAX_SPAN); + size_t j = i + 1; + while (j < elems.size()) { + EncNote* n = dynamic_cast(elems[j]); + if (!n || n->xoffset != anchor->xoffset + || fvLow(n->faceValue) != fvLow(anchor->faceValue) + || window <= 0 + || (n->tick - anchor->tick) >= window) { + break; + } + n->tick = anchor->tick; + ++j; + } + i = j; + } +} + +// --------------------------------------------------------------------------- +// EncMeasure +// --------------------------------------------------------------------------- + +bool EncMeasure::read(QDataStream& ds, const quint32 vs, const EncFormatReader& fmt) +{ + varsize = vs; + qint64 measStart = ds.device()->pos(); + + ds >> bpm >> timeSigGlyph; + ds.skipRawData(1); + ds >> beatTicks >> durTicks; + + ds.device()->seek(measStart + 0x08); + ds >> timeSigNum >> timeSigDen; + + ds.device()->seek(measStart + 0x0C); + ds >> barTypeStart >> barTypeEnd; + ds.skipRawData(1); + ds >> repeatAlternative; + + ds.device()->seek(measStart + 0x1A); + ds >> coda; + + const qint64 elemOffset = static_cast(fmt.elemBlockOffset()); + ds.device()->seek(measStart + elemOffset); + // varsize is an untrusted file value; clampMeasureEnd computes the end in qint64 and never lets + // it run past the device so the element loop below is bounded even for a corrupt/oversized size. + const qint64 measEnd = clampMeasureEnd(measStart, varsize, elemOffset, ds.device()->size()); + + // Guard the first read the same way the loop body does. After the seek, a truncated file has + // no element bytes left: reading would return a zero-filled tick (0, not 0xFFFF) and the loop + // would mis-classify the truncated measure as a real one starting at tick 0 instead of bailing. + if (ds.device()->pos() >= measEnd - 2 || ds.status() != QDataStream::Ok) { + ds.device()->seek(measEnd); + return true; + } + + quint16 tick; + ds >> tick; + if (tick == 0xFFFF) { + ds.device()->seek(measEnd); + return true; + } + + const int MAX_ELEMENTS = 10000; + int elemCount = 0; + + while (tick != 0xFFFF) { + // A read that ran past EOF (truncated/corrupt file) leaves the stream non-Ok; stop + // instead of fabricating zero-filled elements from the past-EOF zero fill. + if (ds.status() != QDataStream::Ok) { + break; + } + if (++elemCount > MAX_ELEMENTS) { + break; + } + if (ds.device()->pos() >= measEnd - 2) { + break; + } + + qint64 elemStart = ds.device()->pos() - 2; + + quint8 typeVoice; + ds >> typeVoice; + if (typeVoice == 0xFF) { + quint8 skip; + ds >> skip; + break; + } + + const quint8 tp = typeVoice >> 4; + const quint8 vo = typeVoice & 0x0F; + + auto elem = createMeasureElement(tick, tp, vo, fmt); + + elem->read(ds); + EncMeasureElem* elemRaw = elem.get(); + + fmt.postProcessElement(elemRaw, ds, elemStart); + + if (static_cast(tp) == EncElemType::REST + && fmt.deduplicateRest(elements, elemRaw)) { + if (elemRaw->size > 0) { + ds.device()->seek(elemStart + fmt.elemSpacing(elemRaw->size)); + } else { + ds.device()->seek(ds.device()->pos() + 1); + } + if (fmt.isMeasureNearEnd(ds, measEnd)) { + break; + } + ds >> tick; + continue; + } + + if (static_cast(tp) != EncElemType::NONE) { + elements.push_back(std::move(elem)); + } + + if (elemRaw->size > 0) { + ds.device()->seek(elemStart + fmt.elemSpacing(elemRaw->size)); + } else { + ds.device()->seek(ds.device()->pos() + 1); + } + + if (fmt.isMeasureNearEnd(ds, measEnd)) { + break; + } + + ds >> tick; + } + + ds.device()->seek(measEnd); + return true; +} + +void EncMeasure::calculateRealDurations(bool hasGraceTimeBorrowing, const EncFormatReader& fmt) +{ + // Collect per-staff boundary ticks from CLEF/KEYCHANGE elements (see computeElementDurations). + std::map > boundaryByStaff; + for (auto& elem : elements) { + const EncElemType et = static_cast(elem->type); + if ((et == EncElemType::CLEF || et == EncElemType::KEYCHANGE) + && elem->tick > 0 && elem->tick < durTicks) { + boundaryByStaff[elem->staffIdx].push_back(elem->tick); + } + } + + std::map, std::vector > groups; + for (auto& elem : elements) { + EncMeasureElem* e = elem.get(); + if (e->tick >= durTicks) { + continue; + } + if (dynamic_cast(e) || dynamic_cast(e)) { + groups[{ e->staffIdx, e->voice }].push_back(e); + } + } + for (auto& [key, elems] : groups) { + std::sort(elems.begin(), elems.end(), [](const EncMeasureElem* a, const EncMeasureElem* b) { + return a->tick < b->tick; + }); + const auto bIt = boundaryByStaff.find(key.first); + const std::vector& boundaries + = (bIt != boundaryByStaff.end()) ? bIt->second : std::vector {}; + if (fmt.clustersChordsByXoffset()) { + normalizeChordColumnTicks(elems); + } + computeElementDurations(elems, durTicks, hasGraceTimeBorrowing, boundaries); + fmt.postProcessVoiceGroup(elems, durTicks); + } + + reconcileStaleNoteTicksByColumn(); +} + +// A note's xoffset column identifies its beat and is consistent across the staves of a system. +// A note edited in Encore can keep a stale MIDI tick that no longer matches its column, so it +// draws on the column's beat but the importer places it later. Snap such a note back to its +// column's tick, keeping the realDuration already computed from its stored tick. Runs after +// duration computation so rdur is not recomputed from the corrected tick. +void EncMeasure::reconcileStaleNoteTicksByColumn() +{ + // Column (xoffset) -> earliest tick a non-grace note occupies it at, across all staves. + std::map colTick; + for (auto& elem : elements) { + const EncNote* en = dynamic_cast(elem.get()); + if (!en || en->graceType() != EncGraceType::NORMAL || en->tick >= durTicks) { + continue; + } + const int xo = static_cast(static_cast(en->xoffset)); + if (xo <= 0) { + continue; + } + auto it = colTick.find(xo); + if (it == colTick.end() || en->tick < it->second) { + colTick[xo] = en->tick; + } + } + // Plan the moves against the ORIGINAL ticks, then apply them, so chord siblings (which + // share a tick and column) all move together rather than the first move making itself look + // like an "earlier voice-mate" that blocks the rest. + std::vector > moves; + for (auto& elem : elements) { + EncNote* en = dynamic_cast(elem.get()); + if (!en || en->graceType() != EncGraceType::NORMAL || en->tick >= durTicks) { + continue; + } + const int xo = static_cast(static_cast(en->xoffset)); + if (xo <= 0) { + continue; + } + auto it = colTick.find(xo); + if (it == colTick.end() || it->second >= en->tick) { + continue; // already the column's earliest tick (or later note is genuine) + } + const qint16 target = it->second; + // Reconcile only a note that is the EARLIEST in its own staff/voice: the stale-tick + // artifact is a whole voice drawn one column too far right. A note with an earlier + // voice-mate is a genuine sequence, leave it. Also never merge two independent notes: + // block when a DIFFERENT column already occupies the target tick on this staff/voice + // (same-column notes are chord siblings). + bool hasEarlierVoiceMate = false; + bool occupied = false; + for (auto& other : elements) { + const EncNote* on = dynamic_cast(other.get()); + if (!on || on == en || on->staffIdx != en->staffIdx || on->voice != en->voice) { + continue; + } + if (on->tick < en->tick) { + hasEarlierVoiceMate = true; + break; + } + if (on->tick == target + && static_cast(static_cast(on->xoffset)) != xo) { + occupied = true; + } + } + if (!hasEarlierVoiceMate && !occupied) { + moves.emplace_back(en, target); + } + } + for (auto& [en, target] : moves) { + en->tick = target; + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-metadata.cpp b/src/importexport/encore/internal/parser/parsers-metadata.cpp new file mode 100644 index 0000000000000..a3b69a7c75259 --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-metadata.cpp @@ -0,0 +1,269 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read the metadata blocks: TK instrument names, LINE per-staff layout/clef/key, and TITL text. + +#include "elem.h" + +#include + +#include "parsers-encoding.h" +#include "readers.h" + +namespace mu::iex::enc { +// --------------------------------------------------------------------------- +// EncInstrument +// --------------------------------------------------------------------------- + +bool EncInstrument::read(QDataStream& ds, quint32 vs, bool probeEncoding) +{ + // The block-size field is little-endian even in SCO5 files, whose payload is otherwise + // big-endian; undo the big-endian read so the low 16 bits hold the size in both byte orders. + // A big-endian read would mask to 0, skip the name-scan loop, and lose every instrument name. + const quint32 sizeField = (ds.byteOrder() == QDataStream::BigEndian) ? qbswap(vs) : vs; + offset = sizeField & 0xFFFF; + // Encoding probe overrides charSize(); see ENCORE_FORMAT.md §Encoding probe. + EncCharSize cs = charSize(); + if (probeEncoding) { + const qint64 savedPos = ds.device()->pos(); + quint8 b0 = 0, b1 = 0; + ds >> b0 >> b1; + ds.device()->seek(savedPos); + if (probeUtf16LE(b0, b1)) { + cs = EncCharSize::TWO_BYTES; + } else if (b0 >= 0x20 && b0 < 0x7F && b1 != 0x00 && b1 >= 0x20 && b1 < 0xFF) { + cs = EncCharSize::ONE_BYTE; + } + } + int nread = 8; + QChar ch; + bool done = false; + // Bound the name scan by the block's declared content length and stream status: without it an + // unterminated name field would spin forever and a truncated one would fabricate zeroed chars. + const int nameLimit = static_cast(offset); + while (!done && nread < nameLimit && ds.status() == QDataStream::Ok) { + if (cs == EncCharSize::ONE_BYTE) { + quint8 b; + ds >> b; + ch = QChar(char16_t(b)); + nread += 1; + } else { + quint8 lo, hi; + ds >> lo >> hi; + ch = QChar(char16_t((hi << 8) + lo)); + nread += 2; + } + if (ch == '\0') { + done = true; + } else { + name.append(ch); + } + } + // Skip whatever remains of the block after the name, clamped to the device (offset is + // an untrusted file value). nread counts bytes consumed from the block's 8-byte header. + skipBlock(ds, static_cast(offset) - nread); + return true; +} + +// --------------------------------------------------------------------------- +// EncLineStaffData / EncLine +// --------------------------------------------------------------------------- + +bool EncLineStaffData::read(QDataStream& ds) +{ + // 30-byte staff entry; byte offsets and field meanings in ENCORE_FORMAT.md §LINE staff entry (30 bytes). + ds.skipRawData(13); // bytes 0-12: visual layout + ds >> staffSizeHint; // byte 13: display size (0=60% .. 3=100%) + qint8 ct; + ds >> ct; // byte 14: clef type + clef = static_cast(ct); + ds >> key >> pageIdx; // bytes 15-16 + quint8 skip0, skip1, showByte; + ds >> skip0 >> skip1 >> showByte; // bytes 17-19 + showStaff = (showByte != 0); + (void)skip0; + (void)skip1; + quint8 st; + ds >> st; // byte 20: staff type + staffType = static_cast(st); + ds >> instrStaffIdx; // byte 21 + ds.skipRawData(8); // bytes 22-29 + return true; +} + +bool EncLine::read(QDataStream& ds, quint32 vs, int staffPerSystem) +{ + // LINE block layout constants. kStaffEntryBytes is one EncLineStaffData staff entry; + // kBlockHeaderBytes is the magic + size already consumed on entry; kLinePrefixBytes is the + // fixed prefix before the staff entries (the header plus the 13 bytes of skip + start + + // measureCount read below). + static constexpr int kBlockHeaderBytes = 8; + static constexpr int kLinePrefixBytes = 21; + static constexpr int kStaffEntryBytes = 30; + + offset = vs; + ds.skipRawData(10); + ds >> start >> measureCount; + // staffPerSystem comes from the header; clamp it to the staff entries the block can + // actually hold so a corrupt count cannot spin the loop fabricating zeroed staves, and + // stop early if the stream runs out. + const int maxStaves = static_cast(offset) / kStaffEntryBytes; + if (staffPerSystem > maxStaves) { + staffPerSystem = maxStaves; + } + for (int i = 0; i < staffPerSystem && ds.status() == QDataStream::Ok; ++i) { + EncLineStaffData lsd; + lsd.read(ds); + staffData.push_back(lsd); + } + // Skip the block's tail after the parsed staff entries, clamped to the device. + const qint64 toSkip = static_cast(offset) + kBlockHeaderBytes + - kLinePrefixBytes - static_cast(kStaffEntryBytes) * staffPerSystem; + skipBlock(ds, toSkip); + return true; +} + +// --------------------------------------------------------------------------- +// Title block +// --------------------------------------------------------------------------- + +// Read 30-byte prefix + text payload of one TITL line; layout in ENCORE_FORMAT.md §TITL block. +// Alignment lives at prefix+14; the text field is a fixed width per encoding. +static constexpr int kTitlTextBytesOneByte = 66; +static constexpr int kTitlTextBytesTwoByte = 1026; + +// blockEnd bounds every read to the TITL block's declared end (startPos + varSize). A truncated +// block would otherwise pull zero-fill past EOF and, worse, a block shorter than the fixed line +// structure would read into the following block and desync the top-level magic scan. No read here +// crosses blockEnd, so EncTitle::read can realign exactly with skipToBlockEnd afterwards. +static EncHeaderFooter readTitleLine(QDataStream& ds, EncCharSize cs, qint64 blockEnd) +{ + const qint64 prefixAvail = std::max(0, blockEnd - ds.device()->pos()); + const int prefixWant = static_cast(std::min(30, prefixAvail)); + QByteArray prefix(30, 0); + int got = (prefixWant > 0) ? ds.readRawData(prefix.data(), prefixWant) : 0; + quint8 alignByte = (got >= 15) ? static_cast(prefix[14]) : 0; + + QString item; + bool done = false; + if (cs == EncCharSize::ONE_BYTE) { + for (int j = 0; j < kTitlTextBytesOneByte; ++j) { + if (ds.device()->pos() >= blockEnd || ds.status() != QDataStream::Ok) { + break; + } + quint8 b; + ds >> b; + if (b == 0) { + done = true; + } + if (!done) { + item.append(QChar(char16_t(b))); + } + } + } else { + for (int j = 0; j < kTitlTextBytesTwoByte;) { + if (ds.device()->pos() >= blockEnd - 1 || ds.status() != QDataStream::Ok) { + break; + } + quint8 lo, hi; + ds >> lo; + ++j; + ds >> hi; + ++j; + QChar ch = QChar(char16_t((hi << 8) + lo)); + if (ch == '\0') { + done = true; + } + if (!done) { + item.append(ch); + } + } + } + + EncHeaderFooter out; + out.text = item; + switch (alignByte) { + case static_cast(EncTextAlign::CENTER): out.align = EncTextAlign::CENTER; + break; + case static_cast(EncTextAlign::RIGHT): out.align = EncTextAlign::RIGHT; + break; + default: out.align = EncTextAlign::LEFT; + break; + } + return out; +} + +QString readTextItem(QDataStream& ds, EncCharSize cs, qint64 blockEnd) +{ + return readTitleLine(ds, cs, blockEnd).text; +} + +bool EncTitle::read(QDataStream& ds, quint32 vs, EncCharSize cs) +{ + // Detect encoding from varsize alone, not from TK-derived cs: the ONE_BYTE and TWO_BYTES + // block layouts differ by ~10x, so varsize resolves it unambiguously. Needed because Encore + // 5.0.2 can write UTF-16 in TITL even when the TK offset says one-byte. + if (vs >= 10000) { + cs = EncCharSize::TWO_BYTES; + } else if (vs > 0 && vs < 5000) { + cs = EncCharSize::ONE_BYTE; + } + // Some files save two identical TITL blocks. Clear vectors so the + // second read replaces the first instead of doubling all lines. + subtitle.clear(); + instruction.clear(); + author.clear(); + header.clear(); + footer.clear(); + copyright.clear(); + + // Bound every line read to the block's declared end so a truncated or short TITL block cannot + // read past EOF or into the following block. varSize excludes the 8-byte block header, so the + // end lies at startPos + varSize. + const qint64 startPos = ds.device()->pos(); + const qint64 blockEnd = startPos + static_cast(vs); + + ds.skipRawData(2); + title = readTextItem(ds, cs, blockEnd); + for (int i = 0; i < 2; ++i) { + subtitle.push_back(readTextItem(ds, cs, blockEnd)); + } + for (int i = 0; i < 3; ++i) { + instruction.push_back(readTextItem(ds, cs, blockEnd)); + } + for (int i = 0; i < 4; ++i) { + author.push_back(readTextItem(ds, cs, blockEnd)); + } + for (int i = 0; i < 2; ++i) { + header.push_back(readTitleLine(ds, cs, blockEnd)); + } + for (int i = 0; i < 2; ++i) { + footer.push_back(readTitleLine(ds, cs, blockEnd)); + } + for (int i = 0; i < 6; ++i) { + copyright.push_back(readTextItem(ds, cs, blockEnd)); + } + // Realign to the block end; the fixed trailing pad (504 / 120 bytes) is whatever is left. + skipToBlockEnd(ds, startPos, static_cast(vs)); + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-note.cpp b/src/importexport/encore/internal/parser/parsers-note.cpp new file mode 100644 index 0000000000000..97386abd4082c --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-note.cpp @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read the note-family elements: NOTE, REST, KEYCHANGE, CLEF, MIDI CC, and grace-type decode. + +#include "elem-note.h" + +namespace mu::iex::enc { +bool EncMeasureElem::read(QDataStream& ds) +{ + quint8 rawStaff; + ds >> size >> rawStaff; + staffIdx = rawStaff & 0x3F; + staffWithin = rawStaff >> 6; + return true; +} + +EncGraceType EncNote::graceType() const +{ + // Decode the grace/cue flags; see ENCORE_FORMAT.md §Grace and cue notes. A no-slash small note + // is reported APPOGGIATURA here; the emitter later reclassifies it as a cue when it stands alone + // with no principal note to ornament. + if (!(grace1 & 0x20)) { + return EncGraceType::NORMAL; + } + if (grace2 & 0x04) { + return EncGraceType::ACCIACCATURA; + } + return EncGraceType::APPOGGIATURA; +} + +bool EncNote::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + + ds >> faceValue >> grace1 >> grace2; + ds.skipRawData(2); + ds >> xoffset; + ds.skipRawData(1); + ds >> position >> tuplet >> dotControl >> semiTonePitch >> playbackDurTicks; + ds.skipRawData(1); + ds >> velocity >> options >> alterationGlyph; + ds.skipRawData(2); + ds >> articulationUp; + ds.skipRawData(1); + ds >> articulationDown; + // No trailing skip to the element end: the measure element loop reseeks to + // elemStart + elemSpacing(size) after every read(), so any remaining bytes are + // skipped there. The same applies to the other element readers below. + return true; +} + +bool EncRest::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + ds >> faceValue; + ds.skipRawData(4); + ds >> xoffset; + ds.skipRawData(2); + ds >> tuplet >> dotControl; + if (static_cast(size) > 15) { + ds >> mrestCount; // multi-measure rest count at element offset +15 + if (mrestCount < 1) { + mrestCount = 1; + } + } + return true; +} + +bool EncKeyChange::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + ds >> tipo; + return true; +} + +bool EncClefChange::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + qint8 ct; + ds >> ct; + clefType = static_cast(ct); + return true; +} + +bool EncGenericElem::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + return true; +} + +bool EncMidiCc::read(QDataStream& ds) +{ + EncMeasureElem::read(ds); + // Controller/value only exist in the full 12-byte element; a short/garbage one stays aligned + // (the measure loop reseeks past it) with controller/value left at 0. + if (size >= 12) { + ds.skipRawData(5); + ds >> controller >> value; + } + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-ornament.cpp b/src/importexport/encore/internal/parser/parsers-ornament.cpp new file mode 100644 index 0000000000000..e1fc50061cdee --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-ornament.cpp @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read ORNAMENT elements (slurs, wedges, staff text, etc.): geometry, spanning measure counts, tind. + +#include "elem-ornament.h" + +namespace mu::iex::enc { +bool EncOrnament::read(QDataStream& ds) +{ + const qint64 elemPos = ds.device()->pos(); // first byte after the type/voice byte; see ENCORE_FORMAT.md §Ornament subtypes + EncMeasureElem::read(ds); + ds >> tipo; + ds.skipRawData(4); + ds >> xoffset; + ds.skipRawData(1); + ds >> yoffset; + ds.skipRawData(2); + ds >> altMezuro; // +16: v0xC2 spanning measure-count + ds.skipRawData(1); + ds >> alMezuro; // +18: v0xC4 spanning measure-count + ds.skipRawData(1); + ds >> xoffset2; + ds.skipRawData(5); + ds >> speguleco; + speguleco &= 3; + ds.skipRawData(1); + ds >> noto; + ds.skipRawData(1); + ds >> tempo; + // v0xC2 size-32: tind overlaps tempo at byte 30. See ENCORE_FORMAT.md §Ornament subtypes. + if (static_cast(size) >= 33) { + ds.skipRawData(1); + ds >> tind; + } else { + tind = tempo; + } + // Compact v0xA6 STAFFTEXT stores tind at a fixed offset from the type/voice byte instead; + // scope the seek to STAFFTEXT and to the device so an unrelated ornament near EOF cannot desync. + if (tindOffset >= 0 && ornType() == EncOrnamentType::STAFFTEXT) { + const qint64 tindPos = elemPos - 1 + tindOffset; + if (tindPos >= 0 && tindPos < ds.device()->size()) { + ds.device()->seek(tindPos); + ds >> tind; + } + } + // Same story for the placement y of compact v0xA6 STAFFTEXT: fixed offset, so the inline read + // above landed on an unrelated byte. Same scoping as tind. + if (yoffOffset >= 0 && ornType() == EncOrnamentType::STAFFTEXT) { + const qint64 yoffPos = elemPos - 1 + yoffOffset; + if (yoffPos >= 0 && yoffPos + 1 < ds.device()->size()) { + ds.device()->seek(yoffPos); + ds >> yoffset; + } + } + // No trailing skip: the element loop reseeks to the element end after read(). + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-root.cpp b/src/importexport/encore/internal/parser/parsers-root.cpp new file mode 100644 index 0000000000000..2d233293a2096 --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-root.cpp @@ -0,0 +1,361 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Top-level file walk: scan block magics and dispatch to each block reader; page/print setup. + +#include "elem.h" + +#include + +#include + +#include "readers.h" + +namespace mu::iex::enc { +// --------------------------------------------------------------------------- +// EncRoot - top-level container +// --------------------------------------------------------------------------- + +// WINI page-setup block: page margins in typographic points (1/72 inch). Layout and field offsets +// in ENCORE_FORMAT.md §WINI block. +void EncPageSetup::read(QDataStream& ds, quint32 varSize) +{ + if (varSize < 40) { + skipBlock(ds, varSize); + return; + } + qint32 t = 0, l = 0, b = 0, r = 0; + ds.skipRawData(24); // skip fields 0..11 (window/screen data) + ds >> t >> l >> b >> r; + // The four margins come from an attacker-controlled block at a magic offset; + // only trust them when the reads stayed within the stream. + const bool ok = (ds.status() == QDataStream::Ok); + skipBlock(ds, static_cast(varSize) - 40); + if (ok && b > 0 && r > 0 && b > t && r > l) { + hasData = true; + top = t; + left = l; + bottomEdge = b; + rightEdge = r; + } +} + +bool isInstrumentMagic(const QString& magic) +{ + return magic.length() == 4 + && magic.at(0) == 'T' && magic.at(1) == 'K' + && magic.at(2).isDigit() && magic.at(3).isDigit(); +} + +bool isKnownMagic(const QString& magic) +{ + return magic == "LINE" || magic == "MEAS" || magic == "TITL" || magic == "TEXT" + || magic == "WINI" || magic == "PREC" || isInstrumentMagic(magic); +} + +QString findNextKnownMagic(QDataStream& ds) +{ + QString magic; + for (int i = 0; i < 4 && !ds.atEnd(); ++i) { + quint8 ch; + ds >> ch; + magic.append(QChar(ch)); + } + // Limit scan to 1 MiB; TK blocks max ~2 KiB, more gap means corrupt file. + constexpr int kMaxScanBytes = 1 << 20; + int scanned = 0; + while (!isKnownMagic(magic) && !ds.atEnd() && scanned < kMaxScanBytes) { + magic.remove(0, 1); + quint8 ch; + ds >> ch; + magic.append(QChar(ch)); + ++scanned; + } + if (!isKnownMagic(magic)) { + magic.clear(); + } + return magic; +} + +// Parse a Windows DEVMODE (the PREC block) into page orientation, paper size and notation +// scale. The device-name prefix is 32 bytes for an ANSI DEVMODE and 64 bytes (UTF-16) for a +// Unicode one; the fixed fields follow at the same relative offsets. Detect the variant by +// trying both bases and keeping the one whose dmOrientation is a valid 1 (portrait) or 2 +// (landscape); range-check the rest so a wrong base or an unusual driver blob is ignored. +// See ENCORE_FORMAT.md §PREC block. +static void parsePrecDevmode(const QByteArray& buf, EncPrintSetup& out) +{ + auto s16 = [&](int off) -> int { + if (off < 0 || off + 2 > buf.size()) { + return -1; + } + return static_cast(static_cast(buf[off]) + | (static_cast(buf[off + 1]) << 8)); + }; + for (int base : { 32, 64 }) { + const int orient = s16(base + 12); + const int paper = s16(base + 14); + const int scale = s16(base + 20); + if ((orient != 1 && orient != 2) || paper < 0) { + continue; + } + out.hasData = true; + out.orientation = orient; + out.paperSize = paper; + out.paperLength = s16(base + 16); + out.paperWidth = s16(base + 18); + out.scale = (scale > 0 && scale <= 400) ? scale : 0; + return; + } +} + +// SCO5 (macOS Encore 5) stores the PREC page setup as an NSPrintInfo XML plist +// rather than a Windows DEVMODE. Pull orientation, paper size and notation scale +// from it; the page margins are NOT in this block (the plist only carries the +// printer's imageable rects, not Encore's document margins). +bool parsePrecPlist(const QByteArray& buf, EncPrintSetup& out) +{ + const QString s = QString::fromUtf8(buf); + if (!s.contains("PMOrientation") && !s.contains("PaperName")) { + return false; + } + auto firstMatch = [&](const QString& pattern) -> QString { + QRegularExpression re(pattern); + QRegularExpressionMatch m = re.match(s); + return m.hasMatch() ? m.captured(1) : QString(); + }; + + // Value entries put the data tag immediately after the key (the dict wrappers do not). + const int orient = firstMatch(QStringLiteral("PMOrientation\\s*(-?\\d+)")).toInt(); + const double scaling = firstMatch(QStringLiteral("PMScaling\\s*([-0-9.]+)")).toDouble(); + QString paper = firstMatch(QStringLiteral("PMTiogaPaperName\\s*([^<]+)")); + if (paper.isEmpty()) { + paper = firstMatch(QStringLiteral("PMPaperName\\s*([^<]+)")); + } + + const QString p = paper.toLower(); + int paperCode = 0; + if (p.contains("a4")) { + paperCode = 9; + } else if (p.contains("a3")) { + paperCode = 8; + } else if (p.contains("a5")) { + paperCode = 11; + } else if (p.contains("legal")) { + paperCode = 5; + } else if (p.contains("letter")) { + paperCode = 1; + } + + if (paperCode == 0 && orient != 1 && orient != 2) { + return false; + } + out.hasData = true; + out.orientation = (orient == 1 || orient == 2) ? orient : 1; + out.paperSize = paperCode; + out.paperLength = 0; + out.paperWidth = 0; + // PMScaling is a fraction (1.2 = 120%); store as a percent like dmScale. + out.scale = (scaling > 0.0 && scaling <= 4.0) + ? static_cast(scaling * 100.0 + 0.5) : 0; + return true; +} + +// PREC carries page orientation, paper size and notation scale. SCOW stores it as a Windows +// DEVMODE; SCO5 (macOS Encore 5) stores it as an NSPrintInfo XML plist. Detect by content. +static void readPrintSetup(QDataStream& ds, quint32 varSize, EncPrintSetup& out) +{ + // varSize is untrusted: a huge value (e.g. 0x40000000) would request ~1 GB and a value above + // INT_MAX casts to a negative int (UB/abort in QByteArray). A PREC block is a Windows DEVMODE + // (~220 bytes) or a small NSPrintInfo plist; clamp to a few KB and to the bytes actually left. + constexpr qint64 kMaxPrecBytes = 64 * 1024; + const qint64 startPos = ds.device()->pos(); + const qint64 remaining = ds.device()->size() - startPos; + qint64 want = static_cast(varSize); + if (want < 0) { + want = 0; + } + want = std::min({ want, remaining, kMaxPrecBytes }); + QByteArray buf(static_cast(want), '\0'); + const int n = (want > 0) ? ds.readRawData(buf.data(), static_cast(want)) : 0; + // Always advance to the declared block end so the next magic scan stays aligned even when we + // only read (or trusted) a clamped prefix. + skipToBlockEnd(ds, startPos, static_cast(varSize)); + if (n <= 0) { + return; + } + buf.resize(n); + if (buf.startsWith("& measures) +{ + std::vector extra(measures.size()); + + for (size_t i = 0; i < measures.size(); ++i) { + for (const auto& elem : measures[i].elements) { + EncMeasureElem* e = elem.get(); + if (auto* orna = dynamic_cast(e)) { + EncOrnamentType ot = orna->ornType(); + if (ot == EncOrnamentType::SLURSTART || ot == EncOrnamentType::WEDGESTART) { + EncOrnamentType endType = (ot == EncOrnamentType::SLURSTART) + ? EncOrnamentType::SLURSTOP + : EncOrnamentType::WEDGESTOP; + auto endOrna = std::make_unique(*orna); + endOrna->setOrnType(endType); + endOrna->xoffset = orna->xoffset2; + int endMeas = static_cast(i) + orna->alMezuro; + if (endMeas >= 0 && static_cast(endMeas) < extra.size()) { + extra[endMeas].push_back(std::move(endOrna)); + } + } + } + } + } + + for (size_t i = 0; i < measures.size(); ++i) { + for (auto& e : extra[i]) { + measures[i].elements.push_back(std::move(e)); + } + } +} + +bool EncRoot::read(QDataStream& ds) +{ + if (!header.readMagicAndVersion(ds)) { + return false; + } + fmt = EncFormatReader::create(header.chuMagio, header.magic); + if (!header.read(ds, *fmt)) { + return false; + } + EncCharSize charsize = EncCharSize::ONE_BYTE; + + while (!ds.atEnd()) { + // Truncated/corrupt input leaves the stream in a non-Ok state; stop rather than + // fabricate zero-filled "valid-looking" blocks from the past-EOF zero fill. + if (ds.status() != QDataStream::Ok) { + break; + } + QString nextId = findNextKnownMagic(ds); + if (nextId.isEmpty()) { + break; + } + quint32 varSize; + ds >> varSize; + + if (nextId == "LINE") { + const qint64 lineContentStart = ds.device()->pos(); + EncLine line; + line.read(ds, varSize, header.staffPerSystem); + // Some formats (v0xA6) store per-staff key indices in the LINE block rather than in + // staffData; let the format reader extract them. No-op for v0xC2/C4. + fmt->readLineStaffKeys(line, ds, lineContentStart); + lines.push_back(std::move(line)); + } else if (nextId == "MEAS") { + EncMeasure meas; + meas.read(ds, varSize, *fmt); + meas.calculateRealDurations(fmt->hasGraceTimeBorrowing(), *fmt); + // Skip extra "ghost" MEAS blocks beyond the declared measureCount. + if (header.measureCount > 0 + && static_cast(measures.size()) >= header.measureCount) { + continue; + } + measures.push_back(std::move(meas)); + } else if (nextId == "TITL") { + EncTitle tmp; + tmp.read(ds, varSize, charsize); + // Encore writes one TITL block per page; page 2+ blocks are often + // blank. Keep the first block that has non-empty content and ignore + // subsequent empty ones. + if (tmp.hasContent() || !titleBlock.hasContent()) { + titleBlock = std::move(tmp); + } + } else if (nextId == "TEXT") { + // Multi-part files write one TEXT block per part view, with the same + // strings reordered. ORN tind indices match only the first (score) + // block, so keep the first non-empty block and skip later ones + // (mirrors the TITL handling above). + EncTextBlock tmp; + tmp.read(ds, varSize, fmt->textBlockEntryTextOffset(), fmt->textBlockEntryHasRunHeader()); + if (textBlock.entries.empty()) { + textBlock = std::move(tmp); + } + } else if (nextId == "WINI") { + pageSetup.read(ds, varSize); + } else if (nextId == "PREC") { + readPrintSetup(ds, varSize, printSetup); + } else if (isInstrumentMagic(nextId)) { + EncInstrument instr; + instr.contentFilePos = ds.device()->pos(); + // v0xA6: Key transposition is at content+42; read before EncInstrument::read. + // v0xC4 reads Key from outside the TK block in readInstrumentMeta instead. + fmt->readKeyFromTKBlock(instr, ds, ds.device()->pos()); + // v0xC4: Encore 5.0.2 may use UTF-16 LE names; probe determines the encoding. + instr.read(ds, varSize, fmt->probeInstrumentEncoding()); + charsize = instr.charSize(); + instruments.push_back(std::move(instr)); + } else { + skipBlock(ds, varSize); + } + } + + if (instruments.empty()) { + // No TK blocks found; seed empty entries so readInstrumentMeta can recover names. + for (int i = 0; i < header.instrumentCount; ++i) { + instruments.emplace_back(); + } + } + + // Pad to instrumentCount: some v0xC4 files have fewer TK blocks than declared. + while (static_cast(instruments.size()) < header.instrumentCount) { + instruments.emplace_back(); + } + + fmt->readInstrumentMeta(instruments, ds, *this); + + // "Part N" fallback for any instrument whose name is still empty after recovery. + for (int i = 0; i < static_cast(instruments.size()); ++i) { + if (instruments[i].name.isEmpty()) { + instruments[i].name = QString("Part %1").arg(i + 1); + } + } + + // Grand-staff instruments have two LINE entries with same instrumentIndex(), staffIndex() 0 and 1. + if (!lines.empty()) { + for (const auto& lsd : lines[0].staffData) { + const int ii = static_cast(lsd.instrumentIndex()); + const int si = static_cast(lsd.staffIndex()); + if (ii >= 0 && ii < static_cast(instruments.size())) { + instruments[ii].nstaves = std::max(instruments[ii].nstaves, si + 1); + } + } + } + + addSpannerEnds(measures); + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/parsers-text.cpp b/src/importexport/encore/internal/parser/parsers-text.cpp new file mode 100644 index 0000000000000..ce98865dc286b --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-text.cpp @@ -0,0 +1,178 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Read the file header (version/counts/score size) and the indexed TEXT block for staff text. + +#include "elem.h" +#include "readers.h" + +namespace mu::iex::enc { +// --------------------------------------------------------------------------- +// EncHeader +// --------------------------------------------------------------------------- + +bool EncHeader::readMagicAndVersion(QDataStream& ds) +{ + for (int i = 0; i < 4; ++i) { + quint8 ch; + ds >> ch; + magic.append(QChar(ch)); + } + if (magic == "SCOW") { + ds.setByteOrder(QDataStream::LittleEndian); + } else if (magic == "SCO5") { + ds.setByteOrder(QDataStream::BigEndian); + } else { + return false; + } + ds >> chuMagio; + return true; +} + +bool EncHeader::read(QDataStream& ds, const EncFormatReader& fmt) +{ + ds.skipRawData(0x28 - 5); // from +5 (just past the 5-byte magic) to the header fields at 0x28 + ds >> chuVersio >> nekon1 >> fiksa1 >> lineCount >> pageCount; + ds >> instrumentCount >> staffPerSystem >> measureCount; // cursor now at 0x36 + // Format-revision byte at 0x3E distinguishes releases that share an app version: + // 1 = Encore 4.5, 4 = Encore 5.0 (both v0xC4 with chuVersio 1056). Meaningless for v0xA6. + ds.skipRawData(0x3E - 0x36); + ds >> formatRev; // cursor now at 0x3F + // Global staff-size selector (1=small … 4=default). v0xC2/C4/C5 store it at 0x52; + // v0xA6 stores it at 0x8D (byte 0x52 is an unrelated field there). Offset from fmt. + const qint64 szOff = fmt.scoreSizeOffset(); + if (fmt.headerEnd() > szOff) { + ds.skipRawData(static_cast(szOff - 0x3F)); // skip from 0x3F to the size byte + quint8 sz; + ds >> sz; + if (sz >= 1 && sz <= 4) { + scoreSize = sz; + } + ds.skipRawData(static_cast(fmt.headerEnd() - szOff - 1)); // skip to header end + } else { + ds.skipRawData(static_cast(fmt.headerEnd() - 0x3F)); + } + return true; +} + +// --------------------------------------------------------------------------- +// EncTextBlock - indexed text payload for STAFFTEXT 0x1E ornaments +// --------------------------------------------------------------------------- + +bool EncTextBlock::read(QDataStream& ds, quint32 varSize, int textOffset, bool hasRunHeader) +{ + // See ENCORE_FORMAT.md §TEXT block for layout. Entry N referenced by ORN tind byte (+32). + // varSize is untrusted; route every skip through skipBlock/skipToBlockEnd so a value above + // INT_MAX cannot wrap negative in skipRawData(quint32) and desync the following magic scan. + const qint64 startPos = ds.device()->pos(); + if (varSize < 8) { + skipBlock(ds, varSize); + return true; + } + quint16 sync = 0; + quint16 count = 0; + quint32 contentSize = 0; + ds >> sync >> count >> contentSize; + (void)sync; + // contentSize is the declared payload length; varSize (the block's own size field) is the + // authoritative bound, so the per-entry checks below clamp to varSize and contentSize is + // not relied upon. + (void)contentSize; + quint32 consumed = 8; + entries.clear(); + entries.reserve(count); + for (quint16 i = 0; i < count && consumed + 2 <= varSize; ++i) { + // A read past EOF on a truncated block leaves the stream non-Ok; stop instead of + // fabricating zero-filled entries from the past-EOF zero fill. + if (ds.status() != QDataStream::Ok) { + break; + } + quint16 entrySize = 0; + ds >> entrySize; + consumed += 2; + if (entrySize == 0 || consumed + entrySize > varSize) { + break; + } + QByteArray payload(entrySize, 0); + int rd = ds.readRawData(payload.data(), entrySize); + if (rd != entrySize) { + break; + } + consumed += entrySize; + // Payload text starts at a format-supplied offset; the rich-text run header pushes it + // further and its length must be derived, not fixed. See ENCORE_FORMAT.md §TEXT block. + int effTextOffset = textOffset; + if (hasRunHeader && entrySize >= 4) { + // The header carries two independent counts: a run-offset table count at +0 (uint32 + // entries) and a formatting-descriptor count at +2 (6-byte descriptors); text starts + // after both, at 4 + tableCount*4 + descCount*6. Assuming a single descriptor reads too + // early on multi-descriptor entries and misdecodes the text as byte-swapped UTF-16. + const int tableCount = static_cast(payload[0]) + | (static_cast(payload[1]) << 8); + const int descCount = static_cast(payload[2]) + | (static_cast(payload[3]) << 8); + if (tableCount >= 1 && descCount >= 1) { + const int computed = 4 + tableCount * 4 + descCount * 6; + if (computed + 2 <= entrySize) { + effTextOffset = computed; + } + } + } + // Payload text at effTextOffset, probe picks UTF-16 LE or Latin-1; + // see ENCORE_FORMAT.md §Encoding probe. + QString text; + if (entrySize >= effTextOffset + 2) { + const quint8 b0 = static_cast(payload[effTextOffset]); + const quint8 b1 = static_cast(payload[effTextOffset + 1]); + const bool isUtf16 = (b0 >= 0x20 && b0 < 0x7F && b1 == 0x00); + // Decode the whole text region, then post-process: multi-line comments separate lines + // with U+0004 and terminate with a U+0000 null. See ENCORE_FORMAT.md §TEXT block. + const int textBytes = entrySize - effTextOffset; + if (textBytes > 0) { + if (isUtf16) { + text = QString::fromUtf16( + reinterpret_cast(payload.constData() + effTextOffset), + textBytes / 2); + } else { + text = QString::fromLatin1(payload.constData() + effTextOffset, textBytes); + } + // Truncate at the U+0000 null terminator. + int nullIdx = text.indexOf(QChar(QChar::Null)); + if (nullIdx >= 0) { + text = text.left(nullIdx); + } + // U+0004 separates lines within a comment; convert to newline. + text.replace(QChar(0x0004), QChar(u'\n')); + // Each line (including the last) is followed by U+0004, leaving a + // trailing newline after conversion; drop trailing newlines. + while (text.endsWith(QChar(u'\n'))) { + text.chop(1); + } + } + } + entries.push_back(text); + } + // Skip any remaining bytes inside the block (padding or unparsed tail), clamped to the device. + skipToBlockEnd(ds, startPos, varSize); + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xa6.cpp b/src/importexport/encore/internal/parser/readers-v0xa6.cpp new file mode 100644 index 0000000000000..2e938129cd56e --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xa6.cpp @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Encore 2.x (v0xA6) reader: note-layout fixups, rest dedup, inner-grace marking, TK MIDI/key/staff-key reads. + +#include "readers-v0xa6.h" + +#include + +#include "elem.h" + +namespace mu::iex::enc { +// v0xA6 inner-grace detection: after a leading grace note (grace1 & 0x30 == 0x20), +// subsequent NORMAL notes with (grace1 & 0x30) == 0x10 and a strictly larger faceValue +// (shorter duration) are inner graces routed through the grace path in the emitters. +static void markInnerGraces(std::vector& elems) +{ + quint8 leadingFv = 0; + for (EncMeasureElem* e : elems) { + EncNote* en = dynamic_cast(e); + if (!en || en->size != 10) { + leadingFv = 0; + continue; + } + if (en->graceType() != EncGraceType::NORMAL) { + if (leadingFv == 0) { + leadingFv = en->faceValue & 0x0F; + } + continue; + } + if ((en->grace1 & 0x30) == 0x10 && leadingFv != 0) { + const quint8 fv = en->faceValue & 0x0F; + if (fv > leadingFv) { + en->isInnerGrace = true; + leadingFv = std::max(leadingFv, fv); + continue; + } + } + leadingFv = 0; + } +} + +// v0xA6 NOTE layouts: size=10 (pitch at +11, tuplet at +7), size=11 (same layout plus one +// articulation byte at +18, e.g. fermata 0x20), size=22 (pitch in tuplet slot), +// size<27 (artic bytes lie beyond boundary, zero them). See ENCORE_FORMAT.md §Note element. +bool EncFormatReader_V0xA6::postProcessElement(EncMeasureElem* elem, + QDataStream& ds, + qint64 rawElemStart) const +{ + EncNote* en = dynamic_cast(elem); + if (!en) { + return false; + } + + if (en->size == 10 || en->size == 11) { + const qint64 savedPos = ds.device()->pos(); + ds.device()->seek(rawElemStart + 11); + quint8 pitchByte; + ds >> pitchByte; + en->semiTonePitch = pitchByte; + ds.device()->seek(rawElemStart + 7); + quint8 tupByte; + ds >> tupByte; + en->tuplet = tupByte; + ds.device()->seek(savedPos); + } + + if (en->size == 22) { + en->semiTonePitch = en->tuplet; + en->tuplet = 0; + } + + if (en->size < 27) { + en->articulationUp = 0; + en->articulationDown = 0; + // A size-11 note carries one articulation byte at +18 (size-10 notes never do); + // 0x20 there is a fermata. The base read pulls articulations from beyond the + // element boundary, so read the real slot explicitly. + if (en->size == 11) { + const qint64 savedPos = ds.device()->pos(); + ds.device()->seek(rawElemStart + 18); + quint8 articByte; + ds >> articByte; + en->articulationUp = articByte; + ds.device()->seek(savedPos); + } + } + + return false; +} + +// v0xA6 back-to-back identical RESTs: Encore shows only one; duplicates break voice routing. +bool EncFormatReader_V0xA6::deduplicateRest( + std::vector >& elements, + EncMeasureElem* candidate) const +{ + if (elements.empty()) { + return false; + } + const EncRest* prevR = dynamic_cast(elements.back().get()); + const EncRest* curR = dynamic_cast(candidate); + if (!prevR || !curR) { + return false; + } + if (prevR->tick == curR->tick + && prevR->staffIdx == curR->staffIdx + && prevR->voice == curR->voice + && prevR->faceValue == curR->faceValue) { + return true; // drop the duplicate + } + return false; +} + +// v0xA6 has no 4-byte sentinel; stop when < 4 bytes remain before measEnd. +bool EncFormatReader_V0xA6::isMeasureNearEnd(QDataStream& ds, qint64 measEnd) const +{ + return ds.device()->pos() >= measEnd - 4; +} + +void EncFormatReader_V0xA6::postProcessVoiceGroup( + std::vector& elems, qint16) const +{ + markInnerGraces(elems); +} + +// v0xA6 MIDI program: byte +52 of the TK content (block start + 60), 1-indexed GM. +bool EncFormatReader_V0xA6::readInstrumentMeta(std::vector& instruments, + QDataStream& ds, + const EncRoot& /*file*/) const +{ + const qint64 savedPos = ds.device()->pos(); + for (EncInstrument& instr : instruments) { + if (instr.contentFilePos < 0) { + continue; + } + if (!ds.device()->seek(instr.contentFilePos + 52)) { + continue; + } + quint8 prg = 0; + ds >> prg; + if (prg >= 1 && prg <= 128) { + instr.midiProgram = static_cast(prg); + } + } + ds.device()->seek(savedPos); + return true; +} + +void EncFormatReader_V0xA6::readKeyFromTKBlock(EncInstrument& instr, + QDataStream& ds, + qint64 contentStart) const +{ + if (!ds.device()->seek(contentStart + 42)) { + return; + } + quint8 raw = 0; + ds >> raw; + const qint8 signedRaw = static_cast(raw); + if (signedRaw >= -33 && signedRaw <= 24) { + instr.keyTransposeSemitones = signedRaw; + } + ds.device()->seek(contentStart); +} + +void EncFormatReader_V0xA6::readLineStaffKeys(EncLine& line, QDataStream& ds, qint64 lineContentStart) const +{ + // v0xA6 staffPerSystem reads 0 so staffData stays empty; parse the key out of each LINE staff + // entry directly so initial key signatures import. The 0x0E 0xFC marker bounds the run. + // See ENCORE_FORMAT.md §v0xA6 staff size and clef. + QIODevice* dev = ds.device(); + const qint64 savedPos = dev->pos(); + const qint64 entriesStart = lineContentStart + 14; + for (int i = 0; i < 64; ++i) { + if (!dev->seek(entriesStart + static_cast(i) * 22)) { + break; + } + unsigned char buf[18]; + if (dev->read(reinterpret_cast(buf), 18) != 18) { + break; + } + if (buf[16] != 0x0E || buf[17] != 0xFC) { + break; + } + line.staffKeys.push_back(buf[14]); + } + dev->seek(savedPos); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xa6.h b/src/importexport/encore/internal/parser/readers-v0xa6.h new file mode 100644 index 0000000000000..cbcb072c409d3 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xa6.h @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef MU_IMPORTEXPORT_ENC_PARSER_READER_V0XA6_H +#define MU_IMPORTEXPORT_ENC_PARSER_READER_V0XA6_H + +#include "readers.h" + +namespace mu::iex::enc { +// Encore 2.x (v0xA6) format reader. See ENCORE_FORMAT.md §Known quirks. +struct EncFormatReader_V0xA6 final : EncFormatReader +{ + qint64 headerEnd() const override { return 0xA6; } + + quint32 elemBlockOffset() const override { return 0x1A; } + + bool postProcessElement(EncMeasureElem* elem, QDataStream& ds, qint64 rawElemStart) const override; + + bool deduplicateRest(std::vector >& elements, EncMeasureElem* candidate) const override; + + qint64 elemSpacing(qint64 rawSize) const override { return rawSize * 2; } + + bool isMeasureNearEnd(QDataStream& ds, qint64 measEnd) const override; + + bool probeInstrumentEncoding() const override { return false; } + + qint64 scoreSizeOffset() const override { return 0x8D; } + + bool readInstrumentMeta(std::vector& instruments, QDataStream& ds, const EncRoot& file) const override; + + void readKeyFromTKBlock(EncInstrument& instr, QDataStream& ds, qint64 contentStart) const override; + + void readLineStaffKeys(EncLine& line, QDataStream& ds, qint64 lineContentStart) const override; + + bool hasGraceTimeBorrowing() const override { return true; } + // Compact lyric: kie byte immediately after rawStaff (+5), text at +6, no gap. + quint8 lyricPreKieSkip() const override { return 0; } + quint8 lyricTextGapAfterKie() const override { return 0; } + // TEXT entries carry no per-entry header; STAFFTEXT tind sits at +26 in the compact ornament. + quint8 textBlockEntryTextOffset() const override { return 0; } + bool textBlockEntryHasRunHeader() const override { return false; } + int staffTextTindOffset() const override { return 26; } + int staffTextYoffsetOffset() const override { return 6; } + const char* formatName() const override { return "v0xA6"; } + + void postProcessVoiceGroup(std::vector& elems, qint16 durTicks) const override; +}; +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_PARSER_READER_V0XA6_H diff --git a/src/importexport/encore/internal/parser/readers-v0xc2.cpp b/src/importexport/encore/internal/parser/readers-v0xc2.cpp new file mode 100644 index 0000000000000..0a5e7fafc190f --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xc2.cpp @@ -0,0 +1,238 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Encore 3.x/4.x (v0xC2) reader: pitch/tuplet slot swap, tie-sender flag, dotted-eighth and +// implied-tuplet fixups, and the two TEMPO/slur layout quirks that diverge from v0xC4. + +#include "readers-v0xc2.h" +#include "readers-v0xc4-base.h" + +#include + +#include "elem.h" +#include "ticks.h" + +namespace mu::iex::enc { +// v0xC2 stores the eighth of a dotted-eighth+sixteenth group as plain (rdur 120) instead of dotted +// (180), placing the sixteenth at tick+120. Force the dot only when the measure is short by exactly +// 60t (the amount the anomaly steals), otherwise a genuine 8th+16th would get a spurious dot. +static void fixDottedEighthPattern(std::vector& elems, qint16 durTicks) +{ + int faceSum = 0; + for (const EncMeasureElem* e : elems) { + quint8 fv = 0; + if (const auto* en = dynamic_cast(e)) { + fv = en->faceValue & 0x0F; + } else if (const auto* er = dynamic_cast(e)) { + fv = er->faceValue & 0x0F; + } + faceSum += faceValue2ticks(fv); + } + if (faceSum + 60 != static_cast(durTicks)) { + return; + } + + for (size_t i = 0; i < elems.size(); ++i) { + EncNote* en = dynamic_cast(elems[i]); + if (!en || (en->faceValue & 0x0F) != 4 || en->realDuration != 120) { + continue; + } + const qint16 targetTick = static_cast(elems[i]->tick + 120); + for (size_t j = i + 1; j < elems.size(); ++j) { + if (elems[j]->tick > targetTick) { + break; + } + if (elems[j]->tick == targetTick) { + const EncNote* enNext = dynamic_cast(elems[j]); + if (enNext + && (enNext->faceValue & 0x0F) == 5 + && enNext->realDuration == 60) { + en->dotControl |= 1; // kept for documentation; dot is forced via forceDotted + en->forceDotted = true; + break; + } + } + } + } +} + +// v0xC2: mark consecutive notes/rests whose rdur/faceValue ratio identifies an implied tuplet. +// Groups same-tick elements as chords before scanning, matching the grouping in +// computeImpliedTupletMembers so the two passes agree on group boundaries. +static void markImpliedTupletMembers(std::vector& elems) +{ + std::vector > chords; + for (EncMeasureElem* e : elems) { + if (!chords.empty() && chords.back()[0]->tick == e->tick) { + chords.back().push_back(e); + } else { + chords.push_back({ e }); + } + } + int n = static_cast(chords.size()); + int i = 0; + while (i < n) { + EncMeasureElem* first = chords[i][0]; + quint8 fv = 0; + if (auto* en = dynamic_cast(first)) { + fv = en->faceValue & 0x0F; + } else if (auto* er = dynamic_cast(first)) { + fv = er->faceValue & 0x0F; + } + if (fv < 4) { + ++i; + continue; + } + int normalN = 0; + int actualN = detectImpliedTuplet(first->realDuration, fv, normalN); + if (actualN < 2 || i + actualN > n) { + ++i; + continue; + } + bool allMatch = true; + for (int k = 1; k < actualN; ++k) { + EncMeasureElem* ek = chords[i + k][0]; + quint8 fvk = 0; + if (auto* en = dynamic_cast(ek)) { + fvk = en->faceValue & 0x0F; + } else if (auto* er = dynamic_cast(ek)) { + fvk = er->faceValue & 0x0F; + } + int nk = 0; + if (fvk < 4 || detectImpliedTuplet(ek->realDuration, fvk, nk) != actualN || nk != normalN) { + allMatch = false; + break; + } + } + if (allMatch) { + for (int k = 0; k < actualN; ++k) { + for (EncMeasureElem* e : chords[i + k]) { + if (auto* en = dynamic_cast(e)) { + en->isImpliedTupletMember = true; + } else if (auto* er = dynamic_cast(e)) { + er->isImpliedTupletMember = true; + } + } + } + i += actualN; + } else { + ++i; + } + } +} + +// Encore 3.x / 4.x (v0xC2) format reader. +// Differences from v0xC4: +// - ORN 0xC4 is an accent, not an up-bow +// - grace1 low nibble encodes the tie-sender flag +// - alMezuro field in ornaments is unreliable +// - Lyric text starts at element offset +18 (not +20) +// - NOTE: MIDI pitch is in tuplet slot; semiTonePitch is 0 (swap them in postProcess) +// - Instrument metadata: names only (no TK-based MIDI/key tables) +struct EncFormatReader_V0xC2 final : EncFormatReader_V0xC4Base +{ + const char* formatName() const override { return "v0xC2"; } + quint8 lyricTextGapAfterKie() const override { return 7; } + + // v0xC2 slur xoffset2 lives in a stale ornament-coordinate origin; anchor endpoints + // explicitly (forward measure-count / next note) instead of by coordinate search. + bool slurXoffset2Stale() const override { return true; } + + bool postProcessElement(EncMeasureElem* elem, QDataStream& ds, qint64 rawElemStart) const override + { + if (EncOrnament* orn = dynamic_cast(elem)) { + // v0xC2: tipo 0xC4 (UPBOW in v0xC4) encodes accent above in this format. + if (orn->tipo == static_cast(EncOrnamentType::UPBOW)) { + orn->tipo = static_cast(EncOrnamentType::ACCENT); + } + // v0xC2 keeps the reliable forward slur span at +16 (altMezuro), not +18 (garbage here): + // 0 = within measure, N = ends N bars later. Marking it valid lets the post-pass anchor + // by measure count instead of the unreliable xoffset2 coordinate. See ENCORE_FORMAT.md §Slur. + if (orn->tipo == static_cast(EncOrnamentType::SLURSTART)) { + orn->alMezuro = orn->altMezuro; + orn->alMezuroValid = true; + } else { + orn->alMezuroValid = false; + } + // v0xC2 has two TEMPO layouts. New (v0xC4-style): beat-unit code at +28, BPM at +30. + // Old: BPM at +28 (read into noto) with a constant in the +30 slot. Discriminate by + // whether +28 holds a valid beat-unit code (low 7 bits 0..6). See ENCORE_FORMAT.md §Ornament subtypes. + if (orn->tipo == static_cast(EncOrnamentType::TEMPO)) { + const quint8 beatUnitCode = orn->noto & 0x7F; + const bool validBeatUnit = (orn->noto != 0) && (beatUnitCode <= 6); + if (!validBeatUnit) { + orn->tempo = orn->noto; // old layout: +28 is the BPM + // Old layout keeps the per-mark beat unit at +26; recover it so the mark shows + // the composer's unit instead of the compound-meter dotted-quarter default. + orn->noto = 0; + const qint64 save = ds.device()->pos(); + if (ds.device()->seek(rawElemStart + 26)) { + quint8 beatUnit = 0; + ds >> beatUnit; + if ((beatUnit & 0x7F) <= 6) { + orn->noto = beatUnit; + } + } + ds.device()->seek(save); + } + } + return false; + } + + EncNote* en = dynamic_cast(elem); + if (!en) { + return false; + } + // v0xC2 usually keeps the MIDI pitch in the tuplet slot (+13) with +15 empty; swap it + // across. Some files instead store a real pitch at +15 and a genuine tuplet ratio at +13. + // Discriminate by whether +15 is a plausible pitch (>= C0), not merely nonzero: a stray + // small flag there must not be read as MIDI 1. See ENCORE_FORMAT.md §Note element. + static constexpr quint8 kMinPlausiblePitch = 12; // C0; below this is not a MIDI note + if (en->tuplet > 0 && en->semiTonePitch < kMinPlausiblePitch) { + en->semiTonePitch = en->tuplet; + en->tuplet = 0; + } + // Decode tie-sender flag from grace1 low nibble (v0xC2 only). + en->isTieSender = ((en->grace1 & 0x0F) == 1); + // size=24 notes carry an articulation byte at +22 (2 bytes after alterGlyph at +21). + if (en->size == 24 && ds.device()->seek(rawElemStart + 22)) { + ds >> en->articulationUp; + en->articulationDown = 0; + } else { + en->articulationUp = 0; + en->articulationDown = 0; + } + return false; + } + + void postProcessVoiceGroup(std::vector& elems, qint16 durTicks) const override + { + fixDottedEighthPattern(elems, durTicks); + markImpliedTupletMembers(elems); + } +}; + +std::unique_ptr makeFormatReader_V0xC2() +{ + return std::make_unique(); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xc2.h b/src/importexport/encore/internal/parser/readers-v0xc2.h new file mode 100644 index 0000000000000..c861b1663f2c6 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xc2.h @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Factory for the Encore 3.x/4.x (v0xC2) format reader; the class itself lives in readers-v0xc2.cpp. + +#pragma once + +#include +#include "readers.h" + +namespace mu::iex::enc { +std::unique_ptr makeFormatReader_V0xC2(); +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xc4-base.cpp b/src/importexport/encore/internal/parser/readers-v0xc4-base.cpp new file mode 100644 index 0000000000000..8424fb3319788 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xc4-base.cpp @@ -0,0 +1,584 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Instrument metadata recovery for v0xC2/v0xC4: names, MIDI programs and key transpositions across +// the large-TK, small-TK, compact and no-TK-block table layouts. + +#include "readers-v0xc4-base.h" + +#include + +#include "elem.h" +#include "parsers-encoding.h" +#include "log.h" + +namespace mu::iex::enc { +namespace { +// Read the 1-byte MIDI program at absolute offset off. Returns the program number when it is +// a valid 1..128 value, or 0 otherwise (including a seek failure). Callers keep their own +// off-in-bounds check; this just collapses the repeated seek + read + range-validate idiom. +static int readMidiByteAt(QDataStream& ds, qint64 off) +{ + if (!ds.device()->seek(off)) { + return 0; + } + quint8 prg = 0; + ds >> prg; + return (prg >= 1 && prg <= 128) ? static_cast(prg) : 0; +} + +// Read the 1-byte signed key-transpose value at absolute offset off. Returns true and sets +// out (-33..24) when valid; returns false otherwise (including a seek failure). Key 0 is a +// valid value, so a bool/out-param is used rather than a sentinel return. +static bool readKeyByteAt(QDataStream& ds, qint64 off, qint8& out) +{ + if (!ds.device()->seek(off)) { + return false; + } + quint8 raw = 0; + ds >> raw; + const qint8 sv = static_cast(raw); + if (sv >= -33 && sv <= 24) { + out = sv; + return true; + } + return false; +} + +// Scans the first 4096 bytes for PAGE/LINE/MEAS block magic; returns ds.device()->size() if not found. +// includePageBlock=false skips PAGE (used by the compact-layout MIDI scan). +static qint64 findFirstBlockOffset(QDataStream& ds, bool includePageBlock = true) +{ + if (!ds.device()->seek(0)) { + return ds.device()->size(); + } + static constexpr int PROBE = 4096; + const QByteArray buf = ds.device()->read(PROBE); + for (int i = 0; i <= buf.size() - 4; ++i) { + const char* p = buf.constData() + i; + if (includePageBlock && p[0] == 'P' && p[1] == 'A' && p[2] == 'G' && p[3] == 'E') { + return static_cast(i); + } + if ((p[0] == 'L' && p[1] == 'I' && p[2] == 'N' && p[3] == 'E') + || (p[0] == 'M' && p[1] == 'E' && p[2] == 'A' && p[3] == 'S')) { + return static_cast(i); + } + } + return ds.device()->size(); +} + +// Find the offset of the first ~~~~ block (v0xC2 compact instrument table). +// Returns -1 if not found. Reads the first 1 KiB in one shot to avoid +// QDataStream buffering issues with interleaved seeks. +static qint64 findTildeBlockOffset(QDataStream& ds) +{ + const qint64 fileSize = static_cast(ds.device()->size()); + static constexpr qint64 kScanLimit = 1024; + const qint64 readSize = qMin(fileSize, kScanLimit + 8); + if (!ds.device()->seek(0)) { + return -1; + } + const QByteArray chunk = ds.device()->read(static_cast(readSize)); + const int n = chunk.size(); + for (int off = 0; off + 7 < n; ++off) { + if ((quint8)chunk[off] != 0x7e || (quint8)chunk[off + 1] != 0x7e + || (quint8)chunk[off + 2] != 0x7e || (quint8)chunk[off + 3] != 0x7e) { + continue; + } + const quint32 vs = (quint8)chunk[off + 4] + | ((quint8)chunk[off + 5] << 8) + | ((quint8)chunk[off + 6] << 16) + | ((quint8)chunk[off + 7] << 24); + if (vs > 0 && static_cast(vs) < fileSize / 2) { + return static_cast(off); + } + } + return -1; +} + +void recoverMissingNames(std::vector& instruments, QDataStream& ds) +{ + // NAME_BASE=202 is the name position of instrument 0 in every compact-table layout; the step + // differs (2158 for large-TK/~~~~-block files, 112 for no-~~~~-block compact files). The + // COMPACT_NAME_BASE fallback fills names left unresolved by the primary probe. + // See ENCORE_FORMAT.md §Instrument block. + static constexpr qint64 NAME_BASE = 202; + static constexpr qint64 NAME_STEP = 2158; + static constexpr qint64 COMPACT_NAME_BASE = 314; + static constexpr qint64 COMPACT_NAME_STEP = 112; + + auto tryReadName = [&](qint64 off) -> QString { + if (off + 2 >= static_cast(ds.device()->size())) { + return {}; + } + if (!ds.device()->seek(off)) { + return {}; + } + quint8 b0 = 0, b1 = 0; + ds >> b0 >> b1; + if (b0 < 0x20 || b0 >= 0x7F) { + return {}; + } + const bool isLatin1 = (b1 != 0x00 && b1 >= 0x20 && b1 < 0xFF); + if (!probeUtf16LE(b0, b1) && !isLatin1) { + return {}; + } + if (!ds.device()->seek(off)) { + return {}; + } + int remaining = static_cast(ds.device()->size() - off); + return readEncodedStringRemaining(ds, remaining); + }; + + // No-~~~~-block format: all instruments sit in a linear table. Detect large-TK (step 2158) vs + // compact (step 112) by comparing firstBlockOff against the large-TK MIDI base (2278). + const bool noTkBlocks = instruments.empty() + || std::all_of(instruments.begin(), instruments.end(), + [](const EncInstrument& i){ return i.contentFilePos < 0; }); + if (noTkBlocks && findTildeBlockOffset(ds) < 0) { + const qint64 firstBlockOff = findFirstBlockOffset(ds, /*includePageBlock=*/ true); + const bool useLargeTk = (firstBlockOff > 2278); + const qint64 step = useLargeTk ? NAME_STEP : COMPACT_NAME_STEP; + for (size_t n = 0; n < instruments.size(); ++n) { + if (!instruments[n].name.isEmpty()) { + continue; + } + const qint64 off = NAME_BASE + static_cast(n) * step; + instruments[n].name = tryReadName(off); + } + return; + } + + // An instrument that has its own TK block (contentFilePos >= 0) carries an authoritative + // name: if that name came back empty, the instrument is genuinely unnamed and must stay + // empty (the "Part N" fallback applies later). Positional recovery from the formula/compact + // offsets is only for instruments WITHOUT a TK block, and for ~~~~-block files whose compact + // table legitimately names even the TK-block instruments. Without a ~~~~ block, probing the + // formula offset for a real-TK instrument reads unrelated music/structure bytes as garbage. + const bool hasTilde = findTildeBlockOffset(ds) >= 0; + auto resolvedByTkBlock = [&](size_t n) { + return !hasTilde && instruments[n].contentFilePos >= 0; + }; + + for (size_t n = 0; n < instruments.size(); ++n) { + if (!instruments[n].name.isEmpty() || resolvedByTkBlock(n)) { + continue; + } + // Primary probe. + const qint64 off = NAME_BASE + static_cast(n) * NAME_STEP; + instruments[n].name = tryReadName(off); + } + + // Compact-layout fallback: entries are stored in FORWARD instrument order + // (entry 0 = first instrument without a name, entry 1 = second, ...). + // Assign names to instruments that are still missing a name, in order. + { + size_t nextTarget = 0; + for (size_t k = 0; k < instruments.size(); ++k) { + while (nextTarget < instruments.size() + && (!instruments[nextTarget].name.trimmed().isEmpty() + || resolvedByTkBlock(nextTarget))) { + ++nextTarget; + } + if (nextTarget >= instruments.size()) { + break; + } + const qint64 cOff = COMPACT_NAME_BASE + static_cast(k) * COMPACT_NAME_STEP; + const QString candidate = tryReadName(cOff); + if (!candidate.trimmed().isEmpty()) { + instruments[nextTarget].name = candidate; + ++nextTarget; + } + } + } +} + +// No-TK layout (instruments[0].contentFilePos < 0). +static void readMidiProgramsNoTk( + std::vector& instruments, + QDataStream& ds, + qint64 firstBlockOff) +{ + // For v0xC2 files with a ~~~~ block, findFirstBlockOffset may return a large offset + // (past the ~~~~ block) because ~~~~ is not a recognized block type. Cap it so + // the compact layout is correctly detected. + const qint64 tildeOff = findTildeBlockOffset(ds); + const qint64 effectiveFirstBlock = (tildeOff >= 0 && tildeOff < firstBlockOff) + ? tildeOff : firstBlockOff; + + static constexpr qint64 LT_BASE = 2278, LT_STEP = 2158; + // Compact v0xC2: MIDI is at byte +93 within each 112-byte instrument entry. + // COMPACT_NAME_BASE=314 = entry_table_start(281) + name_field_offset(33). + // COMPACT_MIDI_BASE = entry_table_start + midi_field_offset = 281 + 93 = 374. + static constexpr qint64 COMPACT_MIDI_BASE = 374; + static constexpr qint64 COMPACT_MIDI_STEP = 112; + + const bool useLargeTk = (effectiveFirstBlock > LT_BASE); + if (useLargeTk) { + for (size_t n = 0; n < instruments.size(); ++n) { + const qint64 off = LT_BASE + static_cast(n) * LT_STEP; + if (off >= effectiveFirstBlock || off >= static_cast(ds.device()->size())) { + break; + } + if (int prg = readMidiByteAt(ds, off)) { + instruments[n].midiProgram = prg; + } + } + } else { + // Compact layout, two sub-layouts: + // a) Encore 3.x compact (older synthetic files): MIDI at CMP_BASE=390, step=276. + // b) Encore 4.x v0xC2 with ~~~~ block: MIDI at byte +93 of each 112-byte entry, + // base=374 (= entry_table_start(281) + midi_field_offset(93)), step=112. + // Instrument entries map to instruments that lack both a name AND a MIDI + // program (instruments with their own named TK block are skipped). + static constexpr qint64 CMP_BASE = 390, CMP_STEP = 276; + + // First try sub-layout (a): check if CMP_BASE has valid MIDI. + quint8 probeA = 0; + if (CMP_BASE < static_cast(ds.device()->size()) && ds.device()->seek(CMP_BASE)) { + ds >> probeA; + } + // probeA is only valid if CMP_BASE is before the first data block. + const bool aLayoutValid = (probeA >= 1 && probeA <= 128 && CMP_BASE < effectiveFirstBlock); + if (aLayoutValid) { + // Sub-layout (a): sequential table (guarded by firstBlockOff). + for (size_t n = 0; n < instruments.size(); ++n) { + const qint64 off = CMP_BASE + static_cast(n) * CMP_STEP; + if (off >= effectiveFirstBlock || off >= static_cast(ds.device()->size())) { + break; + } + if (int prg = readMidiByteAt(ds, off)) { + instruments[n].midiProgram = prg; + } + } + } else if (tildeOff < 0) { + // No-~~~~-block compact format: instrument entries at + // entry_base=176, step=112, MIDI at entry+86. + // NAME_BASE(202) = entry_base(176) + name_off(26), so + // MIDI_BASE = 176 + 86 = 262 = NAME_BASE - 26 + 86. + static constexpr qint64 NC_MIDI_BASE = 262; + static constexpr qint64 NC_MIDI_STEP = 112; + for (size_t n = 0; n < instruments.size(); ++n) { + if (instruments[n].midiProgram != 0) { + continue; + } + const qint64 off = NC_MIDI_BASE + static_cast(n) * NC_MIDI_STEP; + if (off >= static_cast(ds.device()->size())) { + break; + } + if (int prg = readMidiByteAt(ds, off)) { + instruments[n].midiProgram = prg; + } + } + } else { + // ~~~~-block compact format: MIDI at byte +93 of each 112-byte entry. + // Skip instruments that have their own named block (e.g. "Voz " in + // some v0xC2 files), their MIDI is read in the first pass below. + static constexpr qint64 PNB = 202, PNS = 2158; // primary name base/step + auto hasPrimaryBlock = [&](size_t n) -> bool { + const qint64 off = PNB + static_cast(n) * PNS; + if (off + 1 >= static_cast(ds.device()->size())) { + return false; + } + if (!ds.device()->seek(off)) { + return false; + } + quint8 u = 0; + ds >> u; + return u >= 0x20 && u < 0x7F; + }; + + // First pass: MIDI for instruments with an explicit primary block + // (their MIDI is at block_start + 60). + static constexpr qint64 MIDI_AT_BLOCK_START = 60; + for (size_t n = 0; n < instruments.size(); ++n) { + if (!hasPrimaryBlock(n) || instruments[n].midiProgram != 0) { + continue; + } + const qint64 mOff = PNB + static_cast(n) * PNS + MIDI_AT_BLOCK_START; + if (mOff >= static_cast(ds.device()->size())) { + continue; + } + if (int prm = readMidiByteAt(ds, mOff)) { + instruments[n].midiProgram = prm; + } + } + + size_t nextTarget = 0; + for (size_t k = 0; k < instruments.size(); ++k) { + while (nextTarget < instruments.size() + && (instruments[nextTarget].midiProgram != 0 + || instruments[nextTarget].contentFilePos >= 0 + || hasPrimaryBlock(nextTarget))) { + ++nextTarget; + } + if (nextTarget >= instruments.size()) { + break; + } + const qint64 off = COMPACT_MIDI_BASE + static_cast(k) * COMPACT_MIDI_STEP; + if (off >= static_cast(ds.device()->size())) { + break; + } + if (int prg = readMidiByteAt(ds, off)) { + instruments[nextTarget].midiProgram = prg; + } + ++nextTarget; + } + } + } + // Recover names even for no-TK files. + recoverMissingNames(instruments, ds); +} + +// Detect Encore 4.x v0xC4 files where TK varSize encodes the TOTAL block size +// (including the 8-byte magic+size header) rather than just the content length. +// Symptom: consecutive contentFilePos values are spaced exactly `offset` bytes apart +// (not `offset+8` as in the standard Encore 5.x layout). +static bool isTotalBlockSizeTkFmt(const std::vector& instruments) +{ + if (instruments.size() < 2) { + return false; + } + const qint64 stride = instruments[1].contentFilePos - instruments[0].contentFilePos; + return stride > 0 && stride == static_cast(instruments[0].offset); +} + +// SmallTK layout (0 < offset <= 250). +static void readMidiProgramsSmallTk( + std::vector& instruments, + QDataStream& ds) +{ + // Standard Encore 5.x: MIDI is 76 bytes past the content end + // (content is `instr.offset` bytes; absolute = contentFilePos + offset + 76). + // Encore 4.x total-size variant: varSize is the TOTAL block size including header, + // so actual content = varSize-8 = 104 bytes, and MIDI is at content[60]. + static constexpr qint64 MIDI_AFTER_CONTENT = 76; + static constexpr qint64 MIDI_IN_CONTENT = 60; + const bool totalSizeFmt = isTotalBlockSizeTkFmt(instruments); + if (totalSizeFmt) { + LOGD() << "enc: small-TK total-block-size format detected (Encore 4.x): reading MIDI at content+60"; + } + for (auto& instr : instruments) { + if (instr.contentFilePos < 0) { + continue; + } + const qint64 off = totalSizeFmt + ? instr.contentFilePos + MIDI_IN_CONTENT + : instr.contentFilePos + static_cast(instr.offset) + MIDI_AFTER_CONTENT; + if (off >= static_cast(ds.device()->size())) { + continue; + } + if (int prg = readMidiByteAt(ds, off)) { + instr.midiProgram = prg; + } + } +} + +void readMidiPrograms(std::vector& instruments, QDataStream& ds) +{ + // MIDI table offsets vary by layout. See ENCORE_FORMAT.md §Instrument block. + if (instruments.empty()) { + return; + } + const bool noTkBlocks = (instruments[0].contentFilePos < 0); + if (noTkBlocks) { + const qint64 firstBlockOff = findFirstBlockOffset(ds, /*includePageBlock=*/ true); + readMidiProgramsNoTk(instruments, ds, firstBlockOff); + return; + } + const bool compact = (instruments[0].offset == 0); + const bool smallTK = (!compact && instruments[0].offset <= 250); + + if (smallTK) { + readMidiProgramsSmallTk(instruments, ds); + // Fallback for mixed-TK files (e.g. v0xC2 with one named TK block and the + // remaining instruments in a compact ~~~~ block at a fixed layout). + // Apply compact byte-93 MIDI for any instrument that still lacks both a TK + // block (contentFilePos<0) and a MIDI program (midiProgram==0). + { + static constexpr qint64 CMPX_MIDI_BASE = 374; + static constexpr qint64 CMPX_MIDI_STEP = 112; + size_t nextTarget = 0; + for (size_t k = 0; k < instruments.size(); ++k) { + while (nextTarget < instruments.size() + && (instruments[nextTarget].midiProgram != 0 + || instruments[nextTarget].contentFilePos >= 0)) { + ++nextTarget; + } + if (nextTarget >= instruments.size()) { + break; + } + const qint64 off = CMPX_MIDI_BASE + static_cast(k) * CMPX_MIDI_STEP; + if (off >= static_cast(ds.device()->size())) { + break; + } + if (int prg = readMidiByteAt(ds, off)) { + instruments[nextTarget].midiProgram = prg; + } + ++nextTarget; + } + } + return; + } + + // Large-TK or compact layout. + qint64 firstBlockOff = ds.device()->size(); + if (compact) { + firstBlockOff = findFirstBlockOffset(ds, /*includePageBlock=*/ false); + } + + const qint64 base = compact ? 390 : 2278; + const qint64 step = compact ? 276 : 2158; + for (size_t n = 0; n < instruments.size(); ++n) { + const qint64 off = base + static_cast(n) * step; + if (off >= firstBlockOff) { + break; + } + if (off >= static_cast(ds.device()->size())) { + break; + } + if (int prg = readMidiByteAt(ds, off)) { + instruments[n].midiProgram = prg; + } + } +} + +static void readKeyTranspositionsNoTk(std::vector& instruments, QDataStream& ds, qint64 firstBlockOff) +{ + static constexpr qint64 LT_BASE = 2278, LT_STEP = 2158, KEY_OFF = -23; + static constexpr qint64 CMP_BASE = 390; + const bool useLargeTk = (firstBlockOff > LT_BASE); + if (useLargeTk) { + for (size_t n = 0; n < instruments.size(); ++n) { + const qint64 off = LT_BASE + KEY_OFF + static_cast(n) * LT_STEP; + if (off < 0 || off >= static_cast(ds.device()->size())) { + continue; + } + qint8 sv; + if (readKeyByteAt(ds, off, sv)) { + instruments[n].keyTransposeSemitones = sv; + } + } + } else { + if (instruments.size() != 1) { + return; + } + const qint64 off = CMP_BASE + KEY_OFF; + if (off >= static_cast(ds.device()->size())) { + return; + } + qint8 sv; + if (readKeyByteAt(ds, off, sv)) { + instruments[0].keyTransposeSemitones = sv; + } + } +} + +static void readKeyTranspositionsSmallTk(std::vector& instruments, QDataStream& ds) +{ + // Standard Encore 5.x: key is 23 bytes before the MIDI position + // (KEY_OFF = -23 from MIDI base = 76-23 = 53 bytes past content end). + // Encore 4.x total-size variant: key at content[42] (matches v0xA6 TK layout). + static constexpr qint64 KEY_AFTER_CONTENT = 76 - 23; // = 53 (standard 5.x) + static constexpr qint64 KEY_IN_CONTENT = 42; // Encore 4.x total-size variant + const bool totalSizeFmt = isTotalBlockSizeTkFmt(instruments); + for (auto& instr : instruments) { + if (instr.contentFilePos < 0) { + continue; + } + const qint64 off = totalSizeFmt + ? instr.contentFilePos + KEY_IN_CONTENT + : instr.contentFilePos + static_cast(instr.offset) + KEY_AFTER_CONTENT; + if (off < 0 || off >= static_cast(ds.device()->size())) { + continue; + } + qint8 sv; + if (readKeyByteAt(ds, off, sv)) { + instr.keyTransposeSemitones = sv; + } + } +} + +static void readKeyTranspositionsCompact(std::vector& instruments, QDataStream& ds) +{ + if (instruments.size() != 1) { + return; + } + static constexpr qint64 MIDI_BASE = 390, KEY_OFF = -23; + const qint64 off = MIDI_BASE + KEY_OFF; + if (off >= static_cast(ds.device()->size())) { + return; + } + qint8 sv; + if (readKeyByteAt(ds, off, sv)) { + instruments[0].keyTransposeSemitones = sv; + } +} + +void readKeyTranspositions(std::vector& instruments, QDataStream& ds) +{ + if (instruments.empty()) { + return; + } + const bool noTkBlocks = (instruments[0].contentFilePos < 0); + if (noTkBlocks) { + const qint64 firstBlockOff = findFirstBlockOffset(ds, /*includePageBlock=*/ true); + readKeyTranspositionsNoTk(instruments, ds, firstBlockOff); + return; + } + const bool compact = (instruments[0].offset == 0); + const bool tkBased = (instruments[0].offset > 250); + + if (compact) { + readKeyTranspositionsCompact(instruments, ds); + return; + } + + if (!tkBased) { + readKeyTranspositionsSmallTk(instruments, ds); + return; + } + + static constexpr qint64 PRG_BASE = 2278, PRG_STEP = 2158, KEY_OFF = -23; + for (size_t n = 0; n < instruments.size(); ++n) { + const qint64 off = PRG_BASE + KEY_OFF + static_cast(n) * PRG_STEP; + if (off < 0 || off >= static_cast(ds.device()->size())) { + continue; + } + qint8 sv; + if (readKeyByteAt(ds, off, sv)) { + instruments[n].keyTransposeSemitones = sv; + } + } +} +} // namespace + +bool EncFormatReader_V0xC4Base::readInstrumentMeta(std::vector& instruments, + QDataStream& ds, + const EncRoot& /*file*/) const +{ + recoverMissingNames(instruments, ds); + readMidiPrograms(instruments, ds); + readKeyTranspositions(instruments, ds); + return true; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xc4-base.h b/src/importexport/encore/internal/parser/readers-v0xc4-base.h new file mode 100644 index 0000000000000..39783ec6b5167 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xc4-base.h @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Base reader shared by v0xC2 and v0xC4: element block offset and instrument metadata reads. + +#pragma once + +#include "readers.h" + +namespace mu::iex::enc { +// Shared base for v0xC2 and v0xC4 format readers. +// Provides the element block offset, instrument encoding probe, and the full +// instrument-metadata read (MIDI programs + key transpositions) used by v0xC4. +// v0xC2 overrides readInstrumentMeta to skip MIDI/key data. +struct EncFormatReader_V0xC4Base : EncFormatReader +{ + quint32 elemBlockOffset() const override { return 0x36; } + bool probeInstrumentEncoding() const override { return true; } + bool clustersChordsByXoffset() const override { return true; } + + bool readInstrumentMeta(std::vector& instruments, QDataStream& ds, const EncRoot& file) const override; +}; +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xc4.cpp b/src/importexport/encore/internal/parser/readers-v0xc4.cpp new file mode 100644 index 0000000000000..0e7b5b2466bf5 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xc4.cpp @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Encore 5.x (v0xC4) reader and its SCO5 (macOS) variant: mostly base defaults, plus artic-byte +// clearing for undersized notes and uniform page margins for SCO5. + +#include "readers-v0xc4.h" +#include "readers-v0xc4-base.h" + +#include + +#include "elem.h" + +namespace mu::iex::enc { +// Encore 5.x (v0xC4) format reader. +// All defaults inherited from EncFormatReader_V0xC4Base are correct for v0xC4. +// The only v0xC4-specific post-processing is zeroing articulation bytes when +// the element is smaller than 27 bytes (bytes lie beyond the element boundary). +struct EncFormatReader_V0xC4 : EncFormatReader_V0xC4Base +{ + const char* formatName() const override { return "v0xC4"; } + + bool postProcessElement(EncMeasureElem* elem, QDataStream& /*ds*/, qint64 /*rawElemStart*/) const override + { + EncNote* en = dynamic_cast(elem); + if (!en) { + return false; + } + // Clear artic bytes that were read beyond the element boundary for size<27. + if (en->size < 27) { + en->articulationUp = 0; + en->articulationDown = 0; + } + return false; + } +}; + +std::unique_ptr makeFormatReader_V0xC4() +{ + return std::make_unique(); +} + +// macOS Encore 5 (SCO5): identical binary format to v0xC4, but it stores no importable +// document margins (WINI holds only window state, the PREC plist only printer rects), so a +// clean uniform 0.25" margin is the better default than MuseScore's A4-tuned margins. +struct EncFormatReader_SCO5 final : EncFormatReader_V0xC4 +{ + const char* formatName() const override { return "SCO5"; } + bool usesUniformPageMargins() const override { return true; } +}; + +std::unique_ptr makeFormatReader_SCO5() +{ + return std::make_unique(); +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers-v0xc4.h b/src/importexport/encore/internal/parser/readers-v0xc4.h new file mode 100644 index 0000000000000..18365febd3874 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers-v0xc4.h @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Factories for the Encore 5.x (v0xC4) reader and its macOS SCO5 variant; classes live in the cpp. + +#ifndef MU_IMPORTEXPORT_ENC_PARSER_READER_V0XC4_H +#define MU_IMPORTEXPORT_ENC_PARSER_READER_V0XC4_H + +#include +#include "readers.h" + +namespace mu::iex::enc { +// SCO5 is the macOS Encore 5 variant: same binary format as v0xC4 (SCOW) but with no importable +// document margins. +std::unique_ptr makeFormatReader_V0xC4(); +std::unique_ptr makeFormatReader_SCO5(); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_PARSER_READER_V0XC4_H diff --git a/src/importexport/encore/internal/parser/readers.cpp b/src/importexport/encore/internal/parser/readers.cpp new file mode 100644 index 0000000000000..08da3d3795eb1 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers.cpp @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Shared block-skip/clamp helper implementations and the EncFormatReader factory (version to reader). + +#include "readers.h" +#include "readers-v0xa6.h" +#include "readers-v0xc2.h" +#include "readers-v0xc4.h" + +#include +#include + +#include +#include + +#include "log.h" + +namespace mu::iex::enc { +bool skipBlock(QDataStream& ds, qint64 size) +{ + QIODevice* dev = ds.device(); + const qint64 remaining = dev->size() - dev->pos(); + if (size < 0 || size > remaining) { + return false; + } + qint64 left = size; + while (left > 0) { + const int chunk = static_cast(std::min(left, std::numeric_limits::max())); + const int n = ds.skipRawData(chunk); + if (n <= 0) { + return false; + } + left -= n; + } + return true; +} + +bool skipToBlockEnd(QDataStream& ds, qint64 blockStartPos, qint64 declaredLen) +{ + const qint64 toSkip = (blockStartPos + declaredLen) - ds.device()->pos(); + if (toSkip <= 0) { + return false; + } + return skipBlock(ds, toSkip); +} + +qint64 clampMeasureEnd(qint64 measStart, quint32 varsize, qint64 elemBlockOffset, qint64 deviceSize) +{ + const qint64 end = measStart + static_cast(varsize) + elemBlockOffset; + return std::min(end, deviceSize); +} + +// Selects a format reader. SCO5 (macOS Encore 5) is matched by magic string because its chuMagio +// is not 0xC4 even though it shares the v0xC4 format; otherwise chuMagio picks the reader. +std::unique_ptr EncFormatReader::create(quint8 chuMagio, const QString& magic) +{ + if (magic == "SCO5") { + return makeFormatReader_SCO5(); + } + switch (chuMagio) { + case static_cast(EncFormatVersion::V2_X): + return std::make_unique(); + case static_cast(EncFormatVersion::V3_4_X): + return makeFormatReader_V0xC2(); + case static_cast(EncFormatVersion::V5_X): + return makeFormatReader_V0xC4(); + default: + LOGW() << QString("Encore: unsupported format version 0x%1 - import may fail") + .arg(chuMagio, 2, 16, QChar('0')); + return makeFormatReader_V0xC4(); + } +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/readers.h b/src/importexport/encore/internal/parser/readers.h new file mode 100644 index 0000000000000..406f5f570453c --- /dev/null +++ b/src/importexport/encore/internal/parser/readers.h @@ -0,0 +1,196 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// EncFormatReader interface plus shared block-skip and duration-resolution helpers used by all format readers. + +#ifndef MU_IMPORTEXPORT_ENC_PARSER_READER_H +#define MU_IMPORTEXPORT_ENC_PARSER_READER_H + +#include +#include + +#include +#include "elem.h" + +class QDataStream; + +namespace mu::iex::enc { +struct EncMeasureElem; +struct EncInstrument; +struct EncRoot; +struct EncLine; + +// Skip an untrusted file-supplied block size. Returns false (so the caller stops) when the size is +// negative or past EOF; chunks the skip because skipRawData takes int and a >INT_MAX size wraps. +bool skipBlock(QDataStream& ds, qint64 size); + +// Skip from the cursor to (blockStartPos + declaredLen), where declaredLen is an untrusted varSize. +// Returns false when the cursor is already at/past that end or the end lies past EOF. +bool skipToBlockEnd(QDataStream& ds, qint64 blockStartPos, qint64 declaredLen); + +// Device position just past a MEAS element stream, clamped to deviceSize so an oversized varsize +// cannot push the element loop past EOF. Pure so it can be unit-tested with synthetic sizes. +qint64 clampMeasureEnd(qint64 measStart, quint32 varsize, qint64 elemBlockOffset, qint64 deviceSize); + +// Phase 1 of duration resolution: set each element's realDuration from the gap to the next event +// (skipping same-tick chord members and near-simultaneous cluster notes), capping the gap at any +// boundaryTicks (mid-measure CLEF/KEYCHANGE) and applying v0xA6 grace time-borrowing when enabled. +// Declared here so the decision core can be unit-tested with synthetic element lists. +void computeElementDurations(std::vector& elems, int durTicks, bool hasGraceTimeBorrowing, + const std::vector& boundaryTicks = {}); + +// EncFormatReader: per-format binary parsing strategy. Register a new version in EncFormatReader::create(). +struct EncFormatReader +{ + // Byte offset where element block begins in a MEAS block. See ENCORE_FORMAT.md §Known quirks. + virtual quint32 elemBlockOffset() const = 0; + + // Apply format-specific fixups; return true to drop the element (duplicate suppression). + virtual bool postProcessElement(EncMeasureElem* elem, + QDataStream& ds, + qint64 rawElemStart) const + { + (void)elem; + (void)ds; + (void)rawElemStart; + return false; + } + + // True if the candidate REST is a duplicate and should be dropped. + virtual bool deduplicateRest(std::vector >& elements, + EncMeasureElem* candidate) const + { + (void)elements; + (void)candidate; + return false; + } + + // Byte stride past one element block. + virtual qint64 elemSpacing(qint64 rawSize) const { return rawSize; } + + // True when the stream is too close to measEnd for another element. + virtual bool isMeasureNearEnd(QDataStream& ds, qint64 measEnd) const + { + (void)ds; + (void)measEnd; + return false; + } + + // Byte offset where the file header ends; first block starts here. + // See ENCORE_FORMAT.md §Known quirks for per-version values. + virtual qint64 headerEnd() const { return 0xC2; } + + // Read MIDI program, Key, and name metadata stored outside TK blocks. + virtual bool readInstrumentMeta(std::vector& instruments, + QDataStream& ds, + const EncRoot& file) const + { + (void)instruments; + (void)ds; + (void)file; + return true; + } + + // True when TK instrument names need UTF-16 probe. + virtual bool probeInstrumentEncoding() const { return false; } + + // Header byte offset of the global staff-size selector (1-4). v0xC2/C4/C5 use 0x52; + // v0xA6 uses 0x8D. See ENCORE_FORMAT.md §Header. + virtual qint64 scoreSizeOffset() const { return 0x52; } + + // Reads Key transposition from TK content (v0xA6). See ENCORE_FORMAT.md §Instrument block. + virtual void readKeyFromTKBlock(EncInstrument& /*instr*/, + QDataStream& /*ds*/, + qint64 /*contentStart*/) const {} + + // Reads per-staff written key indices from a LINE block into EncLine::staffKeys. + // v0xA6 stores them in its 22-byte staff entries; other formats fill staffData during + // EncLine::read and leave staffKeys empty. The override seeks within the stream and must + // restore the position before returning. See ENCORE_FORMAT.md §v0xA6 staff size and clef. + virtual void readLineStaffKeys(EncLine& /*line*/, + QDataStream& /*ds*/, + qint64 /*lineContentStart*/) const {} + + // True when a slur's stored xoffset2 lives in a stale coordinate origin and the endpoint + // must be anchored explicitly (to the target measure / next note) instead of by coordinate + // search. v0xC2 only. See ENCORE_FORMAT.md §Slur. + virtual bool slurXoffset2Stale() const { return false; } + + // True when the file stores no importable document margins and a uniform 0.25" default is + // preferred over MuseScore's A4-tuned defaults. SCO5 (macOS Encore 5) only. + virtual bool usesUniformPageMargins() const { return false; } + + // Format capability queries, see ENCORE_FORMAT.md §Known quirks for per-version details. + virtual bool hasGraceTimeBorrowing() const { return false; } // v0xA6: grace borrows rdur from next note + virtual const char* formatName() const { return "v0xC4"; } // for logging + + // True when a chord's notes are recorded with staggered playback ticks (a per-chord "strum") + // but share one notated horizontal column (the note xoffset byte). When set, a run of notes + // sharing the same nonzero xoffset and face value is collapsed to one tick before duration + // computation so they form a single chord. See ENCORE_FORMAT.md §Chord column (xoffset). + virtual bool clustersChordsByXoffset() const { return false; } + + // Called once per (staffIdx, voice) element group after computeElementDurations(). + // Override to perform format-specific per-voice post-processing: + // v0xA6: marks inner-grace notes (isInnerGrace) + // v0xC2: fixes dotted-eighth placement and marks implied tuplet members + virtual void postProcessVoiceGroup(std::vector& /*elems*/, + qint16 /*durTicks*/) const {} + // Bytes to skip between kie (byte +10) and text. v0xC4=9 (text at +20), v0xC2=7 (text at +18). + // v0xA6 uses 0 (compact lyric: kie at +5, text at +6). See ENCORE_FORMAT.md §Lyric element. + virtual quint8 lyricTextGapAfterKie() const { return 9; } + + // Bytes from the cursor after the element header (size + rawStaff) to the kie byte. v0xC4/v0xC2 + // use 5 (kie at element +10); v0xA6 uses 0 (kie at +5). See ENCORE_FORMAT.md §Lyric element. + virtual quint8 lyricPreKieSkip() const { return 5; } + + // Byte offset of the text payload within a TEXT-block entry. v0xC4/v0xC2 use 14 (a per-entry + // header precedes the text); v0xA6 uses 0 (text starts at the entry). See ENCORE_FORMAT.md §TEXT block. + virtual quint8 textBlockEntryTextOffset() const { return 14; } + + // True when a TEXT-block entry begins with Encore's rich-text run header: a uint16 run count, + // a flags word, a run-offset table (run count * uint32), then a 6-byte descriptor before the + // text. The text offset is then variable (14 is only the single-run case). v0xA6 has no such + // header. See ENCORE_FORMAT.md §TEXT block. + virtual bool textBlockEntryHasRunHeader() const { return true; } + + // Byte offset of a STAFFTEXT ornament's TEXT-entry index (tind) measured from the type/voice + // byte, or -1 to use the size-based location read inline. v0xA6 stores it at +26 in its compact + // ornament; other formats return -1. See ENCORE_FORMAT.md §Ornament subtypes. + virtual int staffTextTindOffset() const { return -1; } + + // Byte offset of a STAFFTEXT ornament's vertical placement value (signed Cartesian y, positive = + // above, negative = below) measured from the type/voice byte, or -1 to use the offset read inline. + // v0xA6 stores it at +6 in its compact ornament; other formats return -1. See ENCORE_FORMAT.md + // §Ornament subtypes. + virtual int staffTextYoffsetOffset() const { return -1; } + + virtual ~EncFormatReader() = default; + + // Factory: returns the reader for the file. The 4-char magic string is needed because some + // formats are not distinguished by chuMagio (SCO5/macOS Encore 5 shares the v0xC4 format but + // does not carry chuMagio 0xC4). See create() in readers.cpp. + static std::unique_ptr create(quint8 chuMagio, const QString& magic); +}; +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_PARSER_READER_H diff --git a/src/importexport/encore/internal/parser/ticks.cpp b/src/importexport/encore/internal/parser/ticks.cpp new file mode 100644 index 0000000000000..af5692076bc5a --- /dev/null +++ b/src/importexport/encore/internal/parser/ticks.cpp @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Implements the tick table and the 3:2 / 5:4 implied-tuplet probe. + +#include "ticks.h" + +namespace mu::iex::enc { +int faceValue2ticks(quint8 fv) +{ + switch (fv & 0x0F) { + case 1: return 960; + case 2: return 480; + case 3: return 240; + case 4: return 120; + case 5: return 60; + case 6: return 30; + case 7: return 15; + case 8: return 7; + default: return 0; + } +} + +int detectImpliedTuplet(qint16 realDur, quint8 fv, int& normalNotes) +{ + int base = faceValue2ticks(fv); + if (base <= 0 || realDur <= 0) { + normalNotes = 0; + return 0; + } + // Triplet (3:2): realDuration = base * 2/3 + if (realDur * 3 == base * 2) { + normalNotes = 2; + return 3; + } + // Quintuplet (5:4): realDuration = base * 4/5 + if (realDur * 5 == base * 4) { + normalNotes = 4; + return 5; + } + normalNotes = 0; + return 0; +} +} // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/parser/ticks.h b/src/importexport/encore/internal/parser/ticks.h new file mode 100644 index 0000000000000..47ae9b53cbbe1 --- /dev/null +++ b/src/importexport/encore/internal/parser/ticks.h @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Tick model for the Encore parser: face value to ticks and implied-tuplet detection, engraving-free. + +#ifndef MU_IMPORTEXPORT_ENC_PARSER_TICKS_H +#define MU_IMPORTEXPORT_ENC_PARSER_TICKS_H + +#include + +namespace mu::iex::enc { +// Encore's internal tick resolution: 960 ticks per whole note (= 240 per quarter). +// This is fixed for all format versions and all time signatures. +inline constexpr int kEncWholeTicks = 960; + +// Raw tick table: Encore face value (low nibble) to ticks. Pure integer model, no engraving +// types, so the parser can use it without depending on engraving/dom. The rendering decisions +// derived from these ticks (DurationType, dot count, tuplet shape) live in importer/durations.h. +int faceValue2ticks(quint8 fv); + +// Pure-integer implied-tuplet probe: returns the tuplet's actualN (with normalNotes set) when +// realDur is a 3:2 or 5:4 augmentation of the face value, else 0. Used by the v0xC2 parser pass +// and by the importer; carries no engraving dependency, so it stays in the parser layer. +int detectImpliedTuplet(qint16 realDur, quint8 fv, int& normalNotes); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_PARSER_TICKS_H diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt new file mode 100644 index 0000000000000..b5311487369bd --- /dev/null +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: GPL-3.0-only +# MuseScore-Studio-CLA-applies +# +# MuseScore Studio +# Music Composition & Notation +# +# Copyright (C) 2026 MuseScore Limited and others +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +set(MODULE_TEST iex_encore_tests) + +set(MODULE_TEST_SRC + ${CMAKE_CURRENT_LIST_DIR}/environment.cpp + ${CMAKE_CURRENT_LIST_DIR}/testbase.cpp + ${CMAKE_CURRENT_LIST_DIR}/testbase.h + ${CMAKE_CURRENT_LIST_DIR}/tst_smoke.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_parser_ticks.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_parser_v0xa6.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_parser_chord.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_options.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_instruments.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_staff_size.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_structure.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes_mrest.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_tempo.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes_tuplets.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes_ties.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_ornaments.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_repeats.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_hairpins.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_ornaments_slurs.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_grace.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_text.cpp +) + +set(MODULE_TEST_LINK + engraving + iex_encore +) + +set(MODULE_TEST_DATA_ROOT ${CMAKE_CURRENT_LIST_DIR}) + +include(SetupGTest) diff --git a/src/importexport/encore/tests/data/akordo.enc b/src/importexport/encore/tests/data/akordo.enc new file mode 100644 index 0000000000000..387c7bbfd828b Binary files /dev/null and b/src/importexport/encore/tests/data/akordo.enc differ diff --git a/src/importexport/encore/tests/data/bando.enc b/src/importexport/encore/tests/data/bando.enc new file mode 100644 index 0000000000000..2c5165db6e8f4 Binary files /dev/null and b/src/importexport/encore/tests/data/bando.enc differ diff --git a/src/importexport/encore/tests/data/bazo.enc b/src/importexport/encore/tests/data/bazo.enc new file mode 100644 index 0000000000000..9d0d415158290 Binary files /dev/null and b/src/importexport/encore/tests/data/bazo.enc differ diff --git a/src/importexport/encore/tests/data/bazo_left_100.enc b/src/importexport/encore/tests/data/bazo_left_100.enc new file mode 100644 index 0000000000000..d104a51460476 Binary files /dev/null and b/src/importexport/encore/tests/data/bazo_left_100.enc differ diff --git a/src/importexport/encore/tests/data/bazo_top_100.enc b/src/importexport/encore/tests/data/bazo_top_100.enc new file mode 100644 index 0000000000000..f8f7072ede218 Binary files /dev/null and b/src/importexport/encore/tests/data/bazo_top_100.enc differ diff --git a/src/importexport/encore/tests/data/chord_parsing.enc b/src/importexport/encore/tests/data/chord_parsing.enc new file mode 100644 index 0000000000000..2b40e53e13428 Binary files /dev/null and b/src/importexport/encore/tests/data/chord_parsing.enc differ diff --git a/src/importexport/encore/tests/data/encore_symbols.enc b/src/importexport/encore/tests/data/encore_symbols.enc new file mode 100644 index 0000000000000..2b91f5d69b279 Binary files /dev/null and b/src/importexport/encore/tests/data/encore_symbols.enc differ diff --git a/src/importexport/encore/tests/data/grace_ornament.enc b/src/importexport/encore/tests/data/grace_ornament.enc new file mode 100644 index 0000000000000..cc4933c8ee93e Binary files /dev/null and b/src/importexport/encore/tests/data/grace_ornament.enc differ diff --git a/src/importexport/encore/tests/data/importer_2_2_beatticks240_gap_snap.enc b/src/importexport/encore/tests/data/importer_2_2_beatticks240_gap_snap.enc new file mode 100644 index 0000000000000..9b7c440bcee98 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_2_2_beatticks240_gap_snap.enc differ diff --git a/src/importexport/encore/tests/data/importer_2_2_beatticks480_correct.enc b/src/importexport/encore/tests/data/importer_2_2_beatticks480_correct.enc new file mode 100644 index 0000000000000..64c68d437fa8c Binary files /dev/null and b/src/importexport/encore/tests/data/importer_2_2_beatticks480_correct.enc differ diff --git a/src/importexport/encore/tests/data/importer_counter_bytes.enc b/src/importexport/encore/tests/data/importer_counter_bytes.enc new file mode 100644 index 0000000000000..91df98dcce689 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_counter_bytes.enc differ diff --git a/src/importexport/encore/tests/data/importer_cue_mute_flags.enc b/src/importexport/encore/tests/data/importer_cue_mute_flags.enc new file mode 100644 index 0000000000000..b626c8b60f2db Binary files /dev/null and b/src/importexport/encore/tests/data/importer_cue_mute_flags.enc differ diff --git a/src/importexport/encore/tests/data/importer_cue_note.enc b/src/importexport/encore/tests/data/importer_cue_note.enc new file mode 100644 index 0000000000000..8b8fa0ebe344d Binary files /dev/null and b/src/importexport/encore/tests/data/importer_cue_note.enc differ diff --git a/src/importexport/encore/tests/data/importer_dyn_dedup.enc b/src/importexport/encore/tests/data/importer_dyn_dedup.enc new file mode 100644 index 0000000000000..80a6a888cc8fa Binary files /dev/null and b/src/importexport/encore/tests/data/importer_dyn_dedup.enc differ diff --git a/src/importexport/encore/tests/data/importer_dyn_displaced_to_staff_above.enc b/src/importexport/encore/tests/data/importer_dyn_displaced_to_staff_above.enc new file mode 100644 index 0000000000000..626e7073c3ae9 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_dyn_displaced_to_staff_above.enc differ diff --git a/src/importexport/encore/tests/data/importer_dyn_snap_back_by_xoffset.enc b/src/importexport/encore/tests/data/importer_dyn_snap_back_by_xoffset.enc new file mode 100644 index 0000000000000..55b1e485bee1e Binary files /dev/null and b/src/importexport/encore/tests/data/importer_dyn_snap_back_by_xoffset.enc differ diff --git a/src/importexport/encore/tests/data/importer_enc4x_line_size2_70pct.enc b/src/importexport/encore/tests/data/importer_enc4x_line_size2_70pct.enc new file mode 100644 index 0000000000000..af4f59a218f3b Binary files /dev/null and b/src/importexport/encore/tests/data/importer_enc4x_line_size2_70pct.enc differ diff --git a/src/importexport/encore/tests/data/importer_enc4x_line_size3_75pct.enc b/src/importexport/encore/tests/data/importer_enc4x_line_size3_75pct.enc new file mode 100644 index 0000000000000..dd1d38040d959 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_enc4x_line_size3_75pct.enc differ diff --git a/src/importexport/encore/tests/data/importer_full_voice_skipped.enc b/src/importexport/encore/tests/data/importer_full_voice_skipped.enc new file mode 100644 index 0000000000000..69e3d79197b92 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_full_voice_skipped.enc differ diff --git a/src/importexport/encore/tests/data/importer_gap_snap_eighth_meter.enc b/src/importexport/encore/tests/data/importer_gap_snap_eighth_meter.enc new file mode 100644 index 0000000000000..061c92da523d2 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_gap_snap_eighth_meter.enc differ diff --git a/src/importexport/encore/tests/data/importer_grace1_0x30_normal_notes.enc b/src/importexport/encore/tests/data/importer_grace1_0x30_normal_notes.enc new file mode 100644 index 0000000000000..a3dad7a858919 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_grace1_0x30_normal_notes.enc differ diff --git a/src/importexport/encore/tests/data/importer_grace_after_contiguous.enc b/src/importexport/encore/tests/data/importer_grace_after_contiguous.enc new file mode 100644 index 0000000000000..140f1e3fb2ab6 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_grace_after_contiguous.enc differ diff --git a/src/importexport/encore/tests/data/importer_grace_beam.enc b/src/importexport/encore/tests/data/importer_grace_beam.enc new file mode 100644 index 0000000000000..47f80b6ddec5d Binary files /dev/null and b/src/importexport/encore/tests/data/importer_grace_beam.enc differ diff --git a/src/importexport/encore/tests/data/importer_grace_trailing_no_dot.enc b/src/importexport/encore/tests/data/importer_grace_trailing_no_dot.enc new file mode 100644 index 0000000000000..8194eb84438b7 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_grace_trailing_no_dot.enc differ diff --git a/src/importexport/encore/tests/data/importer_hairpin_barline_clamp.enc b/src/importexport/encore/tests/data/importer_hairpin_barline_clamp.enc new file mode 100644 index 0000000000000..31a3f85d06c1a Binary files /dev/null and b/src/importexport/encore/tests/data/importer_hairpin_barline_clamp.enc differ diff --git a/src/importexport/encore/tests/data/importer_hairpin_endpoint_dynamic_wins.enc b/src/importexport/encore/tests/data/importer_hairpin_endpoint_dynamic_wins.enc new file mode 100644 index 0000000000000..06a847016c311 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_hairpin_endpoint_dynamic_wins.enc differ diff --git a/src/importexport/encore/tests/data/importer_hairpin_ends_at_next_dynamic.enc b/src/importexport/encore/tests/data/importer_hairpin_ends_at_next_dynamic.enc new file mode 100644 index 0000000000000..1595db73c6c72 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_hairpin_ends_at_next_dynamic.enc differ diff --git a/src/importexport/encore/tests/data/importer_hairpin_snapstart_at_barline.enc b/src/importexport/encore/tests/data/importer_hairpin_snapstart_at_barline.enc new file mode 100644 index 0000000000000..924033685a314 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_hairpin_snapstart_at_barline.enc differ diff --git a/src/importexport/encore/tests/data/importer_hairpin_speguleco_bit0.enc b/src/importexport/encore/tests/data/importer_hairpin_speguleco_bit0.enc new file mode 100644 index 0000000000000..b1d9877d7eb6b Binary files /dev/null and b/src/importexport/encore/tests/data/importer_hairpin_speguleco_bit0.enc differ diff --git a/src/importexport/encore/tests/data/importer_hairpin_xoffset2_snap.enc b/src/importexport/encore/tests/data/importer_hairpin_xoffset2_snap.enc new file mode 100644 index 0000000000000..4f6b0a3487980 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_hairpin_xoffset2_snap.enc differ diff --git a/src/importexport/encore/tests/data/importer_header_measure_count_truncates_ghost_measures.enc b/src/importexport/encore/tests/data/importer_header_measure_count_truncates_ghost_measures.enc new file mode 100644 index 0000000000000..452d2cbe90cb7 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_header_measure_count_truncates_ghost_measures.enc differ diff --git a/src/importexport/encore/tests/data/importer_inner_tuplet_note_level_cap.enc b/src/importexport/encore/tests/data/importer_inner_tuplet_note_level_cap.enc new file mode 100644 index 0000000000000..fb37c1a0a9193 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_inner_tuplet_note_level_cap.enc differ diff --git a/src/importexport/encore/tests/data/importer_isolated_explicit_tuplet_capped.enc b/src/importexport/encore/tests/data/importer_isolated_explicit_tuplet_capped.enc new file mode 100644 index 0000000000000..a3575d617448a Binary files /dev/null and b/src/importexport/encore/tests/data/importer_isolated_explicit_tuplet_capped.enc differ diff --git a/src/importexport/encore/tests/data/importer_merge_voices_non_overlapping.enc b/src/importexport/encore/tests/data/importer_merge_voices_non_overlapping.enc new file mode 100644 index 0000000000000..67e8bea687996 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_merge_voices_non_overlapping.enc differ diff --git a/src/importexport/encore/tests/data/importer_merge_voices_overlapping.enc b/src/importexport/encore/tests/data/importer_merge_voices_overlapping.enc new file mode 100644 index 0000000000000..912d022be0721 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_merge_voices_overlapping.enc differ diff --git a/src/importexport/encore/tests/data/importer_merge_voices_tremolo.enc b/src/importexport/encore/tests/data/importer_merge_voices_tremolo.enc new file mode 100644 index 0000000000000..370af3150a7f5 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_merge_voices_tremolo.enc differ diff --git a/src/importexport/encore/tests/data/importer_mrest_followed_by_rest.enc b/src/importexport/encore/tests/data/importer_mrest_followed_by_rest.enc new file mode 100644 index 0000000000000..6d58059791cd1 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_mrest_followed_by_rest.enc differ diff --git a/src/importexport/encore/tests/data/importer_mrest_multistaff.enc b/src/importexport/encore/tests/data/importer_mrest_multistaff.enc new file mode 100644 index 0000000000000..3b3b8ca65e6ef Binary files /dev/null and b/src/importexport/encore/tests/data/importer_mrest_multistaff.enc differ diff --git a/src/importexport/encore/tests/data/importer_mrest_preceded_by_rest.enc b/src/importexport/encore/tests/data/importer_mrest_preceded_by_rest.enc new file mode 100644 index 0000000000000..1ff6b82709293 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_mrest_preceded_by_rest.enc differ diff --git a/src/importexport/encore/tests/data/importer_mrest_single_block.enc b/src/importexport/encore/tests/data/importer_mrest_single_block.enc new file mode 100644 index 0000000000000..72163dd989cb3 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_mrest_single_block.enc differ diff --git a/src/importexport/encore/tests/data/importer_perc_bateria.enc b/src/importexport/encore/tests/data/importer_perc_bateria.enc new file mode 100644 index 0000000000000..1eed2c054ef2b Binary files /dev/null and b/src/importexport/encore/tests/data/importer_perc_bateria.enc differ diff --git a/src/importexport/encore/tests/data/importer_rest_caps_in_open_tuplet.enc b/src/importexport/encore/tests/data/importer_rest_caps_in_open_tuplet.enc new file mode 100644 index 0000000000000..d1f098e96038b Binary files /dev/null and b/src/importexport/encore/tests/data/importer_rest_caps_in_open_tuplet.enc differ diff --git a/src/importexport/encore/tests/data/importer_rest_in_tuplet.enc b/src/importexport/encore/tests/data/importer_rest_in_tuplet.enc new file mode 100644 index 0000000000000..bd4b1b1faa2f0 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_rest_in_tuplet.enc differ diff --git a/src/importexport/encore/tests/data/importer_rest_not_chord_anchor.enc b/src/importexport/encore/tests/data/importer_rest_not_chord_anchor.enc new file mode 100644 index 0000000000000..2ca1ec373ae3b Binary files /dev/null and b/src/importexport/encore/tests/data/importer_rest_not_chord_anchor.enc differ diff --git a/src/importexport/encore/tests/data/importer_score_size2.enc b/src/importexport/encore/tests/data/importer_score_size2.enc new file mode 100644 index 0000000000000..4e53125101259 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_score_size2.enc differ diff --git a/src/importexport/encore/tests/data/importer_score_size3.enc b/src/importexport/encore/tests/data/importer_score_size3.enc new file mode 100644 index 0000000000000..647f355fc5211 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_score_size3.enc differ diff --git a/src/importexport/encore/tests/data/importer_slur_cross_measure_fallback.enc b/src/importexport/encore/tests/data/importer_slur_cross_measure_fallback.enc new file mode 100644 index 0000000000000..2fe779260ff42 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_slur_cross_measure_fallback.enc differ diff --git a/src/importexport/encore/tests/data/importer_slur_pixel_span.enc b/src/importexport/encore/tests/data/importer_slur_pixel_span.enc new file mode 100644 index 0000000000000..c84f6f87218a6 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_slur_pixel_span.enc differ diff --git a/src/importexport/encore/tests/data/importer_slur_pixel_span_6_8.enc b/src/importexport/encore/tests/data/importer_slur_pixel_span_6_8.enc new file mode 100644 index 0000000000000..003b77987fbc7 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_slur_pixel_span_6_8.enc differ diff --git a/src/importexport/encore/tests/data/importer_slur_xoffset_unsigned.enc b/src/importexport/encore/tests/data/importer_slur_xoffset_unsigned.enc new file mode 100644 index 0000000000000..4109c53c6a03f Binary files /dev/null and b/src/importexport/encore/tests/data/importer_slur_xoffset_unsigned.enc differ diff --git a/src/importexport/encore/tests/data/importer_to_coda_vs_coda_marker.enc b/src/importexport/encore/tests/data/importer_to_coda_vs_coda_marker.enc new file mode 100644 index 0000000000000..403fad5ccb2e1 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_to_coda_vs_coda_marker.enc differ diff --git a/src/importexport/encore/tests/data/importer_transp_oboe_jota.enc b/src/importexport/encore/tests/data/importer_transp_oboe_jota.enc new file mode 100644 index 0000000000000..52e2fe2298b1f Binary files /dev/null and b/src/importexport/encore/tests/data/importer_transp_oboe_jota.enc differ diff --git a/src/importexport/encore/tests/data/importer_two_dynamics_in_one_measure.enc b/src/importexport/encore/tests/data/importer_two_dynamics_in_one_measure.enc new file mode 100644 index 0000000000000..9cdd12a2ae890 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_two_dynamics_in_one_measure.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0c2_multi_stream_drift.enc b/src/importexport/encore/tests/data/importer_v0c2_multi_stream_drift.enc new file mode 100644 index 0000000000000..d91078218c945 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0c2_multi_stream_drift.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0c2_snap.enc b/src/importexport/encore/tests/data/importer_v0c2_snap.enc new file mode 100644 index 0000000000000..da6b3faca6dbb Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0c2_snap.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_boda_like.enc b/src/importexport/encore/tests/data/importer_v0xa6_boda_like.enc new file mode 100644 index 0000000000000..953a7e3468d07 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_boda_like.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_duplicate_rest_collapse.enc b/src/importexport/encore/tests/data/importer_v0xa6_duplicate_rest_collapse.enc new file mode 100644 index 0000000000000..c9cfc9bd3b4f4 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_duplicate_rest_collapse.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_grace_ongrid_snap_suppressed.enc b/src/importexport/encore/tests/data/importer_v0xa6_grace_ongrid_snap_suppressed.enc new file mode 100644 index 0000000000000..143d163bba026 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_grace_ongrid_snap_suppressed.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_grace_restores_face_value.enc b/src/importexport/encore/tests/data/importer_v0xa6_grace_restores_face_value.enc new file mode 100644 index 0000000000000..b9702bba7a25c Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_grace_restores_face_value.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_header_ends_at_0xa6.enc b/src/importexport/encore/tests/data/importer_v0xa6_header_ends_at_0xa6.enc new file mode 100644 index 0000000000000..9280af68f75ed Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_header_ends_at_0xa6.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_inner_grace_group.enc b/src/importexport/encore/tests/data/importer_v0xa6_inner_grace_group.enc new file mode 100644 index 0000000000000..43613be5f9939 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_inner_grace_group.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_key_transposition.enc b/src/importexport/encore/tests/data/importer_v0xa6_key_transposition.enc new file mode 100644 index 0000000000000..beea9a82649cc Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_key_transposition.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_lyrics_and_stafftext.enc b/src/importexport/encore/tests/data/importer_v0xa6_lyrics_and_stafftext.enc new file mode 100644 index 0000000000000..149d6988facee Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_lyrics_and_stafftext.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_melisma_verse_alignment.enc b/src/importexport/encore/tests/data/importer_v0xa6_melisma_verse_alignment.enc new file mode 100644 index 0000000000000..531ba5fdc5ed1 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_melisma_verse_alignment.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_no_spurious_tremolo.enc b/src/importexport/encore/tests/data/importer_v0xa6_no_spurious_tremolo.enc new file mode 100644 index 0000000000000..5041ab7da8b14 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_no_spurious_tremolo.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_stafftext_placement.enc b/src/importexport/encore/tests/data/importer_v0xa6_stafftext_placement.enc new file mode 100644 index 0000000000000..d7e231fd3bad4 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_stafftext_placement.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_triplet_byte_at_offset_7.enc b/src/importexport/encore/tests/data/importer_v0xa6_triplet_byte_at_offset_7.enc new file mode 100644 index 0000000000000..3000b3c4192e9 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_triplet_byte_at_offset_7.enc differ diff --git a/src/importexport/encore/tests/data/importer_v0xa6_two_verse_alignment.enc b/src/importexport/encore/tests/data/importer_v0xa6_two_verse_alignment.enc new file mode 100644 index 0000000000000..39bf9153d11f9 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_v0xa6_two_verse_alignment.enc differ diff --git a/src/importexport/encore/tests/data/importer_volta_coalesce_and_text.enc b/src/importexport/encore/tests/data/importer_volta_coalesce_and_text.enc new file mode 100644 index 0000000000000..88fe9fe72b92c Binary files /dev/null and b/src/importexport/encore/tests/data/importer_volta_coalesce_and_text.enc differ diff --git a/src/importexport/encore/tests/data/importer_volta_overlapping_bits.enc b/src/importexport/encore/tests/data/importer_volta_overlapping_bits.enc new file mode 100644 index 0000000000000..1b74d617ebc43 Binary files /dev/null and b/src/importexport/encore/tests/data/importer_volta_overlapping_bits.enc differ diff --git a/src/importexport/encore/tests/data/importer_wedge_snap_back_by_xoffset.enc b/src/importexport/encore/tests/data/importer_wedge_snap_back_by_xoffset.enc new file mode 100644 index 0000000000000..ba7696ba88f1a Binary files /dev/null and b/src/importexport/encore/tests/data/importer_wedge_snap_back_by_xoffset.enc differ diff --git a/src/importexport/encore/tests/data/instruments_abbreviated_name_bandurr.enc b/src/importexport/encore/tests/data/instruments_abbreviated_name_bandurr.enc new file mode 100644 index 0000000000000..71f634b67790c Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_abbreviated_name_bandurr.enc differ diff --git a/src/importexport/encore/tests/data/instruments_bass_enckey0_no_octave_transpos.enc b/src/importexport/encore/tests/data/instruments_bass_enckey0_no_octave_transpos.enc new file mode 100644 index 0000000000000..19ca6aa51ee2a Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_bass_enckey0_no_octave_transpos.enc differ diff --git a/src/importexport/encore/tests/data/instruments_bass_guitar_transposing_clef.enc b/src/importexport/encore/tests/data/instruments_bass_guitar_transposing_clef.enc new file mode 100644 index 0000000000000..0a293f3a220ad Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_bass_guitar_transposing_clef.enc differ diff --git a/src/importexport/encore/tests/data/instruments_c2_no_tilde_compact_names_midi.enc b/src/importexport/encore/tests/data/instruments_c2_no_tilde_compact_names_midi.enc new file mode 100644 index 0000000000000..d91ec8d6add85 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_c2_no_tilde_compact_names_midi.enc differ diff --git a/src/importexport/encore/tests/data/instruments_c2_tilde_primary_block_midi.enc b/src/importexport/encore/tests/data/instruments_c2_tilde_primary_block_midi.enc new file mode 100644 index 0000000000000..17e15e0834285 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_c2_tilde_primary_block_midi.enc differ diff --git a/src/importexport/encore/tests/data/instruments_compact_no_tk_midi_oboe.enc b/src/importexport/encore/tests/data/instruments_compact_no_tk_midi_oboe.enc new file mode 100644 index 0000000000000..90a39cd94122d Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_compact_no_tk_midi_oboe.enc differ diff --git a/src/importexport/encore/tests/data/instruments_compact_short_header_no_midi.enc b/src/importexport/encore/tests/data/instruments_compact_short_header_no_midi.enc new file mode 100644 index 0000000000000..3795e4e081a19 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_compact_short_header_no_midi.enc differ diff --git a/src/importexport/encore/tests/data/instruments_compact_tk_ignores_key_byte.enc b/src/importexport/encore/tests/data/instruments_compact_tk_ignores_key_byte.enc new file mode 100644 index 0000000000000..9b8b1f7592d99 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_compact_tk_ignores_key_byte.enc differ diff --git a/src/importexport/encore/tests/data/instruments_fuzzy_name_match.enc b/src/importexport/encore/tests/data/instruments_fuzzy_name_match.enc new file mode 100644 index 0000000000000..243bb7f36bf8d Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_fuzzy_name_match.enc differ diff --git a/src/importexport/encore/tests/data/instruments_gm_perc_chord_notes.enc b/src/importexport/encore/tests/data/instruments_gm_perc_chord_notes.enc new file mode 100644 index 0000000000000..08f445fc45d75 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_gm_perc_chord_notes.enc differ diff --git a/src/importexport/encore/tests/data/instruments_gm_perc_range_taiko.enc b/src/importexport/encore/tests/data/instruments_gm_perc_range_taiko.enc new file mode 100644 index 0000000000000..d3bca6cf89708 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_gm_perc_range_taiko.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_bass_midi_tiebreak.enc b/src/importexport/encore/tests/data/instruments_instr_bass_midi_tiebreak.enc new file mode 100644 index 0000000000000..6b65f7a0f560d Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_bass_midi_tiebreak.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key0.enc b/src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key0.enc new file mode 100644 index 0000000000000..f84d287918c99 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key0.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key_neg2.enc b/src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key_neg2.enc new file mode 100644 index 0000000000000..3a0e8d7e49077 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key_neg2.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_drums_name_drumset.enc b/src/importexport/encore/tests/data/instruments_instr_drums_name_drumset.enc new file mode 100644 index 0000000000000..04b6fc8cd2fba Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_drums_name_drumset.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_empty_name_midi_cello.enc b/src/importexport/encore/tests/data/instruments_instr_empty_name_midi_cello.enc new file mode 100644 index 0000000000000..455bc0dcdb6c7 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_empty_name_midi_cello.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_empty_name_midi_clarinet.enc b/src/importexport/encore/tests/data/instruments_instr_empty_name_midi_clarinet.enc new file mode 100644 index 0000000000000..e863f46f3ff7a Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_empty_name_midi_clarinet.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_laud_accent.enc b/src/importexport/encore/tests/data/instruments_instr_laud_accent.enc new file mode 100644 index 0000000000000..333c386925a9e Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_laud_accent.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_perc_clef_drumset.enc b/src/importexport/encore/tests/data/instruments_instr_perc_clef_drumset.enc new file mode 100644 index 0000000000000..c28b5db2491cc Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_perc_clef_drumset.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_percussion_drumset.enc b/src/importexport/encore/tests/data/instruments_instr_percussion_drumset.enc new file mode 100644 index 0000000000000..e26c6707239b9 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_percussion_drumset.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instr_recorder_midi75_trackname.enc b/src/importexport/encore/tests/data/instruments_instr_recorder_midi75_trackname.enc new file mode 100644 index 0000000000000..0f7b137a5003e Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instr_recorder_midi75_trackname.enc differ diff --git a/src/importexport/encore/tests/data/instruments_instrument_count_padding.enc b/src/importexport/encore/tests/data/instruments_instrument_count_padding.enc new file mode 100644 index 0000000000000..97ea1d4890ffa Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_instrument_count_padding.enc differ diff --git a/src/importexport/encore/tests/data/instruments_name_dash_separator.enc b/src/importexport/encore/tests/data/instruments_name_dash_separator.enc new file mode 100644 index 0000000000000..1ad44c660946b Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_name_dash_separator.enc differ diff --git a/src/importexport/encore/tests/data/instruments_name_recovery.enc b/src/importexport/encore/tests/data/instruments_name_recovery.enc new file mode 100644 index 0000000000000..1a70b1efaf18b Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_name_recovery.enc differ diff --git a/src/importexport/encore/tests/data/instruments_name_trailing_number.enc b/src/importexport/encore/tests/data/instruments_name_trailing_number.enc new file mode 100644 index 0000000000000..24a4fb89bf1a6 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_name_trailing_number.enc differ diff --git a/src/importexport/encore/tests/data/instruments_no_tk_blocks_midi_key.enc b/src/importexport/encore/tests/data/instruments_no_tk_blocks_midi_key.enc new file mode 100644 index 0000000000000..5cc358d435f9e Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_no_tk_blocks_midi_key.enc differ diff --git a/src/importexport/encore/tests/data/instruments_no_tk_large_tk_two_names.enc b/src/importexport/encore/tests/data/instruments_no_tk_large_tk_two_names.enc new file mode 100644 index 0000000000000..225349986428f Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_no_tk_large_tk_two_names.enc differ diff --git a/src/importexport/encore/tests/data/instruments_no_tk_name_fallback.enc b/src/importexport/encore/tests/data/instruments_no_tk_name_fallback.enc new file mode 100644 index 0000000000000..b5bc8c2714a02 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_no_tk_name_fallback.enc differ diff --git a/src/importexport/encore/tests/data/instruments_no_tk_name_latin1.enc b/src/importexport/encore/tests/data/instruments_no_tk_name_latin1.enc new file mode 100644 index 0000000000000..cb5c733b8a8ad Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_no_tk_name_latin1.enc differ diff --git a/src/importexport/encore/tests/data/instruments_no_tk_name_recovered.enc b/src/importexport/encore/tests/data/instruments_no_tk_name_recovered.enc new file mode 100644 index 0000000000000..249722d0587a4 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_no_tk_name_recovered.enc differ diff --git a/src/importexport/encore/tests/data/instruments_rhythm_staff_snare.enc b/src/importexport/encore/tests/data/instruments_rhythm_staff_snare.enc new file mode 100644 index 0000000000000..a5fb55072b0d1 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_rhythm_staff_snare.enc differ diff --git a/src/importexport/encore/tests/data/instruments_sco5_tk_names.enc b/src/importexport/encore/tests/data/instruments_sco5_tk_names.enc new file mode 100644 index 0000000000000..3d59c555c6759 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_sco5_tk_names.enc differ diff --git a/src/importexport/encore/tests/data/instruments_small_tk_key6.enc b/src/importexport/encore/tests/data/instruments_small_tk_key6.enc new file mode 100644 index 0000000000000..70b20b1dee4eb Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_small_tk_key6.enc differ diff --git a/src/importexport/encore/tests/data/instruments_small_tk_midi49.enc b/src/importexport/encore/tests/data/instruments_small_tk_midi49.enc new file mode 100644 index 0000000000000..2434135182506 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_small_tk_midi49.enc differ diff --git a/src/importexport/encore/tests/data/instruments_staff_hidden.enc b/src/importexport/encore/tests/data/instruments_staff_hidden.enc new file mode 100644 index 0000000000000..e898e40eb568e Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_staff_hidden.enc differ diff --git a/src/importexport/encore/tests/data/instruments_tab_clef_keeps_tablature.enc b/src/importexport/encore/tests/data/instruments_tab_clef_keeps_tablature.enc new file mode 100644 index 0000000000000..685f4f3f1bce9 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_tab_clef_keeps_tablature.enc differ diff --git a/src/importexport/encore/tests/data/instruments_tab_template_forced_standard.enc b/src/importexport/encore/tests/data/instruments_tab_template_forced_standard.enc new file mode 100644 index 0000000000000..a3764937303e8 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_tab_template_forced_standard.enc differ diff --git a/src/importexport/encore/tests/data/instruments_tk_empty_name_authoritative.enc b/src/importexport/encore/tests/data/instruments_tk_empty_name_authoritative.enc new file mode 100644 index 0000000000000..944d985b400cf Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_tk_empty_name_authoritative.enc differ diff --git a/src/importexport/encore/tests/data/instruments_tk_onebyte_name.enc b/src/importexport/encore/tests/data/instruments_tk_onebyte_name.enc new file mode 100644 index 0000000000000..cc6cde787e16d Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_tk_onebyte_name.enc differ diff --git a/src/importexport/encore/tests/data/instruments_tk_probe_utf16.enc b/src/importexport/encore/tests/data/instruments_tk_probe_utf16.enc new file mode 100644 index 0000000000000..f6f6a2cc43a63 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_tk_probe_utf16.enc differ diff --git a/src/importexport/encore/tests/data/instruments_tk_utf16_name.enc b/src/importexport/encore/tests/data/instruments_tk_utf16_name.enc new file mode 100644 index 0000000000000..1a6f9a15b3a1a Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_tk_utf16_name.enc differ diff --git a/src/importexport/encore/tests/data/instruments_total_size_tk_two_instrs.enc b/src/importexport/encore/tests/data/instruments_total_size_tk_two_instrs.enc new file mode 100644 index 0000000000000..141939490c694 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_total_size_tk_two_instrs.enc differ diff --git a/src/importexport/encore/tests/data/instruments_unique_name_beats_midi.enc b/src/importexport/encore/tests/data/instruments_unique_name_beats_midi.enc new file mode 100644 index 0000000000000..a0bc792080b40 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_unique_name_beats_midi.enc differ diff --git a/src/importexport/encore/tests/data/instruments_v0xa6_midi_program.enc b/src/importexport/encore/tests/data/instruments_v0xa6_midi_program.enc new file mode 100644 index 0000000000000..b74077e29a3b4 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_v0xa6_midi_program.enc differ diff --git a/src/importexport/encore/tests/data/instruments_weak_name_defers_to_midi.enc b/src/importexport/encore/tests/data/instruments_weak_name_defers_to_midi.enc new file mode 100644 index 0000000000000..e061d6b1b1e07 Binary files /dev/null and b/src/importexport/encore/tests/data/instruments_weak_name_defers_to_midi.enc differ diff --git a/src/importexport/encore/tests/data/kordorkestro.enc b/src/importexport/encore/tests/data/kordorkestro.enc new file mode 100644 index 0000000000000..ef63012c99b87 Binary files /dev/null and b/src/importexport/encore/tests/data/kordorkestro.enc differ diff --git a/src/importexport/encore/tests/data/lyrics_rest_does_not_shift_notes.enc b/src/importexport/encore/tests/data/lyrics_rest_does_not_shift_notes.enc new file mode 100644 index 0000000000000..2d7219d038463 Binary files /dev/null and b/src/importexport/encore/tests/data/lyrics_rest_does_not_shift_notes.enc differ diff --git a/src/importexport/encore/tests/data/lyrics_v0c2_compound_meter.enc b/src/importexport/encore/tests/data/lyrics_v0c2_compound_meter.enc new file mode 100644 index 0000000000000..03e78ea197f75 Binary files /dev/null and b/src/importexport/encore/tests/data/lyrics_v0c2_compound_meter.enc differ diff --git a/src/importexport/encore/tests/data/notes_16th_rdur112_no_triple_dot.enc b/src/importexport/encore/tests/data/notes_16th_rdur112_no_triple_dot.enc new file mode 100644 index 0000000000000..8077ab6b07dcf Binary files /dev/null and b/src/importexport/encore/tests/data/notes_16th_rdur112_no_triple_dot.enc differ diff --git a/src/importexport/encore/tests/data/notes_artic_dedup_trill_on_chord.enc b/src/importexport/encore/tests/data/notes_artic_dedup_trill_on_chord.enc new file mode 100644 index 0000000000000..8f2a02dad9f3c Binary files /dev/null and b/src/importexport/encore/tests/data/notes_artic_dedup_trill_on_chord.enc differ diff --git a/src/importexport/encore/tests/data/notes_canonical_triplet.enc b/src/importexport/encore/tests/data/notes_canonical_triplet.enc new file mode 100644 index 0000000000000..b98e6a867c903 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_canonical_triplet.enc differ diff --git a/src/importexport/encore/tests/data/notes_capped_tuplet_note.enc b/src/importexport/encore/tests/data/notes_capped_tuplet_note.enc new file mode 100644 index 0000000000000..7f589c9fcf25f Binary files /dev/null and b/src/importexport/encore/tests/data/notes_capped_tuplet_note.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_duplicate.enc b/src/importexport/encore/tests/data/notes_chord_duplicate.enc new file mode 100644 index 0000000000000..00e960f45203a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_duplicate.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_duplicate_no_ext_bit.enc b/src/importexport/encore/tests/data/notes_chord_duplicate_no_ext_bit.enc new file mode 100644 index 0000000000000..f461d231b15b4 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_duplicate_no_ext_bit.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_inflated_rdur_keeps_eighth.enc b/src/importexport/encore/tests/data/notes_chord_inflated_rdur_keeps_eighth.enc new file mode 100644 index 0000000000000..34b17db4f6344 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_inflated_rdur_keeps_eighth.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_strum_xoffset.enc b/src/importexport/encore/tests/data/notes_chord_strum_xoffset.enc new file mode 100644 index 0000000000000..58cbe237b4bfe Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_strum_xoffset.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_symbol_fretboard.enc b/src/importexport/encore/tests/data/notes_chord_symbol_fretboard.enc new file mode 100644 index 0000000000000..ae4ca26198c64 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_symbol_fretboard.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_symbol_large_drift.enc b/src/importexport/encore/tests/data/notes_chord_symbol_large_drift.enc new file mode 100644 index 0000000000000..b456f03354a91 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_symbol_large_drift.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_symbol_nearbeat_subdivision.enc b/src/importexport/encore/tests/data/notes_chord_symbol_nearbeat_subdivision.enc new file mode 100644 index 0000000000000..f91ad026996f8 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_symbol_nearbeat_subdivision.enc differ diff --git a/src/importexport/encore/tests/data/notes_chord_symbol_snap_to_beat1.enc b/src/importexport/encore/tests/data/notes_chord_symbol_snap_to_beat1.enc new file mode 100644 index 0000000000000..f4e943f2485b5 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_chord_symbol_snap_to_beat1.enc differ diff --git a/src/importexport/encore/tests/data/notes_corrupted.enc b/src/importexport/encore/tests/data/notes_corrupted.enc new file mode 100644 index 0000000000000..66f820f23900b Binary files /dev/null and b/src/importexport/encore/tests/data/notes_corrupted.enc differ diff --git a/src/importexport/encore/tests/data/notes_cross_staff_false_nesting.enc b/src/importexport/encore/tests/data/notes_cross_staff_false_nesting.enc new file mode 100644 index 0000000000000..5cbedee8d756c Binary files /dev/null and b/src/importexport/encore/tests/data/notes_cross_staff_false_nesting.enc differ diff --git a/src/importexport/encore/tests/data/notes_diff_column_no_merge.enc b/src/importexport/encore/tests/data/notes_diff_column_no_merge.enc new file mode 100644 index 0000000000000..2b7de5e9fa22e Binary files /dev/null and b/src/importexport/encore/tests/data/notes_diff_column_no_merge.enc differ diff --git a/src/importexport/encore/tests/data/notes_dotted_ctrl_bit0_drift.enc b/src/importexport/encore/tests/data/notes_dotted_ctrl_bit0_drift.enc new file mode 100644 index 0000000000000..767816cab0fe2 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_dotted_ctrl_bit0_drift.enc differ diff --git a/src/importexport/encore/tests/data/notes_dotted_note.enc b/src/importexport/encore/tests/data/notes_dotted_note.enc new file mode 100644 index 0000000000000..b0fc64a383535 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_dotted_note.enc differ diff --git a/src/importexport/encore/tests/data/notes_dotted_note_capping.enc b/src/importexport/encore/tests/data/notes_dotted_note_capping.enc new file mode 100644 index 0000000000000..3cc6fa40e62bb Binary files /dev/null and b/src/importexport/encore/tests/data/notes_dotted_note_capping.enc differ diff --git a/src/importexport/encore/tests/data/notes_dotted_rest.enc b/src/importexport/encore/tests/data/notes_dotted_rest.enc new file mode 100644 index 0000000000000..18bae4a8f7ef6 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_dotted_rest.enc differ diff --git a/src/importexport/encore/tests/data/notes_dual_rests_same_tick_routing.enc b/src/importexport/encore/tests/data/notes_dual_rests_same_tick_routing.enc new file mode 100644 index 0000000000000..621f8d1ecb2ae Binary files /dev/null and b/src/importexport/encore/tests/data/notes_dual_rests_same_tick_routing.enc differ diff --git a/src/importexport/encore/tests/data/notes_explicit_tup_rdur_truncated.enc b/src/importexport/encore/tests/data/notes_explicit_tup_rdur_truncated.enc new file mode 100644 index 0000000000000..7539aedfba656 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_explicit_tup_rdur_truncated.enc differ diff --git a/src/importexport/encore/tests/data/notes_grace.enc b/src/importexport/encore/tests/data/notes_grace.enc new file mode 100644 index 0000000000000..db400a9995d6c Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grace.enc differ diff --git a/src/importexport/encore/tests/data/notes_grace1_cascade_filter.enc b/src/importexport/encore/tests/data/notes_grace1_cascade_filter.enc new file mode 100644 index 0000000000000..8bb50c42bd3b3 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grace1_cascade_filter.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_bit6_second_staff.enc b/src/importexport/encore/tests/data/notes_grandstaff_bit6_second_staff.enc new file mode 100644 index 0000000000000..0bef02a7ed29a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_bit6_second_staff.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_high_voice_own_staff.enc b/src/importexport/encore/tests/data/notes_grandstaff_high_voice_own_staff.enc new file mode 100644 index 0000000000000..b18c36db8c61f Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_high_voice_own_staff.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_fermata.enc b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_fermata.enc new file mode 100644 index 0000000000000..e993e7726d0bd Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_fermata.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_four_voices.enc b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_four_voices.enc new file mode 100644 index 0000000000000..f3917f255e0e1 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_four_voices.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_rest_on_second_staff.enc b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_rest_on_second_staff.enc new file mode 100644 index 0000000000000..820f3dff3a56c Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_rest_on_second_staff.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_sequential.enc b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_sequential.enc new file mode 100644 index 0000000000000..6aa3c2fed339e Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_sequential.enc differ diff --git a/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_tie_on_second_staff.enc b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_tie_on_second_staff.enc new file mode 100644 index 0000000000000..86587e0545c8e Binary files /dev/null and b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_tie_on_second_staff.enc differ diff --git a/src/importexport/encore/tests/data/notes_implicit_leading_rest.enc b/src/importexport/encore/tests/data/notes_implicit_leading_rest.enc new file mode 100644 index 0000000000000..70825e0a2062b Binary files /dev/null and b/src/importexport/encore/tests/data/notes_implicit_leading_rest.enc differ diff --git a/src/importexport/encore/tests/data/notes_implicit_trailing_gap.enc b/src/importexport/encore/tests/data/notes_implicit_trailing_gap.enc new file mode 100644 index 0000000000000..e4835a365d2d6 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_implicit_trailing_gap.enc differ diff --git a/src/importexport/encore/tests/data/notes_inflated_rdur_quarter_chord.enc b/src/importexport/encore/tests/data/notes_inflated_rdur_quarter_chord.enc new file mode 100644 index 0000000000000..23a2f456c17a2 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_inflated_rdur_quarter_chord.enc differ diff --git a/src/importexport/encore/tests/data/notes_mixed_duration_triplet.enc b/src/importexport/encore/tests/data/notes_mixed_duration_triplet.enc new file mode 100644 index 0000000000000..923d9c3bcfa99 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_mixed_duration_triplet.enc differ diff --git a/src/importexport/encore/tests/data/notes_mixed_duration_tuplet_boundary_fill.enc b/src/importexport/encore/tests/data/notes_mixed_duration_tuplet_boundary_fill.enc new file mode 100644 index 0000000000000..689ba3e57d3c1 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_mixed_duration_tuplet_boundary_fill.enc differ diff --git a/src/importexport/encore/tests/data/notes_mixed_value_tuplet.enc b/src/importexport/encore/tests/data/notes_mixed_value_tuplet.enc new file mode 100644 index 0000000000000..231f1f6028d62 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_mixed_value_tuplet.enc differ diff --git a/src/importexport/encore/tests/data/notes_multiinstr_compact_routing.enc b/src/importexport/encore/tests/data/notes_multiinstr_compact_routing.enc new file mode 100644 index 0000000000000..ca23d182036c7 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_multiinstr_compact_routing.enc differ diff --git a/src/importexport/encore/tests/data/notes_no_spurious_string_numbers.enc b/src/importexport/encore/tests/data/notes_no_spurious_string_numbers.enc new file mode 100644 index 0000000000000..44cd16edc2928 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_no_spurious_string_numbers.enc differ diff --git a/src/importexport/encore/tests/data/notes_offbeat_canonical.enc b/src/importexport/encore/tests/data/notes_offbeat_canonical.enc new file mode 100644 index 0000000000000..1cd03e9d1fc20 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_offbeat_canonical.enc differ diff --git a/src/importexport/encore/tests/data/notes_overflow_extend.enc b/src/importexport/encore/tests/data/notes_overflow_extend.enc new file mode 100644 index 0000000000000..0584dd296c232 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_overflow_extend.enc differ diff --git a/src/importexport/encore/tests/data/notes_overfull_messy_precontent_tuplet.enc b/src/importexport/encore/tests/data/notes_overfull_messy_precontent_tuplet.enc new file mode 100644 index 0000000000000..305c207b4ed34 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_overfull_messy_precontent_tuplet.enc differ diff --git a/src/importexport/encore/tests/data/notes_overfull_tuplet_with_slur.enc b/src/importexport/encore/tests/data/notes_overfull_tuplet_with_slur.enc new file mode 100644 index 0000000000000..08851dfbb590a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_overfull_tuplet_with_slur.enc differ diff --git a/src/importexport/encore/tests/data/notes_partial_explicit_group.enc b/src/importexport/encore/tests/data/notes_partial_explicit_group.enc new file mode 100644 index 0000000000000..48c1c74846f22 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_partial_explicit_group.enc differ diff --git a/src/importexport/encore/tests/data/notes_partial_triplet_measure_end.enc b/src/importexport/encore/tests/data/notes_partial_triplet_measure_end.enc new file mode 100644 index 0000000000000..570c7b52e8b4f Binary files /dev/null and b/src/importexport/encore/tests/data/notes_partial_triplet_measure_end.enc differ diff --git a/src/importexport/encore/tests/data/notes_partial_triplet_unreduced_cumtick.enc b/src/importexport/encore/tests/data/notes_partial_triplet_unreduced_cumtick.enc new file mode 100644 index 0000000000000..406c66138998d Binary files /dev/null and b/src/importexport/encore/tests/data/notes_partial_triplet_unreduced_cumtick.enc differ diff --git a/src/importexport/encore/tests/data/notes_perc_clef_positions.enc b/src/importexport/encore/tests/data/notes_perc_clef_positions.enc new file mode 100644 index 0000000000000..4e73feda592eb Binary files /dev/null and b/src/importexport/encore/tests/data/notes_perc_clef_positions.enc differ diff --git a/src/importexport/encore/tests/data/notes_perc_clef_standard_drumset_notehead.enc b/src/importexport/encore/tests/data/notes_perc_clef_standard_drumset_notehead.enc new file mode 100644 index 0000000000000..4053eec39068f Binary files /dev/null and b/src/importexport/encore/tests/data/notes_perc_clef_standard_drumset_notehead.enc differ diff --git a/src/importexport/encore/tests/data/notes_perc_notehead_all_nibbles.enc b/src/importexport/encore/tests/data/notes_perc_notehead_all_nibbles.enc new file mode 100644 index 0000000000000..677bb0e4d43a8 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_perc_notehead_all_nibbles.enc differ diff --git a/src/importexport/encore/tests/data/notes_perc_shared_pitch_nibbles.enc b/src/importexport/encore/tests/data/notes_perc_shared_pitch_nibbles.enc new file mode 100644 index 0000000000000..76596e9e6d245 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_perc_shared_pitch_nibbles.enc differ diff --git a/src/importexport/encore/tests/data/notes_rdur_80_stays_16th.enc b/src/importexport/encore/tests/data/notes_rdur_80_stays_16th.enc new file mode 100644 index 0000000000000..444aa0b08d744 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_rdur_80_stays_16th.enc differ diff --git a/src/importexport/encore/tests/data/notes_rdur_non_chord_ext_filtered.enc b/src/importexport/encore/tests/data/notes_rdur_non_chord_ext_filtered.enc new file mode 100644 index 0000000000000..d720d4145cfb5 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_rdur_non_chord_ext_filtered.enc differ diff --git a/src/importexport/encore/tests/data/notes_rdur_snap.enc b/src/importexport/encore/tests/data/notes_rdur_snap.enc new file mode 100644 index 0000000000000..6d10a3e3cba20 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_rdur_snap.enc differ diff --git a/src/importexport/encore/tests/data/notes_rest_before_note_midi_slop.enc b/src/importexport/encore/tests/data/notes_rest_before_note_midi_slop.enc new file mode 100644 index 0000000000000..19d5ee5fc8c40 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_rest_before_note_midi_slop.enc differ diff --git a/src/importexport/encore/tests/data/notes_scale_no_anchor_no_circles.enc b/src/importexport/encore/tests/data/notes_scale_no_anchor_no_circles.enc new file mode 100644 index 0000000000000..119b273928555 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_scale_no_anchor_no_circles.enc differ diff --git a/src/importexport/encore/tests/data/notes_scale_string_numbers_anchor.enc b/src/importexport/encore/tests/data/notes_scale_string_numbers_anchor.enc new file mode 100644 index 0000000000000..eeb0500f51418 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_scale_string_numbers_anchor.enc differ diff --git a/src/importexport/encore/tests/data/notes_segment_no_override_clean_multiple.enc b/src/importexport/encore/tests/data/notes_segment_no_override_clean_multiple.enc new file mode 100644 index 0000000000000..8ec98f708dd2f Binary files /dev/null and b/src/importexport/encore/tests/data/notes_segment_no_override_clean_multiple.enc differ diff --git a/src/importexport/encore/tests/data/notes_segment_override_12plus2.enc b/src/importexport/encore/tests/data/notes_segment_override_12plus2.enc new file mode 100644 index 0000000000000..b9503cb55342a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_segment_override_12plus2.enc differ diff --git a/src/importexport/encore/tests/data/notes_segment_override_15notes.enc b/src/importexport/encore/tests/data/notes_segment_override_15notes.enc new file mode 100644 index 0000000000000..a0bfdf81f18e2 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_segment_override_15notes.enc differ diff --git a/src/importexport/encore/tests/data/notes_sf_tiestart.enc b/src/importexport/encore/tests/data/notes_sf_tiestart.enc new file mode 100644 index 0000000000000..65aa805509c8a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_sf_tiestart.enc differ diff --git a/src/importexport/encore/tests/data/notes_singlestaff_voice4_second_voice.enc b/src/importexport/encore/tests/data/notes_singlestaff_voice4_second_voice.enc new file mode 100644 index 0000000000000..eb7663ee187c2 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_singlestaff_voice4_second_voice.enc differ diff --git a/src/importexport/encore/tests/data/notes_stretch_irregular_fallback.enc b/src/importexport/encore/tests/data/notes_stretch_irregular_fallback.enc new file mode 100644 index 0000000000000..7fac03a5a7aeb Binary files /dev/null and b/src/importexport/encore/tests/data/notes_stretch_irregular_fallback.enc differ diff --git a/src/importexport/encore/tests/data/notes_stretch_rob_rest.enc b/src/importexport/encore/tests/data/notes_stretch_rob_rest.enc new file mode 100644 index 0000000000000..a939b57ab94f4 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_stretch_rob_rest.enc differ diff --git a/src/importexport/encore/tests/data/notes_string_num_orn_no_dup.enc b/src/importexport/encore/tests/data/notes_string_num_orn_no_dup.enc new file mode 100644 index 0000000000000..a0d1b4ad756ed Binary files /dev/null and b/src/importexport/encore/tests/data/notes_string_num_orn_no_dup.enc differ diff --git a/src/importexport/encore/tests/data/notes_swing.enc b/src/importexport/encore/tests/data/notes_swing.enc new file mode 100644 index 0000000000000..37302514de97d Binary files /dev/null and b/src/importexport/encore/tests/data/notes_swing.enc differ diff --git a/src/importexport/encore/tests/data/notes_swing_offgrid.enc b/src/importexport/encore/tests/data/notes_swing_offgrid.enc new file mode 100644 index 0000000000000..30fb2ed41e265 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_swing_offgrid.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie.enc b/src/importexport/encore/tests/data/notes_tie.enc new file mode 100644 index 0000000000000..883bed4886a5d Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_18byte_real_forward.enc b/src/importexport/encore/tests/data/notes_tie_18byte_real_forward.enc new file mode 100644 index 0000000000000..e84258ad01f68 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_18byte_real_forward.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_crossmeasure_arcxx_equal.enc b/src/importexport/encore/tests/data/notes_tie_crossmeasure_arcxx_equal.enc new file mode 100644 index 0000000000000..99834c9c97e8e Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_crossmeasure_arcxx_equal.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_dir_02.enc b/src/importexport/encore/tests/data/notes_tie_dir_02.enc new file mode 100644 index 0000000000000..d5eb09e5680b2 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_dir_02.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_dir_03.enc b/src/importexport/encore/tests/data/notes_tie_dir_03.enc new file mode 100644 index 0000000000000..5294b1f302f26 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_dir_03.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_dir_04_forward.enc b/src/importexport/encore/tests/data/notes_tie_dir_04_forward.enc new file mode 100644 index 0000000000000..c3508c509ce7a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_dir_04_forward.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_dir_fc.enc b/src/importexport/encore/tests/data/notes_tie_dir_fc.enc new file mode 100644 index 0000000000000..f9328d0690898 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_dir_fc.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_intra_chord_arc_no_spurious.enc b/src/importexport/encore/tests/data/notes_tie_intra_chord_arc_no_spurious.enc new file mode 100644 index 0000000000000..87aff718391c7 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_intra_chord_arc_no_spurious.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_partial_chord_source_position.enc b/src/importexport/encore/tests/data/notes_tie_partial_chord_source_position.enc new file mode 100644 index 0000000000000..5f449f3ed0160 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_partial_chord_source_position.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_spurious_far_receiver.enc b/src/importexport/encore/tests/data/notes_tie_spurious_far_receiver.enc new file mode 100644 index 0000000000000..3317cd09eac9d Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_spurious_far_receiver.enc differ diff --git a/src/importexport/encore/tests/data/notes_tie_start_flag_byte6.enc b/src/importexport/encore/tests/data/notes_tie_start_flag_byte6.enc new file mode 100644 index 0000000000000..ff9aaf1bc0bb7 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tie_start_flag_byte6.enc differ diff --git a/src/importexport/encore/tests/data/notes_transposing_respell_melody.enc b/src/importexport/encore/tests/data/notes_transposing_respell_melody.enc new file mode 100644 index 0000000000000..a488d6886101c Binary files /dev/null and b/src/importexport/encore/tests/data/notes_transposing_respell_melody.enc differ diff --git a/src/importexport/encore/tests/data/notes_transposing_written_tpc.enc b/src/importexport/encore/tests/data/notes_transposing_written_tpc.enc new file mode 100644 index 0000000000000..b834c12d3b258 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_transposing_written_tpc.enc differ diff --git a/src/importexport/encore/tests/data/notes_triple_dotted_advance.enc b/src/importexport/encore/tests/data/notes_triple_dotted_advance.enc new file mode 100644 index 0000000000000..46dd0512a7195 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_triple_dotted_advance.enc differ diff --git a/src/importexport/encore/tests/data/notes_triplet_orphan_missing_tup.enc b/src/importexport/encore/tests/data/notes_triplet_orphan_missing_tup.enc new file mode 100644 index 0000000000000..bde5aaa06532b Binary files /dev/null and b/src/importexport/encore/tests/data/notes_triplet_orphan_missing_tup.enc differ diff --git a/src/importexport/encore/tests/data/notes_triplet_orphan_prior_complete_group.enc b/src/importexport/encore/tests/data/notes_triplet_orphan_prior_complete_group.enc new file mode 100644 index 0000000000000..5c6aabe95e030 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_triplet_orphan_prior_complete_group.enc differ diff --git a/src/importexport/encore/tests/data/notes_triplets.enc b/src/importexport/encore/tests/data/notes_triplets.enc new file mode 100644 index 0000000000000..387a9c6cbb58b Binary files /dev/null and b/src/importexport/encore/tests/data/notes_triplets.enc differ diff --git a/src/importexport/encore/tests/data/notes_tuplet_9_4_nontuplet.enc b/src/importexport/encore/tests/data/notes_tuplet_9_4_nontuplet.enc new file mode 100644 index 0000000000000..c5e74bb16cd9a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tuplet_9_4_nontuplet.enc differ diff --git a/src/importexport/encore/tests/data/notes_tuplet_diff_column_keeps_members.enc b/src/importexport/encore/tests/data/notes_tuplet_diff_column_keeps_members.enc new file mode 100644 index 0000000000000..eab80ba2a9a23 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tuplet_diff_column_keeps_members.enc differ diff --git a/src/importexport/encore/tests/data/notes_tuplet_dosillo_2_1.enc b/src/importexport/encore/tests/data/notes_tuplet_dosillo_2_1.enc new file mode 100644 index 0000000000000..55bee227f554e Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tuplet_dosillo_2_1.enc differ diff --git a/src/importexport/encore/tests/data/notes_tuplet_last_note_short_rdur.enc b/src/importexport/encore/tests/data/notes_tuplet_last_note_short_rdur.enc new file mode 100644 index 0000000000000..2eeb547eac6cf Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tuplet_last_note_short_rdur.enc differ diff --git a/src/importexport/encore/tests/data/notes_tuplet_no_gapsnap_spurious_rest.enc b/src/importexport/encore/tests/data/notes_tuplet_no_gapsnap_spurious_rest.enc new file mode 100644 index 0000000000000..4b3cfc8255964 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tuplet_no_gapsnap_spurious_rest.enc differ diff --git a/src/importexport/encore/tests/data/notes_tuplet_sort.enc b/src/importexport/encore/tests/data/notes_tuplet_sort.enc new file mode 100644 index 0000000000000..924e3add5ec27 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_tuplet_sort.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_chord_cluster_5tick.enc b/src/importexport/encore/tests/data/notes_v0c2_chord_cluster_5tick.enc new file mode 100644 index 0000000000000..53209f829783a Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_chord_cluster_5tick.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_common_time_glyph.enc b/src/importexport/encore/tests/data/notes_v0c2_common_time_glyph.enc new file mode 100644 index 0000000000000..aa0ef205e323d Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_common_time_glyph.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_common_time_glyph_uc.enc b/src/importexport/encore/tests/data/notes_v0c2_common_time_glyph_uc.enc new file mode 100644 index 0000000000000..fc2152a891cbd Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_common_time_glyph_uc.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_dotted_eighth.enc b/src/importexport/encore/tests/data/notes_v0c2_dotted_eighth.enc new file mode 100644 index 0000000000000..78e36127ab205 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_dotted_eighth.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_full_measure_no_false_dot.enc b/src/importexport/encore/tests/data/notes_v0c2_full_measure_no_false_dot.enc new file mode 100644 index 0000000000000..ef00277652742 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_full_measure_no_false_dot.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_implied_group_boundary.enc b/src/importexport/encore/tests/data/notes_v0c2_implied_group_boundary.enc new file mode 100644 index 0000000000000..4cdd365007ea1 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_implied_group_boundary.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_multiinstr_compact_routing.enc b/src/importexport/encore/tests/data/notes_v0c2_multiinstr_compact_routing.enc new file mode 100644 index 0000000000000..2c33fc8290a49 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_multiinstr_compact_routing.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_near_simultaneous_chord.enc b/src/importexport/encore/tests/data/notes_v0c2_near_simultaneous_chord.enc new file mode 100644 index 0000000000000..45f81caf2f1e0 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_near_simultaneous_chord.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_plain_sixteenth_no_spurious_dot.enc b/src/importexport/encore/tests/data/notes_v0c2_plain_sixteenth_no_spurious_dot.enc new file mode 100644 index 0000000000000..ae11aa16bd3ba Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_plain_sixteenth_no_spurious_dot.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_size24_artic_pitch.enc b/src/importexport/encore/tests/data/notes_v0c2_size24_artic_pitch.enc new file mode 100644 index 0000000000000..725d2908c246c Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_size24_artic_pitch.enc differ diff --git a/src/importexport/encore/tests/data/notes_v0c2_size24_semitonepitch.enc b/src/importexport/encore/tests/data/notes_v0c2_size24_semitonepitch.enc new file mode 100644 index 0000000000000..f28856e85b127 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_v0c2_size24_semitonepitch.enc differ diff --git a/src/importexport/encore/tests/data/notes_voice_overflow_dropped.enc b/src/importexport/encore/tests/data/notes_voice_overflow_dropped.enc new file mode 100644 index 0000000000000..67c0977810ba0 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_voice_overflow_dropped.enc differ diff --git a/src/importexport/encore/tests/data/notes_whole_rest_2_4.enc b/src/importexport/encore/tests/data/notes_whole_rest_2_4.enc new file mode 100644 index 0000000000000..cb90caf11d2d3 Binary files /dev/null and b/src/importexport/encore/tests/data/notes_whole_rest_2_4.enc differ diff --git a/src/importexport/encore/tests/data/opeco_vochoj.enc b/src/importexport/encore/tests/data/opeco_vochoj.enc new file mode 100644 index 0000000000000..e3fc9dd154185 Binary files /dev/null and b/src/importexport/encore/tests/data/opeco_vochoj.enc differ diff --git a/src/importexport/encore/tests/data/options_overfill_irregular_emitdrop.enc b/src/importexport/encore/tests/data/options_overfill_irregular_emitdrop.enc new file mode 100644 index 0000000000000..11697c664dab4 Binary files /dev/null and b/src/importexport/encore/tests/data/options_overfill_irregular_emitdrop.enc differ diff --git a/src/importexport/encore/tests/data/options_overfill_irregular_facevalue.enc b/src/importexport/encore/tests/data/options_overfill_irregular_facevalue.enc new file mode 100644 index 0000000000000..77517d9548995 Binary files /dev/null and b/src/importexport/encore/tests/data/options_overfill_irregular_facevalue.enc differ diff --git a/src/importexport/encore/tests/data/options_overfill_irregular_twostaves.enc b/src/importexport/encore/tests/data/options_overfill_irregular_twostaves.enc new file mode 100644 index 0000000000000..71746678593f2 Binary files /dev/null and b/src/importexport/encore/tests/data/options_overfill_irregular_twostaves.enc differ diff --git a/src/importexport/encore/tests/data/options_underfill_irregular_empty_staff.enc b/src/importexport/encore/tests/data/options_underfill_irregular_empty_staff.enc new file mode 100644 index 0000000000000..df6b20e80de8f Binary files /dev/null and b/src/importexport/encore/tests/data/options_underfill_irregular_empty_staff.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_accent_nonzero_voice.enc b/src/importexport/encore/tests/data/ornaments_accent_nonzero_voice.enc new file mode 100644 index 0000000000000..1e298035a9af1 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_accent_nonzero_voice.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_accent_offset_tick_nonzero_voice.enc b/src/importexport/encore/tests/data/ornaments_accent_offset_tick_nonzero_voice.enc new file mode 100644 index 0000000000000..f009e988128d3 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_accent_offset_tick_nonzero_voice.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_accent_sibling_no_spillover.enc b/src/importexport/encore/tests/data/ornaments_accent_sibling_no_spillover.enc new file mode 100644 index 0000000000000..1ed011aa15874 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_accent_sibling_no_spillover.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_accent_tick0_xoffset.enc b/src/importexport/encore/tests/data/ornaments_accent_tick0_xoffset.enc new file mode 100644 index 0000000000000..0153bf4b6b5bb Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_accent_tick0_xoffset.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_accents_distributed.enc b/src/importexport/encore/tests/data/ornaments_accents_distributed.enc new file mode 100644 index 0000000000000..799bac8b0bf09 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_accents_distributed.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_arpeggio.enc b/src/importexport/encore/tests/data/ornaments_arpeggio.enc new file mode 100644 index 0000000000000..b3952e132eed6 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_arpeggio.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_articulations.enc b/src/importexport/encore/tests/data/ornaments_articulations.enc new file mode 100644 index 0000000000000..cdb6fba298b0c Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_articulations.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_articulations_combo.enc b/src/importexport/encore/tests/data/ornaments_articulations_combo.enc new file mode 100644 index 0000000000000..86802ad456cb0 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_articulations_combo.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_beamed_triplet_capped.enc b/src/importexport/encore/tests/data/ornaments_beamed_triplet_capped.enc new file mode 100644 index 0000000000000..a34026772c102 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_beamed_triplet_capped.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_bowing.enc b/src/importexport/encore/tests/data/ornaments_bowing.enc new file mode 100644 index 0000000000000..ed5600615ba5e Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_bowing.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_bowing_tick0_xoffset_mismatch.enc b/src/importexport/encore/tests/data/ornaments_bowing_tick0_xoffset_mismatch.enc new file mode 100644 index 0000000000000..bc051fadaf15e Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_bowing_tick0_xoffset_mismatch.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_breath_and_caesura.enc b/src/importexport/encore/tests/data/ornaments_breath_and_caesura.enc new file mode 100644 index 0000000000000..3d24bed441bea Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_breath_and_caesura.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_cross_measure_slur_precision.enc b/src/importexport/encore/tests/data/ornaments_cross_measure_slur_precision.enc new file mode 100644 index 0000000000000..26274f846f336 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_cross_measure_slur_precision.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_double_barline_multi_staff.enc b/src/importexport/encore/tests/data/ornaments_double_barline_multi_staff.enc new file mode 100644 index 0000000000000..7983dee2ec256 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_double_barline_multi_staff.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_dynamics.enc b/src/importexport/encore/tests/data/ornaments_dynamics.enc new file mode 100644 index 0000000000000..b7e66c9e78e91 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_dynamics.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_dynamics_full.enc b/src/importexport/encore/tests/data/ornaments_dynamics_full.enc new file mode 100644 index 0000000000000..f5faf8d8801d6 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_dynamics_full.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_dynamics_stacked.enc b/src/importexport/encore/tests/data/ornaments_dynamics_stacked.enc new file mode 100644 index 0000000000000..7a15b8094d898 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_dynamics_stacked.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_fermata_below_not_in_tuplet.enc b/src/importexport/encore/tests/data/ornaments_fermata_below_not_in_tuplet.enc new file mode 100644 index 0000000000000..fd8944d000105 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_fermata_below_not_in_tuplet.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_fermata_not_in_tuplet.enc b/src/importexport/encore/tests/data/ornaments_fermata_not_in_tuplet.enc new file mode 100644 index 0000000000000..67bf946f72c22 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_fermata_not_in_tuplet.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_fermatas.enc b/src/importexport/encore/tests/data/ornaments_fermatas.enc new file mode 100644 index 0000000000000..b83a9042d96c1 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_fermatas.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_fingering_grandstaff.enc b/src/importexport/encore/tests/data/ornaments_fingering_grandstaff.enc new file mode 100644 index 0000000000000..a358e515395d0 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_fingering_grandstaff.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_fingering_orn.enc b/src/importexport/encore/tests/data/ornaments_fingering_orn.enc new file mode 100644 index 0000000000000..59d23fd22b07c Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_fingering_orn.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_grace_slur_to_later.enc b/src/importexport/encore/tests/data/ornaments_grace_slur_to_later.enc new file mode 100644 index 0000000000000..febef6dd0750a Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_grace_slur_to_later.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_grace_slur_to_main.enc b/src/importexport/encore/tests/data/ornaments_grace_slur_to_main.enc new file mode 100644 index 0000000000000..fe9a7451f790b Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_grace_slur_to_main.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_graphic_line_skipped.enc b/src/importexport/encore/tests/data/ornaments_graphic_line_skipped.enc new file mode 100644 index 0000000000000..07f0cbbb90adb Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_graphic_line_skipped.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_measure_repeat.enc b/src/importexport/encore/tests/data/ornaments_measure_repeat.enc new file mode 100644 index 0000000000000..578aa9c663aec Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_measure_repeat.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_multi_measure_hairpin.enc b/src/importexport/encore/tests/data/ornaments_multi_measure_hairpin.enc new file mode 100644 index 0000000000000..3da42c4f68507 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_multi_measure_hairpin.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_multi_measure_slur.enc b/src/importexport/encore/tests/data/ornaments_multi_measure_slur.enc new file mode 100644 index 0000000000000..27783a7fc4408 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_multi_measure_slur.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_multiinstr_slur_routing.enc b/src/importexport/encore/tests/data/ornaments_multiinstr_slur_routing.enc new file mode 100644 index 0000000000000..3edfc341b6933 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_multiinstr_slur_routing.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_new_artic_bytes.enc b/src/importexport/encore/tests/data/ornaments_new_artic_bytes.enc new file mode 100644 index 0000000000000..da37fff1889eb Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_new_artic_bytes.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_new_artic_types.enc b/src/importexport/encore/tests/data/ornaments_new_artic_types.enc new file mode 100644 index 0000000000000..b2dcdf778a716 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_new_artic_types.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_open_string_and_stick.enc b/src/importexport/encore/tests/data/ornaments_open_string_and_stick.enc new file mode 100644 index 0000000000000..7d5f7624c8958 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_open_string_and_stick.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_ornament_turn.enc b/src/importexport/encore/tests/data/ornaments_ornament_turn.enc new file mode 100644 index 0000000000000..b38b65acc105f Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_ornament_turn.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_ottava_two_spanners.enc b/src/importexport/encore/tests/data/ornaments_ottava_two_spanners.enc new file mode 100644 index 0000000000000..457d41e0a5e36 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_ottava_two_spanners.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_partial_quarter_triplet.enc b/src/importexport/encore/tests/data/ornaments_partial_quarter_triplet.enc new file mode 100644 index 0000000000000..bcee7006f1def Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_partial_quarter_triplet.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_staccatissimo_orns.enc b/src/importexport/encore/tests/data/ornaments_staccatissimo_orns.enc new file mode 100644 index 0000000000000..961094a259aef Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_staccatissimo_orns.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_staccato_orn.enc b/src/importexport/encore/tests/data/ornaments_staccato_orn.enc new file mode 100644 index 0000000000000..a2536cd6b4f2f Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_staccato_orn.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_standalone_trill_end.enc b/src/importexport/encore/tests/data/ornaments_standalone_trill_end.enc new file mode 100644 index 0000000000000..415673a89226a Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_standalone_trill_end.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_technical.enc b/src/importexport/encore/tests/data/ornaments_technical.enc new file mode 100644 index 0000000000000..16f5e3206a2bd Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_technical.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tempo_sym_followtext.enc b/src/importexport/encore/tests/data/ornaments_tempo_sym_followtext.enc new file mode 100644 index 0000000000000..c2c6c15e40df1 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tempo_sym_followtext.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tremolo_orn.enc b/src/importexport/encore/tests/data/ornaments_tremolo_orn.enc new file mode 100644 index 0000000000000..d35d66b2589ab Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tremolo_orn.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tremolo_orn_crossvoice.enc b/src/importexport/encore/tests/data/ornaments_tremolo_orn_crossvoice.enc new file mode 100644 index 0000000000000..d6a24c7f00cb1 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tremolo_orn_crossvoice.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tremolo_orn_no_tie.enc b/src/importexport/encore/tests/data/ornaments_tremolo_orn_no_tie.enc new file mode 100644 index 0000000000000..d6780cc650d19 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tremolo_orn_no_tie.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tremolo_orn_tied_from.enc b/src/importexport/encore/tests/data/ornaments_tremolo_orn_tied_from.enc new file mode 100644 index 0000000000000..57278005a1674 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tremolo_orn_tied_from.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tremolo_r8_r16_r64.enc b/src/importexport/encore/tests/data/ornaments_tremolo_r8_r16_r64.enc new file mode 100644 index 0000000000000..4c9eb52eac557 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tremolo_r8_r16_r64.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tremolos.enc b/src/importexport/encore/tests/data/ornaments_tremolos.enc new file mode 100644 index 0000000000000..3e75100800ff6 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tremolos.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_alt_standalone.enc b/src/importexport/encore/tests/data/ornaments_trill_alt_standalone.enc new file mode 100644 index 0000000000000..3b299f311ec26 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_alt_standalone.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_between_notes.enc b/src/importexport/encore/tests/data/ornaments_trill_between_notes.enc new file mode 100644 index 0000000000000..cd3afc746a270 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_between_notes.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_cross_measure.enc b/src/importexport/encore/tests/data/ornaments_trill_cross_measure.enc new file mode 100644 index 0000000000000..bbb5966aa6160 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_cross_measure.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_mordent.enc b/src/importexport/encore/tests/data/ornaments_trill_mordent.enc new file mode 100644 index 0000000000000..7c2b6619a702f Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_mordent.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_no_end_marker.enc b/src/importexport/encore/tests/data/ornaments_trill_no_end_marker.enc new file mode 100644 index 0000000000000..65fa351fe0050 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_no_end_marker.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_simple_on_note.enc b/src/importexport/encore/tests/data/ornaments_trill_simple_on_note.enc new file mode 100644 index 0000000000000..67177e639a37a Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_simple_on_note.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_spanner.enc b/src/importexport/encore/tests/data/ornaments_trill_spanner.enc new file mode 100644 index 0000000000000..795a5791a8c86 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_spanner.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_trill_with_accidentals.enc b/src/importexport/encore/tests/data/ornaments_trill_with_accidentals.enc new file mode 100644 index 0000000000000..0c390b4760e0b Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_trill_with_accidentals.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_tuplet_mixed_baseLen.enc b/src/importexport/encore/tests/data/ornaments_tuplet_mixed_baseLen.enc new file mode 100644 index 0000000000000..946df4b87683a Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_tuplet_mixed_baseLen.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_constant_slur_count.enc b/src/importexport/encore/tests/data/ornaments_v0c2_constant_slur_count.enc new file mode 100644 index 0000000000000..a8c066aba94c4 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_constant_slur_count.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_cross_measure_slur.enc b/src/importexport/encore/tests/data/ornaments_v0c2_cross_measure_slur.enc new file mode 100644 index 0000000000000..280f3f53b7509 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_cross_measure_slur.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_grace_slur_to_main_coloc.enc b/src/importexport/encore/tests/data/ornaments_v0c2_grace_slur_to_main_coloc.enc new file mode 100644 index 0000000000000..9ecd8d989bc9a Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_grace_slur_to_main_coloc.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_multiinstr_slur_routing.enc b/src/importexport/encore/tests/data/ornaments_v0c2_multiinstr_slur_routing.enc new file mode 100644 index 0000000000000..3e221e73c06b1 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_multiinstr_slur_routing.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_orn_c4_accent.enc b/src/importexport/encore/tests/data/ornaments_v0c2_orn_c4_accent.enc new file mode 100644 index 0000000000000..ede21382208f0 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_orn_c4_accent.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_same_measure_slur_no_cross.enc b/src/importexport/encore/tests/data/ornaments_v0c2_same_measure_slur_no_cross.enc new file mode 100644 index 0000000000000..434d68d34510c Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_same_measure_slur_no_cross.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_slur_firstnote_xoff_mismatch.enc b/src/importexport/encore/tests/data/ornaments_v0c2_slur_firstnote_xoff_mismatch.enc new file mode 100644 index 0000000000000..48c273897b717 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_slur_firstnote_xoff_mismatch.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c2_unreliable_slur_count.enc b/src/importexport/encore/tests/data/ornaments_v0c2_unreliable_slur_count.enc new file mode 100644 index 0000000000000..23653495ab47a Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c2_unreliable_slur_count.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_grace_to_later.enc b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_grace_to_later.enc new file mode 100644 index 0000000000000..7f357f6edaf36 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_grace_to_later.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_in_binary.enc b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_in_binary.enc new file mode 100644 index 0000000000000..d84e5f20d43d5 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_in_binary.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_preceding_notes.enc b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_preceding_notes.enc new file mode 100644 index 0000000000000..7f357f6edaf36 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_preceding_notes.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_slur_to_main.enc b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_slur_to_main.enc new file mode 100644 index 0000000000000..aa8e3e308f287 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_slur_to_main.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c4_grace_slur_to_main_coloc.enc b/src/importexport/encore/tests/data/ornaments_v0c4_grace_slur_to_main_coloc.enc new file mode 100644 index 0000000000000..701a05159d146 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c4_grace_slur_to_main_coloc.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_v0c4_orn_be_accent.enc b/src/importexport/encore/tests/data/ornaments_v0c4_orn_be_accent.enc new file mode 100644 index 0000000000000..6f0edb228c0ba Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_v0c4_orn_be_accent.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_wedgestart_at_measure_end.enc b/src/importexport/encore/tests/data/ornaments_wedgestart_at_measure_end.enc new file mode 100644 index 0000000000000..4cc45f68ef3be Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_wedgestart_at_measure_end.enc differ diff --git a/src/importexport/encore/tests/data/ornaments_zero_hairpin.enc b/src/importexport/encore/tests/data/ornaments_zero_hairpin.enc new file mode 100644 index 0000000000000..55be83911e691 Binary files /dev/null and b/src/importexport/encore/tests/data/ornaments_zero_hairpin.enc differ diff --git a/src/importexport/encore/tests/data/paloteos_7x8.enc b/src/importexport/encore/tests/data/paloteos_7x8.enc new file mode 100644 index 0000000000000..c7f7aa68f40cc Binary files /dev/null and b/src/importexport/encore/tests/data/paloteos_7x8.enc differ diff --git a/src/importexport/encore/tests/data/rest_dotted_before_notes.enc b/src/importexport/encore/tests/data/rest_dotted_before_notes.enc new file mode 100644 index 0000000000000..c5939e67673aa Binary files /dev/null and b/src/importexport/encore/tests/data/rest_dotted_before_notes.enc differ diff --git a/src/importexport/encore/tests/data/ripetoj.enc b/src/importexport/encore/tests/data/ripetoj.enc new file mode 100644 index 0000000000000..b94345baf0df6 Binary files /dev/null and b/src/importexport/encore/tests/data/ripetoj.enc differ diff --git a/src/importexport/encore/tests/data/sintetico_all_features.enc b/src/importexport/encore/tests/data/sintetico_all_features.enc new file mode 100644 index 0000000000000..5da434ecbd17a Binary files /dev/null and b/src/importexport/encore/tests/data/sintetico_all_features.enc differ diff --git a/src/importexport/encore/tests/data/structure_c_clef_key_keeps_clef.enc b/src/importexport/encore/tests/data/structure_c_clef_key_keeps_clef.enc new file mode 100644 index 0000000000000..5f1e40502c400 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_c_clef_key_keeps_clef.enc differ diff --git a/src/importexport/encore/tests/data/structure_clef_change_mid_measure.enc b/src/importexport/encore/tests/data/structure_clef_change_mid_measure.enc new file mode 100644 index 0000000000000..c866e00f7fd67 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_clef_change_mid_measure.enc differ diff --git a/src/importexport/encore/tests/data/structure_clef_trailing_cautionary.enc b/src/importexport/encore/tests/data/structure_clef_trailing_cautionary.enc new file mode 100644 index 0000000000000..fb02e9ee365f7 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_clef_trailing_cautionary.enc differ diff --git a/src/importexport/encore/tests/data/structure_f_clef_8va_from_key.enc b/src/importexport/encore/tests/data/structure_f_clef_8va_from_key.enc new file mode 100644 index 0000000000000..1934a14f3b2f5 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_f_clef_8va_from_key.enc differ diff --git a/src/importexport/encore/tests/data/structure_f_clef_8vb_from_key.enc b/src/importexport/encore/tests/data/structure_f_clef_8vb_from_key.enc new file mode 100644 index 0000000000000..51bbea7b46716 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_f_clef_8vb_from_key.enc differ diff --git a/src/importexport/encore/tests/data/structure_g_clef_8va_from_key.enc b/src/importexport/encore/tests/data/structure_g_clef_8va_from_key.enc new file mode 100644 index 0000000000000..aff171d7a0a61 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_g_clef_8va_from_key.enc differ diff --git a/src/importexport/encore/tests/data/structure_g_clef_key0_stays_plain.enc b/src/importexport/encore/tests/data/structure_g_clef_key0_stays_plain.enc new file mode 100644 index 0000000000000..b1119421f0306 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_g_clef_key0_stays_plain.enc differ diff --git a/src/importexport/encore/tests/data/structure_grandstaff_wedge_out_of_range_voice.enc b/src/importexport/encore/tests/data/structure_grandstaff_wedge_out_of_range_voice.enc new file mode 100644 index 0000000000000..22a0293d4619e Binary files /dev/null and b/src/importexport/encore/tests/data/structure_grandstaff_wedge_out_of_range_voice.enc differ diff --git a/src/importexport/encore/tests/data/structure_hostile_out_of_range_staff.enc b/src/importexport/encore/tests/data/structure_hostile_out_of_range_staff.enc new file mode 100644 index 0000000000000..ed3552090fdf8 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_hostile_out_of_range_staff.enc differ diff --git a/src/importexport/encore/tests/data/structure_hostile_out_of_range_voice.enc b/src/importexport/encore/tests/data/structure_hostile_out_of_range_voice.enc new file mode 100644 index 0000000000000..51e5731167d14 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_hostile_out_of_range_voice.enc differ diff --git a/src/importexport/encore/tests/data/structure_hostile_zero_size_element.enc b/src/importexport/encore/tests/data/structure_hostile_zero_size_element.enc new file mode 100644 index 0000000000000..fdc399cea15bd Binary files /dev/null and b/src/importexport/encore/tests/data/structure_hostile_zero_size_element.enc differ diff --git a/src/importexport/encore/tests/data/structure_hostile_zero_tuplet_nibble.enc b/src/importexport/encore/tests/data/structure_hostile_zero_tuplet_nibble.enc new file mode 100644 index 0000000000000..3bf730d667651 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_hostile_zero_tuplet_nibble.enc differ diff --git a/src/importexport/encore/tests/data/structure_jump_marks.enc b/src/importexport/encore/tests/data/structure_jump_marks.enc new file mode 100644 index 0000000000000..9bab6af5cac9d Binary files /dev/null and b/src/importexport/encore/tests/data/structure_jump_marks.enc differ diff --git a/src/importexport/encore/tests/data/structure_jump_marks_all.enc b/src/importexport/encore/tests/data/structure_jump_marks_all.enc new file mode 100644 index 0000000000000..33f1910fb617b Binary files /dev/null and b/src/importexport/encore/tests/data/structure_jump_marks_all.enc differ diff --git a/src/importexport/encore/tests/data/structure_key_per_staff.enc b/src/importexport/encore/tests/data/structure_key_per_staff.enc new file mode 100644 index 0000000000000..0304691eabecd Binary files /dev/null and b/src/importexport/encore/tests/data/structure_key_per_staff.enc differ diff --git a/src/importexport/encore/tests/data/structure_keychange_to_c.enc b/src/importexport/encore/tests/data/structure_keychange_to_c.enc new file mode 100644 index 0000000000000..c65fbc141ca72 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_keychange_to_c.enc differ diff --git a/src/importexport/encore/tests/data/structure_merge_stray_voice_rests.enc b/src/importexport/encore/tests/data/structure_merge_stray_voice_rests.enc new file mode 100644 index 0000000000000..bc8121d3d2b94 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_merge_stray_voice_rests.enc differ diff --git a/src/importexport/encore/tests/data/structure_non_octave_key_keeps_clef.enc b/src/importexport/encore/tests/data/structure_non_octave_key_keeps_clef.enc new file mode 100644 index 0000000000000..d67282ba07461 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_non_octave_key_keeps_clef.enc differ diff --git a/src/importexport/encore/tests/data/structure_octave_bassa_clef_override.enc b/src/importexport/encore/tests/data/structure_octave_bassa_clef_override.enc new file mode 100644 index 0000000000000..15fa48067e824 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_octave_bassa_clef_override.enc differ diff --git a/src/importexport/encore/tests/data/structure_octave_lower_implicit_silences.enc b/src/importexport/encore/tests/data/structure_octave_lower_implicit_silences.enc new file mode 100644 index 0000000000000..ed1e60d31aca3 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_octave_lower_implicit_silences.enc differ diff --git a/src/importexport/encore/tests/data/structure_overfill_recut_tie.enc b/src/importexport/encore/tests/data/structure_overfill_recut_tie.enc new file mode 100644 index 0000000000000..4093249cf3c0e Binary files /dev/null and b/src/importexport/encore/tests/data/structure_overfill_recut_tie.enc differ diff --git a/src/importexport/encore/tests/data/structure_page_break.enc b/src/importexport/encore/tests/data/structure_page_break.enc new file mode 100644 index 0000000000000..b41866258ef57 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_page_break.enc differ diff --git a/src/importexport/encore/tests/data/structure_page_break_mcount_zero.enc b/src/importexport/encore/tests/data/structure_page_break_mcount_zero.enc new file mode 100644 index 0000000000000..9e6206784e0ac Binary files /dev/null and b/src/importexport/encore/tests/data/structure_page_break_mcount_zero.enc differ diff --git a/src/importexport/encore/tests/data/structure_page_break_spill.enc b/src/importexport/encore/tests/data/structure_page_break_spill.enc new file mode 100644 index 0000000000000..80cae8b14bd32 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_page_break_spill.enc differ diff --git a/src/importexport/encore/tests/data/structure_perc_clef_key_keeps_clef.enc b/src/importexport/encore/tests/data/structure_perc_clef_key_keeps_clef.enc new file mode 100644 index 0000000000000..73e4d1290a73d Binary files /dev/null and b/src/importexport/encore/tests/data/structure_perc_clef_key_keeps_clef.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_casea_sparse.enc b/src/importexport/encore/tests/data/structure_pickup_casea_sparse.enc new file mode 100644 index 0000000000000..ce16926dded27 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_casea_sparse.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_casea_volta.enc b/src/importexport/encore/tests/data/structure_pickup_casea_volta.enc new file mode 100644 index 0000000000000..a88900ae92c59 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_casea_volta.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_caseb_hairpin.enc b/src/importexport/encore/tests/data/structure_pickup_caseb_hairpin.enc new file mode 100644 index 0000000000000..2746730abf752 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_caseb_hairpin.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_caseb_no_reduce_full.enc b/src/importexport/encore/tests/data/structure_pickup_caseb_no_reduce_full.enc new file mode 100644 index 0000000000000..64407d06fc494 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_caseb_no_reduce_full.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_caseb_reduces.enc b/src/importexport/encore/tests/data/structure_pickup_caseb_reduces.enc new file mode 100644 index 0000000000000..418666cff2e83 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_caseb_reduces.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_measure.enc b/src/importexport/encore/tests/data/structure_pickup_measure.enc new file mode 100644 index 0000000000000..b7211b6b66319 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_measure.enc differ diff --git a/src/importexport/encore/tests/data/structure_pickup_measure_same_ts.enc b/src/importexport/encore/tests/data/structure_pickup_measure_same_ts.enc new file mode 100644 index 0000000000000..e2e9541b92483 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_pickup_measure_same_ts.enc differ diff --git a/src/importexport/encore/tests/data/structure_prec_landscape_no_wini.enc b/src/importexport/encore/tests/data/structure_prec_landscape_no_wini.enc new file mode 100644 index 0000000000000..caa1aacd6d20b Binary files /dev/null and b/src/importexport/encore/tests/data/structure_prec_landscape_no_wini.enc differ diff --git a/src/importexport/encore/tests/data/structure_prec_page_a3.enc b/src/importexport/encore/tests/data/structure_prec_page_a3.enc new file mode 100644 index 0000000000000..7c4a0f6e5c40d Binary files /dev/null and b/src/importexport/encore/tests/data/structure_prec_page_a3.enc differ diff --git a/src/importexport/encore/tests/data/structure_prec_page_letter.enc b/src/importexport/encore/tests/data/structure_prec_page_letter.enc new file mode 100644 index 0000000000000..02b066892712f Binary files /dev/null and b/src/importexport/encore/tests/data/structure_prec_page_letter.enc differ diff --git a/src/importexport/encore/tests/data/structure_rest_coincident_with_note.enc b/src/importexport/encore/tests/data/structure_rest_coincident_with_note.enc new file mode 100644 index 0000000000000..9ddc266373a89 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_rest_coincident_with_note.enc differ diff --git a/src/importexport/encore/tests/data/structure_sco5_macos.enc b/src/importexport/encore/tests/data/structure_sco5_macos.enc new file mode 100644 index 0000000000000..4c0df0e545bc0 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_sco5_macos.enc differ diff --git a/src/importexport/encore/tests/data/structure_section_markers.enc b/src/importexport/encore/tests/data/structure_section_markers.enc new file mode 100644 index 0000000000000..acdbc97391dab Binary files /dev/null and b/src/importexport/encore/tests/data/structure_section_markers.enc differ diff --git a/src/importexport/encore/tests/data/structure_stale_tick_by_column.enc b/src/importexport/encore/tests/data/structure_stale_tick_by_column.enc new file mode 100644 index 0000000000000..d4e0070a7111d Binary files /dev/null and b/src/importexport/encore/tests/data/structure_stale_tick_by_column.enc differ diff --git a/src/importexport/encore/tests/data/structure_start_double_barline.enc b/src/importexport/encore/tests/data/structure_start_double_barline.enc new file mode 100644 index 0000000000000..f899b1a6800f7 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_start_double_barline.enc differ diff --git a/src/importexport/encore/tests/data/structure_system_break.enc b/src/importexport/encore/tests/data/structure_system_break.enc new file mode 100644 index 0000000000000..ac308aaf8de29 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_system_break.enc differ diff --git a/src/importexport/encore/tests/data/structure_system_break_mcount_zero.enc b/src/importexport/encore/tests/data/structure_system_break_mcount_zero.enc new file mode 100644 index 0000000000000..34c5e68827617 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_system_break_mcount_zero.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0c2_pitches.enc b/src/importexport/encore/tests/data/structure_v0c2_pitches.enc new file mode 100644 index 0000000000000..61aa09e08d47e Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0c2_pitches.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0c2_spurious_semitone_flag.enc b/src/importexport/encore/tests/data/structure_v0c2_spurious_semitone_flag.enc new file mode 100644 index 0000000000000..7bdaaa6edb83d Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0c2_spurious_semitone_flag.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0c2_triplet_pitch_in_semitone.enc b/src/importexport/encore/tests/data/structure_v0c2_triplet_pitch_in_semitone.enc new file mode 100644 index 0000000000000..b5004ba796b9b Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0c2_triplet_pitch_in_semitone.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0c2_triplets.enc b/src/importexport/encore/tests/data/structure_v0c2_triplets.enc new file mode 100644 index 0000000000000..da6b3faca6dbb Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0c2_triplets.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0c4_irregular_len_reduced.enc b/src/importexport/encore/tests/data/structure_v0c4_irregular_len_reduced.enc new file mode 100644 index 0000000000000..7f1f94c7baa71 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0c4_irregular_len_reduced.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0c4_slur_zero_length_overfull.enc b/src/importexport/encore/tests/data/structure_v0c4_slur_zero_length_overfull.enc new file mode 100644 index 0000000000000..dcb9eb2bbac46 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0c4_slur_zero_length_overfull.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0xa6_basic.enc b/src/importexport/encore/tests/data/structure_v0xa6_basic.enc new file mode 100644 index 0000000000000..d77ba95267629 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0xa6_basic.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0xa6_fermata.enc b/src/importexport/encore/tests/data/structure_v0xa6_fermata.enc new file mode 100644 index 0000000000000..a6170ea7f6501 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0xa6_fermata.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0xa6_key_signature.enc b/src/importexport/encore/tests/data/structure_v0xa6_key_signature.enc new file mode 100644 index 0000000000000..200caa296aab1 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0xa6_key_signature.enc differ diff --git a/src/importexport/encore/tests/data/structure_v0xa6_score_size.enc b/src/importexport/encore/tests/data/structure_v0xa6_score_size.enc new file mode 100644 index 0000000000000..472cacf16da0a Binary files /dev/null and b/src/importexport/encore/tests/data/structure_v0xa6_score_size.enc differ diff --git a/src/importexport/encore/tests/data/structure_voice4_rest_with_notes.enc b/src/importexport/encore/tests/data/structure_voice4_rest_with_notes.enc new file mode 100644 index 0000000000000..2a7bd062082fe Binary files /dev/null and b/src/importexport/encore/tests/data/structure_voice4_rest_with_notes.enc differ diff --git a/src/importexport/encore/tests/data/structure_volta_repeat_playback.enc b/src/importexport/encore/tests/data/structure_volta_repeat_playback.enc new file mode 100644 index 0000000000000..fbf88c9b8d5ef Binary files /dev/null and b/src/importexport/encore/tests/data/structure_volta_repeat_playback.enc differ diff --git a/src/importexport/encore/tests/data/structure_volta_repeat_playcount.enc b/src/importexport/encore/tests/data/structure_volta_repeat_playcount.enc new file mode 100644 index 0000000000000..e549533c195d5 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_volta_repeat_playcount.enc differ diff --git a/src/importexport/encore/tests/data/structure_wini_large_margins_a3.enc b/src/importexport/encore/tests/data/structure_wini_large_margins_a3.enc new file mode 100644 index 0000000000000..ddc24cf70d0e4 Binary files /dev/null and b/src/importexport/encore/tests/data/structure_wini_large_margins_a3.enc differ diff --git a/src/importexport/encore/tests/data/structure_wini_screen_pixel_a4.enc b/src/importexport/encore/tests/data/structure_wini_screen_pixel_a4.enc new file mode 100644 index 0000000000000..971adb230c10c Binary files /dev/null and b/src/importexport/encore/tests/data/structure_wini_screen_pixel_a4.enc differ diff --git a/src/importexport/encore/tests/data/tempo_v0c2_eighth_beat_unit.enc b/src/importexport/encore/tests/data/tempo_v0c2_eighth_beat_unit.enc new file mode 100644 index 0000000000000..7efd59d29ac3e Binary files /dev/null and b/src/importexport/encore/tests/data/tempo_v0c2_eighth_beat_unit.enc differ diff --git a/src/importexport/encore/tests/data/text_chord_quality_table.enc b/src/importexport/encore/tests/data/text_chord_quality_table.enc new file mode 100644 index 0000000000000..2fe538bfcd3db Binary files /dev/null and b/src/importexport/encore/tests/data/text_chord_quality_table.enc differ diff --git a/src/importexport/encore/tests/data/text_chord_sym_latin1.enc b/src/importexport/encore/tests/data/text_chord_sym_latin1.enc new file mode 100644 index 0000000000000..177d2240531d7 Binary files /dev/null and b/src/importexport/encore/tests/data/text_chord_sym_latin1.enc differ diff --git a/src/importexport/encore/tests/data/text_duplicate_titl_block.enc b/src/importexport/encore/tests/data/text_duplicate_titl_block.enc new file mode 100644 index 0000000000000..7b14fd21948ca Binary files /dev/null and b/src/importexport/encore/tests/data/text_duplicate_titl_block.enc differ diff --git a/src/importexport/encore/tests/data/text_header_footer_tokens.enc b/src/importexport/encore/tests/data/text_header_footer_tokens.enc new file mode 100644 index 0000000000000..90cc60f4a73fb Binary files /dev/null and b/src/importexport/encore/tests/data/text_header_footer_tokens.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics.enc b/src/importexport/encore/tests/data/text_lyrics.enc new file mode 100644 index 0000000000000..fb849cad41ce1 Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_6_8_offset_ticks.enc b/src/importexport/encore/tests/data/text_lyrics_6_8_offset_ticks.enc new file mode 100644 index 0000000000000..3b5c360eda638 Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_6_8_offset_ticks.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_grandstaff_routed_notes.enc b/src/importexport/encore/tests/data/text_lyrics_grandstaff_routed_notes.enc new file mode 100644 index 0000000000000..797273ce30087 Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_grandstaff_routed_notes.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_hyphen_across_barline.enc b/src/importexport/encore/tests/data/text_lyrics_hyphen_across_barline.enc new file mode 100644 index 0000000000000..074aa87f619f8 Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_hyphen_across_barline.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_hyphenated_words.enc b/src/importexport/encore/tests/data/text_lyrics_hyphenated_words.enc new file mode 100644 index 0000000000000..bf832428d653b Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_hyphenated_words.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_latin1.enc b/src/importexport/encore/tests/data/text_lyrics_latin1.enc new file mode 100644 index 0000000000000..4bb67769bde2c Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_latin1.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_offgrid_nearest_chord.enc b/src/importexport/encore/tests/data/text_lyrics_offgrid_nearest_chord.enc new file mode 100644 index 0000000000000..61c44d16246fc Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_offgrid_nearest_chord.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_two_verses.enc b/src/importexport/encore/tests/data/text_lyrics_two_verses.enc new file mode 100644 index 0000000000000..1f5b51138243c Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_two_verses.enc differ diff --git a/src/importexport/encore/tests/data/text_lyrics_variable.enc b/src/importexport/encore/tests/data/text_lyrics_variable.enc new file mode 100644 index 0000000000000..c2105f7d7896d Binary files /dev/null and b/src/importexport/encore/tests/data/text_lyrics_variable.enc differ diff --git a/src/importexport/encore/tests/data/text_meas_bpm_suppressed_by_orn_tempo_later_tick.enc b/src/importexport/encore/tests/data/text_meas_bpm_suppressed_by_orn_tempo_later_tick.enc new file mode 100644 index 0000000000000..115e28c1f3871 Binary files /dev/null and b/src/importexport/encore/tests/data/text_meas_bpm_suppressed_by_orn_tempo_later_tick.enc differ diff --git a/src/importexport/encore/tests/data/text_multi_slot_stacked_text.enc b/src/importexport/encore/tests/data/text_multi_slot_stacked_text.enc new file mode 100644 index 0000000000000..2dff21c5bde0f Binary files /dev/null and b/src/importexport/encore/tests/data/text_multi_slot_stacked_text.enc differ diff --git a/src/importexport/encore/tests/data/text_orn_tempo_3_8_dotted_quarter.enc b/src/importexport/encore/tests/data/text_orn_tempo_3_8_dotted_quarter.enc new file mode 100644 index 0000000000000..04146f7158938 Binary files /dev/null and b/src/importexport/encore/tests/data/text_orn_tempo_3_8_dotted_quarter.enc differ diff --git a/src/importexport/encore/tests/data/text_orn_tempo_eighth_beat_not_suppressed.enc b/src/importexport/encore/tests/data/text_orn_tempo_eighth_beat_not_suppressed.enc new file mode 100644 index 0000000000000..811af277c0382 Binary files /dev/null and b/src/importexport/encore/tests/data/text_orn_tempo_eighth_beat_not_suppressed.enc differ diff --git a/src/importexport/encore/tests/data/text_orn_tempo_equals_header.enc b/src/importexport/encore/tests/data/text_orn_tempo_equals_header.enc new file mode 100644 index 0000000000000..e1c3c920a5462 Binary files /dev/null and b/src/importexport/encore/tests/data/text_orn_tempo_equals_header.enc differ diff --git a/src/importexport/encore/tests/data/text_orn_tempo_mismatch_suppressed.enc b/src/importexport/encore/tests/data/text_orn_tempo_mismatch_suppressed.enc new file mode 100644 index 0000000000000..3a1e66f363b77 Binary files /dev/null and b/src/importexport/encore/tests/data/text_orn_tempo_mismatch_suppressed.enc differ diff --git a/src/importexport/encore/tests/data/text_orn_tempo_misplaced_multi_measure.enc b/src/importexport/encore/tests/data/text_orn_tempo_misplaced_multi_measure.enc new file mode 100644 index 0000000000000..74db71c4fcd05 Binary files /dev/null and b/src/importexport/encore/tests/data/text_orn_tempo_misplaced_multi_measure.enc differ diff --git a/src/importexport/encore/tests/data/text_recovered_name_latin1.enc b/src/importexport/encore/tests/data/text_recovered_name_latin1.enc new file mode 100644 index 0000000000000..46e18a7bdd93a Binary files /dev/null and b/src/importexport/encore/tests/data/text_recovered_name_latin1.enc differ diff --git a/src/importexport/encore/tests/data/text_satb_short_names_voice4_lyrics.enc b/src/importexport/encore/tests/data/text_satb_short_names_voice4_lyrics.enc new file mode 100644 index 0000000000000..779064dfa9c26 Binary files /dev/null and b/src/importexport/encore/tests/data/text_satb_short_names_voice4_lyrics.enc differ diff --git a/src/importexport/encore/tests/data/text_staff_text.enc b/src/importexport/encore/tests/data/text_staff_text.enc new file mode 100644 index 0000000000000..c61f8124aa79c Binary files /dev/null and b/src/importexport/encore/tests/data/text_staff_text.enc differ diff --git a/src/importexport/encore/tests/data/text_staff_text_first_block_wins.enc b/src/importexport/encore/tests/data/text_staff_text_first_block_wins.enc new file mode 100644 index 0000000000000..108aed5ddae9d Binary files /dev/null and b/src/importexport/encore/tests/data/text_staff_text_first_block_wins.enc differ diff --git a/src/importexport/encore/tests/data/text_staff_text_multiline.enc b/src/importexport/encore/tests/data/text_staff_text_multiline.enc new file mode 100644 index 0000000000000..1813254a6e9c1 Binary files /dev/null and b/src/importexport/encore/tests/data/text_staff_text_multiline.enc differ diff --git a/src/importexport/encore/tests/data/text_staff_text_multirun.enc b/src/importexport/encore/tests/data/text_staff_text_multirun.enc new file mode 100644 index 0000000000000..0dfcd98f2148b Binary files /dev/null and b/src/importexport/encore/tests/data/text_staff_text_multirun.enc differ diff --git a/src/importexport/encore/tests/data/text_staff_text_placement.enc b/src/importexport/encore/tests/data/text_staff_text_placement.enc new file mode 100644 index 0000000000000..5376e73b604e4 Binary files /dev/null and b/src/importexport/encore/tests/data/text_staff_text_placement.enc differ diff --git a/src/importexport/encore/tests/data/text_staff_text_two_descriptors.enc b/src/importexport/encore/tests/data/text_staff_text_two_descriptors.enc new file mode 100644 index 0000000000000..7f75bb7f97e62 Binary files /dev/null and b/src/importexport/encore/tests/data/text_staff_text_two_descriptors.enc differ diff --git a/src/importexport/encore/tests/data/text_stafftext_tempo_promotion.enc b/src/importexport/encore/tests/data/text_stafftext_tempo_promotion.enc new file mode 100644 index 0000000000000..61476e4ac90c7 Binary files /dev/null and b/src/importexport/encore/tests/data/text_stafftext_tempo_promotion.enc differ diff --git a/src/importexport/encore/tests/data/text_tempo_changes.enc b/src/importexport/encore/tests/data/text_tempo_changes.enc new file mode 100644 index 0000000000000..ed3c4efc159f3 Binary files /dev/null and b/src/importexport/encore/tests/data/text_tempo_changes.enc differ diff --git a/src/importexport/encore/tests/data/text_tempo_orn_compound_68.enc b/src/importexport/encore/tests/data/text_tempo_orn_compound_68.enc new file mode 100644 index 0000000000000..4b35e3af781c9 Binary files /dev/null and b/src/importexport/encore/tests/data/text_tempo_orn_compound_68.enc differ diff --git a/src/importexport/encore/tests/data/text_tempo_orn_explicit_quarter_unit.enc b/src/importexport/encore/tests/data/text_tempo_orn_explicit_quarter_unit.enc new file mode 100644 index 0000000000000..e1d30bc8d91da Binary files /dev/null and b/src/importexport/encore/tests/data/text_tempo_orn_explicit_quarter_unit.enc differ diff --git a/src/importexport/encore/tests/data/text_tempo_orn_v0c2_bpm_offset.enc b/src/importexport/encore/tests/data/text_tempo_orn_v0c2_bpm_offset.enc new file mode 100644 index 0000000000000..4a436c5411bff Binary files /dev/null and b/src/importexport/encore/tests/data/text_tempo_orn_v0c2_bpm_offset.enc differ diff --git a/src/importexport/encore/tests/data/text_tempo_orn_v0c2_v0c4_layout.enc b/src/importexport/encore/tests/data/text_tempo_orn_v0c2_v0c4_layout.enc new file mode 100644 index 0000000000000..ee1460094452c Binary files /dev/null and b/src/importexport/encore/tests/data/text_tempo_orn_v0c2_v0c4_layout.enc differ diff --git a/src/importexport/encore/tests/data/text_tempo_orn_xoffset_downbeat.enc b/src/importexport/encore/tests/data/text_tempo_orn_xoffset_downbeat.enc new file mode 100644 index 0000000000000..4ec7b7e0d859e Binary files /dev/null and b/src/importexport/encore/tests/data/text_tempo_orn_xoffset_downbeat.enc differ diff --git a/src/importexport/encore/tests/data/text_text_block_latin1_decoding.enc b/src/importexport/encore/tests/data/text_text_block_latin1_decoding.enc new file mode 100644 index 0000000000000..2092346f72f80 Binary files /dev/null and b/src/importexport/encore/tests/data/text_text_block_latin1_decoding.enc differ diff --git a/src/importexport/encore/tests/data/text_titl_empty_second_block.enc b/src/importexport/encore/tests/data/text_titl_empty_second_block.enc new file mode 100644 index 0000000000000..7ed287c2ccdc3 Binary files /dev/null and b/src/importexport/encore/tests/data/text_titl_empty_second_block.enc differ diff --git a/src/importexport/encore/tests/data/text_titl_headers_footers.enc b/src/importexport/encore/tests/data/text_titl_headers_footers.enc new file mode 100644 index 0000000000000..8180a63b41555 Binary files /dev/null and b/src/importexport/encore/tests/data/text_titl_headers_footers.enc differ diff --git a/src/importexport/encore/tests/data/text_titl_latin1_small_varsize.enc b/src/importexport/encore/tests/data/text_titl_latin1_small_varsize.enc new file mode 100644 index 0000000000000..4cc1fd82ceab7 Binary files /dev/null and b/src/importexport/encore/tests/data/text_titl_latin1_small_varsize.enc differ diff --git a/src/importexport/encore/tests/data/text_title_instruction_copyright.enc b/src/importexport/encore/tests/data/text_title_instruction_copyright.enc new file mode 100644 index 0000000000000..e9520e95d7dd4 Binary files /dev/null and b/src/importexport/encore/tests/data/text_title_instruction_copyright.enc differ diff --git a/src/importexport/encore/tests/data/timesig_change_2_2_to_4_4.enc b/src/importexport/encore/tests/data/timesig_change_2_2_to_4_4.enc new file mode 100644 index 0000000000000..c4c1e4e0f96f6 Binary files /dev/null and b/src/importexport/encore/tests/data/timesig_change_2_2_to_4_4.enc differ diff --git a/src/importexport/encore/tests/data/timesig_change_6_8_to_3_4.enc b/src/importexport/encore/tests/data/timesig_change_6_8_to_3_4.enc new file mode 100644 index 0000000000000..55eeaa2349e15 Binary files /dev/null and b/src/importexport/encore/tests/data/timesig_change_6_8_to_3_4.enc differ diff --git a/src/importexport/encore/tests/data/tuplet_4to3_quadruplet.enc b/src/importexport/encore/tests/data/tuplet_4to3_quadruplet.enc new file mode 100644 index 0000000000000..f30a523d05c7d Binary files /dev/null and b/src/importexport/encore/tests/data/tuplet_4to3_quadruplet.enc differ diff --git a/src/importexport/encore/tests/data/zbot_from_bazo.enc b/src/importexport/encore/tests/data/zbot_from_bazo.enc new file mode 100644 index 0000000000000..025cb2a8e5bea Binary files /dev/null and b/src/importexport/encore/tests/data/zbot_from_bazo.enc differ diff --git a/src/importexport/encore/tests/data/zbot_single_note.enc b/src/importexport/encore/tests/data/zbot_single_note.enc new file mode 100644 index 0000000000000..cd61e4b4cea5d Binary files /dev/null and b/src/importexport/encore/tests/data/zbot_single_note.enc differ diff --git a/src/importexport/encore/tests/environment.cpp b/src/importexport/encore/tests/environment.cpp new file mode 100644 index 0000000000000..a83296f4142c6 --- /dev/null +++ b/src/importexport/encore/tests/environment.cpp @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Test suite bootstrap: registers engraving modules and loads instrument templates for the Encore importer tests. + +#include "testing/environment.h" + +#include "draw/drawmodule.h" +#include "engraving/engravingmodule.h" + +#include "engraving/dom/instrtemplate.h" +#include "engraving/dom/mscore.h" + +#include "log.h" + +static muse::testing::SuiteEnvironment importexport_se( +{ + new muse::draw::DrawModule(), + new mu::engraving::EngravingModule() +}, + nullptr, + []() { + LOGI() << "encore tests suite post init"; + + mu::engraving::MScore::testMode = true; + mu::engraving::MScore::noGui = true; + + mu::engraving::loadInstrumentTemplates(":/engraving/instruments/instruments.xml"); +} + ); diff --git a/src/importexport/encore/tests/testbase.cpp b/src/importexport/encore/tests/testbase.cpp new file mode 100644 index 0000000000000..4e5ae6596b1c5 --- /dev/null +++ b/src/importexport/encore/tests/testbase.cpp @@ -0,0 +1,154 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// MTest fixture implementation: import an .enc file into a MasterScore, save it, and diff against expected output. + +#include "testbase.h" + +#include +#include + +#include "io/file.h" + +#include "engraving/dom/masterscore.h" +#include "engraving/dom/fret.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/segment.h" + +#include "engraving/engravingerrors.h" +#include "engraving/compat/mscxcompat.h" +#include "engraving/compat/scoreaccess.h" +#include "engraving/compat/writescorehook.h" +#include "engraving/infrastructure/localfileinfoprovider.h" +#include "engraving/rw/rwregister.h" + +#include "log.h" + +using namespace mu; +using namespace muse::io; +using namespace mu::engraving; + +namespace mu::iex::enc { +extern Err importEncore(MasterScore* score, const QString& name, const EncImportOptions& opts = EncImportOptions {}); +} + +namespace mu::engraving { +static MasterScore* loadEncore(const QString& path, const iex::enc::EncImportOptions& opts) +{ + MasterScore* score = compat::ScoreAccess::createMasterScoreWithBaseStyle(nullptr); + score->setFileInfoProvider(std::make_shared(path)); + + ScoreLoad sl; + Err rv = iex::enc::importEncore(score, path, opts); + + if (rv != Err::NoError) { + LOGE() << "cannot load file at " << path; + delete score; + return nullptr; + } + for (Score* s : score->scoreList()) { + s->doLayout(); + } + return score; +} + +Harmony* segmentHarmony(const Segment* seg) +{ + if (!seg) { + return nullptr; + } + for (EngravingItem* ann : seg->annotations()) { + if (!ann) { + continue; + } + if (ann->isHarmony()) { + return toHarmony(ann); + } + if (ann->isFretDiagram()) { + if (Harmony* h = toFretDiagram(ann)->harmony()) { + return h; + } + } + } + return nullptr; +} + +MasterScore* MTest::readEncoreScore(const QString& name) +{ + return loadEncore(root + "/" + name, iex::enc::EncImportOptions {}); +} + +MasterScore* MTest::readEncoreScoreWithOpts(const QString& name, + const iex::enc::EncImportOptions& opts) +{ + return loadEncore(root + "/" + name, opts); +} + +bool MTest::saveScore(Score* score, const QString& name) const +{ + File file(name); + if (file.exists()) { + file.remove(); + } + if (!file.open(IODevice::ReadWrite)) { + return false; + } + return rw::RWRegister::writer()->writeScore(score, &file); +} + +bool MTest::compareFilesFromPaths(const QString& f1, const QString& f2) +{ + QString cmd = "diff"; + QStringList args; + args.append("-u"); + args.append("--strip-trailing-cr"); + args.append(f2); + args.append(f1); + QProcess p; + p.start(cmd, args); + if (!p.waitForFinished() || p.exitCode()) { + QByteArray ba = p.readAll(); + QTextStream outputText(stdout); + outputText << QString(ba); + return false; + } + return true; +} + +bool MTest::compareFiles(const QString& saveName, const QString& compareWith) const +{ + return compareFilesFromPaths(saveName, root + "/" + compareWith); +} + +bool MTest::saveCompareScore(Score* score, const QString& saveName, const QString& compareWith) const +{ + if (!saveScore(score, saveName)) { + return false; + } + return compareFiles(saveName, compareWith); +} + +void MTest::setRootDir(const QString& rootDir) +{ + root = rootDir; +} +} // namespace mu::engraving diff --git a/src/importexport/encore/tests/testbase.h b/src/importexport/encore/tests/testbase.h new file mode 100644 index 0000000000000..47f02585ee179 --- /dev/null +++ b/src/importexport/encore/tests/testbase.h @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Shared test fixture for the Encore importer suite: load .enc scores (with or without options) and compare saved output. + +#ifndef MU_IEX_ENCORE_TESTBASE_H +#define MU_IEX_ENCORE_TESTBASE_H + +#include + +#include "../internal/importer/import-options.h" + +namespace mu::engraving { +class MasterScore; +class Score; +class Segment; +class Harmony; + +// Return the chord symbol attached at seg, whether it sits directly on the segment or is +// nested inside a FretDiagram annotation (the importer wraps recognised chords in a +// fretboard diagram). Returns nullptr when the segment carries no chord symbol. +Harmony* segmentHarmony(const Segment* seg); + +class MTest +{ +protected: + QString root; + + MasterScore* readEncoreScore(const QString& name); + MasterScore* readEncoreScoreWithOpts(const QString& name, const mu::iex::enc::EncImportOptions& opts); + bool saveScore(Score*, const QString& name) const; + bool compareFiles(const QString& saveName, const QString& compareWith) const; + bool saveCompareScore(Score*, const QString& saveName, const QString& compareWith) const; + void setRootDir(const QString& root); + +public: + static bool compareFilesFromPaths(const QString& f1, const QString& f2); +}; +} // namespace mu::engraving + +#endif // MU_IEX_ENCORE_TESTBASE_H diff --git a/src/importexport/encore/tests/tst_grace.cpp b/src/importexport/encore/tests/tst_grace.cpp new file mode 100644 index 0000000000000..abf0c773b922c --- /dev/null +++ b/src/importexport/encore/tests/tst_grace.cpp @@ -0,0 +1,292 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Grace and cue notes: grace-before/after placement, dangling graces as cue notes, cue small/mute state, +// and ornaments applied to grace chords. See ENCORE_FORMAT.md §Grace and cue notes. + +#include + +#include "engraving/dom/articulation.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/durationtype.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/segment.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Grace : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +#define ENC_SANITY_TEST(testName, fileName) \ + TEST_F(Tst_Grace, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } + +// Regression: grace chords wrongly attached to a Segment made beam layout assert. Invariant checked here: +// segment-attached chords are NORMAL and graces live in graceNotes(). +TEST_F(Tst_Grace, grace_with_beamed_eighths_no_layout_crash) +{ + MasterScore* score = readEncoreScore("importer_grace_beam.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_grace_beam.enc"; + EXPECT_GT(score->nmeasures(), 0); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + bool foundGrace = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e || !e->isChord()) { + continue; + } + Chord* c = toChord(e); + EXPECT_EQ(c->noteType(), NoteType::NORMAL) + << "Segment-attached chord must be NORMAL; grace chords belong in graceNotes()"; + for (Chord* gc : c->graceNotes()) { + if (gc->noteType() != NoteType::NORMAL) { + foundGrace = true; + } + } + } + } + } + EXPECT_TRUE(foundGrace) << "Grace eighth should be attached as a graceNotes() child"; + delete score; +} + +// A dangling group of acciaccatura graces with no principal chord to ornament must survive as small +// cue notes (normal-type chords), not be discarded. Fixture: four such quarter graces. +TEST_F(Tst_Grace, grace1_0x30_dangling_graces_become_cue_notes) +{ + MasterScore* score = readEncoreScore("importer_grace1_0x30_normal_notes.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + int cueChords = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (e && e->isChord() && toChord(e)->noteType() == NoteType::NORMAL + && !toChord(e)->notes().empty() && toChord(e)->notes().front()->isSmall()) { + ++cueChords; + } + } + } + } + EXPECT_EQ(cueChords, 4) + << "All four dangling grace notes must survive as small cue chords, not be discarded"; + delete score; +} + +// A cue note (grace1 0x20 + grace2 0x01) keeps its full rhythmic value but imports SMALL and MUTED +// (Note::setPlay(false)), in the spare cue voice. +TEST_F(Tst_Grace, cue_note_small_and_muted) +{ + MasterScore* score = readEncoreScore("importer_cue_note.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + int noteCount = 0; + bool smallAndMuted = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e || !e->isChord()) { + continue; + } + for (Note* n : toChord(e)->notes()) { + ++noteCount; + if (n->isSmall() && !n->play()) { + smallAndMuted = true; + } + } + } + } + } + EXPECT_EQ(noteCount, 1) << "the cue note is the only note in the score"; + EXPECT_TRUE(smallAndMuted) << "cue note must be small and muted"; + delete score; +} + +// A quarter with an acciaccatura at a tick inside its span (contiguous, no silence between) imports +// the ornament as a grace-AFTER the quarter (a *_AFTER note type), keeping it in the same bar. +TEST_F(Tst_Grace, acciaccatura_after_contiguous_note) +{ + MasterScore* score = readEncoreScore("importer_grace_after_contiguous.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + bool foundGraceAfter = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e || !e->isChord()) { + continue; + } + for (Chord* gc : toChord(e)->graceNotes()) { + const NoteType nt = gc->noteType(); + if (nt == NoteType::GRACE8_AFTER || nt == NoteType::GRACE16_AFTER + || nt == NoteType::GRACE32_AFTER) { + foundGraceAfter = true; + } + } + } + } + } + EXPECT_TRUE(foundGraceAfter) + << "an acciaccatura contiguous with the preceding note must be a grace-after"; + delete score; +} + +// A quarter trailed only by a grace at a dotted-quarter distance must stay a PLAIN quarter: a grace +// has no rhythmic footprint and must not promote the preceding note to a dotted note. +TEST_F(Tst_Grace, trailing_grace_does_not_dot_preceding_note) +{ + MasterScore* score = readEncoreScore("importer_grace_trailing_no_dot.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + const Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s && !first; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + } + } + ASSERT_NE(first, nullptr); + EXPECT_EQ(first->durationType().type(), DurationType::V_QUARTER) + << "the quarter must not be inflated by the trailing grace"; + EXPECT_EQ(first->dots(), 0) << "a note trailed only by a grace must not become dotted"; + delete score; +} + +// Regression: an articulation byte on a grace note must become an Ornament, not a plain Articulation +// (it was applied before the grace was attached, so ornaments could not be created). +// Fixture: a normal note plus an acciaccatura grace carrying a trill articulation. +TEST_F(Tst_Grace, grace_articulation_becomes_ornament) +{ + MasterScore* score = readEncoreScore("grace_ornament.enc"); + ASSERT_NE(score, nullptr); + + bool foundOrnamentOnGrace = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e || !e->isChord()) { + continue; + } + for (Chord* gc : toChord(e)->graceNotes()) { + for (Articulation* a : gc->articulations()) { + if (a->isOrnament()) { + foundOrnamentOnGrace = true; + } + } + } + } + } + } + EXPECT_TRUE(foundOrnamentOnGrace) + << "trill artic byte on a grace note must become an Ornament, not a plain Articulation"; + + delete score; +} + +// grace2 bit 0x01 is the Encore per-note MUTE flag (independent of size). grace1 bit 0x20 is small. +// One standalone note per bar: m1 cue muted, m2 cue sounding, m3 normal muted, m4 normal. A small +// note alone in its bar is a cue (small, full value); its play state follows the mute flag. +TEST_F(Tst_Grace, cue_mute_flag_and_sounding_cue) +{ + MasterScore* score = readEncoreScore("importer_cue_mute_flags.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + std::vector > perMeasure; // {isSmall, plays} + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord() && !toChord(e)->notes().empty()) { + const Note* n = toChord(e)->notes().front(); + perMeasure.push_back({ n->isSmall(), n->play() }); + break; + } + } + } + ASSERT_GE(perMeasure.size(), 4u); + EXPECT_TRUE(perMeasure[0].first); + EXPECT_FALSE(perMeasure[0].second); // cue muted: small, silent + EXPECT_TRUE(perMeasure[1].first); + EXPECT_TRUE(perMeasure[1].second); // cue sounding: small, plays + EXPECT_FALSE(perMeasure[2].first); + EXPECT_FALSE(perMeasure[2].second); // normal muted: full, silent + EXPECT_FALSE(perMeasure[3].first); + EXPECT_TRUE(perMeasure[3].second); // normal: full, plays + delete score; +} + +// Covers: grace note filtering (fv>=4 only), ACCIACCATURA +ENC_SANITY_TEST(grace_notes, "notes_grace.enc") diff --git a/src/importexport/encore/tests/tst_hairpins.cpp b/src/importexport/encore/tests/tst_hairpins.cpp new file mode 100644 index 0000000000000..feb44b4b22de7 --- /dev/null +++ b/src/importexport/encore/tests/tst_hairpins.cpp @@ -0,0 +1,408 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Hairpins and dynamics: crescendo/diminuendo direction, swell splitting, endpoint/snap-back by xoffset, +// bar-line clamping, dedup, cross-staff displacement, and Latin-1 text decoding. + +#include + +#include "engraving/dom/dynamic.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/stafftext.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Hairpins : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +#define ENC_SANITY_TEST(testName, fileName) \ + TEST_F(Tst_Hairpins, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } + +// Regression: speguleco direction lives in bit 0; Encore 5 also sets bit 1 (0x02=cresc, 0x03=dim). +// Old `speguleco==0` check treated every Encore 5 hairpin as diminuendo. +TEST_F(Tst_Hairpins, v0c4_hairpin_speguleco_bit0) +{ + MasterScore* score = readEncoreScore("importer_hairpin_speguleco_bit0.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_hairpin_speguleco_bit0.enc"; + + std::vector seenTypes; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + seenTypes.push_back(toHairpin(sp)->hairpinType()); + } + } + ASSERT_EQ(seenTypes.size(), 2u); + EXPECT_EQ(seenTypes[0], HairpinType::CRESC_HAIRPIN) + << "speguleco=0x02 must import as crescendo"; + EXPECT_EQ(seenTypes[1], HairpinType::DIM_HAIRPIN) + << "speguleco=0x03 must import as diminuendo"; + delete score; +} + +// Regression: same-measure CRESC+DIM swell pair must split the measure at the midpoint. +TEST_F(Tst_Hairpins, v0c4_swell_pair_splits_at_measure_midpoint) +{ + MasterScore* score = readEncoreScore("importer_hairpin_speguleco_bit0.enc"); + ASSERT_NE(score, nullptr); + + std::vector hairpins; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + hairpins.push_back(toHairpin(sp)); + } + } + std::sort(hairpins.begin(), hairpins.end(), + [](Hairpin* a, Hairpin* b) { return a->tick() < b->tick(); }); + ASSERT_EQ(hairpins.size(), 2u); + + const Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + const Fraction midTick = m0->tick() + m0->ticks() / 2; + + EXPECT_EQ(hairpins[0]->hairpinType(), HairpinType::CRESC_HAIRPIN); + EXPECT_EQ(hairpins[0]->tick2(), midTick) + << "CRESC in same-measure swell pair must end exactly at the measure midpoint"; + + EXPECT_EQ(hairpins[1]->hairpinType(), HairpinType::DIM_HAIRPIN); + EXPECT_EQ(hairpins[1]->tick(), midTick) + << "DIM in same-measure swell pair must start exactly at the measure midpoint"; + EXPECT_EQ(hairpins[1]->tick2(), m0->tick() + m0->ticks()) + << "DIM in same-measure swell pair must end at the barline"; + delete score; +} + +// Regression: importer extended hairpins to the end of their alMezuro measure, overlapping adjacent hairpins. +// Fix: scan forward for the first Dynamic within the alMezuro window and stop there. +TEST_F(Tst_Hairpins, v0c4_hairpin_ends_at_next_dynamic) +{ + MasterScore* score = readEncoreScore("importer_hairpin_ends_at_next_dynamic.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_hairpin_ends_at_next_dynamic.enc"; + + Hairpin* found = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + found = toHairpin(sp); + break; + } + } + ASSERT_NE(found, nullptr); + EXPECT_EQ(found->tick(), Fraction(1, 4)) + << "hairpin must start at the WEDGESTART tick"; + EXPECT_EQ(found->tick2(), Fraction(3, 4)) + << "hairpin must end at the next Dynamic (tick=720, beat 4), " + "not at the bar line of its alMezuro target measure"; + delete score; +} + +// Regression: ORN xoffset < tagged chord-rest xoffset means the glyph belongs to the preceding chord. +TEST_F(Tst_Hairpins, v0c4_dyn_snap_back_by_xoffset) +{ + MasterScore* score = readEncoreScore("importer_dyn_snap_back_by_xoffset.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_dyn_snap_back_by_xoffset.enc"; + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + Dynamic* found = nullptr; + Fraction foundTick; + for (Segment* s = m1->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isDynamic()) { + found = toDynamic(ann); + foundTick = s->tick(); + break; + } + } + if (found) { + break; + } + } + ASSERT_NE(found, nullptr) << "expected one Dynamic in the measure"; + EXPECT_EQ(foundTick, Fraction(1, 8)) + << "dynamic must snap from the tagged eighth (tick 1/4) back to " + "the previous eighth (tick 1/8) because its xoffset matches " + "that note's region"; + delete score; +} + +// Regression: same snap-back convention for WEDGESTART. +TEST_F(Tst_Hairpins, v0c4_wedge_snap_back_by_xoffset) +{ + MasterScore* score = readEncoreScore("importer_wedge_snap_back_by_xoffset.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_wedge_snap_back_by_xoffset.enc"; + + Hairpin* found = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + found = toHairpin(sp); + break; + } + } + ASSERT_NE(found, nullptr) << "expected one Hairpin"; + EXPECT_EQ(found->tick(), Fraction(0, 1)) + << "hairpin must snap back from the tagged eighth (tick 1/2) to " + "the start of the half note (tick 0) because its xoffset is " + "less than the eighth's xoffset"; + delete score; +} + +// Regression: hairpin xoffset2 < first-note xoffset in target measure means Encore ends at the bar line. +TEST_F(Tst_Hairpins, v0c4_hairpin_barline_clamp) +{ + MasterScore* score = readEncoreScore("importer_hairpin_barline_clamp.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_hairpin_barline_clamp.enc"; + + std::vector hairpins; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + hairpins.push_back(toHairpin(sp)); + } + } + std::sort(hairpins.begin(), hairpins.end(), + [](Hairpin* a, Hairpin* b) { return a->tick() < b->tick(); }); + ASSERT_GE(hairpins.size(), 2u); + EXPECT_EQ(hairpins[0]->hairpinType(), HairpinType::DIM_HAIRPIN); + EXPECT_EQ(hairpins[0]->tick2(), Fraction(1, 1)) + << "dim hairpin with xoffset2 < firstNoteXoff must end at bar line"; + EXPECT_EQ(hairpins[1]->hairpinType(), HairpinType::CRESC_HAIRPIN); + EXPECT_GE(hairpins[1]->tick(), Fraction(1, 1)) + << "cresc hairpin must start inside m2, not before bar line"; + delete score; +} + +// Regression: when xoffset2 falls between two notes in the target measure, the hairpin must end +// at the note with the largest xoffset still <= xoffset2. +TEST_F(Tst_Hairpins, v0c4_hairpin_xoffset2_snap) +{ + MasterScore* score = readEncoreScore("importer_hairpin_xoffset2_snap.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_hairpin_xoffset2_snap.enc"; + + std::vector hairpins; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + hairpins.push_back(toHairpin(sp)); + } + } + std::sort(hairpins.begin(), hairpins.end(), + [](Hairpin* a, Hairpin* b) { return a->tick() < b->tick(); }); + ASSERT_GE(hairpins.size(), 1u); + EXPECT_EQ(hairpins[0]->hairpinType(), HairpinType::DIM_HAIRPIN); + EXPECT_EQ(hairpins[0]->tick2(), Fraction(5, 4)) + << "hairpin with xoffset2 between two notes must end at the note with largest xoffset <= xoffset2"; + delete score; +} + +// Regression: Encore sometimes writes duplicate dynamics on the same (staff, voice, tick). +TEST_F(Tst_Hairpins, v0c4_dyn_dedup) +{ + MasterScore* score = readEncoreScore("importer_dyn_dedup.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_dyn_dedup.enc"; + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + int dynCount = 0; + for (Segment* s = m1->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isDynamic()) { + ++dynCount; + } + } + } + EXPECT_EQ(dynCount, 1) + << "two identical MF ORNs at the same tick must collapse to one " + "Dynamic on the segment"; + delete score; +} + +// Regression: dynamic ORN with yoffset > 0 visually belongs to staffIdx-1 (stored on N, rendered on N-1). +TEST_F(Tst_Hairpins, v0c4_dyn_displaced_to_staff_above) +{ + MasterScore* score = readEncoreScore("importer_dyn_displaced_to_staff_above.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_dyn_displaced_to_staff_above.enc"; + + const track_idx_t trackStaff0 = 0; + const track_idx_t trackStaff1 = static_cast(VOICES); + bool foundOnStaff0 = false; + bool foundOnStaff1 = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (!ann || !ann->isDynamic()) { + continue; + } + if (ann->track() == trackStaff0) { + foundOnStaff0 = true; + } + if (ann->track() == trackStaff1) { + foundOnStaff1 = true; + } + } + } + } + EXPECT_TRUE(foundOnStaff0) + << "MF with yoffset > 0 must be rerouted to staff 0 (the staff above)"; + EXPECT_FALSE(foundOnStaff1) + << "the displaced MF must NOT remain on staff 1"; + delete score; +} + +// Regression: WEDGE at tick==durTicks (bar line) had no ChordRest; snap-start returned next measure's tick. +TEST_F(Tst_Hairpins, v0c4_hairpin_snapstart_at_barline) +{ + MasterScore* score = readEncoreScore("importer_hairpin_snapstart_at_barline.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_hairpin_snapstart_at_barline.enc"; + + Hairpin* dim = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin() && toHairpin(sp)->hairpinType() == HairpinType::DIM_HAIRPIN) { + dim = toHairpin(sp); + break; + } + } + ASSERT_NE(dim, nullptr) << "dim hairpin starting at end of m1 must not be dropped"; + EXPECT_LT(dim->tick(), Fraction(1, 1)) + << "hairpin start must be inside m1 (snap from bar-line tick to last " + "note with xoff <= ornament.xoffset)"; + EXPECT_EQ(dim->tick(), Fraction(1, 2)) + << "start must snap to tick=480 (xoff=90, latest note with xoff<=110)"; + EXPECT_EQ(dim->tick2(), Fraction(1, 1) + Fraction(1, 4)) + << "dim must end at the MF dynamic in m2 (next-dynamic endpoint)"; + delete score; +} + +// Regression: bar-line clamp must yield to next-Dynamic endpoint when one exists. +TEST_F(Tst_Hairpins, v0c4_hairpin_endpoint_dynamic_wins) +{ + MasterScore* score = readEncoreScore("importer_hairpin_endpoint_dynamic_wins.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_hairpin_endpoint_dynamic_wins.enc"; + + Hairpin* dim = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin() && toHairpin(sp)->hairpinType() == HairpinType::DIM_HAIRPIN) { + dim = toHairpin(sp); + break; + } + } + ASSERT_NE(dim, nullptr) << "dim hairpin must be present"; + const Fraction m2tick = Fraction(1, 1); + EXPECT_GT(dim->tick2(), m2tick) + << "hairpin must end at MF dynamic in m2 (after bar line), not at m2.tick; " + "next-dynamic endpoint must win over bar-line clamp"; + EXPECT_EQ(dim->tick2(), m2tick + Fraction(1, 4)) + << "hairpin must end at the MF dynamic tick (m2 + 1/4)"; + delete score; +} + +// Regression: ORNs at tick > durTicks (volta-grouped dynamics) were dropped. +TEST_F(Tst_Hairpins, v0c4_two_dynamics_in_one_measure) +{ + MasterScore* score = readEncoreScore("importer_two_dynamics_in_one_measure.enc"); + ASSERT_NE(score, nullptr) + << "Failed to load importer_two_dynamics_in_one_measure.enc"; + + Measure* first = score->firstMeasure(); + ASSERT_NE(first, nullptr); + std::vector dynTypes; + std::vector textValues; + for (Segment* s = first->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (!ann) { + continue; + } + if (ann->isDynamic()) { + dynTypes.push_back(toDynamic(ann)->dynamicType()); + } else if (ann->isStaffText()) { + textValues.push_back(toStaffText(ann)->plainText()); + } + } + } + ASSERT_EQ(dynTypes.size(), 2u) + << "both the start-of-measure F and the end-of-measure PP must import"; + EXPECT_EQ(dynTypes[0], DynamicType::F); + EXPECT_EQ(dynTypes[1], DynamicType::PP); + ASSERT_EQ(textValues.size(), 2u) + << "both volta-specific stafftext labels must import"; + EXPECT_EQ(textValues[0], String(u"la 1ª vez")); + EXPECT_EQ(textValues[1], String(u"la 2ª")); + delete score; +} + +// Regression: TEXT block payloads in legacy Latin-1 files were decoded as UTF-16 LE. +TEST_F(Tst_Hairpins, v0c4_text_block_latin1_decoding) +{ + MasterScore* score = readEncoreScore("text_text_block_latin1_decoding.enc"); + ASSERT_NE(score, nullptr) + << "Failed to load text_text_block_latin1_decoding.enc"; + + StaffText* found = nullptr; + for (MeasureBase* mb = score->first(); mb && !found; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isStaffText()) { + found = toStaffText(ann); + break; + } + } + } + } + ASSERT_NE(found, nullptr) << "expected at least one StaffText from a Latin-1 TEXT entry"; + EXPECT_EQ(found->plainText(), String(u"la 1ª vez")) + << "Latin-1 TEXT payload must decode as readable text, not UTF-16 gibberish"; + delete score; +} + +ENC_SANITY_TEST(dynamics_size16, "ornaments_dynamics.enc") +ENC_SANITY_TEST(dynamics_full, "ornaments_dynamics_full.enc") +ENC_SANITY_TEST(wedgestart_at_measure_end, "ornaments_wedgestart_at_measure_end.enc") +ENC_SANITY_TEST(double_barline_multi_staff, "ornaments_double_barline_multi_staff.enc") diff --git a/src/importexport/encore/tests/tst_instruments.cpp b/src/importexport/encore/tests/tst_instruments.cpp new file mode 100644 index 0000000000000..6e5124a2dc9ae --- /dev/null +++ b/src/importexport/encore/tests/tst_instruments.cpp @@ -0,0 +1,1234 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Instrument resolution: name/MIDI/clef routing to templates, drumset detection, TK-block name/MIDI/Key +// decoding across format variants, key transposition and octave clefs. See ENCORE_IMPORTER.md §Instrument routing. + +#include + +#include "engraving/dom/chord.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftype.h" +#include "engraving/dom/tuplet.h" + +#include "engraving/dom/instrtemplate.h" +#include "importexport/encore/internal/importer/mappers.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Instruments : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// =========================================================================== +// FIX: name + MIDI scoring lets "Bass" + GM 32 (Acoustic Bass) beat the choral Bass template (program 52). +// =========================================================================== +TEST_F(Tst_Instruments, instrument_name_midi_tiebreaks_to_acoustic_bass) +{ + MasterScore* score = readEncoreScore("instruments_instr_bass_midi_tiebreak.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"acoustic-bass")) + << "Bass + program 32 must reach the instrumental template, not the choral voice"; + delete score; +} + +// =========================================================================== +// FIX: v0xA6 stores the MIDI program at TK content+52 (block+60). The v0xA6 reader +// used to skip it, leaving midiProgram=0 (Grand Piano). MIDI=119 is in the GM +// percussive range (>=113) and must route to a drumset. +// =========================================================================== +TEST_F(Tst_Instruments, v0xa6_reads_midi_program_from_tk_block) +{ + MasterScore* score = readEncoreScore("instruments_v0xa6_midi_program.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_NE(inst->drumset(), nullptr) + << "v0xA6 MIDI 119 (>=113) must be read from TK+52 and route to a drumset, not Grand Piano"; + delete score; +} + +// =========================================================================== +// FIX: a name/MIDI match may land on a tablature template variant ("Classical +// Guitar (tablature)"), but Encore stores a normal clef. The importer must swap +// to the standard-notation sibling so the staff is a normal pitched staff. +// =========================================================================== +TEST_F(Tst_Instruments, tab_template_swapped_to_standard_when_clef_not_tab) +{ + MasterScore* score = readEncoreScore("instruments_tab_template_forced_standard.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->staves().empty()); + const StaffType* st = score->staff(0)->staffType(Fraction(0, 1)); + ASSERT_NE(st, nullptr); + EXPECT_NE(st->group(), StaffGroup::TAB) + << "Encore stores a normal clef, so the tablature template must be swapped to standard notation"; + delete score; +} + +// =========================================================================== +// FIX: when Encore stores EncClefType::TAB the importer must swap a standard +// "Classical Guitar" match to the tablature sibling so the staff is tablature. +// =========================================================================== +TEST_F(Tst_Instruments, standard_template_swapped_to_tablature_when_clef_tab) +{ + MasterScore* score = readEncoreScore("instruments_tab_clef_keeps_tablature.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->staves().empty()); + const StaffType* st = score->staff(0)->staffType(Fraction(0, 1)); + ASSERT_NE(st, nullptr); + EXPECT_EQ(st->group(), StaffGroup::TAB) + << "EncClefType::TAB must select the tablature template variant"; + delete score; +} + +// =========================================================================== +// FEATURE: RHYTHM staffType (=2) in LINE block routes to snare-drum regardless of name or midiProgram. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_rhythm_staff_routes_to_snare_drum) +{ + MasterScore* score = readEncoreScore("instruments_rhythm_staff_snare.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"snare-drum")) + << "RHYTHM staffType must route to snare-drum template (step 6), not Grand Piano"; + EXPECT_NE(inst->drumset(), nullptr) + << "Snare-drum instrument must carry a drumset"; + delete score; +} + +// =========================================================================== +// FEATURE: EncClefType::PERC on first staff routes to drumset regardless of name or midiProgram. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_perc_clef_routes_to_drumset) +{ + MasterScore* score = readEncoreScore("instruments_instr_perc_clef_drumset.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"drumset")) + << "PERC clef must override name+MIDI and route to drumset (primary path)"; + delete score; +} + +// =========================================================================== +// FEATURE: keyword fallback path. "Drums" (contains "drum") triggers step 4 and routes +// to drumset regardless of midiProgram=1 (which would otherwise give Grand Piano). +// =========================================================================== +TEST_F(Tst_Instruments, instrument_name_drums_english_routes_to_drumset) +{ + MasterScore* score = readEncoreScore("instruments_instr_drums_name_drumset.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"drumset")) + << "'Drums' must reach drumset via findDrumsetTemplate, not fall back to piano"; + delete score; +} + +// =========================================================================== +// FIX: percussion tracks store midiProgram=1 (Grand Piano) regardless of instrument. +// "Percusión" must reach drumset via findDrumsetTemplate (localized name), not MIDI fallback. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_name_routes_percussion_to_drumset) +{ + MasterScore* score = readEncoreScore("instruments_instr_percussion_drumset.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"drumset")) + << "Encore 'Percusión' must reach the drum-kit template, not the MIDI piano fallback"; + delete score; +} + +// =========================================================================== +// FIX: matching is diacritics-insensitive. Template "Laúd" (id=laud) must be reached +// by files that write "Laud" (no accent), as real corpora frequently do. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_name_diacritics_insensitive_match) +{ + MasterScore* score = readEncoreScore("instruments_instr_laud_accent.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"laud")) + << "Encore 'Laud' must match the laud template whose trackName carries the diacritic"; + delete score; +} + +// =========================================================================== +// FEATURE: TK block instrument name encoding (UTF-16 probe for v0xC4) +// =========================================================================== + +// A large-varsize TK block selects two-byte (UTF-16) name decoding directly, no probe needed. +TEST_F(Tst_Instruments, tk_utf16_name_charsize_reads_full_name) +{ + MasterScore* score = readEncoreScore("instruments_tk_utf16_name.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + + const String longName = score->parts()[0]->longName(); + EXPECT_EQ(longName, String(u"Bandurria")) + << "UTF-16 TK name (charSize=TWO_BYTES by offset) must be fully decoded"; + + delete score; +} + +// A small-varsize TK block defaults to one-byte, but a NUL second byte probes as UTF-16 and upgrades it. +TEST_F(Tst_Instruments, tk_probe_upgrades_onebyte_to_utf16) +{ + MasterScore* score = readEncoreScore("instruments_tk_probe_utf16.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + + const String longName = score->parts()[0]->longName(); + EXPECT_EQ(longName, String(u"Bandurria")) + << "Probe must upgrade ONE_BYTE charSize to TWO_BYTES for UTF-16 content"; + + delete score; +} + +// A non-NUL second byte keeps one-byte (Latin-1) decoding rather than misreading it as UTF-16. +TEST_F(Tst_Instruments, tk_probe_keeps_onebyte_for_latin1) +{ + MasterScore* score = readEncoreScore("instruments_tk_onebyte_name.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "ONE_BYTE TK name file must produce a clean score"; + EXPECT_EQ(score->parts().size(), 1u) + << "ONE_BYTE TK name file must produce exactly 1 part"; + delete score; +} + +// =========================================================================== +// FIX: compact v0xC4 (no TK blocks) stores MIDI at offset 390 (step 276), not the TK formula (offset 2278). +// Also: findTemplateByMidi prefers "common" genre: MIDI 68 is Oboe (common) before Dulzaina (world). +// =========================================================================== +TEST_F(Tst_Instruments, compact_no_tk_midi_reads_from_compact_area) +{ + MasterScore* score = readEncoreScore("instruments_compact_no_tk_midi_oboe.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"oboe")) + << "Compact file (no TK blocks): MIDI 69 must read from compact offset and resolve to oboe"; + delete score; +} + +// =========================================================================== +// FIX: MIDI step 5 must fire even when name is empty. Old guard skipped step 5 for names < 4 chars, +// forcing Grand Piano even with a valid midiProgram. TK00 zeroed + MIDI 43 must resolve to Cello. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_empty_name_midi_resolves_to_cello) +{ + MasterScore* score = readEncoreScore("instruments_instr_empty_name_midi_cello.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"violoncello")) + << "Empty name + MIDI 43 (Cello) must resolve to violoncello via step5, not Grand Piano"; + delete score; +} + +// =========================================================================== +// FIX: a matched template with no (e.g. the bare "recorder" +// template, whose UI name comes from muse_instruments, not instruments.xml) +// must not leave the imported part with an empty track name. The importer +// backfills the track name from the Encore instrument name so the mixer and +// the Instruments panel show a name instead of a blank entry. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_recorder_midi75_keeps_track_name) +{ + MasterScore* score = readEncoreScore("instruments_instr_recorder_midi75_trackname.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"recorder")) + << "MIDI 75 must resolve to the recorder template via step5"; + EXPECT_EQ(inst->trackName(), String(u"Recorder")) + << "recorder template has no trackName; importer must derive the sounding " + "instrument name from the template id, not from the Encore part label"; + EXPECT_EQ(inst->nameAsPlainText(), String(u"Txistu")) + << "the Encore instrument name stays as the part long name"; + delete score; +} + +// =========================================================================== +// FIX: Bb clarinet (MIDI 72, Key=0) must not fall back to Grand Piano, and +// its transposition must be zeroed (encKey=0 means 'sounds as written' in +// Encore so no chromatic shift should be applied at display time). +// =========================================================================== +TEST_F(Tst_Instruments, instrument_clarinet_midi72_key0_resolves_not_piano) +{ + MasterScore* score = readEncoreScore("instruments_instr_clarinet_midi72_key0.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"bb-clarinet")) + << "Clarinete + MIDI 72 + Key=0 must resolve to bb-clarinet (step2), not Grand Piano"; + EXPECT_EQ(inst->transpose().chromatic, 0) + << "encKey=0 means Encore stores written pitch with no shift; template transposition " + "must be zeroed so notes display at their Encore written pitch"; + delete score; +} + +// =========================================================================== +// FIX: nameless Bb clarinet (MIDI 72, Key=0) must not fall back to Grand Piano. +// When name is too short to trigger step 2, only step 5 (MIDI-only) can fire. +// The old transposition filter in step 5 rejected bb-clarinet (tmplChr=-2) +// whenever encKey==0, causing piano fallback. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_empty_name_midi_clarinet_resolves_not_piano) +{ + MasterScore* score = readEncoreScore("instruments_instr_empty_name_midi_clarinet.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"bb-clarinet")) + << "Empty name + MIDI 72 + Key=0 must resolve to bb-clarinet (step5), not Grand Piano"; + delete score; +} + +// =========================================================================== +// REGRESSION: Bb clarinet with Key=-2 (correctly configured) must still work. +// Guards against a fix for Key=0 accidentally breaking the correct-Key path. +// =========================================================================== +TEST_F(Tst_Instruments, instrument_clarinet_midi72_key_neg2_resolves_correctly) +{ + MasterScore* score = readEncoreScore("instruments_instr_clarinet_midi72_key_neg2.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"bb-clarinet")) + << "Clarinete + MIDI 72 + Key=-2 must still resolve to bb-clarinet via transposition filter"; + delete score; +} + +// =========================================================================== +// FIX: compact v0xC4 with short header (LINE block before offset 390) must not read MIDI from inside LINE. +// Byte 0x30=48 at offset 390 is LINE layout data; guard must fall back to Grand Piano. +// =========================================================================== +TEST_F(Tst_Instruments, compact_short_header_ignores_line_data_as_midi) +{ + MasterScore* score = readEncoreScore("instruments_compact_short_header_no_midi.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_NE(inst->id(), String(u"timpani")) + << "Byte 0x30=48 inside LINE block must NOT select Timpani; no MIDI program in pre-LINE area"; + delete score; +} + +TEST_F(Tst_Instruments, instrument_name_recovery_without_tk_block) +{ + // instrumentCount=2, 1 TK block (TK00 "Bandurria"). "Guitarra" has no TK04 header but its + // UTF-16 LE name is at NAME_BASE + 1*NAME_STEP; importer must detect and recover it. + MasterScore* score = readEncoreScore("instruments_name_recovery.enc"); + ASSERT_NE(score, nullptr); + ASSERT_GE(score->parts().size(), 2u) + << "Both instruments must be created (1 TK block + 1 recovered)"; + + const String part1Name = score->parts()[1]->longName(); + EXPECT_EQ(part1Name, String(u"Guitarra")) + << "Instrument name must be recovered from formula position, not 'Soprano Guitar'"; + + delete score; +} + +TEST_F(Tst_Instruments, staff_hidden_flag) +{ + // LINE staffData byte +19 = showByte; 0x00 means hidden. Importer must call part->setShow(false). + MasterScore* score = readEncoreScore("instruments_staff_hidden.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + + EXPECT_FALSE(score->parts()[0]->show()) + << "Staff with showByte=0x00 must be hidden (part->show()==false)"; + + delete score; +} + +TEST_F(Tst_Instruments, instrument_count_padding) +{ + // header instrumentCount=2 but only 1 TK block. Instruments vector must be padded to instrumentCount + // so both parts are created (padded entry uses MIDI fallback). + MasterScore* score = readEncoreScore("instruments_instrument_count_padding.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "score must be clean: " << ret.text(); + + EXPECT_EQ(score->parts().size(), 2u) + << "Both instruments must be imported even when only 1 TK block exists"; + + delete score; +} + +TEST_F(Tst_Instruments, transposition_filter_prefers_compatible_key) +{ + // The transposition filter is a preference, not a hard rejection: when no compatible template exists it + // falls back to the best name+MIDI match rather than returning nullptr (which would give Grand Piano). + MasterScore* score = readEncoreScore("instruments_instrument_count_padding.enc"); + ASSERT_NE(score, nullptr); + delete score; + + using namespace mu::iex::enc; + + const InstrumentTemplate* filtered = findEncoreInstrumentTemplate( + QStringLiteral("Dulzaina 2"), -1, 0); + ASSERT_NE(filtered, nullptr) + << "When no transposition-compatible match exists, must fall back to best name match " + "(not nullptr) to avoid Grand Piano fallback"; + EXPECT_EQ(filtered->transpose.chromatic, 6) + << "Castilian Dulzaina (chromatic=6) is the only dulzaina template, so it is the fallback"; + + const InstrumentTemplate* unfiltered = findEncoreInstrumentTemplate( + QStringLiteral("Dulzaina 2"), -1); + ASSERT_NE(unfiltered, nullptr); + EXPECT_EQ(unfiltered->transpose.chromatic, 6) + << "Unfiltered call must also find Castilian Dulzaina (chromatic=6)"; +} + +// Regression: a distinctive name match (a needle unique to a single template, e.g. +// "Dulzaina" -> only "Castilian Dulzaina") must NOT be overridden by the MIDI program. +// The instrument is named "Dulzaina" with MIDI 69 (Oboe); before the fix the contains-only +// match was treated as weak and replaced by Oboe. Ambiguous substrings ("Bajo") still defer +// to MIDI; see instrument_name_midi_tiebreaks_to_acoustic_bass and the tuba test. +TEST_F(Tst_Instruments, unique_name_match_not_overridden_by_midi) +{ + MasterScore* score = readEncoreScore("instruments_unique_name_beats_midi.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_TRUE(inst->id().contains(String(u"dulzaina"))) + << "unique name 'Dulzaina' must win over the Oboe MIDI program; got " + << inst->id().toStdString(); + delete score; +} + +// FEATURE: last-resort fuzzy (edit-distance) name match for spelling typos the exact/contains +// search misses. "Clarynet" (one substitution from "Clarinet") has no substring match and no +// MIDI program, so without the fuzzy pass it falls back to Grand Piano; it must map to a clarinet. +TEST_F(Tst_Instruments, fuzzy_name_match_typo) +{ + MasterScore* score = readEncoreScore("instruments_fuzzy_name_match.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_TRUE(inst->id().contains(String(u"clarinet"))) + << "typo 'Clarynet' must fuzzy-match a clarinet, not fall back to piano; got " + << inst->id().toStdString(); + delete score; +} + +// When a file has no TK blocks, instrument names are recovered from fixed offsets; the "Part N" fallback +// must be applied only after recovery so it does not pre-empt a name present in the file. +TEST_F(Tst_Instruments, no_tk_blocks_name_recovered_from_fixed_offset) +{ + MasterScore* score = readEncoreScore("instruments_no_tk_name_recovered.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Part* part = score->parts().front(); + ASSERT_NE(part, nullptr); + const QString longName = part->longName().toQString(); + EXPECT_EQ(longName, QString("Dulzaina")) + << "Instrument name 'Dulzaina' stored at NAME_BASE=202 (UTF-16) must be " + "recovered when TK blocks are absent; without fix it stays 'Part 1'"; + delete score; +} + +TEST_F(Tst_Instruments, no_tk_blocks_name_recovered_latin1_encoding) +{ + // instruments_no_tk_name_latin1.enc: TK00 magic zeroed, "Tamboril" written + // as Latin-1 (b0='T', b1='a' → not UTF-16, is Latin-1) at NAME_BASE=202. + MasterScore* score = readEncoreScore("instruments_no_tk_name_latin1.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Part* part = score->parts().front(); + ASSERT_NE(part, nullptr); + const QString longName = part->longName().toQString(); + EXPECT_EQ(longName, QString("Tamboril")) + << "Latin-1 instrument name at NAME_BASE=202 must be recovered when TK blocks absent"; + delete score; +} + +TEST_F(Tst_Instruments, no_tk_blocks_name_falls_back_to_part_n_when_not_recoverable) +{ + // instruments_no_tk_name_fallback.enc: TK00 magic zeroed, offset 202 is 0x00 + // (b0 < 0x20 → recoverMissingNames skips it). "Part 1" fallback must fire. + MasterScore* score = readEncoreScore("instruments_no_tk_name_fallback.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Part* part = score->parts().front(); + ASSERT_NE(part, nullptr); + const QString longName = part->longName().toQString(); + EXPECT_FALSE(longName.isEmpty()) + << "Part must have a non-empty name even when name recovery fails"; + delete score; +} + +TEST_F(Tst_Instruments, small_tk_midi_read_from_correct_offset) +{ + // smallTK layout stores the MIDI program at a different offset; reading the wrong one lands in the + // zero-padded name area (program 0 -> Grand Piano). MIDI 49 must resolve to a non-Piano template. + MasterScore* score = readEncoreScore("instruments_small_tk_midi49.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_NE(inst->id(), String(u"grand-piano")) + << "MIDI 49 must be read from smallTK offset (contentFilePos+offset+76=390), " + "not zero-padded name area; result must not be Grand Piano fallback"; + delete score; +} + +TEST_F(Tst_Instruments, small_tk_key_read_from_correct_offset) +{ + // smallTK layout stores the Key at a distinct offset the reader used to skip; +6 must be read and applied. + MasterScore* score = readEncoreScore("instruments_small_tk_key6.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->transpose().chromatic, 6) + << "Key=6 must be read from smallTK offset (contentFilePos+varSize+53=367)"; + delete score; +} + +TEST_F(Tst_Instruments, total_size_tk_midi_read_from_content_offset) +{ + // Encore 4.x TK varSize is the TOTAL block size, so the stride equals varSize and MIDI sits at + // content[60]. Both instruments' programs must be read from there, not the 5.x-layout offset. + MasterScore* score = readEncoreScore("instruments_total_size_tk_two_instrs.enc"); + ASSERT_NE(score, nullptr); + ASSERT_GE(static_cast(score->parts().size()), 2) + << "File has 2 instruments; both must be parsed"; + const Instrument* inst0 = score->parts()[0]->instrument(); + const Instrument* inst1 = score->parts()[1]->instrument(); + ASSERT_NE(inst0, nullptr); + ASSERT_NE(inst1, nullptr); + EXPECT_NE(inst0->id(), String(u"grand-piano")) + << "MIDI=49 must be read from total-size TK content[60]; must not fall back to Grand Piano"; + EXPECT_NE(inst1->id(), String(u"grand-piano")) + << "MIDI=34 must be read from total-size TK content[60]; must not fall back to Grand Piano"; + delete score; +} + +// An empty name on a real TK block is authoritative: the importer must fall back to "Part N" rather than +// probe the formula offset (where unrelated bytes would produce a garbage name). +TEST_F(Tst_Instruments, tk_empty_name_is_authoritative_not_recovered) +{ + MasterScore* score = readEncoreScore("instruments_tk_empty_name_authoritative.enc"); + ASSERT_NE(score, nullptr); + ASSERT_GE(static_cast(score->parts().size()), 2) + << "File declares 2 instruments; both must appear as parts"; + EXPECT_EQ(score->parts()[1]->longName(), String(u"Part 2")) + << "An empty name on a real TK block is authoritative; instrument 1 " + "must fall back to 'Part 2', not recover garbage from the formula offset"; + delete score; +} + +TEST_F(Tst_Instruments, no_tk_blocks_reads_midi_and_key_from_large_tk_offsets) +{ + // instruments_no_tk_blocks_midi_key.enc: TK00 magic zeroed, MIDI=69 at 2278, Key=6 at 2255. + // Expected: oboe template selected (MIDI 68 0-indexed = oboe), transposition +6. + MasterScore* score = readEncoreScore("instruments_no_tk_blocks_midi_key.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"oboe")) + << "MIDI=69 (oboe) must be read from large-TK offset 2278 even when TK blocks are absent"; + EXPECT_EQ(inst->transpose().chromatic, 6) + << "Key=6 must be read from large-TK offset 2255 and applied as transposition"; + delete score; +} + +TEST_F(Tst_Instruments, no_tk_blocks_large_tk_layout_reads_all_instrument_names) +{ + // No-TK files with a large-TK layout must use the large stride when recovering names, or the second + // instrument reads from empty bytes and falls back to "Part 2". + MasterScore* score = readEncoreScore("instruments_no_tk_large_tk_two_names.enc"); + ASSERT_NE(score, nullptr); + ASSERT_GE(static_cast(score->parts().size()), 2) + << "File declares 2 instruments; both must appear as parts"; + const QString name0 = score->parts()[0]->longName().toQString(); + const QString name1 = score->parts()[1]->longName().toQString(); + EXPECT_EQ(name0, QString("Oboe")) + << "Instrument 0 name must be read from NAME_BASE=202 with step=2158"; + EXPECT_EQ(name1, QString("Cello")) + << "Instrument 1 name must be read from NAME_BASE+2158=2360 (not 202+112=314)"; + delete score; +} + +// A drumset staff (GM percussion range) must get a percussion clef, overriding the pitched clef stored in +// the LINE block. See ENCORE_IMPORTER.md §Instruments in the GM Percussive range (MIDI programs 113 to 128). +TEST_F(Tst_Instruments, gm_perc_range_drumset_staff_gets_perc_clef) +{ + MasterScore* score = readEncoreScore("instruments_gm_perc_range_taiko.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->staves().empty()); + + const Staff* st = score->staff(0); + ASSERT_NE(st, nullptr); + + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + Segment* cs = m0->findSegment(SegmentType::HeaderClef, m0->tick()); + ASSERT_NE(cs, nullptr) << "First measure must have a header clef segment"; + + bool foundPerc = false; + for (EngravingItem* el : cs->elist()) { + if (el && el->isClef()) { + const Clef* clef = toClef(el); + if (clef->clefType() == ClefType::PERC || clef->clefType() == ClefType::PERC2) { + foundPerc = true; + } + } + } + EXPECT_TRUE(foundPerc) + << "Drumset staff (GM prg=116) must use percussion clef, " + "not the LINE-block clef (C3L/C4L/F)"; + + delete score; +} + +// Genuine simultaneous chord tones on a percussion staff must survive the short-rdur MIDI-artifact filter. +// The first on-staff note and chord extensions bypass the filter so a close-tick chord is not thinned. +TEST_F(Tst_Instruments, gm_perc_chord_notes_not_dropped_by_artifact_filter) +{ + MasterScore* score = readEncoreScore("instruments_gm_perc_chord_notes.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + + Segment* firstSeg = m0->first(SegmentType::ChordRest); + ASSERT_NE(firstSeg, nullptr); + EngravingItem* el = firstSeg->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isChord()); + + const Chord* chord = toChord(el); + EXPECT_EQ(static_cast(chord->notes().size()), 2) + << "Both chord notes (pit=60 and pit=64) must survive the MIDI artifact " + "filter: note@0 is the first on-staff note (bypass: savedPrevMidiTick<0) " + "and note@5 is a chord extension (bypass: isChordExt=true)"; + + delete score; +} + +// A MIDI program in the GM percussive range with a name matching no template must route to drumset, +// not fall back to Grand Piano. +TEST_F(Tst_Instruments, gm_perc_range_midi_program_routes_to_drumset) +{ + MasterScore* score = readEncoreScore("instruments_gm_perc_range_taiko.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"drumset")) + << "MIDI program 116 (Taiko Drum, GM Percussive range 113-128) must route " + "to drumset even when the instrument name matches nothing"; + EXPECT_NE(inst->drumset(), nullptr) + << "Drumset instrument must carry a drumset object"; + delete score; +} + +// encKey=0 ("sounds as written") must zero even an octave template transposition (e.g. acoustic-bass -12), +// so notes display at Encore's written pitch. See ENCORE_IMPORTER.md §Per-instrument Key transposition. +TEST_F(Tst_Instruments, key0_zeroes_octave_template_transposition) +{ + MasterScore* score = readEncoreScore("instruments_bass_enckey0_no_octave_transpos.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->transpose().chromatic, 0) + << "encKey=0 (sounds as written) must zero the template's octave transposition (-12) " + "so notes display at Encore's written pitch, not one octave higher"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_TRUE(el && el->isChord()); + EXPECT_EQ(toChord(el)->notes().front()->pitch(), 45) + << "Concert pitch A2(45) must be stored unchanged; octave shift from template must not apply"; + + delete score; +} + +// MIDI lookup must use only each template's primary channel, so MIDI 44 (Tremolo Strings) does not match +// acoustic-bass via its tremolo secondary channel. +TEST_F(Tst_Instruments, midi44_does_not_resolve_to_acoustic_bass_via_tremolo_channel) +{ + MasterScore* score = readEncoreScore("instruments_compact_no_tk_midi_oboe.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"oboe")) + << "MIDI 69 must resolve to oboe; acoustic-bass must not match via tremolo channel"; + delete score; +} + +// Trailing punctuation must be stripped from the name needle so "Bandurr. I" matches Bandurria. +TEST_F(Tst_Instruments, abbreviated_name_with_trailing_dot_matches_bandurria) +{ + MasterScore* score = readEncoreScore("instruments_abbreviated_name_bandurr.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts().front()->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"bandurria")) + << "Name 'Bandurr. I' must resolve to bandurria after punctuation stripping"; + delete score; +} + +// v0xC2 files without a ~~~~ block use a distinct compact-table layout; using the ~~~~ stride/offsets +// shifts each instrument's name and MIDI by one entry. Names must stay on their own instrument. +TEST_F(Tst_Instruments, c2_no_tilde_compact_instr1_name_not_duplicated_to_instr0) +{ + MasterScore* score = readEncoreScore("instruments_c2_no_tilde_compact_names_midi.enc"); + ASSERT_NE(score, nullptr); + ASSERT_EQ(score->parts().size(), 3u); + EXPECT_EQ(score->parts()[1]->longName(), String(u"Guitarra")) + << "Name 'Guitarra' must be assigned to instrument [1], not [0]"; + EXPECT_NE(score->parts()[0]->longName(), String(u"Guitarra")) + << "Instrument [0] must not receive instrument [1]'s name"; + delete score; +} + +TEST_F(Tst_Instruments, c2_no_tilde_compact_midi_assigned_to_correct_instr) +{ + // Same file: MIDI programs must not be shifted by one entry, so adjacent instruments do not collapse + // to the same template. + MasterScore* score = readEncoreScore("instruments_c2_no_tilde_compact_names_midi.enc"); + ASSERT_NE(score, nullptr); + ASSERT_EQ(score->parts().size(), 3u); + const String id0 = score->parts()[0]->instrument()->id(); + const String id1 = score->parts()[1]->instrument()->id(); + const String id2 = score->parts()[2]->instrument()->id(); + EXPECT_NE(id0, id1) << "Instrument [0] must not match instrument [1] (MIDI shifted bug)"; + EXPECT_NE(id1, id2) << "Instrument [1] must not match instrument [2]"; + delete score; +} + +// In v0xC2 ~~~~-block files, an instrument whose name block has printable ASCII must still have its MIDI +// read from block+60 rather than being skipped (which left midiProgram 0 -> Grand Piano). +TEST_F(Tst_Instruments, c2_tilde_primary_block_midi_read_from_offset_60) +{ + MasterScore* score = readEncoreScore("instruments_c2_tilde_primary_block_midi.enc"); + ASSERT_NE(score, nullptr); + ASSERT_EQ(score->parts().size(), 1u); + EXPECT_NE(score->parts()[0]->instrument()->id(), String(u"grand-piano")) + << "Primary-block MIDI=25 at block+60 must be read; Grand Piano means midiProgram stayed 0"; + delete score; +} +TEST_F(Tst_Instruments, orchestra_loads_with_all_parts) +{ + MasterScore* score = readEncoreScore("kordorkestro.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->parts().size(), 1u) << "Orchestra should have multiple parts"; + EXPECT_GT(score->nstaves(), 1u) << "Orchestra should have multiple staves"; + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Instruments, orchestra_sanity_check) +{ + MasterScore* score = readEncoreScore("kordorkestro.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "kordorkestro should pass sanityCheck: " << ret.text(); + delete score; +} + +// Positive octave Key: a clef is NOT decorated with an 8va (octave-up clefs are rare and Encore +// shows such instruments with a plain clef). The octave becomes a playback transposition, so the +// staff keeps its plain G clef and the notes stay at their written height. +TEST_F(Tst_Instruments, v0c4_positive_octave_key_keeps_g_clef) +{ + MasterScore* score = readEncoreScore("structure_g_clef_8va_from_key.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->findSegment(SegmentType::HeaderClef, m->tick()); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_TRUE(el && el->isClef()); + EXPECT_EQ(toClef(el)->clefType(), ClefType::G) + << "G clef + Key=+12 must keep a plain G clef (no 8va)"; + ASSERT_FALSE(score->parts().empty()); + EXPECT_EQ(score->parts()[0]->instrument()->transpose().chromatic, 12) + << "positive octave is carried as a playback transposition (+12)"; + delete score; +} + +// Binary-driven clef rule: F clef + Key=-12 -> F8_VB. No template required. +TEST_F(Tst_Instruments, v0c4_f_clef_8vb_from_key) +{ + MasterScore* score = readEncoreScore("structure_f_clef_8vb_from_key.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->findSegment(SegmentType::HeaderClef, m->tick()); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_TRUE(el && el->isClef()); + EXPECT_EQ(toClef(el)->clefType(), ClefType::F8_VB) + << "F clef + Key=-12 must yield F8_VB"; + delete score; +} + +// Positive octave Key with an F clef (the tuba "Bajo" case): keep a plain F clef and carry the +// octave as a playback transposition, so the bass instrument reads as clave de fa, not F8va. +TEST_F(Tst_Instruments, v0c4_positive_octave_key_keeps_f_clef) +{ + MasterScore* score = readEncoreScore("structure_f_clef_8va_from_key.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->findSegment(SegmentType::HeaderClef, m->tick()); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_TRUE(el && el->isClef()); + EXPECT_EQ(toClef(el)->clefType(), ClefType::F) + << "F clef + Key=+12 must keep a plain F clef (no 8va)"; + ASSERT_FALSE(score->parts().empty()); + EXPECT_EQ(score->parts()[0]->instrument()->transpose().chromatic, 12) + << "positive octave is carried as a playback transposition (+12)"; + delete score; +} + +// Tokenizer: a trailing ordinal after a non-space separator ("Trumpet-1") must be stripped so +// the base name "Trumpet" still matches. MIDI 41 (Violin) is the wrong answer if it is not. +TEST_F(Tst_Instruments, instrument_name_trailing_number_after_dash_stripped) +{ + MasterScore* score = readEncoreScore("instruments_name_trailing_number.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts()[0]->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_TRUE(inst->id().contains(String(u"trumpet"))) + << "\"Trumpet-1\" must match a Trumpet (trailing \"-1\" stripped), not the MIDI fallback; got " + << inst->id().toStdString(); + delete score; +} + +// Tokenizer: words split on '-' (not only spaces), so "French-Horn" yields the needle "horn" +// and matches the Horn template. MIDI 41 (Violin) is the wrong answer if the split fails. +TEST_F(Tst_Instruments, instrument_name_splits_on_dash_separator) +{ + MasterScore* score = readEncoreScore("instruments_name_dash_separator.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts()[0]->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_TRUE(inst->id().contains(String(u"horn"))) + << "\"French-Horn\" must split on '-' and match a Horn, not the MIDI fallback; got " + << inst->id().toStdString(); + delete score; +} + +// Weak (substring-only) name match must not let a treble bugle sharing the tuba's MIDI program +// outrank the GM instrument. "Contrabass" + MIDI 59 (Tuba) resolves to a bass-clef instrument, +// not the treble "Contrabass Bugle". Mirrors the Spanish "Bajo" -> "Clarín contrabajo" case. +TEST_F(Tst_Instruments, instrument_weak_substring_name_defers_to_midi) +{ + MasterScore* score = readEncoreScore("instruments_weak_name_defers_to_midi.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + const Instrument* inst = score->parts()[0]->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_NE(inst->id(), String(u"contrabass-bugle")) + << "weak substring name match must not pick the treble bugle"; + EXPECT_TRUE(inst->id().contains(String(u"tuba"))) + << "must defer to MIDI program 59 (Tuba); got " << inst->id().toStdString(); + delete score; +} + +// Binary-driven clef rule: G clef + Key=-7 (non-octave) -> plain G. +TEST_F(Tst_Instruments, v0c4_non_octave_key_keeps_clef) +{ + MasterScore* score = readEncoreScore("structure_non_octave_key_keeps_clef.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->findSegment(SegmentType::HeaderClef, m->tick()); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_TRUE(el && el->isClef()); + EXPECT_EQ(toClef(el)->clefType(), ClefType::G) + << "G clef + Key=-7 (non-octave) must keep plain G"; + delete score; +} + +// Regression: laud has plain G clef in Encore but Key=-12. Binary-driven rule maps G+Key=-12 -> G8_VB. +TEST_F(Tst_Instruments, v0c4_octave_bassa_clef_override) +{ + MasterScore* score = readEncoreScore("structure_octave_bassa_clef_override.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_octave_bassa_clef_override.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + ASSERT_EQ(score->parts().size(), 1u); + const Instrument* inst = score->parts()[0]->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"laud")); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* clefSeg = m->findSegment(SegmentType::HeaderClef, m->tick()); + ASSERT_NE(clefSeg, nullptr) << "header clef segment missing"; + EngravingItem* clefEl = clefSeg->element(0); + ASSERT_NE(clefEl, nullptr); + ASSERT_TRUE(clefEl->isClef()); + EXPECT_EQ(toClef(clefEl)->clefType(), ClefType::G8_VB) + << "laud staff must carry G8_VB (template clef), not the plain G stored by Encore"; + + Chord* firstChord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + firstChord = toChord(el); + break; + } + } + ASSERT_NE(firstChord, nullptr); + ASSERT_EQ(firstChord->notes().size(), 1u); + EXPECT_EQ(firstChord->notes()[0]->pitch(), 76 - 12); + delete score; +} + +// Regression: bass guitar F clef + Key=-12 must yield F8_VB (binary-driven rule). +TEST_F(Tst_Instruments, v0c4_bass_guitar_transposing_clef) +{ + MasterScore* score = readEncoreScore("instruments_bass_guitar_transposing_clef.enc"); + ASSERT_NE(score, nullptr) << "Failed to load instruments_bass_guitar_transposing_clef.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + ASSERT_EQ(score->parts().size(), 1u); + const Instrument* inst = score->parts()[0]->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->transpose().chromatic, -12) + << "matched instrument template must be a bass-guitar variant (transposeChromatic = -12)"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* clefSeg = m->findSegment(SegmentType::HeaderClef, m->tick()); + ASSERT_NE(clefSeg, nullptr); + EngravingItem* clefEl = clefSeg->element(0); + ASSERT_NE(clefEl, nullptr); + ASSERT_TRUE(clefEl->isClef()); + EXPECT_EQ(toClef(clefEl)->clefType(), ClefType::F8_VB) + << "F clef + Key=-12 must yield F8_VB (binary-driven rule); " + "template transposing clef (plain F) is no longer preferred"; + + Chord* firstChord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + firstChord = toChord(el); + break; + } + } + ASSERT_NE(firstChord, nullptr); + ASSERT_EQ(firstChord->notes().size(), 1u); + EXPECT_EQ(firstChord->notes()[0]->pitch(), 45 - 12) + << "Key = -12 applied: binary 45 -> m_pitch 33 (sounding A1)"; + delete score; +} + +// Regression: Key offset (signed int8 at PRG_BASE-23+n*PRG_STEP) must be applied per-staff. +TEST_F(Tst_Instruments, v0c4_key_transposition_per_staff) +{ + MasterScore* score = readEncoreScore("structure_key_per_staff.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_key_per_staff.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto firstChordTopPitch = [m](int staffIdx) -> int { + const track_idx_t base = staffIdx * VOICES; + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* el = s->element(base + v); + if (el && el->isChord()) { + return toChord(el)->upNote()->pitch(); + } + } + } + return -1; + }; + + EXPECT_EQ(firstChordTopPitch(0), 69) + << "staff 0 first chord top pitch (Key=0): expected 69"; + EXPECT_EQ(firstChordTopPitch(1), 69 - 12) + << "staff 1 first chord top pitch (Key=-12): expected 57"; + delete score; +} + +// Regression: voice >= VOICES (e.g. voice=4) was silently dropped; fix maps out-of-range voices to voice 0. +// Also: short names (1-3 chars) skip name+MIDI matcher and fall through to Grand Piano template. +TEST_F(Tst_Instruments, v0c4_satb_short_names_with_voice4_bass_lyrics) +{ + MasterScore* score = readEncoreScore("text_satb_short_names_voice4_lyrics.enc"); + ASSERT_NE(score, nullptr) << "Failed to load text_satb_short_names_voice4_lyrics.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + const int totalStaves = static_cast(score->nstaves()); + ASSERT_EQ(totalStaves, 4) << "score must carry 4 staves (SATB)"; + const int bassStaff = totalStaves - 1; + + int bassChords = 0; + int bassLyrics = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* el = s->element(bassStaff * VOICES + v); + if (el && el->isChord()) { + Chord* c = toChord(el); + ++bassChords; + bassLyrics += static_cast(c->lyrics().size()); + } + } + } + } + EXPECT_GE(bassChords, 4) << "bass staff must carry the four voice-4 chords"; + EXPECT_GE(bassLyrics, 4) << "bass lyrics must attach to the voice-0 chords"; + + ASSERT_EQ(score->parts().size(), 4u); + const String expectedLabels[] = { u"S", u"C", u"T", u"B" }; + for (size_t i = 0; i < 4; ++i) { + const Part* part = score->parts()[i]; + const Instrument* inst = part->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"grand-piano")) + << "part " << i << " (label '" << expectedLabels[i].toStdString() + << "'): short names must fall back to Grand Piano"; + EXPECT_EQ(part->longName(), expectedLabels[i]) + << "the original Encore label is preserved as longName"; + } + delete score; +} + +// Oboe with keyTransposeSemitones=5: instrument transposition must be chromatic=5, diatonic=3. +TEST_F(Tst_Instruments, key_transposition_non_octave_oboe) +{ + MasterScore* score = readEncoreScore("importer_transp_oboe_jota.enc"); + ASSERT_NE(score, nullptr); + Staff* staff = score->staff(0); + ASSERT_NE(staff, nullptr); + const Interval iv = staff->part()->instrument()->transpose(); + EXPECT_EQ(iv.chromatic, 5); + EXPECT_EQ(iv.diatonic, 3); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + delete score; +} + +// Must import cleanly: correct measure count, no DOM corruption. +TEST_F(Tst_Instruments, percussion_drum_kit_no_crash) +{ + MasterScore* score = readEncoreScore("importer_perc_bateria.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->nmeasures(), 36); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + delete score; +} + +// Regression: compact-TK files (varsize=112) don't follow PRG_BASE+n*PRG_STEP for Key bytes. +TEST_F(Tst_Instruments, v0c4_compact_tk_ignores_key_byte) +{ + MasterScore* score = readEncoreScore("instruments_compact_tk_ignores_key_byte.enc"); + ASSERT_NE(score, nullptr) << "Failed to load instruments_compact_tk_ignores_key_byte.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Chord* firstChord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + firstChord = toChord(el); + break; + } + } + ASSERT_NE(firstChord, nullptr); + ASSERT_EQ(firstChord->notes().size(), 1u); + EXPECT_EQ(firstChord->notes()[0]->pitch(), 76) + << "compact-TK file: garbage Key byte at formula offset must be " + "ignored; imported pitch stays at binary 76 (not 76+8=84)"; + delete score; +} + +// =========================================================================== +// FIX: the importer never rebuilt the MIDI mapping, so every part's channel +// stayed at -1. Reading the score into an .mscz hides this (the file read path +// rebuilds the mapping on load), but exporting straight to MusicXML calls +// Part::midiPort(), which indexes m_midiMapping[-1] and crashes. After import, +// every part must own a valid (>= 0) MIDI channel and a safe MIDI port. +// =========================================================================== +TEST_F(Tst_Instruments, midi_mapping_is_built_for_every_part) +{ + MasterScore* score = readEncoreScore("instruments_compact_tk_ignores_key_byte.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + for (const Part* part : score->parts()) { + const Instrument* inst = part->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_GE(inst->channel(0)->channel(), 0) + << "import must assign a MIDI channel; -1 makes midiPort() index out of bounds"; + EXPECT_GE(part->midiPort(), 0) + << "midiPort() must be valid so MusicXML export does not crash"; + } + delete score; +} + +// Name-confidence matcher tests, calling the matcher directly. A confident match (exact, unique, or an +// unambiguous normalized/plural/fuzzy match) must be flagged confident so the weak-name -> MIDI override +// does not discard it for a placeholder program. +TEST_F(Tst_Instruments, plural_name_depluralized_and_unique) +{ + using namespace mu::iex::enc; + bool exact = false, unique = false; + // A plural name must collapse to its singular stem and match the unique template confidently. + const InstrumentTemplate* t = findEncoreInstrumentTemplate( + QStringLiteral("Bandurrias"), -1, ENC_KEY_NO_FILTER, &exact, &unique); + ASSERT_NE(t, nullptr); + EXPECT_EQ(t->id, String(u"bandurria")); + EXPECT_TRUE(exact || unique) + << "a depluralized stem must match the Bandurria template confidently, not weakly"; +} + +TEST_F(Tst_Instruments, attached_digit_name_normalized) +{ + using namespace mu::iex::enc; + bool exact = false, unique = false; + // "Bandurria1" (digit attached, no separator) must normalize to "Bandurria". + const InstrumentTemplate* t = findEncoreInstrumentTemplate( + QStringLiteral("Bandurria1"), -1, ENC_KEY_NO_FILTER, &exact, &unique); + ASSERT_NE(t, nullptr); + EXPECT_EQ(t->id, String(u"bandurria")); + EXPECT_TRUE(exact || unique) << "attached part number must be stripped to a confident match"; +} + +TEST_F(Tst_Instruments, fuzzy_unique_name_reported_unique) +{ + using namespace mu::iex::enc; + bool exact = false, unique = false; + // "Acordeon" fuzzy-matches only "accordion"; a single close template is confident. + const InstrumentTemplate* t = findEncoreInstrumentTemplate( + QStringLiteral("Acordeon"), -1, ENC_KEY_NO_FILTER, &exact, &unique); + ASSERT_NE(t, nullptr); + EXPECT_EQ(t->id, String(u"accordion")); + EXPECT_TRUE(unique) << "a fuzzy match that is the only template above threshold must be unique"; +} + +TEST_F(Tst_Instruments, ambiguous_name_still_not_unique) +{ + using namespace mu::iex::enc; + bool exact = false, unique = false; + // Regression guard: "Guitarra" contains-matches many guitar templates, so it must stay + // non-unique and keep deferring to the MIDI program (Classical Guitar via MIDI 25). + const InstrumentTemplate* t = findEncoreInstrumentTemplate( + QStringLiteral("Guitarra"), -1, ENC_KEY_NO_FILTER, &exact, &unique); + ASSERT_NE(t, nullptr); + EXPECT_FALSE(exact); + EXPECT_FALSE(unique) << "an ambiguous substring name must remain weak so MIDI can correct it"; +} + +// Class B: a configured MIDI program that no template carries as its primary sound must fall +// back to the nearest template in the same General MIDI family, not to Grand Piano. +TEST_F(Tst_Instruments, gm_family_fallback_for_unmapped_program) +{ + using namespace mu::iex::enc; + constexpr int kPizzicatoStrings0 = 45; // GM 46, 0-indexed; Strings family (40..47) + EXPECT_EQ(findTemplateByMidi(kPizzicatoStrings0), nullptr) + << "precondition: no template has Pizzicato Strings as its primary program"; + const InstrumentTemplate* t = findTemplateByMidiFamily(kPizzicatoStrings0); + ASSERT_NE(t, nullptr); + ASSERT_FALSE(t->channel.empty()); + const int prog = t->channel.front().program(); + EXPECT_GE(prog, 40); + EXPECT_LE(prog, 47) << "family fallback must stay within the Strings family, not Grand Piano"; +} + +// Regression: SCO5 (big-endian macOS Encore 5) frames every block's size big-endian +// except the TK instrument blocks, whose size is little-endian ("70 00 00 00" = 112). +// Reading it big-endian and masking to 16 bits yielded 0, so the name-scan loop ran +// zero times and only the first instrument name survived (recovered by position); the +// rest imported as "Part N". The importer must undo the big-endian read of the TK size. +TEST_F(Tst_Instruments, sco5_tk_block_instrument_names) +{ + MasterScore* score = readEncoreScore("instruments_sco5_tk_names.enc"); + ASSERT_NE(score, nullptr) << "Failed to load instruments_sco5_tk_names.enc"; + ASSERT_EQ(score->parts().size(), 2u) << "expected 2 instruments"; + EXPECT_EQ(score->parts().at(0)->longName(), String(u"CORNETA 1")); + EXPECT_EQ(score->parts().at(1)->longName(), String(u"TROMPETA 2")) + << "second TK-block name must import, not fall back to a default"; + delete score; +} diff --git a/src/importexport/encore/tests/tst_notes.cpp b/src/importexport/encore/tests/tst_notes.cpp new file mode 100644 index 0000000000000..6510240870af4 --- /dev/null +++ b/src/importexport/encore/tests/tst_notes.cpp @@ -0,0 +1,2353 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Note import: pitch and tick scaling, dotted values, boundary/overflow handling, and the general +// note-element decoding shared across formats. See ENCORE_FORMAT.md §Note element. + +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/drumset.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/fret.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/textbase.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +static Measure* measureAt(MasterScore* score, int n) +{ + int idx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + if (idx == n) { + return toMeasure(mb); + } + ++idx; + } + return nullptr; +} + +class Tst_Notes : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// Encore ticks (240/quarter) scale to MuseScore (480/quarter), so quarter positions land on 480 multiples. +TEST_F(Tst_Notes, tick_scaling_quarter_positions) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 1); + ASSERT_NE(m, nullptr); + Fraction mTick = m->tick(); + + std::vector relTicks; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + relTicks.push_back((s->tick() - mTick).ticks()); + } + } + for (int t : relTicks) { + EXPECT_EQ(t % 480, 0) << "Note at rel tick " << t << " should be on a quarter-note boundary"; + } + delete score; +} + +TEST_F(Tst_Notes, note_pitches_whole_note) +{ + MasterScore* score = readEncoreScore("akordo.enc"); + ASSERT_NE(score, nullptr); + Chord* foundChord = nullptr; + for (MeasureBase* mb = score->first(); mb && !foundChord; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s && !foundChord; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + foundChord = toChord(e); + } + } + } + ASSERT_NE(foundChord, nullptr) << "Should find at least one chord in akordo.enc"; + EXPECT_GT(foundChord->notes().size(), 0u); + int pitch = foundChord->notes().front()->pitch(); + EXPECT_GE(pitch, 21) << "MIDI pitch should be in valid piano range"; + EXPECT_LE(pitch, 108) << "MIDI pitch should be in valid piano range"; + delete score; +} + +// A rest of 180 Encore ticks must import as a dotted eighth (V_EIGHTH + 1 dot). +TEST_F(Tst_Notes, dotted_quarter_note) +{ + MasterScore* score = readEncoreScore("notes_swing.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isRest()) { + Rest* rest = toRest(e); + if (rest->durationType().type() == DurationType::V_EIGHTH) { + EXPECT_EQ(rest->dots(), 1) << "Dotted eighth rest should have 1 dot"; + break; + } + } + } + delete score; +} + +// A note at tick == durTicks belongs to the next measure and must not overflow the current one. +TEST_F(Tst_Notes, boundary_notes_not_in_current_measure) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Chord Parsing should pass sanityCheck: " << ret.text(); + delete score; +} + +TEST_F(Tst_Notes, measures_do_not_overflow_4_4) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + if (m->timesig() != Fraction(4, 4)) { + continue; + } + m->setCorrupted(0, false); // reset first + } + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "No 4/4 measure should overflow: " << ret.text(); + delete score; +} + +// A note at tick == durTicks has zero real duration and must be skipped, so no chord ends up V_MEASURE. +TEST_F(Tst_Notes, last_note_real_duration_not_zero) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e || !e->isChord()) { + continue; + } + Chord* chord = toChord(e); + EXPECT_NE(chord->durationType().type(), DurationType::V_MEASURE) + << "No chord should have V_MEASURE type (indicates zero real duration)"; + } + } + } + delete score; +} + +// After tick scaling every segment must fall within its measure's tick range (no notes placed outside). +TEST_F(Tst_Notes, tick_scaling_no_note_outside_measure) +{ + MasterScore* score = readEncoreScore("bando.enc"); + ASSERT_NE(score, nullptr); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + Fraction mStart = m->tick(); + Fraction mEnd = m->endTick(); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EXPECT_GE(s->tick(), mStart) + << "Segment tick should be >= measure start"; + EXPECT_LT(s->tick(), mEnd) + << "Segment tick should be < measure end"; + } + } + delete score; +} + +// An invalid faceValue (0 or > 8) must be skipped rather than yielding a garbage duration and crashing. +TEST_F(Tst_Notes, invalid_facevalue_no_crash) +{ + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr) << "Opus 27 should load despite faceValue=0/28 corruption"; + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, invalid_facevalue_notes_have_valid_duration_type) +{ + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e) { + continue; + } + ChordRest* cr = toChordRest(e); + DurationType dt = cr->durationType().type(); + EXPECT_NE(dt, DurationType::V_ZERO) << "No chord/rest should have V_ZERO duration"; + EXPECT_NE(dt, DurationType::V_INVALID) << "No chord/rest should have V_INVALID duration"; + } + } + } + delete score; +} + +// Notes only a few ticks apart are MIDI timing artifacts (realDuration < 15); dropping them must leave no +// two chords sharing a tick in voice 0. +TEST_F(Tst_Notes, tiny_duration_notes_do_not_create_overlaps) +{ + MasterScore* score = readEncoreScore("notes_swing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + std::set seenTicks; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e) { + continue; + } + EXPECT_EQ(seenTicks.count(s->tick()), 0u) + << "Tiny-duration note was not skipped: overlap detected"; + seenTicks.insert(s->tick()); + } + delete score; +} + +// A voice >= VOICES must be skipped, not clamped to voice 3 (which collided with real voice-3 elements); +// no element's track may exceed maxTrack. +TEST_F(Tst_Notes, no_voice_conflict_from_clamping) +{ + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + track_idx_t maxTrack = static_cast(score->nstaves()) * VOICES; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (e) { + EXPECT_LT(e->track(), maxTrack) + << "Element track " << e->track() << " should be < maxTrack " << maxTrack; + } + } + } + } + delete score; +} + +// Encore encodes leading silence via an absolute tick offset, not a REST element; the importer snaps to +// that tick so beat positions are preserved. +TEST_F(Tst_Notes, implicit_leading_rest_keeps_note_positions) +{ + MasterScore* score = readEncoreScore("notes_implicit_leading_rest.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->ticks(), Fraction(3, 4)) << "measure must be 3/4"; + + // Voice 0 should produce: quarter rest @ beat 1, quarter note @ beat 2, + // quarter note @ beat 3. Same order as Encore -- no reordering. + std::vector ticks; + std::vector isRest; + std::vector pitches; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + ticks.push_back(s->tick() - m->tick()); + if (el->isRest()) { + isRest.push_back(true); + pitches.push_back(-1); + } else if (el->isChord()) { + isRest.push_back(false); + pitches.push_back(toChord(el)->upNote()->pitch()); + } + } + ASSERT_EQ(ticks.size(), 3u) << "voice 0 must contain rest + 2 notes"; + EXPECT_TRUE(isRest[0]) << "beat 1 must be a rest, not a note"; + EXPECT_EQ(ticks[0], Fraction(0, 1)); + EXPECT_FALSE(isRest[1]); + EXPECT_EQ(ticks[1], Fraction(1, 4)); + EXPECT_EQ(pitches[1], 72); + EXPECT_FALSE(isRest[2]); + EXPECT_EQ(ticks[2], Fraction(2, 4)); + EXPECT_EQ(pitches[2], 74); + delete score; +} + +// =========================================================================== +// FIX: calculateRealDurations inflates rdur to gap-to-measure-end for isolated notes (e.g. 720 in 3/4). +// The importer rejects dotted-half promotion when rdur exceeds face's tick count AND isn't a real dotted multiple. +// =========================================================================== +TEST_F(Tst_Notes, inflated_rdur_keeps_face_value_quarter_chord) +{ + MasterScore* score = readEncoreScore("notes_inflated_rdur_quarter_chord.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + // Voice 1 (MuseScore track 1 of staff 0) holds the encVoice=1 chord. + Chord* chord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(1); + if (el && el->isChord()) { + chord = toChord(el); + break; + } + } + ASSERT_NE(chord, nullptr) << "encVoice=1 chord must land on MuseScore voice 1"; + EXPECT_EQ(chord->durationType().type(), DurationType::V_QUARTER) + << "Face=3 (quarter) must win over rdur=720 (inflated to dotted-half ratio)"; + EXPECT_EQ(chord->durationType().dots(), 0) + << "rdur=720 is not a real dotted multiple of quarter; no dot"; + EXPECT_EQ(chord->notes().size(), 2u) + << "the two NOTE elements at tick 0 are merged into one chord"; + delete score; +} + +// An eighth chord whose next note kept its original quarter position has an inflated rdur (gap to next); +// the face value (eighth) must win over that rdur so the chord imports as an eighth, not a quarter. +TEST_F(Tst_Notes, inflated_rdur_eighth_chord_keeps_face_value) +{ + MasterScore* score = readEncoreScore("notes_chord_inflated_rdur_keeps_eighth.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Chord* chordAtQ1 = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + Chord* c = toChord(el); + if (c->tick() == m->tick() + Fraction(1, 4)) { + chordAtQ1 = c; + break; + } + } + } + ASSERT_NE(chordAtQ1, nullptr) << "Chord at beat 2 (tick=1/4) not found"; + EXPECT_EQ(chordAtQ1->durationType().type(), DurationType::V_EIGHTH) + << "fv=4 (eighth) must win over rdur=240 (inflated by trailing gap); chord must be eighth"; + EXPECT_EQ(chordAtQ1->notes().size(), 2u) + << "G4 and A4 at same tick must merge into one chord"; + delete score; +} + +// A triplet-spaced rdur (e.g. 80) must not promote past the face value: a 16th stays a 16th, not an eighth. +TEST_F(Tst_Notes, note_rdur_80_stays_16th_face_value) +{ + MasterScore* score = readEncoreScore("notes_rdur_80_stays_16th.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector types; + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + types.push_back(toChord(el)->durationType().type()); + } + } + ASSERT_GE(types.size(), 2u); + EXPECT_EQ(types[0], DurationType::V_16TH) + << "First note had rdur=80 (was V_EIGHTH under the old triplet table); must stay 16th"; + EXPECT_EQ(types[1], DurationType::V_16TH); + delete score; +} + +// Grace notes are detected only for eighth-or-shorter face values; grace chords live in graceNotes() while +// segment-attached chords stay NORMAL. +TEST_F(Tst_Notes, grace_notes_only_on_short_facevalues) +{ + MasterScore* score = readEncoreScore("notes_grace.enc"); + ASSERT_NE(score, nullptr); + bool foundGrace = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (!e || !e->isChord()) { + continue; + } + Chord* c = toChord(e); + EXPECT_EQ(c->noteType(), NoteType::NORMAL) + << "Segment-attached chord must be NORMAL; grace chords belong in graceNotes()"; + for (Chord* gc : c->graceNotes()) { + if (gc->noteType() != NoteType::NORMAL) { + foundGrace = true; + DurationType dt = gc->durationType().type(); + bool shortEnough = (dt == DurationType::V_EIGHTH + || dt == DurationType::V_16TH + || dt == DurationType::V_32ND + || dt == DurationType::V_64TH); + EXPECT_TRUE(shortEnough) + << "Grace note must have eighth or shorter duration (fv<4 filter), got " + << int(dt); + } + } + } + } + } + EXPECT_TRUE(foundGrace) << "Should have at least one grace note (from the fv=4 eighth)"; + delete score; +} + +// Off-beat MIDI ticks (a note 1 tick late) must be placed by cumulative face value, not the raw MIDI tick, +// so notes land at canonical positions without spurious gap fills. +TEST_F(Tst_Notes, offbeat_notes_canonical_placement) +{ + MasterScore* score = readEncoreScore("notes_offbeat_canonical.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Off-beat MIDI ticks should produce clean measure via cumTick: " + << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + std::vector noteTicks; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + noteTicks.push_back(s->tick() - m->tick()); + } + } + ASSERT_EQ(noteTicks.size(), 2u) << "Should have exactly 2 quarter notes"; + EXPECT_EQ(noteTicks[0], Fraction(0, 1)) << "First note at tick 0"; + EXPECT_EQ(noteTicks[1], Fraction(1, 4)) << "Second note at tick 1/4 (canonical, not MIDI-offset)"; + delete score; +} + +// On a PERC staff, note lines come from the Encore position byte and noteheads from the faceValue high +// nibble, so distinct positions map to distinct lines instead of all collapsing to line 0. +TEST_F(Tst_Notes, perc_clef_note_positions_from_encore_position_byte) +{ + MasterScore* score = readEncoreScore("notes_perc_clef_positions.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector notes; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + Chord* c = toChord(e); + if (!c->notes().empty()) { + notes.push_back(c->notes().front()); + } + } + } + ASSERT_GE(notes.size(), 3u); + + EXPECT_EQ(notes[0]->pitch(), 62); + EXPECT_EQ(notes[1]->pitch(), 65); + EXPECT_EQ(notes[2]->pitch(), 81); + + // Line is derived from the Encore position byte as line = max(-4, 10 - position). + EXPECT_EQ(notes[0]->line(), 9) << "pitch 62 position=1 must be at line 9"; + EXPECT_EQ(notes[1]->line(), 7) << "pitch 65 position=3 must be at line 7"; + EXPECT_EQ(notes[2]->line(), -2) << "pitch 81 position=12 must be at line -2"; + + // Verify drumset registration: the visual head is determined by the drumset entry. + // note->headGroup() is a user-override property (stays HEAD_NORMAL unless the user + // explicitly changes it); the rendering path uses drumset->noteHead(pitch) directly. + const Drumset* ds = notes[0]->part()->instrument()->drumset(); + ASSERT_NE(ds, nullptr) << "Staff must have a drumset assigned (PERC clef)"; + + EXPECT_EQ(ds->noteHead(81), NoteHeadGroup::HEAD_XCIRCLE) + << "fv high nibble=5 must register HEAD_XCIRCLE in drumset"; + + EXPECT_EQ(ds->noteHead(62), NoteHeadGroup::HEAD_NORMAL) + << "fv high nibble=0 must register HEAD_NORMAL in drumset"; + EXPECT_EQ(ds->noteHead(65), NoteHeadGroup::HEAD_NORMAL) + << "fv high nibble=0 must register HEAD_NORMAL in drumset"; + + delete score; +} + +// The faceValue notehead must override the standard drumset's pre-registered head: a normal-head note on +// pitch 40 (which the standard drumset registers as HEAD_SLASH) must become HEAD_NORMAL. +TEST_F(Tst_Notes, perc_clef_facevalue_overrides_standard_drumset_notehead) +{ + MasterScore* score = readEncoreScore("notes_perc_clef_standard_drumset_notehead.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Note* note = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s && !note; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + Chord* c = toChord(e); + if (!c->notes().empty()) { + note = c->notes().front(); + } + } + } + ASSERT_NE(note, nullptr); + ASSERT_EQ(note->pitch(), 40); + + const Drumset* ds = note->part()->instrument()->drumset(); + ASSERT_NE(ds, nullptr) << "Staff must have a drumset assigned (PERC clef)"; + EXPECT_EQ(ds->noteHead(40), NoteHeadGroup::HEAD_NORMAL) + << "faceValue normal must override HEAD_SLASH from standard drumset (pitch 40)"; + + delete score; +} + +// All 10 faceValue high-nibble notehead types map to the correct NoteHeadGroup; non-zero nibbles set +// note->setFixed(true) so layout does not override the head from the shared drumset entry. +TEST_F(Tst_Notes, perc_notehead_all_nibble_types) +{ + MasterScore* score = readEncoreScore("notes_perc_notehead_all_nibbles.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // Notes are spread across 3 measures (4 per measure), iterate all. + std::vector notes; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + notes.push_back(n); + } + } + } + } + ASSERT_EQ(notes.size(), 10u) << "Expected 10 notes (one per nibble 0-9) across 3 measures"; + + using G = NoteHeadGroup; + const std::vector expected = { + G::HEAD_NORMAL, // nibble 0 + G::HEAD_DIAMOND, // nibble 1 + G::HEAD_TRIANGLE_UP, // nibble 2 + G::HEAD_CUSTOM, // nibble 3 (square) + G::HEAD_CROSS, // nibble 4 + G::HEAD_XCIRCLE, // nibble 5 + G::HEAD_PLUS, // nibble 6 + G::HEAD_SLASH, // nibble 7 + G::HEAD_LARGE_DIAMOND, // nibble 8 + G::HEAD_NORMAL, // nibble 9 (invisible, head=NORMAL) + }; + + for (size_t i = 0; i < notes.size(); ++i) { + EXPECT_EQ(notes[i]->headGroup(), expected[i]) + << "nibble " << i << " (pitch " << notes[i]->pitch() << ")"; + // nibble=9: note must be invisible (sin_cabeza) + if (i == 9) { + EXPECT_FALSE(notes[i]->visible()) << "nibble 9 must be invisible (sin_cabeza)"; + } + } + + delete score; +} + +// Two PERC notes at the same pitch but different faceValue nibbles must each keep their own notehead; +// they cannot both defer to the single shared drumset entry. +TEST_F(Tst_Notes, perc_shared_pitch_two_nibbles_stay_fixed) +{ + MasterScore* score = readEncoreScore("notes_perc_shared_pitch_nibbles.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector notes; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + notes.push_back(n); + } + } + } + } + ASSERT_GE(notes.size(), 2u); + EXPECT_EQ(notes[0]->headGroup(), NoteHeadGroup::HEAD_SLASH) + << "nibble=7 at pitch=60 must keep HEAD_SLASH even when pitch is shared"; + EXPECT_EQ(notes[1]->headGroup(), NoteHeadGroup::HEAD_LARGE_DIAMOND) + << "nibble=8 at pitch=60 must keep HEAD_LARGE_DIAMOND even when pitch is shared"; + delete score; +} + +// Notes a few ticks apart (MIDI drift) must merge into one chord: the near-simultaneous cluster is skipped +// in the rdur calc so the first note is not dropped by the short-rdur filter. +TEST_F(Tst_Notes, near_simultaneous_notes_form_chord) +{ + MasterScore* score = readEncoreScore("notes_v0c2_near_simultaneous_chord.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Near-simultaneous chord should produce clean 2/4: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + break; + } + } + ASSERT_NE(first, nullptr) << "Must have at least one chord"; + EXPECT_EQ(first->notes().size(), 2u) + << "Near-simultaneous notes (ticks 0 and 3) must form a 2-note chord"; + delete score; +} + +// A triple-dotted eighth advances by 15/64, not 14/64, so the following chord starts flush with no overrun. +TEST_F(Tst_Notes, triple_dotted_advance_matches_chord_ticks) +{ + MasterScore* score = readEncoreScore("notes_triple_dotted_advance.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Triple-dotted advance must equal chord ticks: " << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + break; + } + } + ASSERT_NE(first, nullptr); + EXPECT_EQ(first->ticks(), Fraction(15, 64)) << "Must be triple-dotted 8th (15/64)"; + EXPECT_EQ(first->dots(), 3) << "Must have 3 augmentation dots"; + Chord* second = nullptr; + for (Segment* s = first->segment()->next(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + second = toChord(e); + break; + } + } + ASSERT_NE(second, nullptr); + EXPECT_EQ(second->segment()->tick() - m->tick(), Fraction(15, 64)) + << "Second chord must start at 15/64 (triple-dotted advance), not 14/64"; + delete score; +} + +// The dotControl byte (not the MIDI-drifted realDuration) must decide a note's dot count: a dotted eighth +// stays dotted even when the next note's start makes rdur look undotted. +TEST_F(Tst_Notes, dotted_note_uses_dotcontrol_byte) +{ + MasterScore* score = readEncoreScore("notes_dotted_note.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Dotted note test must produce clean score: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + break; + } + } + ASSERT_NE(first, nullptr) << "Must have a first chord"; + EXPECT_EQ(first->durationType().type(), DurationType::V_EIGHTH) + << "First note must be an 8th (dotted)"; + EXPECT_EQ(first->dots(), 1) + << "dotControl=180=8th*3/2 must produce 1 augmentation dot on the note"; + delete score; +} + +// Same dotControl rule for rests: a dotted eighth rest stays dotted despite a MIDI-drifted rdur. +TEST_F(Tst_Notes, dotted_rest_uses_dotcontrol_byte) +{ + MasterScore* score = readEncoreScore("notes_dotted_rest.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Dotted rest test must produce clean score: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(3, 4)); + + Rest* dottedRest = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isRest()) { + dottedRest = toRest(e); + break; + } + } + ASSERT_NE(dottedRest, nullptr) << "Must find a rest after the quarter note"; + EXPECT_EQ(dottedRest->durationType().type(), DurationType::V_EIGHTH) + << "Rest must be an eighth rest (dotted)"; + EXPECT_EQ(dottedRest->dots(), 1) + << "dotControl=180=8th*3/2 must produce 1 augmentation dot"; + delete score; +} + +// With dotControl=0, an rdur 1 tick off a double-dotted value must snap to the dotted count (tolerance 1). +TEST_F(Tst_Notes, rdur_snap_corrects_dot_count) +{ + MasterScore* score = readEncoreScore("notes_rdur_snap.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "rdur snap test must produce clean score: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + break; + } + } + ASSERT_NE(first, nullptr) << "Must have a first chord"; + EXPECT_EQ(first->durationType().type(), DurationType::V_EIGHTH) + << "Note must be an 8th (double-dotted)"; + EXPECT_EQ(first->dots(), 2) + << "rdur=211 is 1 tick away from dd8th=210: must snap to 2 augmentation dots"; + delete score; +} + +// A 32nd rest whose rdur is shortened by the next note's MIDI start must be kept in order: for face value +// >= 32nd the face value is trusted over the short rdur, so the rest is not dropped or reordered. +TEST_F(Tst_Notes, rest_before_note_midi_slop_keeps_rest) +{ + MasterScore* score = readEncoreScore("notes_rest_before_note_midi_slop.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "score must be clean: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector crs; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + crs.push_back(toChordRest(e)); + } + } + ASSERT_GE(crs.size(), 6u) << "Must have 6 ChordRest elements in M1"; + EXPECT_TRUE(crs[0]->isChord()) + << "First element must be a chord (eighth note)"; + EXPECT_EQ(crs[0]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_TRUE(crs[1]->isRest()) + << "Second element must be a rest (32nd); without fix it appears last"; + EXPECT_EQ(crs[1]->durationType().type(), DurationType::V_32ND) + << "Rest must be a 32nd (face value preserved despite rdur=5)"; + EXPECT_TRUE(crs[2]->isChord()) + << "Third element must be a chord (dotted 16th)"; + EXPECT_EQ(crs[2]->durationType().type(), DurationType::V_16TH); + delete score; +} + +// A short-rdur note that is not a chord extension must still be filtered as a MIDI artifact; the chord-ext +// test must use the previous element's tick so a lone 64th (rdur=11) is not mistaken for a chord tone. +TEST_F(Tst_Notes, rdur_non_chord_ext_filtered) +{ + MasterScore* score = readEncoreScore("notes_rdur_non_chord_ext_filtered.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "score must be clean: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + + ASSERT_EQ(chords.size(), 1u) + << "Only the Q E4 must be placed; the 64th C4 artifact must be filtered"; + EXPECT_EQ(chords[0]->durationType().type(), DurationType::V_QUARTER) + << "Placed note must be a quarter"; + ASSERT_GE(chords[0]->notes().size(), 1u); + EXPECT_EQ(chords[0]->notes()[0]->pitch(), 64) + << "Placed note must be E4 (pitch=64), not the filtered C4 (pitch=60)"; + + delete score; +} + +// =========================================================================== +// BUG FIX: grace1 low-nibble cascade filter for MIDI artifact continuation notes +// =========================================================================== + +TEST_F(Tst_Notes, grace1_cascade_filter) +{ + // 64th C4 (g1low=1) filtered as artifact; Q C4 (g1low=2) is its tie-receiver and must also be filtered. + // Fix: when g1low=1 note is filtered, record its pitch; next note with g1low=2 and same pitch is cascade-filtered. + MasterScore* score = readEncoreScore("notes_grace1_cascade_filter.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "score must be clean: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + + ASSERT_EQ(chords.size(), 1u) + << "Both C4 notes must be filtered; only Q E4 must appear"; + ASSERT_GE(chords[0]->notes().size(), 1u); + EXPECT_EQ(chords[0]->notes()[0]->pitch(), 64) + << "Only E4 (pitch=64) must appear; C4 (pitch=60) must be cascade-filtered"; + + delete score; +} + +// Four live-recorded notes a few ticks apart must form one chord (not split), tied to a 4-note receiver. +TEST_F(Tst_Notes, chord_cluster_5tick_v0c2) +{ + MasterScore* score = readEncoreScore("notes_v0c2_chord_cluster_5tick.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "score must be clean: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + + ASSERT_EQ(chords.size(), 2u) << "Must have exactly 2 chords (sender + receiver)"; + EXPECT_EQ(chords[0]->notes().size(), 4u) + << "All 4 live-recorded chord notes must be in one chord, not split"; + + int tiedCount = 0; + for (Note* n : chords[0]->notes()) { + if (n->tieFor() && n->tieFor()->endNote()) { + ++tiedCount; + } + } + EXPECT_EQ(tiedCount, 4) + << "All 4 sender notes must have outgoing ties to the receiver chord"; + + EXPECT_EQ(chords[1]->notes().size(), 4u) + << "Receiver chord must have all 4 notes"; + + delete score; +} + +// A pitch encoded twice in the same chord cluster must collapse to one notehead, regardless of the +// grace1 0x40 chord-extension bit. +TEST_F(Tst_Notes, duplicate_pitch_in_chord_cluster_suppressed) +{ + MasterScore* score = readEncoreScore("notes_chord_duplicate.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + Segment* firstSeg = m->first(SegmentType::ChordRest); + ASSERT_NE(firstSeg, nullptr); + EngravingItem* elem = firstSeg->element(0); + ASSERT_NE(elem, nullptr) << "Expected a chord at tick=0"; + ASSERT_TRUE(elem->isChord()) << "Expected a Chord, got something else"; + + Chord* chord = toChord(elem); + EXPECT_EQ(chord->notes().size(), 1u) + << "Duplicate pitch (tick=0, pitch=60 encoded twice) must produce exactly one notehead"; + + delete score; +} + +// Duplicate suppression must also fire when neither copy carries the chord-extension bit (some v0xC2 files). +TEST_F(Tst_Notes, duplicate_pitch_no_ext_bit_suppressed) +{ + MasterScore* score = readEncoreScore("notes_chord_duplicate_no_ext_bit.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* firstSeg = m->first(SegmentType::ChordRest); + ASSERT_NE(firstSeg, nullptr); + EngravingItem* elem = firstSeg->element(0); + ASSERT_NE(elem, nullptr) << "Expected a chord at tick=0"; + ASSERT_TRUE(elem->isChord()); + + EXPECT_EQ(toChord(elem)->notes().size(), 1u) + << "Duplicate pitch (both grace1=0x00, no ext bit) must produce exactly one notehead"; + + delete score; +} + +// Grand-staff files encode the target staff in the high bits of the raw staff byte (staffWithin), so +// voices marked staffWithin=1 must land on staff 2 while staffWithin=0 stays on staff 1. +TEST_F(Tst_Notes, grandstaff_staffwithin_routes_voices_to_correct_staff) +{ + MasterScore* score = readEncoreScore("notes_grandstaff_bit6_second_staff.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + ASSERT_EQ(score->nstaves(), 2) << "Piano grand staff must have 2 staves"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto notesOnStaff = [&](int staffIdx) { + std::vector pitches; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(staffIdx * VOICES + v)); + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + pitches.push_back(n->pitch()); + } + } + } + } + return pitches; + }; + + auto s1 = notesOnStaff(0); + auto s2 = notesOnStaff(1); + + EXPECT_EQ(s1.size(), 2u) << "Treble staff must have 2 notes"; + EXPECT_EQ(s2.size(), 2u) << "Bass staff must have 2 notes (bit6 routing broken)"; + + for (int p : s1) { + EXPECT_GE(p, 60) << "Treble staff note must be middle C or above"; + } + for (int p : s2) { + EXPECT_LT(p, 60) << "Bass staff note must be below middle C"; + } + + delete score; +} + +// A REST with staffWithin=1 must land on staff 2, like notes do. +TEST_F(Tst_Notes, grandstaff_staffwithin_rest_on_second_staff) +{ + MasterScore* score = readEncoreScore("notes_grandstaff_staffwithin_rest_on_second_staff.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + ASSERT_EQ(score->nstaves(), 2); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + bool hasRestOnStaff2 = false; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(1 * VOICES + v)); + if (e && e->isRest()) { + hasRestOnStaff2 = true; + } + } + } + EXPECT_TRUE(hasRestOnStaff2) << "Rest with staffWithin=1 must land on staff 2"; + + bool hasNoteOnStaff1 = false; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* e = seg->element(0); + if (e && e->isChord()) { + hasNoteOnStaff1 = true; + } + } + EXPECT_TRUE(hasNoteOnStaff1) << "Treble note (staffWithin=0) must stay on staff 1"; + + delete score; +} + +// A TIE with staffWithin=1 must resolve on staff 2 (with its note), not leave a dangling tie on staff 1. +TEST_F(Tst_Notes, grandstaff_staffwithin_tie_on_second_staff) +{ + // Single 4/4 measure: treble C5 half+half, bass E3 half tied to E3 half. + // Both bass notes use voice=2, staffWithin=1 (raw staff byte = 0x40). + // The TIE element also carries staffWithin=1; the tieStartSet routing must + // key the tie by the ROUTED (staffIdx=1, voice=0) rather than the raw values + // to correctly link the two E3 chords on staff 2. + MasterScore* score = readEncoreScore("notes_grandstaff_staffwithin_tie_on_second_staff.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + ASSERT_EQ(score->nstaves(), 2); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + Note* tieStart = nullptr; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(1 * VOICES + v)); + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + if (n->pitch() == 52 && !tieStart) { + tieStart = n; + } + } + } + } + } + ASSERT_NE(tieStart, nullptr) << "Bass E3 (pitch=52) must be on staff 2"; + EXPECT_NE(tieStart->tieFor(), nullptr) + << "First E3 on staff 2 must carry tie-for (staffWithin tie routing broken)"; + + if (tieStart->tieFor()) { + Note* tieEnd = tieStart->tieFor()->endNote(); + EXPECT_NE(tieEnd, nullptr) << "Tie must resolve to second E3 on staff 2"; + if (tieEnd) { + EXPECT_EQ(tieEnd->pitch(), 52) << "Tie end note must also be E3 (pitch=52)"; + } + } + + delete score; +} + +// All four Encore voices distribute correctly across the grand staff: voices 0-1 to the treble staff, +// voices 2-3 to the bass staff. +TEST_F(Tst_Notes, grandstaff_staffwithin_four_voices) +{ + MasterScore* score = readEncoreScore("notes_grandstaff_staffwithin_four_voices.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + ASSERT_EQ(score->nstaves(), 2); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto pitchesOnStaff = [&](int staffIdx) { + std::vector pitches; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(staffIdx * VOICES + v)); + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + pitches.push_back(n->pitch()); + } + } + } + } + return pitches; + }; + + auto s1 = pitchesOnStaff(0); + auto s2 = pitchesOnStaff(1); + + EXPECT_EQ(s1.size(), 2u) << "Treble must have 2 notes (C5, E5)"; + EXPECT_EQ(s2.size(), 2u) << "Bass must have 2 notes (G3, B3)"; + + for (int p : s1) { + EXPECT_GE(p, 72) << "Treble note must be >= 72 (C5)"; + } + for (int p : s2) { + EXPECT_LT(p, 60) << "Bass note must be < 60 (middle C)"; + } + + delete score; +} + +// A voice number above the staff-2 marker (voice 5..7, staffWithin 0) is a genuine extra voice on +// its OWN staff, not a request to move to the next staff. On a grand-staff instrument the old +// voice>=VOICES rule pushed a voice-7 top-staff note onto the bass staff. Fixture: a voice-7 note +// (raw_staff = top staff) on a Piano grand staff must land on staff 0, not staff 1. +TEST_F(Tst_Notes, grandstaff_high_voice_stays_on_own_staff) +{ + MasterScore* score = readEncoreScore("notes_grandstaff_high_voice_own_staff.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + bool onTopStaff = false, onBassStaff = false; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (int tr = 0; tr < 2 * static_cast(VOICES); ++tr) { + EngravingItem* e = seg->element(static_cast(tr)); + if (e && e->isChord() && !toChord(e)->notes().empty() + && toChord(e)->notes().front()->pitch() == 67) { + (tr < static_cast(VOICES) ? onTopStaff : onBassStaff) = true; + } + } + } + EXPECT_TRUE(onTopStaff) << "the voice-7 note must stay on its own (top) staff"; + EXPECT_FALSE(onBassStaff) << "the voice-7 note must not be pushed onto the bass staff"; + delete score; +} + +// On a single-staff instrument, Encore voice nibble 4 is a genuine second melodic voice (not the +// grand-staff silent-voice marker), so it must import as a separate voice 1 rather than being +// concatenated onto voice 0 (which produced overfull, non-dyadic bars that failed to open). +TEST_F(Tst_Notes, singlestaff_voice4_second_voice) +{ + MasterScore* score = readEncoreScore("notes_singlestaff_voice4_second_voice.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto countChordsInVoice = [&](int voice) { + int count = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* e = seg->element(static_cast(voice)); + if (e && e->isChord()) { + ++count; + } + } + return count; + }; + + EXPECT_EQ(countChordsInVoice(0), 4) << "voice 0 must hold only its own four notes"; + EXPECT_EQ(countChordsInVoice(1), 4) << "the voice-4 second voice must import as a separate voice 1"; + delete score; +} + +// =========================================================================== +// grandstaff_staffwithin_sequential +// +// Sequential notes on both staves use independent cumTick accumulators. +// Each staff advances its own position; notes must not bleed across staves. +// Fixture: treble C5 at tick=0, E5 at tick=240; bass C3 at tick=0, E3 at 240. +// Each staff must have exactly 2 notes placed at the correct ticks. +// =========================================================================== +TEST_F(Tst_Notes, grandstaff_staffwithin_sequential) +{ + MasterScore* score = readEncoreScore("notes_grandstaff_staffwithin_sequential.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + ASSERT_EQ(score->nstaves(), 2); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto countNotesOnStaff = [&](int staffIdx) { + int count = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(staffIdx * VOICES + v)); + if (e && e->isChord()) { + count += static_cast(toChord(e)->notes().size()); + } + } + } + return count; + }; + + EXPECT_EQ(countNotesOnStaff(0), 2) << "Treble must have 2 notes"; + EXPECT_EQ(countNotesOnStaff(1), 2) << "Bass must have 2 notes"; + + delete score; +} + +// A transposing instrument's written spelling must use the concert key, so a written F4 does not drift to +// a double-flat (Gbb) spelling. +TEST_F(Tst_Notes, transposing_instrument_written_tpc_not_double_flat) +{ + MasterScore* score = readEncoreScore("notes_transposing_written_tpc.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + EngravingItem* e = seg->element(0); + ASSERT_NE(e, nullptr); + ASSERT_TRUE(e->isChord()); + Note* note = toChord(e)->upNote(); + ASSERT_NE(note, nullptr); + + EXPECT_EQ(note->pitch(), 71) + << "Concert pitch must be 65 (written F4) + 6 = 71 (B4)"; + EXPECT_EQ(note->tpc2(), 13) + << "Written TPC must be 13 (F natural), not 1 (Gbb) or 7 (Cb); " + "double-flat spellings indicate the wrong key context in computeWindow"; + + delete score; +} + +// score->spell() can drift a whole transposing-staff melody to double-flats when the written key is flat +// but the concert key is sharp; respellTransposingStaves re-derives TPCs from sounding pitch + concert key +// so no note ends up double-flat. The single-note fix does not catch this, hence the melody fixture. +TEST_F(Tst_Notes, transposing_melody_no_double_flat_after_spell) +{ + MasterScore* score = readEncoreScore("notes_transposing_respell_melody.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + const std::vector expectedTpc1 = { 18, 19, 22, 18 }; // E B G# E (concert) + const std::vector expectedTpc2 = { 12, 13, 16, 12 }; // Bb F D Bb (written) + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + size_t i = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* e = seg->element(0); + if (!e || !e->isChord()) { + continue; + } + Note* note = toChord(e)->upNote(); + ASSERT_NE(note, nullptr); + ASSERT_LT(i, expectedTpc1.size()); + EXPECT_EQ(note->tpc1(), expectedTpc1[i]) + << "Concert TPC at note " << i << " must follow the A-major concert key, not a flat drift"; + EXPECT_EQ(note->tpc2(), expectedTpc2[i]) + << "Written TPC at note " << i << " must not be a double-flat (spell() drift)"; + ++i; + } + EXPECT_EQ(i, expectedTpc1.size()) << "Expected 4 melody notes on the transposing staff"; + + delete score; +} +// grandstaff_staffwithin_fermata +TEST_F(Tst_Notes, grandstaff_staffwithin_fermata) +{ + MasterScore* score = readEncoreScore("notes_grandstaff_staffwithin_fermata.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + ASSERT_EQ(score->nstaves(), 2); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto fermatasOnStaff = [&](int staffIdx) { + std::vector symIds; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (EngravingItem* e : seg->annotations()) { + if (e->isFermata() && e->staffIdx() == static_cast(staffIdx)) { + symIds.push_back(toFermata(e)->symId()); + } + } + } + return symIds; + }; + + auto s1 = fermatasOnStaff(0); + auto s2 = fermatasOnStaff(1); + + EXPECT_EQ(s1.size(), 1u) << "Treble staff must have 1 fermata (tipo 0xCC)"; + EXPECT_EQ(s2.size(), 1u) << "Bass staff must have 1 fermata (tipo 0xCD, staffWithin=1)"; + + if (!s1.empty()) { + EXPECT_EQ(s1[0], SymId::fermataAbove) + << "Treble fermata must be above (tipo 0xCC)"; + } + if (!s2.empty()) { + EXPECT_EQ(s2[0], SymId::fermataBelow) + << "Bass fermata must be below (tipo 0xCD); staffWithin routing broken for ORNs"; + } + + delete score; +}// scale_string_numbers_from_anchor_bytes +TEST_F(Tst_Notes, scale_string_numbers_from_anchor_bytes) +{ + // Fixture: M1 has 4 notes with au=0x39 on note 1 (explicit string 1) and au=0x00 + // on notes 2-4. The anchor unlocks opt-based circles for the whole measure: + // all 4 notes show strings 1-4 via pos+1. + MasterScore* score = readEncoreScore("notes_scale_string_numbers_anchor.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector nums; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* sub : n->el()) { + if (sub && sub->isFingering()) { + Fingering* fg = toFingering(sub); + if (fg->textStyleType() == TextStyleType::STRING_NUMBER) { + bool ok; + int v = fg->plainText().toInt(&ok); + if (ok) { + nums.push_back(v); + } + } + } + } + } + } + + EXPECT_EQ(nums.size(), 4u) << "Anchor byte 0x39 must enable circles on all 4 notes"; + for (int i = 0; i < (int)nums.size(); ++i) { + EXPECT_EQ(nums[i], i + 1) << "Note " << i + 1 << " must show string " << i + 1; + } + + delete score; +} + +// scale_no_anchor_produces_no_circles +TEST_F(Tst_Notes, scale_no_anchor_produces_no_circles) +{ + // Notes with opt bit 0 and pos in 0-7 but NO au=0x39..0x40 anchor → no circles. + MasterScore* score = readEncoreScore("notes_scale_no_anchor_no_circles.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + int fingerCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* seg = toMeasure(mb)->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < VOICES; ++v) { + EngravingItem* el = seg->element(static_cast(v)); + if (el && el->isChord()) { + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* sub : n->el()) { + if (sub && sub->isFingering()) { + ++fingerCount; + } + } + } + } + } + } + } + EXPECT_EQ(fingerCount, 0) + << "Without 0x39..0x40 anchor bytes, options-bit-0 notes must not show circles"; + + delete score; +} + +// =========================================================================== +// REGRESSION: Standalone string-number ORN (0xE6 = string 2) must NOT duplicate +// the string number that the per-note hasScaleStringAnchors options-bit-0 path +// already placed on the same note. +// Fixture: n1 artUp=0x39 (string 1, sets anchor); ORN 0xE6 at tick=240 (string 2) +// + n2 with options bit 0 and position=1. Without the dedup guard in the resolver, +// n2 would get TWO "2" string numbers. +// =========================================================================== +TEST_F(Tst_Notes, string_num_orn_does_not_duplicate_anchor_path_number) +{ + MasterScore* score = readEncoreScore("notes_string_num_orn_no_dup.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::map > numsByBeat; // beat_index → list of string numbers + int beat = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* sub : n->el()) { + if (sub && sub->isFingering() + && toFingering(sub)->textStyleType() == TextStyleType::STRING_NUMBER) { + bool ok; + int v = toFingering(sub)->plainText().toInt(&ok); + if (ok) { + numsByBeat[beat].push_back(v); + } + } + } + } + ++beat; + } + EXPECT_EQ(numsByBeat[0].size(), 1u) << "n1 must have exactly one string number (1)"; + EXPECT_EQ(numsByBeat[1].size(), 1u) << "n2 must have exactly one string number (2), not two"; + if (!numsByBeat[0].empty()) { + EXPECT_EQ(numsByBeat[0][0], 1); + } + if (!numsByBeat[1].empty()) { + EXPECT_EQ(numsByBeat[1][0], 2); + } + + delete score; +} + +// voice_overflow_notes_dropped_not_routed_to_voice2 +TEST_F(Tst_Notes, voice_overflow_notes_dropped_not_routed_to_voice2) +{ + MasterScore* score = readEncoreScore("notes_voice_overflow_dropped.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Overflow must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + // Voice 0 should have exactly 2 half notes + int v0Chords = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + ++v0Chords; + } + } + EXPECT_EQ(v0Chords, 2) << "Only notes 1-2 fit; notes 3-5 must be dropped"; + + // Voice 1 must be empty (overflow notes are not routed to voice 2) + int v1Chords = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(1); + if (el && el->isChord()) { + ++v1Chords; + } + } + EXPECT_EQ(v1Chords, 0) << "Overflow notes must be dropped, NOT routed to voice 2"; + + delete score; +} + +TEST_F(Tst_Notes, chord_symbol_snaps_to_beat1_despite_midi_offset) +{ + // Fixture: quarter note at tick=0, chord symbol CHD at tick=6 (6/960 offset), + // quarter note at tick=240. CHD must attach to the beat-1 segment. + MasterScore* score = readEncoreScore("notes_chord_symbol_snap_to_beat1.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "CHD snap must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + // The first ChordRest segment must have the Harmony attached. + Segment* firstSeg = m->first(SegmentType::ChordRest); + ASSERT_NE(firstSeg, nullptr); + + EXPECT_NE(segmentHarmony(firstSeg), nullptr) + << "Chord symbol with tick=6 (MIDI offset from note at tick=0) must snap to beat-1 segment"; + + // The second segment must NOT have the Harmony. + Segment* secondSeg = firstSeg->next(SegmentType::ChordRest); + if (secondSeg) { + EXPECT_EQ(segmentHarmony(secondSeg), nullptr) + << "Chord symbol must NOT land on beat-2 segment due to MIDI drift"; + } + + delete score; +} + +TEST_F(Tst_Notes, chord_symbol_large_midi_drift_still_on_beat1) +{ + MasterScore* score = readEncoreScore("notes_chord_symbol_large_drift.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + Segment* first = m->first(SegmentType::ChordRest); + ASSERT_NE(first, nullptr); + + EXPECT_NE(segmentHarmony(first), nullptr) + << "CHD@87 (large drift from note@0) must still snap to beat-1 segment"; + + delete score; +} + +TEST_F(Tst_Notes, chord_symbol_snaps_to_beat_not_nearby_subdivision) +{ + // tick=62, beat=240 → beatStart=0 → first note in [0..62] is at tick=0, not tick=60 + MasterScore* score = readEncoreScore("notes_chord_symbol_nearbeat_subdivision.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + // Beat-1 segment (tick offset = 0) + Segment* beat1seg = m->first(SegmentType::ChordRest); + ASSERT_NE(beat1seg, nullptr); + EXPECT_EQ(beat1seg->tick() - m->tick(), Fraction(0, 1)) + << "First segment must be at tick=0 (beat 1)"; + + EXPECT_NE(segmentHarmony(beat1seg), nullptr) + << "CHD@62 with note at tick=60 only 2t away must NOT snap to tick=60; " + "beat-floor forces it to tick=0 (beat 1)"; + + // Second segment (tick=60) must NOT have a harmony + Segment* seg60 = beat1seg->next(SegmentType::ChordRest); + if (seg60) { + EXPECT_EQ(segmentHarmony(seg60), nullptr) + << "CHD must not land on the tick=60 subdivision segment"; + } + + delete score; +} + +TEST_F(Tst_Notes, chord_symbol_gets_fretboard_diagram) +{ + // A FretDiagram is drawn only when Encore's fret-frame bit is set, independent of whether MuseScore's + // chord database recognizes the name (gating on database recognition alone put a frame under every chord). + MasterScore* score = readEncoreScore("notes_chord_symbol_fretboard.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + auto scanSeg = [](Segment* s, FretDiagram** fdOut, Harmony** bareOut) { + *fdOut = nullptr; + *bareOut = nullptr; + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isFretDiagram()) { + *fdOut = toFretDiagram(ann); + } else if (ann && ann->isHarmony()) { + *bareOut = toHarmony(ann); + } + } + }; + + // Measure 0: "Am" with frame bit -> FretDiagram wrapping the Harmony. + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + Segment* s0 = m0->first(SegmentType::ChordRest); + ASSERT_NE(s0, nullptr); + FretDiagram* fd0 = nullptr; + Harmony* bare0 = nullptr; + scanSeg(s0, &fd0, &bare0); + ASSERT_NE(fd0, nullptr) << "\"Am\" with the frame bit must be wrapped in a FretDiagram"; + EXPECT_FALSE(fd0->isClear()) << "FretDiagram for \"Am\" must be populated from the database"; + ASSERT_NE(fd0->harmony(), nullptr) << "FretDiagram must carry the Harmony as its child"; + EXPECT_EQ(fd0->harmony()->harmonyName(), String(u"Am")); + EXPECT_EQ(bare0, nullptr) + << "Harmony must live under the FretDiagram, not directly on the segment"; + + // Measure 1: "Am" WITHOUT the frame bit -> plain Harmony, no FretDiagram. + Measure* m1 = measureAt(score, 1); + ASSERT_NE(m1, nullptr); + Segment* s1 = m1->first(SegmentType::ChordRest); + ASSERT_NE(s1, nullptr); + FretDiagram* fd1 = nullptr; + Harmony* bare1 = nullptr; + scanSeg(s1, &fd1, &bare1); + EXPECT_EQ(fd1, nullptr) + << "\"Am\" WITHOUT the frame bit must NOT get a FretDiagram, even though the database knows it"; + EXPECT_NE(bare1, nullptr) << "\"Am\" without the frame bit must remain a plain Harmony"; + + // Measure 2: "Zzz" with frame bit but unknown chord -> plain Harmony (no diagram to draw). + Measure* m2 = measureAt(score, 2); + ASSERT_NE(m2, nullptr); + Segment* s2 = m2->first(SegmentType::ChordRest); + ASSERT_NE(s2, nullptr); + FretDiagram* fd2 = nullptr; + Harmony* bare2 = nullptr; + scanSeg(s2, &fd2, &bare2); + EXPECT_EQ(fd2, nullptr) << "Unknown chord \"Zzz\" must NOT get a FretDiagram"; + EXPECT_NE(bare2, nullptr) << "Unknown chord \"Zzz\" must remain a plain Harmony"; + + delete score; +} + +// Compact rawStaff encodes staffWithin in the high bits and the instrument index in the low bits; the low +// bits must not be read as a LINE slot, or the second instrument's notes land on the first's staves. +TEST_F(Tst_Notes, notes_multiinstr_compact_routing) +{ + MasterScore* score = readEncoreScore("notes_multiinstr_compact_routing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->nstaves(), 4) << "score must have 4 staves (2 instruments x 2 each)"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + + auto pitchOnStaff = [&](int staffIdx) -> int { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(staffIdx * VOICES + v)); + if (e && e->isChord()) { + return toChord(e)->notes().front()->pitch(); + } + } + return -1; + }; + + EXPECT_EQ(pitchOnStaff(0), 60) << "staff 0 (instr 0 treble) must have C4"; + EXPECT_EQ(pitchOnStaff(1), 48) << "staff 1 (instr 0 bass) must have C3"; + EXPECT_EQ(pitchOnStaff(2), 64) << "staff 2 (instr 1 treble) must have E4"; + EXPECT_EQ(pitchOnStaff(3), 52) << "staff 3 (instr 1 bass) must have E3"; + + delete score; +} + +TEST_F(Tst_Notes, notes_v0c2_multiinstr_compact_routing) +{ + // v0xC2 counterpart: same compact rawStaff encoding in an older file format. + // Verifies the lineSlotByRawByte lookup in emitters.cpp works for v0xC2 (size=22 notes). + MasterScore* score = readEncoreScore("notes_v0c2_multiinstr_compact_routing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->nstaves(), 4); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + + auto pitchOnStaff = [&](int staffIdx) -> int { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = seg->element(static_cast(staffIdx * VOICES + v)); + if (e && e->isChord()) { + return toChord(e)->notes().front()->pitch(); + } + } + return -1; + }; + + EXPECT_EQ(pitchOnStaff(0), 60) << "staff 0 (instr 0 treble) must have C4"; + EXPECT_EQ(pitchOnStaff(1), 48) << "staff 1 (instr 0 bass) must have C3"; + EXPECT_EQ(pitchOnStaff(2), 64) << "staff 2 (instr 1 treble) must have E4"; + EXPECT_EQ(pitchOnStaff(3), 52) << "staff 3 (instr 1 bass) must have E3"; + + delete score; +} + +// v0xC2 size=24 notes carry pitch and articulation at the same offsets as size=22; reading the v0xC4 pitch +// slot yields 0 (C-1). See ENCORE_FORMAT.md §v0xC2 note (size 22 or 24). +TEST_F(Tst_Notes, notes_v0c2_size24_correct_pitch_and_artic) +{ + MasterScore* score = readEncoreScore("notes_v0c2_size24_artic_pitch.enc"); + ASSERT_NE(score, nullptr); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector pitches; + std::vector artics; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + pitches.push_back(c->notes().front()->pitch()); + for (Articulation* a : c->articulations()) { + artics.push_back(a->symId()); + } + } + + ASSERT_EQ(pitches.size(), 2u); + EXPECT_EQ(pitches[0], 67) << "First note should be G4 (67), not C-1 (0)"; + EXPECT_EQ(pitches[1], 64) << "Second note should be E4 (64), not C-1 (0)"; + + // MuseScore flips Above/Below based on stem direction after layout; compare kind only. + auto isStaccato = [](SymId s) { + return s == SymId::articStaccatoAbove || s == SymId::articStaccatoBelow; + }; + auto isTenuto = [](SymId s) { + return s == SymId::articTenutoAbove || s == SymId::articTenutoBelow; + }; + ASSERT_EQ(artics.size(), 2u); + EXPECT_TRUE(isStaccato(artics[0])) << "G4 should have staccato (0x1d)"; + EXPECT_TRUE(isTenuto(artics[1])) << "E4 should have tenuto (0x1c)"; + + delete score; +} + +// In some v0xC2 size=24 notes the pitch is already in semiTonePitch (tuplet==0); the pitch-swap must be +// skipped so it is preserved. +TEST_F(Tst_Notes, notes_v0c2_size24_semitone_pitch) +{ + MasterScore* score = readEncoreScore("notes_v0c2_size24_semitonepitch.enc"); + ASSERT_NE(score, nullptr); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector pitches; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + pitches.push_back(toChord(el)->notes().front()->pitch()); + } + + ASSERT_EQ(pitches.size(), 2u); + EXPECT_EQ(pitches[0], 60) << "C4 (60): pitch from semiTonePitch must survive; swap must not fire when tuplet==0"; + EXPECT_EQ(pitches[1], 64) << "E4 (64): pitch from semiTonePitch must survive; swap must not fire when tuplet==0"; + + delete score; +} + +// When a non-first measure has explicit notes filling only part of the +// duration, the trailing empty space must be filled with invisible gap rests, +// not visible rests. Measure 0 is fully filled (to avoid pickup shortening). +// Measure 1 has two eighth notes (cumTick=1/4); trailing 3/4 must be invisible. +TEST_F(Tst_Notes, trailing_space_uses_invisible_gap_rests) +{ + MasterScore* score = readEncoreScore("notes_implicit_trailing_gap.enc"); + ASSERT_NE(score, nullptr); + + Measure* m1 = measureAt(score, 1); // measure 1 has partial content + ASSERT_NE(m1, nullptr); + + int visibleRests = 0; + int gapRests = 0; + for (Segment* s = m1->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isRest()) { + if (toRest(el)->isGap()) { + ++gapRests; + } else { + ++visibleRests; + } + } + } + + EXPECT_EQ(visibleRests, 0) << "Trailing silence must use invisible gap rests, not visible rests"; + EXPECT_GT(gapRests, 0) << "Must have at least one invisible gap rest for the trailing 3/4"; + + delete score; +} + +// A 16th note with rdur=112 must not become triple-dotted: 112 is the integer-truncated triple-dot +// threshold (true value 112.5), which used to misalign the rest of the measure. +TEST_F(Tst_Notes, rdur112_16th_note_not_triple_dotted) +{ + MasterScore* score = readEncoreScore("notes_16th_rdur112_no_triple_dot.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + + Segment* firstSeg = m0->first(SegmentType::ChordRest); + ASSERT_NE(firstSeg, nullptr); + EngravingItem* el = firstSeg->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isChord()); + + Chord* first = toChord(el); + EXPECT_EQ(first->durationType(), DurationType::V_16TH) + << "First chord must be a plain 16th note"; + EXPECT_EQ(first->dots(), 0) + << "rdur=112 for a 16th must not be interpreted as triple-dotted"; + + delete score; +} + +// Two explicit RESTs at the same tick (voices routing to the same MuseScore voice) must not both advance +// cumTick: the second is a duplicate at an already-filled position, or subsequent notes shift by an eighth. +TEST_F(Tst_Notes, dual_explicit_rests_same_tick_no_cumtick_drift) +{ + MasterScore* score = readEncoreScore("notes_dual_rests_same_tick_routing.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + const Fraction measTick = m->tick(); + + std::vector chordTicks; + int restCount = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + if (el->isChord()) { + chordTicks.push_back(s->tick() - measTick); + } else if (el->isRest()) { + ++restCount; + } + } + + ASSERT_EQ(static_cast(chordTicks.size()), 2) + << "Expected exactly two chords: D3+F#3 at start, B2+D3 at half-measure"; + + EXPECT_EQ(chordTicks[0].ticks(), 0) + << "First chord (D3+F#3) must be at the start of the measure"; + + EXPECT_EQ(chordTicks[1].ticks(), 960) + << "Second chord (B2+D3) must be at half-measure (MuseScore tick 960); " + "the two duplicate rests at enc tick=120 must not shift it to tick 720"; + + // At least one rest must appear (for the enc tick=120 eighth rest). + // A gap-fill rest may also appear between the explicit rest and the quarter + // note, so we only assert the minimum; the key invariant is the chord tick. + EXPECT_GE(restCount, 1) + << "At least one rest must appear for the enc tick=120 explicit rest"; + + Segment* seg2 = m->findSegment(SegmentType::ChordRest, measTick + Fraction(960, 1920)); + if (seg2) { + EngravingItem* el2 = seg2->element(0); + if (el2 && el2->isChord()) { + std::set pitches; + for (Note* n : toChord(el2)->notes()) { + pitches.insert(n->pitch()); + } + EXPECT_TRUE(pitches.count(47)) << "B2 (midi=47) must be in the second chord"; + EXPECT_TRUE(pitches.count(50)) << "D3 (midi=50) must be in the second chord"; + } + } + + delete score; +} + +// A v0xC2 note whose dotControl bit 0 is coincidentally set but whose realDuration exactly matches the +// plain face value must not be dotted; the bit-0 fallback must not fire and overflow the measure. +TEST_F(Tst_Notes, v0c2_plain_sixteenth_with_spurious_dotctrl_bit0_no_dot) +{ + MasterScore* score = readEncoreScore("notes_v0c2_plain_sixteenth_no_spurious_dot.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Measure must pass sanityCheck: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(4, 4)); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_EQ(chords.size(), 5u) + << "5 chords expected (2x16th + 3x8th); fewer means overflow truncated a note"; + EXPECT_EQ(chords[0]->durationType().type(), DurationType::V_16TH); + EXPECT_EQ(chords[0]->dots(), 0) << "16th with dotControl=0x39 must NOT be dotted"; + EXPECT_EQ(chords[1]->durationType().type(), DurationType::V_16TH); + EXPECT_EQ(chords[1]->dots(), 0) << "16th with dotControl=0x39 must NOT be dotted"; + EXPECT_EQ(chords[2]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_EQ(chords[3]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_EQ(chords[4]->durationType().type(), DurationType::V_EIGHTH); + delete score; +} +TEST_F(Tst_Notes, bazo) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, akordo) +{ + MasterScore* score = readEncoreScore("akordo.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, ripetoj) +{ + MasterScore* score = readEncoreScore("ripetoj.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, opeco_vochoj) +{ + MasterScore* score = readEncoreScore("opeco_vochoj.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, bando) +{ + MasterScore* score = readEncoreScore("bando.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, kordorkestro) +{ + MasterScore* score = readEncoreScore("kordorkestro.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, chord_parsing) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Score has corrupted measures: " << ret.text(); + delete score; +} + +TEST_F(Tst_Notes, encore_symbols) +{ + MasterScore* score = readEncoreScore("encore_symbols.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Score has corrupted measures: " << ret.text(); + delete score; +} + +// No sanityCheck: swing timing files produce slight measure shortfalls (by design). +TEST_F(Tst_Notes, swing_timing) +{ + MasterScore* score = readEncoreScore("notes_swing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Notes, multiple_voices_loaded) +{ + MasterScore* score = readEncoreScore("opeco_vochoj.enc"); + ASSERT_NE(score, nullptr); + bool foundVoice1 = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + if (s->element(1) && s->element(1)->isChordRest()) { + foundVoice1 = true; + break; + } + } + if (foundVoice1) { + break; + } + } + EXPECT_TRUE(foundVoice1) << "opeco_vochoj.enc should have notes in voice 2"; + delete score; +} + +// Regression: both notes and rests updated prevMidiTick; a note at the same tick as a rest was mis-detected +// as chord extension, replacing the rest's segment while cumTick was already advanced past it. +TEST_F(Tst_Notes, rest_does_not_anchor_chord_extension) +{ + MasterScore* score = readEncoreScore("importer_rest_not_chord_anchor.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// Regression: rest with open tuplet skipped first cap; tuplet closed; second cap shortened cumTick advance +// but left rest ticks at uncapped face value. cr->actualTicks() exceeded cumTick advance, overrunning the measure. +TEST_F(Tst_Notes, rest_caps_its_ticks_when_advance_is_capped) +{ + MasterScore* score = readEncoreScore("importer_rest_caps_in_open_tuplet.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// Regression: duplicate tt.placedTicks += advance in rest path double-counted the rest's contribution, +// masking the undershoot and skipping closeTuplet shrink. checkMeasure reported "Incomplete measure". +TEST_F(Tst_Notes, rest_in_tuplet_does_not_double_count_placed_ticks) +{ + MasterScore* score = readEncoreScore("importer_rest_in_tuplet.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_rest_in_tuplet.enc"; + EXPECT_GT(score->nmeasures(), 0); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// A dotted rest must be recognized via the realDuration snap fallback (matching the note handler); passing +// the dotControl bitmask as a tick count yielded 0 dots, turning a dotted-quarter rest into a plain one. +TEST_F(Tst_Notes, v0c4_dotted_rest_correct_duration) +{ + MasterScore* score = readEncoreScore("rest_dotted_before_notes.enc"); + ASSERT_NE(score, nullptr) << "Failed to load rest_dotted_before_notes.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Dotted rest must import without measure corruption: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(7, 8)); + + Segment* first = m->first(SegmentType::ChordRest); + ASSERT_NE(first, nullptr); + EngravingItem* el = first->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isRest()) << "First element must be a rest"; + Rest* rest = toRest(el); + EXPECT_EQ(rest->durationType().type(), DurationType::V_QUARTER) << "Base type: quarter"; + EXPECT_EQ(rest->dots(), 1) << "Must have 1 dot (dotted-quarter rest)"; + EXPECT_EQ(rest->ticks(), Fraction(3, 8)) << "Dotted-quarter rest spans 3/8"; + delete score; +} + +// When MIDI drift puts rdur more than 1 tick off the dotted value, the dotControl bit-0 flag must still +// mark the note dotted (overriding calcDotsSnap's 0). +TEST_F(Tst_Notes, v0c4_dotted_note_dotctrl_bit0_drift) +{ + MasterScore* score = readEncoreScore("notes_dotted_ctrl_bit0_drift.enc"); + ASSERT_NE(score, nullptr) << "Failed to load notes_dotted_ctrl_bit0_drift.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "dotControl bit-0 dotted note with rdur drift must not corrupt: " + << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + Segment* first = m->first(SegmentType::ChordRest); + ASSERT_NE(first, nullptr); + EngravingItem* el = first->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isChord()) << "First element must be a chord, not a rest"; + Chord* c = toChord(el); + EXPECT_EQ(c->durationType().type(), DurationType::V_EIGHTH) + << "First note base type: eighth"; + EXPECT_EQ(c->dots(), 1) + << "dotControl bit 0 forces 1 dot when calcDotsSnap misses due to rdur drift"; + delete score; +} + +// Regression: options byte bit 0 and position field must NOT produce string numbers on plain +// piano/vocal notes. +TEST_F(Tst_Notes, v0c4_no_spurious_string_numbers_from_options_byte) +{ + MasterScore* score = readEncoreScore("notes_no_spurious_string_numbers.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + int fingerCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < score->nstaves() * VOICES; ++v) { + EngravingItem* el = s->element(static_cast(v)); + if (el && el->isChord()) { + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* sub : n->el()) { + if (sub && sub->isFingering()) { + ++fingerCount; + } + } + } + } + } + } + } + EXPECT_EQ(fingerCount, 0) + << "Piano/grand-staff notes must not get circled string numbers."; + + delete score; +} + +// Regression: gap snap used denominator 4*beatTicks (correct only for x/4). x/8 beatTicks=120 gave +// half the correct whole-note value, overflowing by one beat. +TEST_F(Tst_Notes, v0c4_gap_snap_eighth_meter) +{ + MasterScore* score = readEncoreScore("importer_gap_snap_eighth_meter.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_gap_snap_eighth_meter.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector ticks; + std::vector isRest; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + ticks.push_back(s->tick() - m->tick()); + isRest.push_back(el->isRest()); + } + ASSERT_GE(ticks.size(), 3u); + EXPECT_EQ(ticks[0], Fraction(0, 1)); + EXPECT_TRUE(isRest[0]) << "beat 1 must be a rest"; + EXPECT_EQ(ticks[1], Fraction(1, 8)); + EXPECT_TRUE(isRest[1]) << "beat 2 must be a rest"; + EXPECT_EQ(ticks[2], Fraction(2, 8)); + EXPECT_FALSE(isRest[2]) << "beat 3 must carry the chord"; + delete score; +} + +// Regression: gap snap + inflated-rdur guard with Key=-12 (Octave Lower). Voice 0 has implicit +// leading silence (tick offsets, no REST); voice 1 has trailing silence; Key shifts all pitches by -12. +TEST_F(Tst_Notes, v0c4_octave_lower_implicit_silences) +{ + MasterScore* score = readEncoreScore("structure_octave_lower_implicit_silences.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_octave_lower_implicit_silences.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + const track_idx_t baseTrack = 0; + + std::vector v0Ticks; + std::vector v0IsRest; + std::vector v0Pitches; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(baseTrack); + if (!el) { + continue; + } + v0Ticks.push_back(s->tick() - m->tick()); + if (el->isRest()) { + v0IsRest.push_back(true); + v0Pitches.push_back(-1); + } else if (el->isChord()) { + v0IsRest.push_back(false); + v0Pitches.push_back(toChord(el)->upNote()->pitch()); + } + } + ASSERT_EQ(v0Ticks.size(), 3u) << "voice 0 must contain rest + 2 notes"; + EXPECT_TRUE(v0IsRest[0]) << "beat 1 must be a rest, not a note"; + EXPECT_EQ(v0Ticks[0], Fraction(0, 1)); + EXPECT_FALSE(v0IsRest[1]); + EXPECT_EQ(v0Ticks[1], Fraction(1, 4)); + EXPECT_EQ(v0Pitches[1], 73 - 12); + EXPECT_FALSE(v0IsRest[2]); + EXPECT_EQ(v0Ticks[2], Fraction(2, 4)); + EXPECT_EQ(v0Pitches[2], 74 - 12); + + Chord* v1Chord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(baseTrack + 1); + if (el && el->isChord()) { + v1Chord = toChord(el); + break; + } + } + ASSERT_NE(v1Chord, nullptr) << "encVoice=1 chord must land on MuseScore voice 2"; + EXPECT_EQ(v1Chord->durationType().type(), DurationType::V_QUARTER) + << "face=quarter must win over rdur inflated to dotted-half ratio (720)"; + EXPECT_EQ(v1Chord->durationType().dots(), 0); + ASSERT_EQ(v1Chord->notes().size(), 2u); + std::set pitches{ v1Chord->notes()[0]->pitch(), v1Chord->notes()[1]->pitch() }; + EXPECT_EQ(pitches, (std::set { 64 - 12, 73 - 12 })); + delete score; +} + +// Regression: single-switch voice overflow placed a note into a full voice (e.g. half rest). +TEST_F(Tst_Notes, multi_stream_switch_skips_voice_filled_by_rest) +{ + MasterScore* score = readEncoreScore("importer_full_voice_skipped.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_full_voice_skipped.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// Regression: gap snap fired on drift ticks, producing a zero-length rhythmic gap that aborted +// populateRhythmicList. +TEST_F(Tst_Notes, v0c2_multi_stream_drift_imports_cleanly) +{ + MasterScore* score = readEncoreScore("importer_v0c2_multi_stream_drift.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0c2_multi_stream_drift.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// Regression guard: 2/2 with the correct beatTicks=480 still imports all notes after wholeTicks became the +// constant 960 (was beatTicks*timeSigDen). +TEST_F(Tst_Notes, v0c4_2_2_beatticks480_correct_encoding_still_works) +{ + MasterScore* score = readEncoreScore("importer_2_2_beatticks480_correct.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + + std::vector > elements; + for (Segment* s = m0->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + bool isGap = el->isRest() && toRest(el)->isGap(); + if (!isGap) { + elements.emplace_back(s->tick() - m0->tick(), el->isRest()); + } + } + + ASSERT_EQ(elements.size(), 7u) + << "Correct 2/2 (beatTicks=480) must also produce 7 elements"; + EXPECT_EQ(elements[2].first, Fraction(3, 8)) + << "Third element must be at 3/8, no regression from wholeTicks fix"; + EXPECT_FALSE(elements[2].second) << "Third element must be a chord, not a rest"; + + delete score; +} + +// With a non-standard beatTicks=240 in 2/2, gap-snap must use the constant 960 ticks/whole so it does not +// fire at the wrong positions and drop notes. +TEST_F(Tst_Notes, v0c4_2_2_beatticks240_gap_snap_no_false_fire) +{ + MasterScore* score = readEncoreScore("importer_2_2_beatticks240_gap_snap.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + + std::vector > elements; + for (Segment* s = m0->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + bool isGap = el->isRest() && toRest(el)->isGap(); + if (!isGap) { + elements.emplace_back(s->tick() - m0->tick(), el->isRest()); + } + } + + ASSERT_EQ(elements.size(), 7u) + << "Measure must contain exactly 7 elements (rest + Q + 5 eighths); " + "gap-snap with wrong wholeTicks drops notes 3-7"; + + EXPECT_EQ(elements[0].first, Fraction(0, 1)); + EXPECT_TRUE(elements[0].second) << "element 0 must be a rest"; + + EXPECT_EQ(elements[1].first, Fraction(1, 8)); + EXPECT_FALSE(elements[1].second) << "element 1 must be a chord (Q)"; + + EXPECT_EQ(elements[2].first, Fraction(3, 8)) + << "element 2 must be at 3/8 (tick 360); " + "false gap-snap would place it at 3/4 (tick 720)"; + EXPECT_FALSE(elements[2].second) << "element 2 must be a chord, not a rest"; + + EXPECT_EQ(elements[3].first, Fraction(4, 8)); + EXPECT_EQ(elements[4].first, Fraction(5, 8)); + EXPECT_EQ(elements[5].first, Fraction(6, 8)); + EXPECT_EQ(elements[6].first, Fraction(7, 8)); + for (int i = 3; i <= 6; ++i) { + EXPECT_FALSE(elements[i].second) << "element " << i << " must be a chord"; + } + + delete score; +} + +#ifndef ENC_SANITY_TEST_NOTES +#define ENC_SANITY_TEST_NOTES(testName, fileName) \ + TEST_F(Tst_Notes, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } +#endif + +// Covers: tuplet=0xFF (degenerate), faceValue=0 (invalid), voice>=4, open SLURSTART +ENC_SANITY_TEST_NOTES(corrupted_elements, "notes_corrupted.enc") + +// Covers: explicit 3:2 triplets, 3/4 time sig, multi-measure +ENC_SANITY_TEST_NOTES(explicit_triplets_3_4, "notes_triplets.enc") diff --git a/src/importexport/encore/tests/tst_notes_mrest.cpp b/src/importexport/encore/tests/tst_notes_mrest.cpp new file mode 100644 index 0000000000000..471198b7b011f --- /dev/null +++ b/src/importexport/encore/tests/tst_notes_mrest.cpp @@ -0,0 +1,257 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Multi-measure rest expansion: a single MEAS block with mrestCount>1 expands to that many measures, +// across successor/predecessor rest cases and multi-staff files, plus the createMultiMeasureRests style flag. + +#include + +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/clef.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/part.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/slur.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/volta.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftype.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_NotesMrest : public ::testing::Test, public MTest +{ +protected: + void SetUp() override + { + setRootDir(ENC_DIR); + } +}; + +// A single MEAS block whose lone REST has mrestCount>1 must expand to that many measures, +// keeping system locks aligned. Fixture: 7 blocks with MEAS[3] mrest=3 -> 9 measures. +TEST_F(Tst_NotesMrest, mrest_single_block_expands_and_system_locks_correct) +{ + MasterScore* score = readEncoreScore("importer_mrest_single_block.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_mrest_single_block.enc"; + ASSERT_EQ(score->nmeasures(), 9) + << "7 MEAS blocks + 2 extra from MEAS[3] mrestCount=3"; + + auto measureAt = [](MasterScore* sc, int idx) -> Measure* { + Measure* m = sc->firstMeasure(); + for (int i = 0; i < idx && m; ++i) { + m = m->nextMeasure(); + } + return m; + }; + auto hasPitchedNotes = [](Measure* m) -> bool { + if (!m) { + return false; + } + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < VOICES; ++v) { + EngravingItem* el = s->element(v); + if (el && el->isChord()) { + return true; + } + } + } + return false; + }; + + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 0))) << "measure 1 must have notes"; + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 1))) << "measure 2 must have notes"; + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 2))) << "measure 3 must have notes"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 3))) << "measure 4 must be empty"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 4))) << "measure 5 must be empty"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 5))) << "measure 6 must be empty"; + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 6))) << "measure 7 must have notes"; + + delete score; +} + +// Regression: mrest expansion must not depend on the successor block having notes; a mrest=3 +// followed by a plain rest measure must still expand (encMeasDisplayCount used to collapse it). +TEST_F(Tst_NotesMrest, mrest_single_block_expands_when_successor_is_rest) +{ + MasterScore* score = readEncoreScore("importer_mrest_followed_by_rest.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_mrest_followed_by_rest.enc"; + ASSERT_EQ(score->nmeasures(), 8) + << "5 real MEAS blocks + 2 extra from mrestCount=3 + 1 filler; without fix only 6"; + + auto measureAt = [](MasterScore* sc, int idx) -> Measure* { + Measure* m = sc->firstMeasure(); + for (int i = 0; i < idx && m; ++i) { + m = m->nextMeasure(); + } + return m; + }; + auto hasPitchedNotes = [](Measure* m) -> bool { + if (!m) { + return false; + } + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < VOICES; ++v) { + EngravingItem* el = s->element(v); + if (el && el->isChord()) { + return true; + } + } + } + return false; + }; + + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 0))) << "measure 1 must have notes"; + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 1))) << "measure 2 must have notes"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 2))) << "measure 3 (mrest) must be empty"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 3))) << "measure 4 (mrest) must be empty"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 4))) << "measure 5 (mrest) must be empty"; + // Measure 6 is the single-rest successor: the case that used to suppress expansion. + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 5))) << "measure 6 (single rest) must be empty"; + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 6))) << "measure 7 must have notes"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 7))) << "measure 8 (filler) must be empty"; + + delete score; +} + +// Regression: a mrest>1 block must expand even when the preceding block is a plain single-measure +// rest; encMeasHasSingleRest used to ignore mrestCount and suppress expansion after any rest. +TEST_F(Tst_NotesMrest, mrest_single_block_expands_when_preceded_by_rest) +{ + MasterScore* score = readEncoreScore("importer_mrest_preceded_by_rest.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_mrest_preceded_by_rest.enc"; + ASSERT_EQ(score->nmeasures(), 12) + << "4 MEAS blocks + 2 pad + 6 extra from mrestCount=7; without fix only 6 measures"; + + auto measureAt = [](MasterScore* sc, int idx) -> Measure* { + Measure* m = sc->firstMeasure(); + for (int i = 0; i < idx && m; ++i) { + m = m->nextMeasure(); + } + return m; + }; + auto hasPitchedNotes = [](Measure* m) -> bool { + if (!m) { + return false; + } + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < VOICES; ++v) { + EngravingItem* el = s->element(v); + if (el && el->isChord()) { + return true; + } + } + } + return false; + }; + + // Measures 1-2 are the plain single-measure rests preceding the mrest (the trigger case). + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 0))) << "measure 1 must be empty"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 1))) << "measure 2 must be empty"; + for (int i = 2; i < 9; ++i) { + EXPECT_FALSE(hasPitchedNotes(measureAt(score, i))) + << "measure " << (i + 1) << " must be empty (mrest expansion)"; + } + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 9))) << "measure 10 must have notes"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 10))) << "measure 11 (pad) must be empty"; + EXPECT_FALSE(hasPitchedNotes(measureAt(score, 11))) << "measure 12 (pad) must be empty"; + + delete score; +} + +// Regression: a multi-staff mrest block holds one REST per staff, so the old size!=1 guard +// collapsed the block to a single measure for any multi-staff file. Fixture: 2 staves, mrest=7. +TEST_F(Tst_NotesMrest, mrest_single_block_expands_for_multi_staff_file) +{ + MasterScore* score = readEncoreScore("importer_mrest_multistaff.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_mrest_multistaff.enc"; + ASSERT_EQ(score->nmeasures(), 8) + << "mrest=7 across 2 staves must expand to 7 measures + 1 note; without fix only 2"; + + auto measureAt = [](MasterScore* sc, int idx) -> Measure* { + Measure* m = sc->firstMeasure(); + for (int i = 0; i < idx && m; ++i) { + m = m->nextMeasure(); + } + return m; + }; + auto hasPitchedNotes = [](Measure* m) -> bool { + if (!m) { + return false; + } + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < VOICES; ++v) { + EngravingItem* el = s->element(v); + if (el && el->isChord()) { + return true; + } + } + } + return false; + }; + + for (int i = 0; i < 7; ++i) { + EXPECT_FALSE(hasPitchedNotes(measureAt(score, i))) + << "measure " << (i + 1) << " must be empty (mrest expansion)"; + } + EXPECT_TRUE(hasPitchedNotes(measureAt(score, 7))) << "measure 8 must have notes"; + + delete score; +} + +// createMultiMeasureRests must be set only when the file has a mrestCount>1 block; it used to be +// always true, wrongly collapsing separate rest measures in files with only individual rests. +TEST_F(Tst_NotesMrest, mmrest_flag_off_when_file_has_no_mrest_blocks) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + EXPECT_FALSE(score->style().styleB(Sid::createMultiMeasureRests)) + << "createMultiMeasureRests must be FALSE when the file has no mrestCount>1 blocks"; + delete score; +} + +TEST_F(Tst_NotesMrest, mmrest_flag_on_when_file_has_mrest_block) +{ + MasterScore* score = readEncoreScore("importer_mrest_single_block.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->style().styleB(Sid::createMultiMeasureRests)) + << "createMultiMeasureRests must be TRUE when the file contains mrestCount>1 blocks"; + delete score; +} diff --git a/src/importexport/encore/tests/tst_notes_ties.cpp b/src/importexport/encore/tests/tst_notes_ties.cpp new file mode 100644 index 0000000000000..f330c088e2e87 --- /dev/null +++ b/src/importexport/encore/tests/tst_notes_ties.cpp @@ -0,0 +1,580 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// TIE element import: the many direction/flag/arc-extent/source-position encodings that mark a real tie start, +// consecutive-receiver matching, and a comprehensive all-features smoke fixture. +// See ENCORE_FORMAT.md §TIE element and ENCORE_IMPORTER.md §TIE element handling. + +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/drumset.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/textbase.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +static Measure* measureAt(MasterScore* score, int n) +{ + int idx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + if (idx == n) { + return toMeasure(mb); + } + ++idx; + } + return nullptr; +} + +class Tst_NotesTies : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +TEST_F(Tst_NotesTies, tie_direction_fc_creates_tie) +{ + MasterScore* score = readEncoreScore("notes_tie_dir_fc.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 1) << "expected one tie from the 0xfc TIE element"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_direction_02_creates_tie) +{ + MasterScore* score = readEncoreScore("notes_tie_dir_02.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 1) << "expected one tie from the 0x02 TIE dir element"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_18byte_intra_chord_arc_no_spurious_tie) +{ + // An 18-byte TIE with arcX1==arcX2 is an intra-chord arc, not a forward tie, so no ties are created. + MasterScore* score = readEncoreScore("notes_tie_intra_chord_arc_no_spurious.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "intra-chord arc test must produce clean score: " << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 0) + << "18-byte TIE with arcX1==arcX2 is an intra-chord arc; must produce NO forward ties"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_18byte_real_forward_still_creates_tie) +{ + // arcX1 != arcX2 is a real forward tie; C4@0 must tie to C4@480. + MasterScore* score = readEncoreScore("notes_tie_18byte_real_forward.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "18-byte real forward tie must produce clean score: " << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 1) + << "18-byte TIE with arcX1!=arcX2 is a real forward tie; must still create one Tie"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_direction_04_forward_creates_tie) +{ + // Regression: the +5 byte is a signed arc curvature, not a bitfield, so 0x04 (curves down) is a real + // tie just like 0xFC. A forward tie is marked by arcX1 < arcX2 regardless of the +5 sign; the old + // bit-based rule dropped every downward-curving tie. + MasterScore* score = readEncoreScore("notes_tie_dir_04_forward.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 1) << "expected one forward tie from the +5=0x04 (arcX1sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 1) << "expected one tie from the 0x03 TIE dir element"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_start_flag_on_byte6_creates_tie) +{ + MasterScore* score = readEncoreScore("notes_tie_start_flag_byte6.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 1) + << "expected one tie from the +6=0x80 TIE-start-flag element"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_source_position_partial_chord) +{ + // Regression: a TIE carries a sourcePosition selecting which chord note it starts from. It must tie + // only that note (A4 at pos=5), not every note at the chord tick (C#4 at pos=0 must stay untied). + MasterScore* score = readEncoreScore("notes_tie_partial_chord_source_position.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "partial-chord source-position tie must produce clean score: " << ret.text(); + + std::map tiesByPitch; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + tiesByPitch[n->pitch()]++; + } + } + } + } + + EXPECT_EQ(tiesByPitch.count(61), 0) + << "C#4 (pitch=61, pos=0) must NOT get a tie: sourcePosition=5 does not match pos=0"; + EXPECT_EQ(tiesByPitch.count(69), 1) + << "A4 (pitch=69, pos=5) must get exactly one tie: sourcePosition=5 matches pos=5"; + + delete score; +} + +TEST_F(Tst_NotesTies, tie_crossmeasure_arcxx_equal_with_startflag) +{ + // Regression: an explicit startFlag=0x80 must create a cross-measure tie even when arcX1==arcX2 + // (zero horizontal extent) would otherwise suppress it. + MasterScore* score = readEncoreScore("notes_tie_crossmeasure_arcxx_equal.enc"); + ASSERT_NE(score, nullptr); + + std::vector notes; + for (MeasureBase* mb = score->first(); mb && notes.size() < 2; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + Chord* c = toChord(el); + if (!c->notes().empty()) { + notes.push_back(c->notes().front()); + } + } + } + } + ASSERT_GE(notes.size(), 2u); + ASSERT_EQ(notes[0]->pitch(), 60); + ASSERT_EQ(notes[1]->pitch(), 60); + + ASSERT_NE(notes[0]->tieFor(), nullptr) + << "cross-measure tie (arcX1==arcX2, startFlag=0x80) must not be suppressed"; + EXPECT_EQ(notes[0]->tieFor()->endNote(), notes[1]) + << "tie must connect the whole note in measure 1 to the whole note in measure 2"; + + delete score; +} + +TEST_F(Tst_NotesTies, tie_spurious_far_receiver_dropped) +{ + // Regression: a tie completes only when the receiver begins exactly where the tie-start note ends. + // A same-pitch note later in the measure (C4@720 after C4@0 ends at 240) is not consecutive, so the + // tie must be dropped rather than arcing across the bar. + MasterScore* score = readEncoreScore("notes_tie_spurious_far_receiver.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "spurious far-receiver test must produce clean score: " << ret.text(); + + int tieCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + if (n->tieFor()) { + ++tieCount; + } + } + } + } + EXPECT_EQ(tieCount, 0) + << "tie-start with no consecutive same-pitch note must not arc to a far receiver"; + delete score; +} + +TEST_F(Tst_NotesTies, tie_element_creates_mscore_tie) +{ + // A basic TIE element must link two consecutive same-pitch notes with a real Tie (they used to be dropped). + MasterScore* score = readEncoreScore("notes_tie.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Tie test must produce clean score: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + break; + } + } + ASSERT_NE(first, nullptr) << "Must have a first chord"; + ASSERT_GE(first->notes().size(), 1u); + + Note* startNote = first->notes()[0]; + EXPECT_EQ(startNote->pitch(), 60) << "First note must be C4 (pitch=60)"; + + ASSERT_NE(startNote->tieFor(), nullptr) + << "TIE element must create a Tie from the first C4 to the second C4"; + EXPECT_NE(startNote->tieFor()->endNote(), nullptr) + << "Tie must link to an end note"; + if (startNote->tieFor()->endNote()) { + EXPECT_EQ(startNote->tieFor()->endNote()->pitch(), 60) + << "Tie end note must also be C4"; + } + delete score; +} + +TEST_F(Tst_NotesTies, sf_tiestart_not_filtered_by_rdur) +{ + // A 64th/128th note that starts a tie must bypass the short-rdur (<15) MIDI-artifact filter. + MasterScore* score = readEncoreScore("notes_sf_tiestart.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "sf tiestart test must produce clean score: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Chord* first = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + first = toChord(e); + break; + } + } + ASSERT_NE(first, nullptr) << "Must have a first chord (the 64th)"; + EXPECT_EQ(first->durationType().type(), DurationType::V_64TH) + << "64th note with TIE element must not be filtered by rdur<15"; + ASSERT_GE(first->notes().size(), 1u); + + Note* sfNote = first->notes()[0]; + ASSERT_NE(sfNote->tieFor(), nullptr) + << "64th note must have an outgoing tie to the Q note"; + if (sfNote->tieFor()) { + Note* endNote = sfNote->tieFor()->endNote(); + ASSERT_NE(endNote, nullptr); + EXPECT_EQ(endNote->chord()->durationType().type(), DurationType::V_QUARTER) + << "Tie end must be the Q note"; + } + delete score; +} + +#ifndef ENC_SANITY_TEST_TIES +#define ENC_SANITY_TEST_TIES(testName, fileName) \ + TEST_F(Tst_NotesTies, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } +#endif + +ENC_SANITY_TEST_TIES(tie_start_flag_byte6, "notes_tie_start_flag_byte6.enc") +ENC_SANITY_TEST_TIES(tie_direction_fc, "notes_tie_dir_fc.enc") + +// Comprehensive synthetic fixture exercising every reader/importer feature; checks clean import and counts. +TEST_F(Tst_NotesTies, sintetico_all_features_imports_cleanly) +{ + MasterScore* score = readEncoreScore("sintetico_all_features.enc"); + ASSERT_NE(score, nullptr) << "Failed to load sintetico_all_features.enc"; + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "sanityCheck failed: " << ret.text(); + + EXPECT_EQ(score->parts().size(), 2u) << "2 instruments expected"; + EXPECT_EQ(score->nmeasures(), 20) << "20 measures expected"; + + int fermatas=0, tuplets=0, lyrics_count=0, hairpins=0, spanners=0; + int tremolos=0, arpeggios=0, tempos=0, dynamics=0, markers=0; + + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + if (m->repeatStart()) { + ++spanners; + } + for (EngravingItem* me : m->el()) { + if (me && me->isMarker()) { + ++markers; + } + } + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (!e) { + continue; + } + if (e->isFermata()) { + ++fermatas; + } + if (e->isDynamic()) { + ++dynamics; + } + if (e->isTempoText()) { + ++tempos; + } + } + for (int ti = 0; ti < static_cast(score->nstaves() * VOICES); ++ti) { + EngravingItem* el = s->element(static_cast(ti)); + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + if (c->tremoloSingleChord()) { + ++tremolos; + } + if (c->arpeggio()) { + ++arpeggios; + } + for (Lyrics* ly : c->lyrics()) { + if (ly) { + ++lyrics_count; + } + } + } + if (s->isChordRestType()) { + Tuplet* tup = nullptr; + EngravingItem* el = s->element(0); + if (el && el->isChordRest()) { + tup = toChordRest(el)->tuplet(); + } + if (tup && tup->elements().front() == s->element(0)) { + ++tuplets; + } + } + } + } + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + ++hairpins; + } + } + + EXPECT_GE(fermatas, 1) << "at least 1 fermata (non-tuplet note with articUp=0x20)"; + EXPECT_GE(tuplets, 2) << "at least 2 tuplet groups (triplets in m3)"; + EXPECT_GE(lyrics_count, 4) << "4 lyrics syllables (do re mi fa)"; + EXPECT_GE(dynamics, 8) << "at least 8 of the 13 dynamics"; + EXPECT_GE(tempos, 3) << "at least 3 TempoText marks"; + EXPECT_GE(hairpins, 1) << "at least 1 hairpin"; + EXPECT_GE(tremolos, 1) << "at least 1 TremoloSingleChord"; + EXPECT_GE(arpeggios, 1) << "at least 1 arpeggio"; + EXPECT_GE(markers, 2) << "at least 2 section markers"; + EXPECT_GE(spanners, 1) << "at least 1 repeat-start barline"; + + delete score; +} diff --git a/src/importexport/encore/tests/tst_notes_tuplets.cpp b/src/importexport/encore/tests/tst_notes_tuplets.cpp new file mode 100644 index 0000000000000..1dd424e9ca783 --- /dev/null +++ b/src/importexport/encore/tests/tst_notes_tuplets.cpp @@ -0,0 +1,1423 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Tuplet import: ratio and actual-tick reconstruction, nested/irregular groups, and orphan-member repair. +// See ENCORE_IMPORTER.md §Rhythm: face value, dots, tuplets. + +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/drumset.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/textbase.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +static Measure* measureAt(MasterScore* score, int n) +{ + int idx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + if (idx == n) { + return toMeasure(mb); + } + ++idx; + } + return nullptr; +} + +class Tst_NotesTuplets : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +TEST_F(Tst_NotesTuplets, explicit_triplets_in_score) +{ + // notes_triplets.enc: measure 1 has 9 explicit triplet eighths (tup=0x32) + // forming three 3:2 groups. Verifies tuplets are parsed and have non-zero ticks. + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + + int measWithTuplets = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (e->isTuplet()) { + Tuplet* t = toTuplet(e); + EXPECT_NE(t->ticks(), Fraction(0, 1)) << "Tuplet ticks must be non-zero"; + EXPECT_EQ(t->ratio().reduced(), Fraction(3, 2)) << "Should be 3:2 triplet"; + ++measWithTuplets; + break; + } + } + } + EXPECT_GT(measWithTuplets, 0) << "Should have at least one measure with triplets"; + delete score; +} + +TEST_F(Tst_NotesTuplets, tuplet_notes_have_correct_actual_ticks) +{ + // For a 3:2 triplet of eighth notes, actualTicks = (1/8) / (3/2) = 1/12. + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + + bool foundTripletNote = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (!e->isTuplet()) { + continue; + } + Tuplet* t = toTuplet(e); + if (t->ratio().reduced() != Fraction(3, 2)) { + continue; + } + for (DurationElement* de : t->elements()) { + if (!de->isChordRest()) { + continue; + } + EXPECT_EQ(toChordRest(de)->actualTicks(), Fraction(1, 12)) + << "Triplet eighth note should have actualTicks = 1/12"; + foundTripletNote = true; + break; + } + if (foundTripletNote) { + break; + } + } + if (foundTripletNote) { + break; + } + } + EXPECT_TRUE(foundTripletNote) << "Should find a 3:2 triplet note"; + delete score; +} + +TEST_F(Tst_NotesTuplets, tuplet_measure_fills_correctly) +{ + // Both measures of notes_triplets.enc must pass sanityCheck. + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Triplet score should pass sanityCheck: " << ret.text(); + delete score; +} + +TEST_F(Tst_NotesTuplets, tuplet_ticks_not_zero) +{ + // Tuplet::ticks() must be non-zero (setTicks called), or checkMeasure sees duration 0 and adds rests. + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (e->isTuplet()) { + EXPECT_NE(toTuplet(e)->ticks(), Fraction(0, 1)) + << "No tuplet should have zero ticks after fix"; + } + } + } + delete score; +} + +TEST_F(Tst_NotesTuplets, tuplet_state_cleared_between_measures) +{ + // Tuplet state must be cleared between measures, or a triplet opened in one measure stays active in the + // next and wrongly absorbs its plain notes. + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + + int measIdx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + ++measIdx; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; + seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (!el || !el->isChord()) { + continue; + } + Chord* ch = toChord(el); + if (!ch->tuplet()) { + int den = ch->durationType().fraction().denominator(); + bool isPow2 = (den > 0) && ((den & (den - 1)) == 0); + EXPECT_TRUE(isPow2) + << "Measure " << measIdx << " non-tuplet chord has denominator " + << den << ", tuplet state may have bled from the previous measure"; + } + } + } + delete score; +} + +TEST_F(Tst_NotesTuplets, tuplet_note_sorts_before_non_tuplet_at_same_tick) +{ + // notes_tuplet_sort.enc has, at tick=0, a non-tuplet quarter written + // BEFORE a tuplet eighth in the binary stream. + // Without the sort fix: the quarter creates the chord → no tuplet started → + // voice sum = 3/4 (wrong for a 2/4 measure). + // With the fix: the tuplet eighth sorts first → V_EIGHTH + 3:2 tuplet group → + // voice sum = 1/4 (triplet) + 1/4 (trailing quarter) = 2/4. + MasterScore* score = readEncoreScore("notes_tuplet_sort.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Tuplet-sort file should pass sanityCheck: " << ret.text(); + delete score; +} + +TEST_F(Tst_NotesTuplets, no_degenerate_tuplet_ratios) +{ + // A degenerate tuplet byte (0xFF -> 15:15) must be skipped; no tuplet may reduce to 1:1. + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (!e->isTuplet()) { + continue; + } + Tuplet* t = toTuplet(e); + Fraction r = t->ratio().reduced(); + EXPECT_NE(r, Fraction(1, 1)) << "No tuplet should have 1:1 ratio"; + EXPECT_LE(r.numerator(), 9) << "Tuplet numerator should be <= 9"; + EXPECT_LE(r.denominator(), 9) << "Tuplet denominator should be <= 9"; + } + } + delete score; +} + +TEST_F(Tst_NotesTuplets, swing_offgrid_spurious_triplet_removed) +{ + // Note at tick=560 (rdur=160) triggers implied 3:2 triplet but MS offset is off canonical grid (swing artifact). + // adjustMeasureTuplets removes the spurious triplet; 3 plain quarters = 3/4. Without this: sum overflows 3/4. + MasterScore* score = readEncoreScore("notes_swing_offgrid.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Off-grid swing file should pass sanityCheck after tuplet removal: " + << ret.text(); + delete score; +} + +TEST_F(Tst_NotesTuplets, canonical_implied_triplet_preserved) +{ + // notes_canonical_triplet.enc: 3/4, 3 EXPLICIT 3:2 triplet quarter notes + // (tuplet=0x32) + 1 plain quarter. + // With faceValue-cumulative placement: tuplets advance by 1/4*2/3=1/6 each. + // Sum: 3*(1/6) + 1/4 = 1/2 + 1/4 = 3/4 = mLen → sanityCheck passes. + // Tuplets are explicitly encoded → always detected regardless of implied-detection logic. + MasterScore* score = readEncoreScore("notes_canonical_triplet.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Explicit triplets should produce correct 3/4 measure: " << ret.text(); + bool hasTuplet = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (e->isTuplet()) { + hasTuplet = true; + break; + } + } + if (hasTuplet) { + break; + } + } + EXPECT_TRUE(hasTuplet) << "Explicit 3:2 triplet notes should produce a Tuplet element"; + delete score; +} + +TEST_F(Tst_NotesTuplets, overflow_measure_extended) +{ + // notes_overflow_extend.enc: 2/4 measure with 2 notes (fv=2, half). + // With faceValue-cumulative placement: + // Note 1 (fv=2=half): cumTick=0, advance by 1/2. cumTick = 1/2 = mLen. + // Note 2: cumTick already = mLen → skipped (voice full). + // Result: 1 half note = 1/2 = mLen. sanityCheck passes cleanly. + // Time signature unchanged at 2/4. + MasterScore* score = readEncoreScore("notes_overflow_extend.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Measure with face-value overflow should pass sanityCheck " + "(second note skipped when voice full): " << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)) << "Time signature should stay 2/4"; + delete score; +} + +TEST_F(Tst_NotesTuplets, whole_rest_in_partial_measure) +{ + // notes_whole_rest_2_4.enc: 2/4 measure with a single rest (faceValue=1). + // Encore encodes a whole-measure rest as fv=1 regardless of the time signature. + // realDuration2DurationType(480, 1) must return V_HALF (not V_WHOLE) because + // A whole-measure rest in 2/4 must import as a half rest (from rdur), not a whole rest that overfills. + MasterScore* score = readEncoreScore("notes_whole_rest_2_4.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "2/4 whole-measure rest should pass sanityCheck: " << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)) << "Time signature should be 2/4"; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isRest()) { + EXPECT_EQ(toRest(e)->durationType().type(), DurationType::V_HALF) + << "Whole-measure rest in 2/4 should be a half rest (rdur=480 maps to V_HALF)"; + break; + } + } + delete score; +} + +TEST_F(Tst_NotesTuplets, explicit_tuplet_facevalue_not_rdur) +{ + // Explicitly-marked tuplet notes must take their duration from the face value, not a truncated rdur + // (a following rest can shorten the last member's rdur and wrongly demote it). + MasterScore* score = readEncoreScore("notes_explicit_tup_rdur_truncated.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Explicit triplet with truncated rdur should pass sanityCheck: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + int tupletEighthCount = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e || !e->isChord()) { + continue; + } + Chord* ch = toChord(e); + if (ch->tuplet()) { + EXPECT_EQ(ch->durationType().type(), DurationType::V_EIGHTH) + << "Explicit tuplet notes should have V_EIGHTH (face value), not V_32ND from rdur"; + ++tupletEighthCount; + } + } + EXPECT_EQ(tupletEighthCount, 3) << "Should have exactly 3 tuplet notes"; + delete score; +} + +TEST_F(Tst_NotesTuplets, partial_explicit_group_treated_as_plain) +{ + // A 4th note carrying the tuplet byte after a complete 3:2 group is not a valid group member and must + // be treated as a plain note, or it starts a partial tuplet and the measure overshoots 4/4. + MasterScore* score = readEncoreScore("notes_partial_explicit_group.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Partial explicit group should pass sanityCheck: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_EQ(chords.size(), 5u) << "Should have 5 chords"; + EXPECT_NE(chords[0]->tuplet(), nullptr) << "Note 1 should be in tuplet"; + EXPECT_NE(chords[1]->tuplet(), nullptr) << "Note 2 should be in tuplet"; + EXPECT_NE(chords[2]->tuplet(), nullptr) << "Note 3 should be in tuplet"; + // Chord 4: isolated tup=0x32, must be treated as plain (no tuplet) + EXPECT_EQ(chords[3]->tuplet(), nullptr) << "Note 4 (isolated tup byte) should NOT be in tuplet"; + // Chord 5: plain + EXPECT_EQ(chords[4]->tuplet(), nullptr) << "Note 5 (plain) should NOT be in tuplet"; + delete score; +} + +TEST_F(Tst_NotesTuplets, dotted_note_capped_to_remaining_space) +{ + // notes_dotted_note_capping.enc: 2/4 measure with 3 sixteenth notes + // (ticks 0, 40, 80) followed by a quarter note (tick=120, rdur=360). + // rdur=360 → realDuration2DurationType gives V_QUARTER; calcDots gives dots=1 + // (dotted quarter, 3/8 = 6/16). cumTick after 3 sixteenths = 3/16. + // remaining = 2/4 - 3/16 = 5/16. + // + // Bug: TDuration(V_QUARTER).fraction() = 1/4 = 4/16 ≤ 5/16 → capping does + // NOT fire → chord placed as dotted quarter (3/8 = 6/16) → sum=9/16 > 2/4. + // Fix: include dots in comparison: 6/16 > 5/16 → capping fires → chord placed + // as plain quarter (1/4 = 4/16) → sum = 3/16+1/4+1/16(fill) = 8/16 = 2/4. PASS. + MasterScore* score = readEncoreScore("notes_dotted_note_capping.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Dotted note past measure end should be capped: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + // The 4th chord (dotted Q, rdur=360) must be capped to V_QUARTER (no dots). + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_GE(chords.size(), 4u) << "Should have at least 4 chords"; + // First 3: V_16TH + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(chords[i]->durationType().type(), DurationType::V_16TH) + << "First 3 chords should be sixteenth notes"; + } + // 4th: capped to V_QUARTER (not dotted quarter which would overflow) + EXPECT_EQ(chords[3]->durationType().type(), DurationType::V_QUARTER) + << "Dotted quarter must be capped to plain quarter when it overflows"; + EXPECT_EQ(chords[3]->dots(), 0) << "Capped chord must have 0 dots"; + delete score; +} + +TEST_F(Tst_NotesTuplets, dotted_note_dotctrl_bit0_with_rdur_drift) +{ + // When MIDI drift exceeds the snap tolerance (calcDots and calcDotsSnap both return 0), the dotControl + // bit-0 flag must still force the dot, or a dotted eighth imports plain and leaves a phantom rest. + MasterScore* score = readEncoreScore("notes_dotted_ctrl_bit0_drift.enc"); + ASSERT_NE(score, nullptr) << "Failed to load notes_dotted_ctrl_bit0_drift.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Dotted note with rdur drift must not corrupt measure: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + std::vector chords; + std::vector rests; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e) { + continue; + } + if (e->isChord()) { + chords.push_back(toChord(e)); + } else if (e->isRest()) { + Rest* r = toRest(e); + if (!r->isGap()) { + rests.push_back(r); + } + } + } + ASSERT_EQ(chords.size(), 4u) << "Measure must have exactly 4 chords"; + EXPECT_EQ(rests.size(), 0u) << "No phantom rests: measure must fill exactly 2/4"; + + EXPECT_EQ(chords[0]->durationType().type(), DurationType::V_EIGHTH) + << "Note 0 base type must be eighth"; + EXPECT_EQ(chords[0]->dots(), 1) + << "Note 0 must have 1 dot (dotControl bit 0 = dotted flag)"; + EXPECT_EQ(chords[1]->durationType().type(), DurationType::V_16TH); + EXPECT_EQ(chords[1]->dots(), 0); + EXPECT_EQ(chords[2]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_EQ(chords[2]->dots(), 0); + EXPECT_EQ(chords[3]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_EQ(chords[3]->dots(), 0); + delete score; +} + +TEST_F(Tst_NotesTuplets, v0c2_dotted_eighth_detected_from_tick_pattern) +{ + // In v0xC2 the sixteenth of a dotted-eighth+sixteenth pair is stored at tick+plain-eighth, and the + // eighth's dotControl lacks the dotted bit. The E->S@tick+120 pattern must be detected and the eighth + // marked dotted, or the measure comes up short and generates a phantom rest. + MasterScore* score = readEncoreScore("notes_v0c2_dotted_eighth.enc"); + ASSERT_NE(score, nullptr) << "Failed to load notes_v0c2_dotted_eighth.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "v0xC2 dotted-eighth measure must pass sanityCheck: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(3, 4)); + + std::vector chords; + std::vector rests; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e) { + continue; + } + if (e->isChord()) { + chords.push_back(toChord(e)); + } else if (e->isRest()) { + Rest* r = toRest(e); + if (!r->isGap()) { + rests.push_back(r); + } + } + } + + ASSERT_EQ(chords.size(), 3u) << "Must have exactly 3 chords (dotted-E, S, H); phantom rest signals unfixed bug"; + EXPECT_EQ(rests.size(), 0u) << "No phantom rests: measure must fill 3/4 exactly"; + + // First chord: dotted eighth (bit-0 fallback applied by tick-pattern fix) + EXPECT_EQ(chords[0]->durationType().type(), DurationType::V_EIGHTH) + << "Note 0 base type must be eighth"; + EXPECT_EQ(chords[0]->dots(), 1) + << "Note 0 must have 1 dot (v0xC2 tick-pattern fix sets dotControl bit 0)"; + + // Second chord: plain sixteenth + EXPECT_EQ(chords[1]->durationType().type(), DurationType::V_16TH); + EXPECT_EQ(chords[1]->dots(), 0); + + // Third chord: plain half + EXPECT_EQ(chords[2]->durationType().type(), DurationType::V_HALF); + EXPECT_EQ(chords[2]->dots(), 0); + + delete score; +} + +// The dotted-eighth pattern fix must not fire in an already-full measure: the 8th+16th binary pattern is +// ambiguous, so it only applies when faceSum + 60 == durTicks (measure short by an eighth's dot). +TEST_F(Tst_NotesTuplets, v0c2_full_measure_eighth_plus_sixteenth_no_false_dot) +{ + MasterScore* score = readEncoreScore("notes_v0c2_full_measure_no_false_dot.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Full measure must pass sanityCheck: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(4, 4)); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_EQ(chords.size(), 5u) + << "5 chords expected (8th+16th+16th+8th+8th); spurious dot on first 8th " + "would overflow the measure and truncate/reshape later notes"; + EXPECT_EQ(chords[0]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_EQ(chords[0]->dots(), 0) << "First 8th must NOT be dotted (full measure: faceSum guard)"; + EXPECT_EQ(chords[1]->durationType().type(), DurationType::V_16TH); + EXPECT_EQ(chords[1]->dots(), 0); + EXPECT_EQ(chords[2]->durationType().type(), DurationType::V_16TH); + EXPECT_EQ(chords[2]->dots(), 0); + EXPECT_EQ(chords[3]->durationType().type(), DurationType::V_EIGHTH); + EXPECT_EQ(chords[4]->durationType().type(), DurationType::V_EIGHTH); + delete score; +} + +TEST_F(Tst_NotesTuplets, mixed_value_tuplet_exact_ticks_and_isolated_partial) +{ + // A mixed-value tuplet group (Q,Q,8th,8th with tup=0x32) is grouped by face-value sum reaching the + // threshold, closing as one complete group; exact-ticks correction keeps the following plain notes valid. + MasterScore* score = readEncoreScore("notes_mixed_value_tuplet.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Mixed-value tuplet group should produce clean 2/4: " << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_EQ(chords.size(), 4u) << "Should have 4 chords (one complete 4-element group)"; + EXPECT_NE(chords[0]->tuplet(), nullptr) << "Q 1 in tuplet"; + EXPECT_NE(chords[1]->tuplet(), nullptr) << "Q 2 in tuplet"; + EXPECT_NE(chords[2]->tuplet(), nullptr) << "8th 3 in tuplet"; + EXPECT_NE(chords[3]->tuplet(), nullptr) << "8th 4 in same complete group"; + EXPECT_EQ(chords[0]->tuplet(), chords[3]->tuplet()) << "All 4 in same tuplet"; + EXPECT_EQ(chords[3]->actualTicks(), Fraction(1, 12)) + << "8th actualTicks = (1/8)*(2/3) = 1/12"; + delete score; +} + +TEST_F(Tst_NotesTuplets, implied_group_boundary_no_spurious_new_group) +{ + // Once a complete implied tuplet group closes, an isolated note right after it must not start a new + // unvalidated group (guarded by groupFull); it must be a plain note so the measure does not overflow. + MasterScore* score = readEncoreScore("notes_v0c2_implied_group_boundary.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Isolated note after complete implied group should be plain: " + << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + // Notes 4-6 (ticks 240-280-320) should be in the same tuplet (complete 3:2 group). + // Note 7 (tick 360, isolated rdur=40) should NOT be in a tuplet. + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_GE(chords.size(), 7u) << "Should have at least 7 chords"; + EXPECT_NE(chords[3]->tuplet(), nullptr) << "Note 4 (triplet 16th 1) should be in tuplet"; + EXPECT_NE(chords[4]->tuplet(), nullptr) << "Note 5 (triplet 16th 2) should be in tuplet"; + EXPECT_NE(chords[5]->tuplet(), nullptr) << "Note 6 (triplet 16th 3) should be in tuplet"; + EXPECT_EQ(chords[6]->tuplet(), nullptr) + << "Note 7 (isolated rdur=40 after complete group) should NOT be in a tuplet"; + delete score; +} + +TEST_F(Tst_NotesTuplets, truncate_overfull_tuplet_no_partial_tuplet) +{ + // notes_capped_tuplet_note.enc: 4/4 measure with 3 plain quarters + // (cumTick=3/4) followed by 3 explicit 3:2 triplet quarters (tup=0x32). The full + // content overflows 4/4. + // + // Default overfill strategy is "Remove extra notes" (Truncate): a tuplet is atomic, so + // the trailing tuplet is DISSOLVED to plain quarters, then trailing notes are removed + // until the bar is filled, and the last survivor is dotted to fill exactly (here the + // 3 originals + 1 dissolved quarter fill 4/4 exactly, so no dots and no rest). + // + // Regression: the old note-loop cap ripped one note out of the tuplet, leaving an + // INVALID partial tuplet (3:2 with <3 members) that broke copy/paste with + // "Tuplet cannot cross barlines". The measure must contain NO tuplet at all. + MasterScore* score = readEncoreScore("notes_capped_tuplet_note.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Truncated measure must pass sanity check: " << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(4, 4)); + EXPECT_EQ(m->ticks(), Fraction(4, 4)) << "Truncate keeps a standard 4/4 measure"; + Fraction sum(0, 1); + int tupletChords = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + ChordRest* cr = toChordRest(e); + sum += cr->actualTicks(); + if (cr->tuplet()) { + ++tupletChords; + } + } + } + EXPECT_EQ(tupletChords, 0) << "Truncate must leave NO tuplet (no partial tuplet)"; + EXPECT_EQ(sum, Fraction(4, 4)) << "Voice 0 must sum to exactly 4/4"; + delete score; +} + +TEST_F(Tst_NotesTuplets, truncate_overfull_messy_precontent_fills_to_4_4) +{ + // notes_overfull_messy_precontent_tuplet.enc mirrors a real overfull measure: 4/4 with + // 17/32 of pre-content (16th+32nd+16th+dotted-quarter) then a 3:2 quarter triplet, + // total 33/32. Default Truncate must dissolve the triplet, drop trailing notes, dot + // the survivor, and leave an EXACT 4/4 (no underfull gap, no partial tuplet). + MasterScore* score = readEncoreScore("notes_overfull_messy_precontent_tuplet.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Truncated messy measure must pass sanity check"; + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->ticks(), Fraction(4, 4)) << "Truncate keeps a standard 4/4 measure"; + Fraction sum(0, 1); + int tupletChords = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + ChordRest* cr = toChordRest(e); + sum += cr->actualTicks(); + if (cr->tuplet()) { + ++tupletChords; + } + } + } + EXPECT_EQ(tupletChords, 0) << "No partial tuplet left"; + EXPECT_EQ(sum, Fraction(4, 4)) << "Voice 0 must fill exactly 4/4 (no underfull gap)"; + delete score; +} + +TEST_F(Tst_NotesTuplets, truncate_overfull_tuplet_with_slur_no_crash) +{ + // notes_overfull_tuplet_with_slur.enc: overfull 4/4 with a 3:2 quarter triplet and a + // SLURSTART spanning into it. Truncate dissolves and removes tuplet members; a slur + // whose endpoint resolves into the modified region must not leave a dangling reference + // that crashes during layout or at score teardown. + MasterScore* score = readEncoreScore("notes_overfull_tuplet_with_slur.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + delete score; +} + +TEST_F(Tst_NotesTuplets, mixed_duration_tuplet_boundary_fill) +{ + // Encore omits a tuplet group's final note when it lands on the measure boundary; closeTupletWithFill + // must add an invisible fill rest for the missing face duration so the measure sums correctly. + MasterScore* score = readEncoreScore("notes_mixed_duration_tuplet_boundary_fill.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Mixed-duration tuplet with boundary-omitted note should pass sanityCheck: " + << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(4, 4)); + + std::vector crs; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + crs.push_back(toChordRest(e)); + } + } + // half + 3 tuplet notes + 1 invisible fill rest = 5 elements + ASSERT_GE(crs.size(), 5u) << "Expected half + 3 tuplet notes + fill rest"; + + EXPECT_EQ(crs[0]->tuplet(), nullptr) << "Half not in tuplet"; + EXPECT_EQ(crs[0]->durationType().type(), DurationType::V_HALF); + + ASSERT_NE(crs[1]->tuplet(), nullptr) << "qtr 1 in tuplet"; + ASSERT_NE(crs[2]->tuplet(), nullptr) << "qtr 2 in tuplet"; + ASSERT_NE(crs[3]->tuplet(), nullptr) << "8th note in tuplet"; + EXPECT_EQ(crs[1]->tuplet(), crs[2]->tuplet()) << "qtrs share bracket"; + EXPECT_EQ(crs[2]->tuplet(), crs[3]->tuplet()) << "8th shares bracket with qtrs"; + + ASSERT_NE(crs[4]->tuplet(), nullptr) << "Fill rest in tuplet"; + EXPECT_EQ(crs[3]->tuplet(), crs[4]->tuplet()) << "Fill rest shares bracket"; + EXPECT_TRUE(crs[4]->isRest()) << "Fill is a rest"; + EXPECT_FALSE(crs[4]->visible()) << "Fill rest is invisible"; + EXPECT_EQ(crs[4]->durationType().type(), DurationType::V_EIGHTH) << "Fill rest is 8th"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, partial_triplet_at_measure_end_no_voice_overflow) +{ + // A 2-note partial 3:2 triplet at the measure end must fit the remaining space (bracket shortened, the + // last member reduced to a 16th) rather than advancing by full face value and overflowing into voice 1. + MasterScore* score = readEncoreScore("notes_partial_triplet_measure_end.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Partial measure-end triplet must not corrupt score: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_GE(chords.size(), 5u) << "Should have 3 plain eighths + 2 triplet notes"; + + // Plain eighths are not in a tuplet. + EXPECT_EQ(chords[0]->tuplet(), nullptr) << "Plain 8th 1 no tuplet"; + EXPECT_EQ(chords[1]->tuplet(), nullptr) << "Plain 8th 2 no tuplet"; + EXPECT_EQ(chords[2]->tuplet(), nullptr) << "Plain 8th 3 no tuplet"; + + // Both partial-triplet notes are in the same tuplet (not overflowed to voice 1). + EXPECT_NE(chords[3]->tuplet(), nullptr) << "Triplet note 1 (eighth) should be in tuplet"; + EXPECT_NE(chords[4]->tuplet(), nullptr) << "Triplet note 2 (sixteenth) should be in tuplet"; + EXPECT_EQ(chords[3]->tuplet(), chords[4]->tuplet()) << "Both triplet notes in same bracket"; + + // Note 1 displays as eighth (2 triplet slots), note 2 as sixteenth (1 slot). + EXPECT_EQ(chords[3]->durationType().type(), DurationType::V_EIGHTH) + << "First triplet note: V_EIGHTH (2-slot face value)"; + EXPECT_EQ(chords[4]->durationType().type(), DurationType::V_16TH) + << "Second triplet note: V_16TH (1-slot, shortened by dt-reduction fix)"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, cascade_fill_residual_filled_by_vmeasure_rest) +{ + // The post-checkMeasure micro-fill handles cascade-fill residuals: + // toRhythmicDurationList decomposes a non-standard gap G with standard durations + // (1/64 + 1/256 + 1/1024 = 21/1024 for G=1/48) but leaves a residual + // (1/3072 = 1/48 - 21/1024) that no standard duration can represent. + // A V_MEASURE gap rest with ticks = residual bridges the last 1/3072. + // + // We verify this property through two synthetic files that exercise the + // preconditions: exact-ticks tuplet correction and isolated-partial-tuplet + // fill. Both must pass sanityCheck with no large denominators from residuals. + auto checkNoResidual = [&](MasterScore* score) { + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Should pass sanityCheck: " << ret.text(); + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (track_idx_t t = 0; t < static_cast(score->nstaves()) * 4; ++t) { + Fraction sum(0, 1); + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(t); + if (e) { + sum += toChordRest(e)->actualTicks(); + } + } + EXPECT_LE(sum.denominator(), 3072) + << "Voice sum denom > 3072 indicates unfilled cascade residual"; + } + } + delete score; + }; + + // File 1: implied-group boundary (triggers !tt.groupFull() guard) + checkNoResidual(readEncoreScore("notes_v0c2_implied_group_boundary.enc")); + + // File 2: mixed-value tuplet + isolated partial fill (triggers exact-ticks correction + // and isolated-partial-tuplet fill; together these may create non-standard gaps) + checkNoResidual(readEncoreScore("notes_mixed_value_tuplet.enc")); +} + +TEST_F(Tst_NotesTuplets, mixed_duration_triplet_face_value_sum_grouping) +{ + // notes_mixed_duration_triplet.enc: 2/4 measure with two 3:2 triplet + // brackets, the first containing mixed note values (8+8_rest+16_rest+16). + // + // The first bracket: face values 1/8+1/8+1/16+1/16 = 3/8 = 3×(1/8) = threshold. + // The second bracket: 3 equal 8ths, face sum = 3/8 = threshold. + // Together: 2 × (3/8 × 2/3) = 2 × 1/4 = 1/2 = 2/4. PASS. + // + // Bug: with count-based grouping (close after actualN=3 notes), the first + // bracket closes after 8+8_rest+16_rest (only 3 elements), then the 16th + // note at tick=200 starts a new incomplete group → sum ≠ 2/4 → FAIL. + // Fix: close when face-value sum reaches actualN × baseLen (= 3/8 for 3:2 8th). + // First bracket spans all 4 elements; both brackets fill exactly 2/4. + MasterScore* score = readEncoreScore("notes_mixed_duration_triplet.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Mixed-duration triplet should produce clean 2/4: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)); + + // Count chords/rests in the measure, should have 7 elements (plus fills) + // First 4 should be in the same tuplet, next 3 in another. + std::vector crs; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e) { + continue; + } + ChordRest* cr = toChordRest(e); + bool gap = cr->isRest() && toRest(cr)->isGap(); + if (!gap) { + crs.push_back(cr); + } + } + ASSERT_GE(crs.size(), 7u) << "Should have 7 non-gap elements"; + // Elements 0-3: first mixed-value bracket (all in same tuplet) + EXPECT_NE(crs[0]->tuplet(), nullptr) << "Element 0 (8th note) in tuplet"; + EXPECT_NE(crs[1]->tuplet(), nullptr) << "Element 1 (8th rest) in tuplet"; + EXPECT_NE(crs[2]->tuplet(), nullptr) << "Element 2 (16th rest) in tuplet"; + EXPECT_NE(crs[3]->tuplet(), nullptr) << "Element 3 (16th note) in tuplet"; + EXPECT_EQ(crs[0]->tuplet(), crs[1]->tuplet()) << "All 4 in same first tuplet"; + EXPECT_EQ(crs[0]->tuplet(), crs[2]->tuplet()); + EXPECT_EQ(crs[0]->tuplet(), crs[3]->tuplet()); + // Elements 4-6: second bracket (3 equal 8ths) + EXPECT_NE(crs[4]->tuplet(), nullptr) << "Element 4 in second tuplet"; + EXPECT_NE(crs[5]->tuplet(), nullptr) << "Element 5 in second tuplet"; + EXPECT_NE(crs[6]->tuplet(), nullptr) << "Element 6 in second tuplet"; + EXPECT_NE(crs[0]->tuplet(), crs[4]->tuplet()) << "Two different tuplets"; + delete score; +} + +TEST_F(Tst_NotesTuplets, mixed_baseLen_QE_bracket_closes_after_two_notes) +{ + // ornaments_tuplet_mixed_baseLen.enc: 4/4 measure with structure: + // plain-Q + {Q,E} triplet bracket + {Q,Q,Q} triplet bracket + // + // Bug: the old threshold algorithm used baseLen=Q so the target was 3Q=3/4. + // faceSum(Q+E)=3/8 never reached it, pulling the following Q into the same + // bracket and causing a measure overrun. + // + // Fix: close the group when faceSum/actualN is a valid TDuration. + // {Q,E}/3 = E, valid → closes after 2 notes. + // {Q,Q,Q}/3 = Q, valid → closes after 3 notes. + // Sum: Q + Q*(2/3) + E*(2/3) + 3*Q*(2/3) = 1/4+1/6+1/12+1/2 = 4/4. PASS. + MasterScore* score = readEncoreScore("ornaments_tuplet_mixed_baseLen.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Mixed baseLen brackets should produce clean 4/4: " << ret.text(); + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(4, 4)); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_EQ(chords.size(), 6u) << "plain-Q + 2 in bracket1 + 3 in bracket2"; + + // chords[0]: plain quarter, not in a tuplet + EXPECT_EQ(chords[0]->tuplet(), nullptr) << "Plain Q must not be in a tuplet"; + + // chords[1] (Q) and chords[2] (E): first bracket {Q,E} + ASSERT_NE(chords[1]->tuplet(), nullptr) << "First Q in bracket 1"; + ASSERT_NE(chords[2]->tuplet(), nullptr) << "E in bracket 1"; + EXPECT_EQ(chords[1]->tuplet(), chords[2]->tuplet()) << "Q and E in same bracket"; + + // chords[3-5]: second bracket {Q,Q,Q} + ASSERT_NE(chords[3]->tuplet(), nullptr) << "Q 1 in bracket 2"; + ASSERT_NE(chords[4]->tuplet(), nullptr) << "Q 2 in bracket 2"; + ASSERT_NE(chords[5]->tuplet(), nullptr) << "Q 3 in bracket 2"; + EXPECT_EQ(chords[3]->tuplet(), chords[4]->tuplet()) << "All three in same bracket"; + EXPECT_EQ(chords[3]->tuplet(), chords[5]->tuplet()); + + // The two brackets must be distinct + EXPECT_NE(chords[1]->tuplet(), chords[3]->tuplet()) << "Two separate brackets"; + + // Verify actual advances: Q-face rdur=160 in 3:2 → Q*(2/3)=1/6; E-face rdur=80 → E*(2/3)=1/12. + EXPECT_EQ(chords[0]->actualTicks(), Fraction(1, 4)) << "Plain Q = 1/4"; + EXPECT_EQ(chords[1]->actualTicks(), Fraction(1, 6)) << "Q in 3:2 = Q*(2/3) = 1/6"; + EXPECT_EQ(chords[2]->actualTicks(), Fraction(1, 12)) << "E in 3:2 = E*(2/3) = 1/12"; + EXPECT_EQ(chords[3]->actualTicks(), Fraction(1, 6)) << "Q in 3:2 = 1/6"; + delete score; +} + +TEST_F(Tst_NotesTuplets, mixed_value_tuplet_ticks_corrected_for_overshoot) +{ + // notes_mixed_duration_triplet.enc: first bracket {16,16,Q} in a 3:2 group. + // faceSum = 1/16+1/16+1/4 = 3/8 >= threshold = 3/16 (= baseLen(16th)*3). + // placedTicks = 3*(2/3) advance = 1/12+1/12+1/6 = 5/24. + // expected = baseLen*normalN = (1/16)*2 = 1/8. + // placedTicks(5/24) > expected(1/8) AND faceTicks(3/8) > fullFaceSum(3/16): + // mixedValueOvershoot = true → tuplet->setTicks(5/24). + // Without the ticks correction, tuplet->ticks()=1/8 (too small), checkMeasure + // sees next chord at P+5/24 > P+1/8 → inserts fill → sum > 2/4 → corrupted. + MasterScore* score = readEncoreScore("notes_mixed_duration_triplet.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Mixed-value tuplet ticks correction should produce clean 2/4: " + << ret.text(); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + Tuplet* firstTuplet = nullptr; + for (EngravingItem* e : m->el()) { + if (e->isTuplet()) { + firstTuplet = toTuplet(e); + break; + } + } + ASSERT_NE(firstTuplet, nullptr) << "Must have at least one tuplet"; + // First tuplet spans {16+16+Q} with placedTicks = 1/24+1/24+1/6 = 1/4. + // Its ticks must be 1/4 (not the default baseLen*normalN = (1/16)*2 = 1/8) + // for checkMeasure to correctly advance past the group. + EXPECT_EQ(firstTuplet->ticks(), Fraction(1, 4)) + << "Tuplet ticks must equal placedTicks (1/4) not default 1/8"; + delete score; +} + +TEST_F(Tst_NotesTuplets, partial_triplet_unreduced_cumtick_no_crash) +{ + // Large gap triggers gap-snap storing cumTick as Fraction(800,960) (unreduced). Fix-3 must call .reduced() + // before constructing TDuration; otherwise TDuration(Fraction(160,1920), truncate=false) asserts. + MasterScore* score = readEncoreScore("notes_partial_triplet_unreduced_cumtick.enc"); + ASSERT_NE(score, nullptr) << "File must import without TDuration assertion failure"; + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_NotesTuplets, no_spurious_rests_inside_active_tuplet_gapsnap_suppressed) +{ + MasterScore* score = readEncoreScore("notes_tuplet_no_gapsnap_spurious_rest.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Triplet + half must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + int chordCount = 0; + bool anyVisibleRestInsideTriplet = false; + Tuplet* activeTup = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + if (el->isChord()) { + ++chordCount; + activeTup = toChord(el)->tuplet(); + } else if (el->isRest()) { + Rest* r = toRest(el); + if (!r->isGap() && activeTup != nullptr) { + anyVisibleRestInsideTriplet = true; + } + } + } + EXPECT_EQ(chordCount, 4) << "3 triplet quarters + 1 half = 4 chords"; + EXPECT_FALSE(anyVisibleRestInsideTriplet) + << "No visible rests must appear inside the tuplet bracket (gap-snap suppressed)"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, segment_override_15notes_becomes_15_8) +{ + MasterScore* score = readEncoreScore("notes_segment_override_15notes.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "15-note segment override must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + EXPECT_EQ(chords.size(), 15u) << "All 15 notes must be placed (none dropped)"; + + // All must be in the same Tuplet with ratio 15:8 + Tuplet* tup = chords.empty() ? nullptr : chords[0]->tuplet(); + ASSERT_NE(tup, nullptr) << "Notes must be in a Tuplet bracket"; + EXPECT_EQ(tup->ratio().numerator(), 15) << "Override actualN must be 15"; + EXPECT_EQ(tup->ratio().denominator(), 8) << "Override normalN must be 8"; + EXPECT_EQ(tup->baseLen().type(), DurationType::V_EIGHTH); + for (size_t i = 1; i < chords.size(); ++i) { + EXPECT_EQ(chords[i]->tuplet(), tup) << "All 15 notes must be in the same Tuplet"; + } + + // No second voice, no rests outside the bracket + int voice1Chords = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + if (s->element(1)) { + ++voice1Chords; + } + } + EXPECT_EQ(voice1Chords, 0) << "Overflow notes must be dropped, not routed to voice 2"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, segment_override_12notes_plus_2plain_becomes_12_6) +{ + MasterScore* score = readEncoreScore("notes_segment_override_12plus2.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "12+2 segment override must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector allChords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + allChords.push_back(toChord(el)); + } + } + EXPECT_EQ(allChords.size(), 14u) << "12 tuplet + 2 plain = 14 notes total"; + + // First 12 in [12:6] + Tuplet* tup = allChords.empty() ? nullptr : allChords[0]->tuplet(); + ASSERT_NE(tup, nullptr); + EXPECT_EQ(tup->ratio().numerator(), 12); + EXPECT_EQ(tup->ratio().denominator(), 6); + for (int i = 0; i < 12; ++i) { + EXPECT_EQ(allChords[i]->tuplet(), tup) << "Note " << i + 1 << " must be in the [12:6] bracket"; + } + // Last 2 are plain (not in any tuplet) + EXPECT_EQ(allChords[12]->tuplet(), nullptr) << "Trailing note 13 must be plain"; + EXPECT_EQ(allChords[13]->tuplet(), nullptr) << "Trailing note 14 must be plain"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, segment_override_does_not_fire_for_clean_multiple) +{ + MasterScore* score = readEncoreScore("notes_segment_no_override_clean_multiple.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Two standard 3:2 groups must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + ASSERT_EQ(chords.size(), 6u) << "All 6 notes must be placed"; + + // Must form two separate Tuplets, each [3:2] + Tuplet* t1 = chords[0]->tuplet(); + Tuplet* t2 = chords[3]->tuplet(); + ASSERT_NE(t1, nullptr); + ASSERT_NE(t2, nullptr); + EXPECT_NE(t1, t2) << "6 notes with 3:2 must form TWO separate groups, not one [6:m]"; + EXPECT_EQ(t1->ratio().numerator(), 3); + EXPECT_EQ(t1->ratio().denominator(), 2); + EXPECT_EQ(t2->ratio().numerator(), 3); + EXPECT_EQ(t2->ratio().denominator(), 2); + // Notes 1-3 in group 1, notes 4-6 in group 2 + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(chords[i]->tuplet(), t1); + } + for (int i = 3; i < 6; ++i) { + EXPECT_EQ(chords[i]->tuplet(), t2); + } + + delete score; +} + +TEST_F(Tst_NotesTuplets, non_standard_tuplet_dosillo_2_1) +{ + MasterScore* score = readEncoreScore("notes_tuplet_dosillo_2_1.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Dosillo 2:1 must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + ASSERT_EQ(chords.size(), 2u) << "Dosillo must produce exactly 2 notes"; + + Tuplet* tup = chords[0]->tuplet(); + ASSERT_NE(tup, nullptr) << "Both notes must be inside a 2:1 bracket"; + EXPECT_EQ(tup->ratio().numerator(), 2); + EXPECT_EQ(tup->ratio().denominator(), 1); + EXPECT_EQ(chords[0]->tuplet(), chords[1]->tuplet()); + + delete score; +} + +TEST_F(Tst_NotesTuplets, non_standard_tuplet_9_4_nontuplet) +{ + MasterScore* score = readEncoreScore("notes_tuplet_9_4_nontuplet.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "9:4 nontuplet must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + EXPECT_EQ(chords.size(), 9u) << "All 9 notes must be in the 9:4 bracket"; + + Tuplet* tup = chords.empty() ? nullptr : chords[0]->tuplet(); + ASSERT_NE(tup, nullptr); + EXPECT_EQ(tup->ratio().numerator(), 9); + EXPECT_EQ(tup->ratio().denominator(), 4); + for (auto* c : chords) { + EXPECT_EQ(c->tuplet(), tup); + } + + delete score; +} + +TEST_F(Tst_NotesTuplets, last_tuplet_note_short_rdur_not_dropped) +{ + MasterScore* score = readEncoreScore("notes_tuplet_last_note_short_rdur.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Last note with tiny rdur must not corrupt"; + + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + chords.push_back(toChord(el)); + } + } + EXPECT_EQ(chords.size(), 10u) + << "Note 10 with rdur=6 (< 15) must NOT be dropped by the MIDI artifact filter"; + + Tuplet* tup = chords.empty() ? nullptr : chords[0]->tuplet(); + ASSERT_NE(tup, nullptr); + EXPECT_EQ(tup->ratio().numerator(), 10); + EXPECT_EQ(tup->ratio().denominator(), 4); + EXPECT_EQ(chords[9]->tuplet(), tup) << "Note 10 must be inside the tuplet bracket"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, triplet_orphan_middle_note_missing_tup_byte) +{ + MasterScore* score = readEncoreScore("notes_triplet_orphan_missing_tup.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + + // The first segment must carry a chord that is inside a 3:2 tuplet. + Segment* firstSeg = m0->first(SegmentType::ChordRest); + ASSERT_NE(firstSeg, nullptr); + EngravingItem* el = firstSeg->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isChord()) << "First element should be a chord (triplet note 1)"; + + Chord* firstChord = toChord(el); + ASSERT_NE(firstChord->tuplet(), nullptr) << "Triplet note 1 must be inside a tuplet"; + + Tuplet* tup = firstChord->tuplet(); + EXPECT_EQ(tup->ratio().reduced(), Fraction(3, 2)) << "Tuplet must be 3:2"; + EXPECT_EQ(static_cast(tup->elements().size()), 3) + << "Triplet must have 3 elements (orphan middle note must NOT be dropped)"; + + // The measure must be properly full (no overflow from treating orphan as plain 8th). + Fraction measLen = m0->ticks(); + Fraction usedTicks(0, 1); + for (Segment* s = m0->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + ChordRest* cr = e && e->isChordRest() ? toChordRest(e) : nullptr; + if (cr && !(cr->isRest() && toRest(cr)->isGap())) { + usedTicks += cr->actualTicks(); + } + } + EXPECT_EQ(usedTicks, measLen) << "Measure must be exactly full (no overflow or gap)"; + + delete score; +} + +TEST_F(Tst_NotesTuplets, triplet_orphan_with_prior_complete_group) +{ + MasterScore* score = readEncoreScore("notes_triplet_orphan_prior_complete_group.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + + std::vector tuplets; + for (EngravingItem* e : m0->el()) { + if (e->isTuplet()) { + tuplets.push_back(toTuplet(e)); + } + } + ASSERT_EQ(tuplets.size(), 2u) << "Measure must contain exactly two 3:2 tuplets"; + for (int t = 0; t < 2; ++t) { + EXPECT_EQ(static_cast(tuplets[t]->elements().size()), 3) + << "Tuplet " << t << " must have 3 elements (orphan must not be dropped)"; + EXPECT_EQ(tuplets[t]->ratio().reduced(), Fraction(3, 2)) + << "Tuplet " << t << " must be 3:2"; + } + + delete score; +} + +// Regression: isolated explicit tuplet note placed with face value but cumTick advance was capped; +// voice overran by face - capped. Fix: always set chord duration to the capped value. +TEST_F(Tst_NotesTuplets, isolated_explicit_tuplet_caps_chord_ticks) +{ + MasterScore* score = readEncoreScore("importer_isolated_explicit_tuplet_capped.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_isolated_explicit_tuplet_capped.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// Regression: note-level path-A cap deleted a chord that belonged to an inner (nested) tuplet. +// Old code called tt.currentTuplet->remove(chord), the outer tuplet, which does not contain the +// chord. Fix: use chord->tuplet() (the actual owning tuplet) rather than tt.currentTuplet. +TEST_F(Tst_NotesTuplets, inner_tuplet_note_level_cap_no_crash) +{ + MasterScore* score = readEncoreScore("importer_inner_tuplet_note_level_cap.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_inner_tuplet_note_level_cap.enc"; + EXPECT_GT(score->nmeasures(), 0); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + delete score; +} + +// =========================================================================== +// BUG FIX: mixed-duration explicit tuplet bracket {Q,E} in a 3:2 group +// was not closing correctly. faceSum(Q+E)=3/8 never reached the old +// threshold 3Q=3/4, pulling subsequent notes into the same bracket. +// Fix: close a group when faceSum/actualN is a valid standard TDuration. +// =========================================================================== +TEST_F(Tst_NotesTuplets, v0c4_mixed_duration_tuplet_bracket_closes_correctly) +{ + MasterScore* score = readEncoreScore("ornaments_tuplet_mixed_baseLen.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_tuplet_mixed_baseLen.enc"; + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Measure is corrupt (overrun): " << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + std::set tuplets; + int noteCount = 0; + for (Segment* s = m1->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + ++noteCount; + Chord* c = toChord(el); + if (c->tuplet()) { + tuplets.insert(c->tuplet()); + } + } + + EXPECT_EQ(noteCount, 6); + EXPECT_EQ(tuplets.size(), 2u) + << "Must form 2 tuplet brackets: {Q,E} and {Q,Q,Q}, not one big group"; + delete score; +} + +// =========================================================================== +// BUG FIX: 4:3 quadruplet (tup=0x43) was not recognized; notes appeared +// as plain, with wrong advance (Q instead of E per slot). +// =========================================================================== +TEST_F(Tst_NotesTuplets, v0c4_4to3_quadruplet_correct_advance) +{ + MasterScore* score = readEncoreScore("tuplet_4to3_quadruplet.enc"); + ASSERT_NE(score, nullptr) << "Failed to load tuplet_4to3_quadruplet.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "4:3 quadruplet must import without measure corruption: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + ASSERT_EQ(chords.size(), 4u) << "Must have 4 chords in the 4:3 quadruplet"; + + for (int i = 0; i < 4; ++i) { + ASSERT_NE(chords[i]->tuplet(), nullptr) << "Chord " << i << " must be in a 4:3 tuplet"; + EXPECT_EQ(chords[i]->tuplet(), chords[0]->tuplet()) << "All 4 in same bracket"; + } + EXPECT_EQ(chords[0]->tuplet()->ratio(), Fraction(4, 3)) << "Ratio must be 4:3"; + EXPECT_EQ(chords[0]->actualTicks(), Fraction(3, 32)) << "E in 4:3 = E*(3/4) = 3/32"; + delete score; +} + +// Nested-tuplet detection must not spuriously fire on a plain 3:2 group, and it must not pull another +// staff's notes into an inner group (both corrupted the drum staff's brackets and overflowed the measure). +TEST_F(Tst_NotesTuplets, cross_staff_false_nesting_and_drum_corruption) +{ + MasterScore* score = readEncoreScore("notes_cross_staff_false_nesting.enc"); + ASSERT_NE(score, nullptr) << "Failed to load notes_cross_staff_false_nesting.enc"; + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Drum staff corruption or false nesting caused measure overflow: " << ret.text(); + + ASSERT_GE(score->nstaves(), 2) << "Fixture must have at least 2 staves"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector drumChords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* e = s->element(static_cast(1 * VOICES + v)); + if (e && e->isChord()) { + Chord* c = toChord(e); + if (!c->isRest() || !toRest(c)->isGap()) { + drumChords.push_back(c); + } + } + } + } + + EXPECT_EQ(drumChords.size(), 12u) + << "Staff 1 must have 12 eighth notes (4 triplets x 3); " + "drum corruption shifts notes outside measure, reducing the count"; + + // All 12 drum chords must be in tuplets. + for (size_t i = 0; i < drumChords.size(); ++i) { + EXPECT_NE(drumChords[i]->tuplet(), nullptr) + << "Drum chord " << i << " must be in a 3:2 tuplet"; + } + + // There must be exactly 4 distinct tuplet objects on staff 1. + std::set drumTuplets; + for (Chord* c : drumChords) { + if (c->tuplet()) { + drumTuplets.insert(c->tuplet()); + } + } + EXPECT_EQ(drumTuplets.size(), 4u) + << "Staff 1 must form 4 independent 3:2 triplet brackets; " + "cross-staff contamination collapses them into fewer groups"; + + delete score; +} diff --git a/src/importexport/encore/tests/tst_options.cpp b/src/importexport/encore/tests/tst_options.cpp new file mode 100644 index 0000000000000..634821d490878 --- /dev/null +++ b/src/importexport/encore/tests/tst_options.cpp @@ -0,0 +1,1139 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Import option behaviors: page layout/breaks, system locks, staff size, tempo-text promotion, under/overfill +// strategies, pickup handling, instrument search mode, and voice merging. See ENCORE_IMPORTER.md §Import option details. + +#include + +#include "engraving/compat/scoreaccess.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/layoutbreak.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/volta.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/note.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/instrument.h" +#include "engraving/dom/part.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/stafftype.h" +#include "engraving/dom/tempotext.h" +#include "engraving/style/style.h" +#include "engraving/types/fraction.h" + +#include "../internal/importer/import-options.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; +using namespace mu::iex::enc; + +class Tst_Options : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// =========================================================================== +// importPageLayout +// =========================================================================== + +TEST_F(Tst_Options, importPageLayout_false_keeps_ms_default_top_margin) +{ + MasterScore* ref = compat::ScoreAccess::createMasterScoreWithBaseStyle(nullptr); + const double defaultTop = ref->style().styleD(Sid::pageOddTopMargin); + delete ref; + + EncImportOptions opts; + opts.importPageLayout = false; + MasterScore* score = readEncoreScoreWithOpts("bazo.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_DOUBLE_EQ(score->style().styleD(Sid::pageOddTopMargin), defaultTop); + delete score; +} + +TEST_F(Tst_Options, importPageLayout_true_overrides_default_top_margin) +{ + MasterScore* ref = compat::ScoreAccess::createMasterScoreWithBaseStyle(nullptr); + const double defaultTop = ref->style().styleD(Sid::pageOddTopMargin); + delete ref; + + MasterScore* score = readEncoreScore("bazo_top_100.enc"); + ASSERT_NE(score, nullptr); + EXPECT_NE(score->style().styleD(Sid::pageOddTopMargin), defaultTop) + << "bazo_top_100 must produce a top margin different from the MS default"; + delete score; +} + +// =========================================================================== +// importPageBreaks +// structure_page_break.enc: 2 LINE blocks, both pageIdx=0 → page break after +// the last measure of the first system. +// =========================================================================== + +TEST_F(Tst_Options, importPageBreaks_true_places_page_break) +{ + MasterScore* score = readEncoreScore("structure_page_break.enc"); + ASSERT_NE(score, nullptr); + + bool foundPageBreak = false; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (EngravingItem* e : m->el()) { + if (e && e->isLayoutBreak() && toLayoutBreak(e)->isPageBreak()) { + foundPageBreak = true; + break; + } + } + if (foundPageBreak) { + break; + } + } + EXPECT_TRUE(foundPageBreak) + << "Default (importPageBreaks=true): score must contain at least one page break"; + delete score; +} + +TEST_F(Tst_Options, importPageBreaks_false_produces_no_page_breaks) +{ + EncImportOptions opts; + opts.importPageBreaks = false; + MasterScore* score = readEncoreScoreWithOpts("structure_page_break.enc", opts); + ASSERT_NE(score, nullptr); + + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (EngravingItem* e : m->el()) { + EXPECT_FALSE(e && e->isLayoutBreak() && toLayoutBreak(e)->isPageBreak()) + << "importPageBreaks=false must produce no page breaks"; + } + } + delete score; +} + +// =========================================================================== +// importSystemLocks +// =========================================================================== + +TEST_F(Tst_Options, importSystemLocks_true_creates_system_locks) +{ + MasterScore* score = readEncoreScore("structure_system_break.enc"); + ASSERT_NE(score, nullptr); + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + EXPECT_TRUE(m0->isStartOfSystemLock()) + << "Default: first measure must be start of a SystemLock"; + delete score; +} + +TEST_F(Tst_Options, importSystemLocks_false_produces_no_system_locks) +{ + EncImportOptions opts; + opts.importSystemLocks = false; + MasterScore* score = readEncoreScoreWithOpts("structure_system_break.enc", opts); + ASSERT_NE(score, nullptr); + bool foundLock = false; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + if (m->isStartOfSystemLock() || m->isEndOfSystemLock()) { + foundLock = true; + break; + } + } + EXPECT_FALSE(foundLock) << "importSystemLocks=false must produce no SystemLocks"; + delete score; +} + +// =========================================================================== +// importStaffSize +// All test files in data/ have scoreSize=3, which maps to MAG 1.00 (100%). +// =========================================================================== + +TEST_F(Tst_Options, importStaffSize_true_applies_encore_scale) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + const double mag = score->staff(0)->staffType(Fraction(0, 1))->userMag(); + EXPECT_DOUBLE_EQ(mag, 1.00) + << "importStaffSize=true (default) must apply Encore scoreSize=3 → MAG 1.00"; + delete score; +} + +TEST_F(Tst_Options, importStaffSize_false_keeps_unit_scale) +{ + EncImportOptions opts; + opts.importStaffSize = false; + MasterScore* score = readEncoreScoreWithOpts("bazo.enc", opts); + ASSERT_NE(score, nullptr); + const double mag = score->staff(0)->staffType(Fraction(0, 1))->userMag(); + EXPECT_DOUBLE_EQ(mag, 1.0) + << "importStaffSize=false must leave staff MAG at the MuseScore default (1.0)"; + delete score; +} + +// =========================================================================== +// importTempoTextSemantic +// =========================================================================== + +// text_stafftext_tempo_promotion has "Allegro" as a STAFFTEXT element. +// Default: promoted to TempoText. With importTempoTextSemantic=false: stays StaffText. +TEST_F(Tst_Options, importTempoTextSemantic_false_keeps_italian_term_as_stafftext) +{ + EncImportOptions opts; + opts.importTempoTextSemantic = false; + MasterScore* score = readEncoreScoreWithOpts("text_stafftext_tempo_promotion.enc", opts); + ASSERT_NE(score, nullptr); + + bool foundAllegroAsStaffText = false; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (!e) { + continue; + } + if (e->isTempoText()) { + const String text = toTempoText(e)->plainText(); + EXPECT_FALSE(text.contains(String(u"Allegro"), muse::CaseInsensitive)) + << "Italian term must not be promoted to TempoText when semantic mode is off"; + } + if (e->isStaffText() + && toStaffText(e)->plainText().contains(String(u"Allegro"), + muse::CaseInsensitive)) { + foundAllegroAsStaffText = true; + } + } + } + } + EXPECT_TRUE(foundAllegroAsStaffText) + << "Allegro should remain as StaffText when importTempoTextSemantic=false"; + delete score; +} + +// Default opts: "Allegro" is promoted to TempoText (existing behavior, regression guard). +TEST_F(Tst_Options, importTempoTextSemantic_true_promotes_italian_term_to_tempotext) +{ + MasterScore* score = readEncoreScore("text_stafftext_tempo_promotion.enc"); + ASSERT_NE(score, nullptr); + + bool foundAllegroAsTempoText = false; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText() + && toTempoText(e)->plainText().contains(String(u"Allegro"), + muse::CaseInsensitive)) { + foundAllegroAsTempoText = true; + } + } + } + } + EXPECT_TRUE(foundAllegroAsTempoText) + << "Allegro must be promoted to TempoText under default (semantic=true) opts"; + delete score; +} + +// =========================================================================== +// underfillMeasureStrategy +// =========================================================================== + +TEST_F(Tst_Options, underfill_default_creates_gap_rests) +{ + // structure_pickup_casea_sparse has sparse voices, producing gap rests by default. + MasterScore* score = readEncoreScore("structure_pickup_casea_sparse.enc"); + ASSERT_NE(score, nullptr); + + int gapCount = 0; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (track_idx_t track = 0; track < score->ntracks(); ++track) { + EngravingItem* e = s->element(track); + if (e && e->isRest() && toRest(e)->isGap()) { + ++gapCount; + } + } + } + } + EXPECT_GT(gapCount, 0) + << "Default InvisibleRests must produce at least one gap rest in this file"; + delete score; +} + +TEST_F(Tst_Options, underfill_visible_rests_produces_no_gap_rests) +{ + EncImportOptions opts; + opts.underfillMeasureStrategy = UnderfillStrategy::VisibleRests; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_casea_sparse.enc", opts); + ASSERT_NE(score, nullptr); + + int gapCount = 0; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (track_idx_t track = 0; track < score->ntracks(); ++track) { + EngravingItem* e = s->element(track); + if (e && e->isRest() && toRest(e)->isGap()) { + ++gapCount; + } + } + } + } + EXPECT_EQ(gapCount, 0) + << "VisibleRests strategy must not produce any gap (invisible) rests"; + delete score; +} + +// A partial gap must be filled with exact-valued rests, not a whole-measure (V_MEASURE) rest: +// a V_MEASURE rest renders as a centered whole rest whatever its real duration, which is wrong +// next to notes. A fully empty measure may still hold a whole-measure rest, so only measures +// that contain a note are checked. +TEST_F(Tst_Options, visible_rests_use_exact_durations_not_whole_measure) +{ + EncImportOptions opts; + opts.underfillMeasureStrategy = UnderfillStrategy::VisibleRests; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_casea_sparse.enc", opts); + ASSERT_NE(score, nullptr); + + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + bool hasNote = false; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (track_idx_t t = 0; t < score->ntracks(); ++t) { + const EngravingItem* e = s->element(t); + if (e && e->isChord()) { + hasNote = true; + } + } + } + if (!hasNote) { + continue; + } + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (track_idx_t t = 0; t < score->ntracks(); ++t) { + const EngravingItem* e = s->element(t); + if (e && e->isRest()) { + EXPECT_NE(toRest(e)->durationType().type(), DurationType::V_MEASURE) + << "partial-gap fill must use exact rest durations, not a whole-measure rest"; + } + } + } + } + delete score; +} + +// =========================================================================== +// firstMeasureIsPickup +// =========================================================================== + +// Default: first measure is shortened to the pickup duration (1/4 for this file). +// firstMeasureIsPickup=false: first measure keeps its full nominal duration. +TEST_F(Tst_Options, firstMeasure_default_is_shortened_to_pickup) +{ + MasterScore* score = readEncoreScore("structure_pickup_measure.enc"); + ASSERT_NE(score, nullptr); + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + EXPECT_NE(m0->ticks(), m0->timesig()) + << "Default: first measure must be shortened as pickup"; + delete score; +} + +TEST_F(Tst_Options, firstMeasure_not_pickup_keeps_full_nominal_duration) +{ + EncImportOptions opts; + opts.firstMeasureIsPickup = false; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_measure.enc", opts); + ASSERT_NE(score, nullptr); + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + EXPECT_EQ(m0->ticks(), m0->timesig()) + << "firstMeasureIsPickup=false: first measure must retain full nominal duration"; + delete score; +} + +// Regression: with firstMeasureIsPickup=false and IrregularMeasure, a tick/duration mismatch on the first +// measure shifted every later measure, placing volta brackets mid-measure instead of at barlines. +static Volta* findVolta(MasterScore* score, const String& label) +{ + for (auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isVolta() && toVolta(sp)->beginText() == label) { + return toVolta(sp); + } + } + return nullptr; +} + +static bool isAtImpliedBarline(Volta* volta, MasterScore* score) +{ + Fraction cumTick(0, 1); + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + if (cumTick == volta->tick()) { + return true; + } + cumTick += m->ticks(); + } + return false; +} + +TEST_F(Tst_Options, firstMeasure_not_pickup_irregular_volta_at_barline) +{ + EncImportOptions opts; + opts.firstMeasureIsPickup = false; + opts.underfillMeasureStrategy = UnderfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_casea_volta.enc", opts); + ASSERT_NE(score, nullptr); + + Volta* v1 = findVolta(score, String(u"1.")); + Volta* v2 = findVolta(score, String(u"2.")); + ASSERT_NE(v1, nullptr) << "score must contain a '1.' volta"; + ASSERT_NE(v2, nullptr) << "score must contain a '2.' volta"; + + EXPECT_TRUE(isAtImpliedBarline(v1, score)) + << "Volta '1.' tick (" << v1->tick().ticks() + << ") must coincide with a measure barline (cumulative durations)"; + EXPECT_TRUE(isAtImpliedBarline(v2, score)) + << "Volta '2.' tick (" << v2->tick().ticks() + << ") must coincide with a measure barline (cumulative durations)"; + delete score; +} + +TEST_F(Tst_Options, firstMeasure_pickup_irregular_volta_at_barline) +{ + EncImportOptions opts; + opts.underfillMeasureStrategy = UnderfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_casea_volta.enc", opts); + ASSERT_NE(score, nullptr); + + Volta* v1 = findVolta(score, String(u"1.")); + Volta* v2 = findVolta(score, String(u"2.")); + ASSERT_NE(v1, nullptr) << "score must contain a '1.' volta"; + ASSERT_NE(v2, nullptr) << "score must contain a '2.' volta"; + + EXPECT_TRUE(isAtImpliedBarline(v1, score)) + << "Volta '1.' tick (" << v1->tick().ticks() + << ") must coincide with a measure barline (pickup=true, regression guard)"; + EXPECT_TRUE(isAtImpliedBarline(v2, score)) + << "Volta '2.' tick (" << v2->tick().ticks() + << ") must coincide with a measure barline (pickup=true, regression guard)"; + delete score; +} + +// =========================================================================== +// importUnsupportedArticulationsAsText +// ornaments_open_string_and_stick.enc: note 1 = 0x46 (open string, mapped), +// note 2 = 0x47 (stick technique, unmapped). +// =========================================================================== + +TEST_F(Tst_Options, unsupported_artic_default_drops_silently) +{ + MasterScore* score = readEncoreScore("ornaments_open_string_and_stick.enc"); + ASSERT_NE(score, nullptr); + int staffTextCount = 0; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + ++staffTextCount; + } + } + } + } + EXPECT_EQ(staffTextCount, 0) + << "Default: unsupported artic bytes must be dropped with no StaffText"; + delete score; +} + +TEST_F(Tst_Options, unsupported_artic_as_text_emits_stafftext) +{ + EncImportOptions opts; + opts.importUnsupportedArticulationsAsText = true; + MasterScore* score = readEncoreScoreWithOpts("ornaments_open_string_and_stick.enc", opts); + ASSERT_NE(score, nullptr); + int staffTextCount = 0; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + ++staffTextCount; + } + } + } + } + EXPECT_GT(staffTextCount, 0) + << "importUnsupportedArticulationsAsText=true must emit at least one StaffText for 0x47"; + delete score; +} + +// =========================================================================== +// underfillMeasureStrategy = IrregularMeasure +// =========================================================================== + +TEST_F(Tst_Options, underfill_irregular_measure_produces_no_gap_rests) +{ + EncImportOptions opts; + opts.underfillMeasureStrategy = UnderfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_casea_sparse.enc", opts); + ASSERT_NE(score, nullptr); + int gapCount = 0; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (track_idx_t tr = 0; tr < score->ntracks(); ++tr) { + EngravingItem* e = s->element(tr); + if (e && e->isRest() && toRest(e)->isGap()) { + ++gapCount; + } + } + } + } + EXPECT_EQ(gapCount, 0) + << "IrregularMeasure must not produce any gap rests"; + delete score; +} + +TEST_F(Tst_Options, underfill_irregular_measure_passes_sanity_check) +{ + EncImportOptions opts; + opts.underfillMeasureStrategy = UnderfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_pickup_casea_sparse.enc", opts); + ASSERT_NE(score, nullptr); + const muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "IrregularMeasure: score failed sanity check: " << ret.text(); + delete score; +} + +// A bar where only one staff has (sparse) notes and another staff is silent must NOT be +// shrunk by IrregularMeasure: the silent staff is a whole-bar rest, so the longest staff is +// the full bar. The bug measured only the note-bearing staff, shrank the whole bar, shifted +// every following measure and corrupted them. Guards both the no-shrink decision and that the +// following full bars survive intact. +TEST_F(Tst_Options, underfill_irregular_does_not_shrink_bar_with_silent_staff) +{ + EncImportOptions opts; + opts.underfillMeasureStrategy = UnderfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("options_underfill_irregular_empty_staff.enc", opts); + ASSERT_NE(score, nullptr); + + const muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "silent-staff bar corrupted the score: " << ret.text(); + + auto staffSum = [](Measure* m, size_t st) { + Fraction sum(0, 1); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(static_cast(st * VOICES)); + if (e && e->isChordRest()) { + sum += toChordRest(e)->actualTicks(); + } + } + return sum; + }; + + // Bar 1 (sparse staff0 + silent staff1) keeps its nominal 4/4; both staves fill it. + Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + Measure* m1 = m0->nextMeasure(); + ASSERT_NE(m1, nullptr); + EXPECT_EQ(m1->ticks(), Fraction(4, 4)) << "bar with a silent staff must not shrink"; + for (size_t st = 0; st < score->nstaves(); ++st) { + EXPECT_EQ(staffSum(m1, st), Fraction(4, 4)) << "sparse bar staff " << st << " must fill the bar"; + } + + // The surrounding full bars (0, 2, 3) keep their 4/4 content intact. + for (Measure* m = m0; m; m = m->nextMeasure()) { + if (m == m1) { + continue; + } + EXPECT_EQ(m->ticks(), Fraction(4, 4)) << "full bar must stay 4/4"; + for (size_t st = 0; st < score->nstaves(); ++st) { + EXPECT_EQ(staffSum(m, st), Fraction(4, 4)) << "full bar staff " << st << " content lost"; + } + } + delete score; +} + +// =========================================================================== +// overfillMeasureStrategy -- reserved variants: sanity-only tests +// =========================================================================== + +TEST_F(Tst_Options, overfill_stretch_last_note_does_not_crash) +{ + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::StretchLastNote; + MasterScore* score = readEncoreScoreWithOpts("bazo.enc", opts); + ASSERT_NE(score, nullptr) << "StretchLastNote strategy must not crash during import"; + delete score; +} + +TEST_F(Tst_Options, stretch_compresses_tuplet_keeps_all_notes) +{ + // notes_capped_tuplet_note.enc: 4/4 with 3 plain quarters + a 3:2 quarter triplet + // that overflows. "Stretch last notes" preserves ALL three triplet notes by + // compressing the tuplet bracket from a half (480) down to a quarter (240): the + // members become eighths in a 3:2 group filling the last beat. The tuplet stays + // intact (3 members) and the measure remains a standard 4/4. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::StretchLastNote; + MasterScore* score = readEncoreScoreWithOpts("notes_capped_tuplet_note.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Stretch-compressed measure must pass sanity check"; + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->ticks(), m->timesig()) << "Compression keeps a standard 4/4 measure"; + Fraction sum(0, 1); + int tupletMembers = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + ChordRest* cr = toChordRest(e); + sum += cr->actualTicks(); + if (cr->tuplet()) { + ++tupletMembers; + } + } + } + EXPECT_EQ(tupletMembers, 3) << "All three triplet notes preserved inside the tuplet"; + EXPECT_EQ(sum, Fraction(4, 4)) << "Voice 0 sums to exactly 4/4"; + delete score; +} + +TEST_F(Tst_Options, stretch_falls_back_to_irregular_for_tiny_bracket) +{ + // notes_stretch_irregular_fallback.enc: 3 plain quarters + a 3:2 HALF-note triplet + // (natural bracket = a whole note). Only a quarter of space is left, so the largest + // bracket that fits is < half the natural span: Stretch declines to compress and + // falls back to IrregularMeasure, extending the bar and keeping all three members. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::StretchLastNote; + MasterScore* score = readEncoreScoreWithOpts("notes_stretch_irregular_fallback.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Stretch irregular fallback must pass sanity check"; + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_GT(m->ticks(), m->timesig()) << "Fallback extends the measure past 4/4"; + int tupletMembers = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest() && toChordRest(e)->tuplet()) { + ++tupletMembers; + } + } + EXPECT_EQ(tupletMembers, 3) << "All three half-note-triplet members preserved"; + delete score; +} + +TEST_F(Tst_Options, stretch_robs_preceding_rest_to_fit_overflow) +{ + // notes_stretch_rob_rest.enc: a 4/4 bar filled by quarter + quarter-rest + quarter + quarter-rest, + // then a 3-sixteenth flourish that arrives after the voice is already full. StretchLastNote keeps + // the flourish (rather than dropping it at the voice-full guard) and its tier 1 reclaims the + // preceding rests so all three sixteenths survive in a standard 4/4 bar. Without the fix the + // flourish is dropped and only the two quarters remain. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::StretchLastNote; + MasterScore* score = readEncoreScoreWithOpts("notes_stretch_rob_rest.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Rest-robbed measure must pass sanity check"; + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->ticks(), m->timesig()) << "Rest-robbing keeps a standard 4/4 bar (no extension)"; + + std::vector chordDurs; + Fraction sum(0, 1); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest()) { + ChordRest* cr = toChordRest(e); + sum += cr->actualTicks(); + if (cr->isChord()) { + chordDurs.push_back(cr->actualTicks()); + } + } + } + EXPECT_EQ(sum, Fraction(4, 4)) << "Voice 0 sums to exactly 4/4"; + ASSERT_EQ(chordDurs.size(), 5u) << "All five notes preserved (2 quarters + 3-sixteenth flourish)"; + EXPECT_EQ(chordDurs[2], Fraction(1, 16)); + EXPECT_EQ(chordDurs[3], Fraction(1, 16)); + EXPECT_EQ(chordDurs[4], Fraction(1, 16)) << "The flourish sixteenths survive instead of being dropped"; + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_measure_does_not_crash) +{ + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("bazo.enc", opts); + ASSERT_NE(score, nullptr) << "IrregularMeasure overfill strategy must not crash during import"; + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_measure_extends_measure_ticks) +{ + // Fixture Q+H+H in 4/4: the last H overruns the barline. IrregularMeasure must skip the per-note caps + // and extend the measure past its time signature instead of shrinking the note. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("options_overfill_irregular_facevalue.enc", opts); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_NE(m->ticks(), m->timesig()) + << "IrregularMeasure overfill: measure ticks must differ from timesig (measure must be extended)"; + EXPECT_GT(m->ticks(), m->timesig()) + << "IrregularMeasure overfill: measure ticks must be greater than timesig"; + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_measure_length_is_reduced) +{ + // A 2/4 bar overfilled with eighth-note triplets extends to hold the content; summing triplet ticks + // leaves an unreduced fraction (21/24), so the stored actual duration must be reduced to lowest terms (7/8). + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_v0c4_irregular_len_reduced.enc", opts); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_GT(m->ticks(), m->timesig()) << "measure extended past 2/4 to hold the triplet content"; + EXPECT_EQ(m->ticks(), Fraction(7, 8)) << "extended actual duration equals the content"; + EXPECT_EQ(m->ticks().reduced(), m->ticks()) + << "irregular measure duration must be stored in lowest terms, not the raw 21/24"; + EXPECT_EQ(m->ticks().denominator(), 8) + << "reduced 7/8 has denominator 8, not the unreduced 24"; + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_measure_extends_past_exact_boundary) +{ + // Fixture Q+DH+Q+Q: after Q+DH cumTick hits the barline exactly. IrregularMeasure must bypass the + // overflow guard so notes 3-4 are kept and the measure extends, rather than dropping them. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("options_overfill_irregular_emitdrop.enc", opts); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + int chordCount = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (el && el->isChord()) { + ++chordCount; + } + } + EXPECT_EQ(chordCount, 4) + << "Expected 4 chords (Q+DH+Q+Q) in measure, got " << chordCount; + EXPECT_GT(m->ticks(), m->timesig()) + << "IrregularMeasure: notes past exact measure boundary must extend the measure"; + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_measure_fills_short_staves) +{ + // Staff 0 overruns to 6/4 while staff 1 is only 3/4: after IrregularMeasure extends the measure, the + // short staff must be filled with rests so no measure is incomplete. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("options_overfill_irregular_twostaves.enc", opts); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_GT(m->ticks(), m->timesig()) + << "Measure must be extended past 4/4 by the overfilling staff"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) + << "IrregularMeasure: short staves must be filled with rests after extension: " << ret.text(); + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_crossing_note_keeps_full_duration) +{ + // Q+H+H in 4/4: the third H starts at 3/4 with only 1/4 left. IrregularMeasure must keep its face-value + // V_HALF (not cap it to a quarter) and extend the measure to 5/4. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("options_overfill_irregular_facevalue.enc", opts); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (el && el->isChord()) { + chords.push_back(toChordRest(el)); + } + } + ASSERT_EQ(chords.size(), 3u) << "Q + H + H: 3 chords expected"; + EXPECT_EQ(chords[2]->durationType().type(), DurationType::V_HALF) + << "Crossing note must keep face-value V_HALF, not be capped to V_QUARTER"; + EXPECT_EQ(m->ticks(), m->timesig() + Fraction(1, 4)) + << "Measure must extend to exactly 5/4 (one quarter past 4/4 timesig)"; + delete score; +} + +TEST_F(Tst_Options, overfill_irregular_single_staff_sanity_check) +{ + // Same Q+H+H fixture: after extending to 5/4 the single staff must be complete so sanityCheck passes. + EncImportOptions opts; + opts.overfillMeasureStrategy = OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("options_overfill_irregular_facevalue.enc", opts); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) + << "IrregularMeasure single-staff overfill must pass sanityCheck: " << ret.text(); + delete score; +} + +TEST_F(Tst_Options, overfill_truncate_caps_crossing_note) +{ + // Negative guard: Truncate mode (default) must still cap the barline-crossing note and keep the bar 4/4. + MasterScore* score = readEncoreScore("options_overfill_irregular_facevalue.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (el && el->isChord()) { + chords.push_back(toChordRest(el)); + } + } + ASSERT_GE(chords.size(), 3u) << "All 3 notes must be emitted (none before barline)"; + EXPECT_NE(chords[2]->durationType().type(), DurationType::V_HALF) + << "Truncate mode must cap the crossing note (must not remain V_HALF)"; + EXPECT_EQ(m->ticks(), m->timesig()) + << "Truncate mode must not extend the measure past its time signature"; + delete score; +} + +TEST_F(Tst_Options, overfill_truncate_drops_notes_at_barline) +{ + // Negative guard: Truncate mode must drop notes 3-4 (Q+DH already fills the bar) and keep it 4/4. + MasterScore* score = readEncoreScore("options_overfill_irregular_emitdrop.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + int chordCount = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (el && el->isChord()) { + ++chordCount; + } + } + EXPECT_LT(chordCount, 4) + << "Truncate: notes 3 and 4 start at/past the barline and must be dropped"; + EXPECT_EQ(m->ticks(), m->timesig()) + << "Truncate mode must not extend the measure"; + delete score; +} + +// =========================================================================== +// instrumentSearchMode +// =========================================================================== + +// Piano mode: all instruments fall back to Grand Piano. +TEST_F(Tst_Options, instrumentSearchMode_piano_assigns_grand_piano_to_all) +{ + EncImportOptions opts; + opts.instrumentSearchMode = InstrumentSearchMode::Piano; + MasterScore* score = readEncoreScoreWithOpts("bazo.enc", opts); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + for (const Part* part : score->parts()) { + const Instrument* inst = part->instrument(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->id(), String(u"grand-piano")) + << "Piano mode: every instrument must be Grand Piano"; + } + delete score; +} + +// MidiOnly mode: name matching is skipped, only MIDI program drives selection. +TEST_F(Tst_Options, instrumentSearchMode_midi_only_does_not_crash) +{ + EncImportOptions opts; + opts.instrumentSearchMode = InstrumentSearchMode::MidiOnly; + MasterScore* score = readEncoreScoreWithOpts("bazo.enc", opts); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "MidiOnly mode must not produce a corrupt score: " << ret.text(); + delete score; +} + +// Default mode: name+MIDI gives a better result than MidiOnly when the name matches. +TEST_F(Tst_Options, instrumentSearchMode_name_and_midi_resolves_bandurria) +{ + // instruments_abbreviated_name_bandurr.enc has name "Bandurr. I" which matches + // "Bandurria" via substring (after punctuation stripping). + MasterScore* score = readEncoreScore("instruments_abbreviated_name_bandurr.enc"); + ASSERT_NE(score, nullptr); + ASSERT_FALSE(score->parts().empty()); + EXPECT_EQ(score->parts().front()->instrument()->id(), String(u"bandurria")) + << "Name+MIDI default: 'Bandurr. I' must resolve to bandurria template"; + delete score; +} + +// =========================================================================== +// Instrument template bracket clearing +// =========================================================================== + +// Accordion template has a brace with span=2 that would overflow into the next +// part when the accordion has only 1 staff. After clearing template brackets, +// no spurious cross-part bracket should appear. +TEST_F(Tst_Options, template_brackets_cleared_no_spurious_brace) +{ + // akordo.enc has multiple instruments; if template bracket clearing fails, + // layout may crash or produce wrong bracket spans. + MasterScore* score = readEncoreScore("akordo.enc"); + ASSERT_NE(score, nullptr); + for (staff_idx_t si = 0; si < score->nstaves(); ++si) { + Staff* st = score->staff(si); + ASSERT_NE(st, nullptr); + const size_t span = st->bracketSpan(0); + if (span > 1) { + EXPECT_LE(si + span, score->nstaves()) + << "Bracket on staff " << si << " spans " << span + << " but score only has " << score->nstaves() << " staves"; + } + } + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + delete score; +} + +// =========================================================================== +// mergeVoices +// importer_merge_voices_non_overlapping.enc: one staff, voice 0 = quarter C4 on +// beat 1, voice 1 = quarter E4 on beat 2 (the two voices never overlap). +// importer_merge_voices_overlapping.enc: one staff, voice 0 = half C4 over beats +// 1-2, voice 1 = quarter E4 on beat 2 (the two voices overlap in time). +// =========================================================================== + +static int voicesWithChords(MasterScore* score, staff_idx_t staffIdx) +{ + bool used[VOICES] = { false, false, false, false }; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + EngravingItem* e = s->element(staffIdx * VOICES + v); + if (e && e->isChord()) { + used[v] = true; + } + } + } + } + int count = 0; + for (bool u : used) { + if (u) { + ++count; + } + } + return count; +} + +TEST_F(Tst_Options, mergeVoices_default_off_keeps_separate_voices) +{ + // struct fallback used by tests has mergeVoices = false, so the two + // non-overlapping voices are left as the importer split them. + MasterScore* score = readEncoreScore("importer_merge_voices_non_overlapping.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(voicesWithChords(score, 0), 2) + << "mergeVoices=false (test default) must keep both voices"; + delete score; +} + +TEST_F(Tst_Options, mergeVoices_collapses_non_overlapping_voices) +{ + EncImportOptions opts; + opts.mergeVoices = true; + MasterScore* score = readEncoreScoreWithOpts("importer_merge_voices_non_overlapping.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_EQ(voicesWithChords(score, 0), 1) + << "mergeVoices=true must collapse two non-overlapping voices into voice 1"; + EXPECT_TRUE(score->sanityCheck()) << "merged score must pass sanity check"; + delete score; +} + +TEST_F(Tst_Options, mergeVoices_keeps_overlapping_voices) +{ + EncImportOptions opts; + opts.mergeVoices = true; + MasterScore* score = readEncoreScoreWithOpts("importer_merge_voices_overlapping.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_EQ(voicesWithChords(score, 0), 2) + << "mergeVoices=true must leave genuinely overlapping voices untouched (all-or-nothing)"; + EXPECT_TRUE(score->sanityCheck()) << "untouched score must pass sanity check"; + delete score; +} + +// Regression: merging voices rebuilds the destination chord and used to carry over articulations, lyrics +// and slurs but not a single-chord tremolo, so it vanished. mergeVoices must preserve the tremolo. +TEST_F(Tst_Options, mergeVoices_preserves_single_chord_tremolo) +{ + EncImportOptions opts; + opts.mergeVoices = true; + MasterScore* score = readEncoreScoreWithOpts("importer_merge_voices_tremolo.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "merged score must pass sanity check"; + EXPECT_EQ(voicesWithChords(score, 0), 1) + << "mergeVoices=true must collapse the non-overlapping voices into voice 1"; + + bool foundTremolo = false; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (voice_idx_t v = 0; v < VOICES; ++v) { + EngravingItem* e = s->element(v); + if (e && e->isChord() && toChord(e)->tremoloSingleChord()) { + foundTremolo = true; + } + } + } + } + EXPECT_TRUE(foundTremolo) + << "single-chord tremolo must survive when mergeVoices collapses its voice into voice 1"; + delete score; +} + +// Regression: an upper voice holding only rests over a bar voice 0 already fills is not a real voice; with +// voice merging on, those stray rests must be removed rather than left as a spurious empty second voice. +TEST_F(Tst_Options, v0c4_merge_removes_stray_upper_voice_rests) +{ + mu::iex::enc::EncImportOptions opts; + opts.mergeVoices = true; + MasterScore* score = readEncoreScoreWithOpts("structure_merge_stray_voice_rests.enc", opts); + ASSERT_NE(score, nullptr) << "Failed to load structure_merge_stray_voice_rests.enc"; + + int upperVoiceElems = 0; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (int v = 1; v < (int)VOICES; ++v) { + if (s->element(v)) { + ++upperVoiceElems; + } + } + } + } + EXPECT_EQ(upperVoiceElems, 0) + << "stray upper-voice rests must be removed when merging voices"; + delete score; +} + +// Regression: Encore's "voice 4" is a silent-voice placeholder that routing folds into +// voice 0. A whole-measure rest stored there (face value an eighth, but spanning the bar) +// used to be emitted as a leading eighth rest in voice 0, shifting the real notes right and +// inflating an otherwise-4/4 bar to 9/8. The importer must drop the voice-4 rest when the +// staff already carries real notes. +TEST_F(Tst_Options, v0c4_voice4_rest_dropped_when_staff_has_notes) +{ + mu::iex::enc::EncImportOptions opts; + opts.overfillMeasureStrategy = mu::iex::enc::OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_voice4_rest_with_notes.enc", opts); + ASSERT_NE(score, nullptr) << "Failed to load structure_voice4_rest_with_notes.enc"; + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->ticks(), m->timesig()) + << "the redundant voice-4 rest must not inflate the 4/4 bar to 9/8"; + EXPECT_TRUE(score->sanityCheck()); + delete score; +} + +// Regression: Encore lays notes out left-to-right, so a note's xoffset column identifies its +// beat consistently across a system. A note edited in Encore can keep a stale MIDI tick that +// no longer matches its column -- it draws at the column's beat but imports one beat late. Here +// a half note drawn in the beat-1 column (xoff 8) but stored at tick 480 (beat 3) must import +// as note (beat 1) + rest (beat 3), not rest + note. +TEST_F(Tst_Options, v0c4_stale_note_tick_snaps_to_xoffset_column) +{ + MasterScore* score = readEncoreScore("structure_stale_tick_by_column.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_stale_tick_by_column.enc"; + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + ChordRest* firstV1 = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s && !firstV1; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(1); // voice 1 + if (e && e->isChordRest()) { + firstV1 = toChordRest(e); + } + } + ASSERT_NE(firstV1, nullptr) << "voice 1 must have content"; + EXPECT_EQ(firstV1->tick(), m->tick()) + << "the stale-tick half note must land on beat 1 (its xoffset column), not beat 3"; + EXPECT_TRUE(firstV1->isChord()) + << "beat 1 must carry the note, with the rest after it"; + EXPECT_TRUE(score->sanityCheck()); + delete score; +} + +TEST_F(Tst_Options, overfull_note_recut_to_tied_chain) +{ + // A note overrunning the barline (dotted half in a 5/8 bar) must be recut into a chain of tied figures + // ending exactly at the barline (half tied to eighth), not collapsed to a half plus a leftover rest. + // Regression check: the second element is a tied chord, not a rest. Verified for Truncate and Stretch. + for (OverfillStrategy strat : { OverfillStrategy::Truncate, OverfillStrategy::StretchLastNote }) { + EncImportOptions opts; + opts.overfillMeasureStrategy = strat; + MasterScore* score = readEncoreScoreWithOpts("structure_overfill_recut_tie.enc", opts); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "recut measure must pass sanity check"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->ticks(), Fraction(5, 8)) << "Truncate/Stretch keep the nominal 5/8 bar"; + + // Third staff (index 2), voice 0: the overrunning dotted half. + const track_idx_t tr = 2 * VOICES; + std::vector crs; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(tr); + if (e && e->isChordRest()) { + crs.push_back(toChordRest(e)); + } + } + ASSERT_EQ(crs.size(), 2u) << "recut note becomes exactly two tied chords, no trailing rest"; + + ASSERT_TRUE(crs[0]->isChord()); + EXPECT_EQ(crs[0]->actualTicks(), Fraction(1, 2)); + Chord* first = toChord(crs[0]); + ASSERT_EQ(first->notes().size(), 1u); + EXPECT_NE(first->notes()[0]->tieFor(), nullptr) << "first figure must tie into the leftover"; + + ASSERT_TRUE(crs[1]->isChord()) << "leftover must be a tied note, not a rest"; + EXPECT_EQ(crs[1]->actualTicks(), Fraction(1, 8)); + Chord* second = toChord(crs[1]); + ASSERT_EQ(second->notes().size(), 1u); + EXPECT_NE(second->notes()[0]->tieBack(), nullptr) << "leftover must be tied from the first figure"; + EXPECT_EQ(first->notes()[0]->pitch(), second->notes()[0]->pitch()); + + delete score; + } +} diff --git a/src/importexport/encore/tests/tst_ornaments.cpp b/src/importexport/encore/tests/tst_ornaments.cpp new file mode 100644 index 0000000000000..4f9c122237fb9 --- /dev/null +++ b/src/importexport/encore/tests/tst_ornaments.cpp @@ -0,0 +1,2341 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Ornaments and articulations: trills, mordents, fermatas, breaths/caesuras, arpeggios, tremolos and the +// articulation-byte mapping, plus their placement/anchoring. See ENCORE_FORMAT.md §Ornament element. + +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/textbase.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/breath.h" +#include "engraving/dom/measurerepeat.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/trill.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Ornaments : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// =========================================================================== +// REGRESSION: Capped 3:2 triplet produces non-TDuration placedTicks; closeTuplet must not assert in beam layout. +// =========================================================================== + +TEST_F(Tst_Ornaments, beamed_triplet_capped_no_beam_assert) +{ + MasterScore* score = readEncoreScore("ornaments_beamed_triplet_capped.enc"); + ASSERT_NE(score, nullptr) << "File should load without beam-layout assert"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + for (auto& [tick, sp] : score->spannerMap().map()) { + EXPECT_LT(sp->tick(), sp->tick2()) << "Spanner has non-positive span"; + } + delete score; +} + +// =========================================================================== +// REGRESSION: WEDGESTART with alMezuro=0 must span the current measure, not collapse to zero. +// =========================================================================== + +TEST_F(Tst_Ornaments, zero_length_hairpin_dropped_cleanly) +{ + MasterScore* score = readEncoreScore("ornaments_zero_hairpin.enc"); + ASSERT_NE(score, nullptr) << "File should load without Spanner::setTicks assert"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + for (auto& [tick, sp] : score->spannerMap().map()) { + EXPECT_LT(sp->tick(), sp->tick2()) << "Spanner has non-positive span"; + } + delete score; +} + +// =========================================================================== +// REGRESSION: Partial 3:2 quarter triplet (placedTicks=1/3, not a TDuration fraction) must not assert in beam layout. +// =========================================================================== + +TEST_F(Tst_Ornaments, partial_quarter_triplet_layout_does_not_assert) +{ + MasterScore* score = readEncoreScore("ornaments_partial_quarter_triplet.enc"); + ASSERT_NE(score, nullptr) << "File must load and lay out without assert in beam.cpp"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + delete score; +} + +TEST_F(Tst_Ornaments, articulations_mapped_beyond_fermata) +{ + MasterScore* score = readEncoreScore("ornaments_articulations.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // Check by type predicate rather than exact SymId; layout may flip Above<->Below per stem direction. + enum class ArtKind { + Staccato, Accent, Tenuto, Marcato, Other + }; + auto kindOf = [](Articulation* a) -> ArtKind { + if (a->isStaccato()) { + return ArtKind::Staccato; + } + if (a->isAccent()) { + return ArtKind::Accent; + } + if (a->isTenuto()) { + return ArtKind::Tenuto; + } + if (a->isMarcato()) { + return ArtKind::Marcato; + } + return ArtKind::Other; + }; + std::vector expected = { + ArtKind::Staccato, ArtKind::Accent, ArtKind::Tenuto, ArtKind::Marcato, + }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + seen.push_back(kindOf(a)); + } + } + } + EXPECT_EQ(seen.size(), expected.size()); + for (size_t i = 0; i < std::min(seen.size(), expected.size()); ++i) { + EXPECT_EQ(seen[i], expected[i]) << "articulation #" << i; + } + delete score; +} + +// =========================================================================== +// FEATURE: Combo articulation bytes (e.g. 0x24 = tenuto + staccato) expand to two Articulation elements. +// =========================================================================== +TEST_F(Tst_Ornaments, articulation_combos_expand_to_two_glyphs) +{ + MasterScore* score = readEncoreScore("ornaments_articulations_combo.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // Combined single-glyph articulations map to their own kind. + // Individual components (Tenuto, Staccato, etc.) are for single-symbol artics. + enum class K { + Tenuto, Staccato, Accent, Marcato, Staccatissimo, + MarcatoStaccato, MarcatoTenuto, AccentStaccato, TenutoStaccato, TenutoAccent, + Other + }; + auto kindOf = [](Articulation* a) -> K { + using mu::engraving::SymId; + switch (SymId(a->subtype())) { + case SymId::articTenutoAbove: return K::Tenuto; + case SymId::articStaccatoAbove: return K::Staccato; + case SymId::articAccentAbove: return K::Accent; + case SymId::articMarcatoAbove: return K::Marcato; + case SymId::articStaccatissimoAbove: return K::Staccatissimo; + case SymId::articMarcatoStaccatoAbove: return K::MarcatoStaccato; + case SymId::articMarcatoTenutoAbove: return K::MarcatoTenuto; + case SymId::articAccentStaccatoAbove: return K::AccentStaccato; + case SymId::articTenutoStaccatoAbove: return K::TenutoStaccato; + case SymId::articTenutoAccentAbove: return K::TenutoAccent; + default: return K::Other; + } + }; + // Palette pairs (below-byte, above-byte), verified in Encore 5 against + // "Accidentals Marks and others": 0x22/0x23 tenuto-accent, 0x24/0x25 + // tenuto-staccato, 0x26/0x27 marcato-tenuto (= tenuto + heavy-accent), + // 0x2A/0x2B heavy-accent + staccatissimo, 0x2C/0x2D tenuto + staccatissimo. + // m1: 0x24 TenutoStaccato, 0x17 AccentStaccato, 0x27 MarcatoTenuto, 0x15 MarcatoStaccato. + // m2: 0x23 TenutoAccent, 0x2D Tenuto+Staccatissimo, 0x2B Marcato+Staccatissimo, 0x24 TenutoStaccato. + // m3: 0x14 MarcatoStaccatoBelow, 0x26 MarcatoTenutoBelow. + // m4: 0x25 TenutoStaccato, 0x2A Marcato+Staccatissimo, 0x2C Tenuto+Staccatissimo, 0x22 TenutoAccent. + const std::vector > expected = { + { K::TenutoStaccato }, // 0x24 → articTenutoStaccatoAbove + { K::AccentStaccato }, // 0x17 → articAccentStaccatoAbove + { K::MarcatoTenuto }, // 0x27 → articMarcatoTenutoAbove + { K::MarcatoStaccato }, // 0x15 → articMarcatoStaccatoAbove + { K::TenutoAccent }, // 0x23 → articTenutoAccentAbove + { K::Tenuto, K::Staccatissimo }, // 0x2D (no single glyph) + { K::Marcato, K::Staccatissimo }, // 0x2B heavy accent + staccatissimo + { K::TenutoStaccato }, // 0x24 again + { K::MarcatoStaccato }, // 0x14 → articMarcatoStaccatoBelow + { K::MarcatoTenuto }, // 0x26 → articMarcatoTenutoBelow + { K::TenutoStaccato }, // 0x25 → articTenutoStaccatoAbove + { K::Marcato, K::Staccatissimo }, // 0x2A heavy accent + staccatissimo + { K::Tenuto, K::Staccatissimo }, // 0x2C (no single glyph) + { K::TenutoAccent }, // 0x22 → articTenutoAccentAbove + }; + std::vector > seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + std::set kinds; + for (Articulation* a : toChord(el)->articulations()) { + kinds.insert(kindOf(a)); + } + if (!kinds.empty()) { + seen.push_back(kinds); + } + } + } + ASSERT_EQ(seen.size(), expected.size()); + for (size_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(seen[i], expected[i]) << "chord #" << i; + } + delete score; +} + +// =========================================================================== +// FEATURE: Per-chord staccato from size-16 ORN tipo=0xC9; deduped against per-note artic byte 0x1D. +// =========================================================================== +TEST_F(Tst_Ornaments, staccato_from_orn_c9) +{ + MasterScore* score = readEncoreScore("ornaments_staccato_orn.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + int staccatoCount = 0; + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::articStaccatoAbove + || a->symId() == SymId::articStaccatoBelow) { + ++staccatoCount; + } + } + seen.push_back(staccatoCount == 1); + EXPECT_LE(staccatoCount, 1) << "no duplicate staccato per chord"; + } + } + // chords 0,1,3 have staccato; chord 2 has none; chord 3 dedupes 0xC9 + artic byte 0x1D. + const std::vector expected = { true, true, false, true }; + EXPECT_EQ(seen, expected); + delete score; +} + +// TRILL_START/TRILL_END markers create a Trill spanner (tr + wavy line), while a TRILL_ALT inside that span +// stays a glyph-only Ornament. +TEST_F(Tst_Ornaments, trill_spanner_start_markers) +{ + MasterScore* score = readEncoreScore("ornaments_trill_spanner.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // Count Trill spanners (0x36 → 0x35) + int trillSpanners = 0; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isTrill()) { + ++trillSpanners; + } + } + EXPECT_EQ(trillSpanners, 1) + << "0x36 + 0x35 must create exactly one Trill spanner (tr + wavy line)"; + + // Count Ornament glyphs (0x37 stays as glyph, secondary tr mark within the span) + int ornamentGlyphs = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::ornamentTrill) { + ++ornamentGlyphs; + } + } + } + } + EXPECT_EQ(ornamentGlyphs, 1) + << "0x37 (TRILL_ALT) must remain an Ornament glyph (secondary tr, not a spanner)"; + + if (trillSpanners == 1) { + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isTrill()) { + const Fraction startQ = sp->tick(); // 0 quarters from measure start + const Fraction endQ = sp->tick2(); // 2 quarters in (where 0x35 was) + EXPECT_EQ(startQ.numerator() % startQ.denominator(), 0) + << "Trill spanner start must align to a beat"; + EXPECT_GT(endQ, startQ) + << "Trill spanner must have positive duration"; + } + } + } + + delete score; +} + +// =========================================================================== +// REGRESSION: TRILL_START (0x36) with no TRILL_END and alMezuro=0 must +// fall back to Ornament glyph. No Trill spanner must be created. +// =========================================================================== +TEST_F(Tst_Ornaments, trill_no_end_marker_creates_glyph_not_spanner) +{ + MasterScore* score = readEncoreScore("ornaments_trill_no_end_marker.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int trillSpanners = 0; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isTrill()) { + ++trillSpanners; + } + } + EXPECT_EQ(trillSpanners, 0) + << "0x36 without 0x35 and alMezuro=0 must NOT create a Trill spanner"; + + int ornGlyphs = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::ornamentTrill) { + ++ornGlyphs; + } + } + } + } + } + EXPECT_EQ(ornGlyphs, 1) + << "0x36 without span info must produce exactly one Ornament glyph"; + + delete score; +} + +// =========================================================================== +// FEATURE: TRILL_START with alMezuro=2 creates a Trill spanner spanning +// to the end of the 2nd measure after the start measure. +// =========================================================================== +TEST_F(Tst_Ornaments, trill_cross_measure_span_from_almezuro) +{ + MasterScore* score = readEncoreScore("ornaments_trill_cross_measure.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int trillSpanners = 0; + Fraction spanStart, spanEnd; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isTrill()) { + ++trillSpanners; + spanStart = sp->tick(); + spanEnd = sp->tick2(); + } + } + EXPECT_EQ(trillSpanners, 1) + << "alMezuro=2 must create exactly one Trill spanner"; + + if (trillSpanners == 1) { + // The spanner starts at tick=0 (start of TRILL_START note). + EXPECT_EQ(spanStart, Fraction(0, 1)) + << "Trill spanner must start at tick=0 (TRILL_START note)"; + // alMezuro=2 targets ctx.measuresByIdx[0+2] = measure 2. + // In 4/4, each measure = Fraction(1,1) whole note, so measure 2 ends at Fraction(3,1). + // The span end must reach past measure 1 (Fraction(2,1)). + EXPECT_GT(spanEnd, Fraction(2, 1)) + << "Trill spanner with alMezuro=2 must end at or beyond the 2nd measure boundary"; + } + + delete score; +} + +// =========================================================================== +// FEATURE: Per-note technical markings: fingering 1..5 (0x0D..0x11), open-string (0x46), thumb (0x44/0x45), harmonic (0x1E/0x1F). +// =========================================================================== +TEST_F(Tst_Ornaments, technical_markings_per_note_artic_byte) +{ + MasterScore* score = readEncoreScore("ornaments_technical.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector fingerings; + std::vector articulations; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* e : n->el()) { + if (e && e->isFingering()) { + fingerings.push_back(toFingering(e)->plainText()); + } + } + } + for (Articulation* a : toChord(el)->articulations()) { + articulations.push_back(a->symId()); + } + } + } + const std::vector expectedFingerings = { + u"0", u"1", u"2", u"3", u"4", u"5", + }; + EXPECT_EQ(fingerings, expectedFingerings); + const std::vector expectedArticulations = { + SymId::stringsThumbPosition, + SymId::stringsHarmonic, + }; + EXPECT_EQ(articulations, expectedArticulations); + delete score; +} + +// =========================================================================== +// FEATURE: Fermata anchored on segment (not chord); direction from artic slot: articUp=0x20 (above), articDown=0x21 (below). +// =========================================================================== +TEST_F(Tst_Ornaments, fermatas_emit_segment_anchored_element) +{ + MasterScore* score = readEncoreScore("ornaments_fermatas.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isFermata()) { + seen.push_back(toFermata(e)->placement()); + } + } + } + } + ASSERT_EQ(seen.size(), 2u); + EXPECT_EQ(seen[0], PlacementV::BELOW) + << "articDown=0x21 must produce an inverted (below) fermata"; + EXPECT_EQ(seen[1], PlacementV::ABOVE) + << "articUp=0x20 must produce an upright (above) fermata"; + delete score; +} + +// =========================================================================== +// FEATURE: Single-note tremolos from per-note artic byte (0x41/0x42/0x43/0x03 → R8/R16/R32). +// =========================================================================== +TEST_F(Tst_Ornaments, tremolos_from_per_note_artic_byte) +{ + MasterScore* score = readEncoreScore("ornaments_tremolos.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + if (TremoloSingleChord* trem = toChord(el)->tremoloSingleChord()) { + seen.push_back(trem->tremoloType()); + } + } + } + const std::vector expected = { + TremoloType::R8, TremoloType::R16, TremoloType::R32, TremoloType::R32, + }; + EXPECT_EQ(seen, expected); + delete score; +} + +// =========================================================================== +// FEATURE: Trill-mark / mordent / inverted-mordent from per-note artic byte, emitted as Ornament for MusicXML. +// =========================================================================== +TEST_F(Tst_Ornaments, trill_mordent_from_per_note_artic_byte) +{ + MasterScore* score = readEncoreScore("ornaments_trill_mordent.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + seen.push_back(a->symId()); + } + } + } + const std::vector expected = { + SymId::ornamentTrill, + SymId::ornamentShortTrill, // 0x0A: inverted-mordent (short) + SymId::ornamentMordent, // 0x0B: simple lower mordent + SymId::ornamentPrallMordent, // 0x2F: double/long lower mordent + }; + EXPECT_EQ(seen, expected); + delete score; +} + +// An ornament-family articulation byte (e.g. turn) must create an Ornament, not a plain Articulation: +// layout calls Ornament-only methods on those SymIds and crashes on a plain Articulation. +TEST_F(Tst_Ornaments, ornament_turn_created_as_ornament_not_articulation) +{ + MasterScore* score = readEncoreScore("ornaments_ornament_turn.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isChord()); + const auto& arts = toChord(el)->articulations(); + ASSERT_EQ(arts.size(), 1u) << "One ornament expected on the first note"; + + Articulation* art = arts.front(); + EXPECT_EQ(art->symId(), SymId::ornamentTurn) + << "SymId must be ornamentTurn"; + EXPECT_TRUE(art->isOrnament()) + << "ornamentTurn must be stored as Ornament, not plain Articulation; " + "a plain Articulation with an ornament SymId causes SIGSEGV in layout"; + + delete score; +} + +// =========================================================================== +// REGRESSION: DOUBLE barline must be applied to every staff, not only track 0. +// =========================================================================== +TEST_F(Tst_Ornaments, double_barline_lands_on_every_staff) +{ + MasterScore* score = readEncoreScore("ornaments_double_barline_multi_staff.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + const size_t nstaves = score->nstaves(); + ASSERT_GE(nstaves, 2u) << "fixture must have at least two staves"; + + Segment* endBarSeg = m1->findSegment(SegmentType::EndBarLine, m1->endTick()); + ASSERT_NE(endBarSeg, nullptr) << "m1 must have an EndBarLine segment"; + + int doubleBarStaves = 0; + for (size_t s = 0; s < nstaves; ++s) { + EngravingItem* el = endBarSeg->element(s * VOICES); + if (!el || !el->isBarLine()) { + continue; + } + if (toBarLine(el)->barLineType() == BarLineType::DOUBLE) { + ++doubleBarStaves; + } + } + EXPECT_EQ(doubleBarStaves, static_cast(nstaves)) + << "DOUBLE barline must be present on every staff, not only track 0"; + delete score; +} +// =========================================================================== +// REGRESSION: WEDGESTART at tick == durTicks (measure-end boundary) must not be dropped. +// =========================================================================== +TEST_F(Tst_Ornaments, wedgestart_at_measure_end_boundary) +{ + MasterScore* score = readEncoreScore("ornaments_wedgestart_at_measure_end.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int hairpinCount = 0; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isHairpin()) { + ++hairpinCount; + EXPECT_LT(sp->tick(), sp->tick2()) << "hairpin span must be positive"; + } + } + EXPECT_EQ(hairpinCount, 1) + << "WEDGESTART at tick == durTicks must produce a hairpin"; + delete score; +} + +// =========================================================================== +// FEATURE: Dynamics from size-16 ORN cluster (0x81=pp, 0x82=p, 0x85=f, 0x86=ff). +// =========================================================================== +TEST_F(Tst_Ornaments, dynamics_from_size16_ornaments) +{ + MasterScore* score = readEncoreScore("ornaments_dynamics.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isDynamic()) { + seen.push_back(toDynamic(e)->dynamicType()); + } + } + } + } + const std::vector expected = { + DynamicType::P, DynamicType::PP, DynamicType::F, DynamicType::FF, + }; + EXPECT_EQ(seen, expected); + delete score; +} + +// Two dynamic ORNs at the same tick+xoffset on one staff must collapse to the first; Encore renders only +// one dynamic per beat. +TEST_F(Tst_Ornaments, dynamics_stacked_collapsed_to_first) +{ + MasterScore* score = readEncoreScore("ornaments_dynamics_stacked.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isDynamic()) { + seen.push_back(toDynamic(e)->dynamicType()); + } + } + } + } + // Only the first-read dynamic (ff) survives; the stacked fff is dropped. + const std::vector expected = { DynamicType::FF }; + EXPECT_EQ(seen, expected); + delete score; +} + +// =========================================================================== +// FEATURE: Voice=4 ORN with staffByte high bit (0x40) produces system-level dynamics (full 0x80..0x8A ladder). +// =========================================================================== +TEST_F(Tst_Ornaments, dynamics_full_ladder_voice4_system_mark) +{ + MasterScore* score = readEncoreScore("ornaments_dynamics_full.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isDynamic()) { + seen.push_back(toDynamic(e)->dynamicType()); + } + } + } + } + const std::vector expected = { + DynamicType::PPP, DynamicType::PP, DynamicType::P, DynamicType::MP, + DynamicType::MF, DynamicType::F, DynamicType::FF, DynamicType::FFF, + DynamicType::SFZ, DynamicType::SFFZ, DynamicType::FP, DynamicType::FZ, + DynamicType::SF, + }; + EXPECT_EQ(seen, expected); + delete score; +} + +// =========================================================================== +// FEATURE: Arpeggio from ORN tipo=0x22 attaches to the chord. +// =========================================================================== +TEST_F(Tst_Ornaments, arpeggio_attaches_to_chord) +{ + MasterScore* score = readEncoreScore("ornaments_arpeggio.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int arpeggioCount = 0; + int notesUnderArpeggio = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + if (c->arpeggio()) { + ++arpeggioCount; + notesUnderArpeggio = static_cast(c->notes().size()); + } + } + } + EXPECT_EQ(arpeggioCount, 1) << "exactly one arpeggio expected"; + EXPECT_EQ(notesUnderArpeggio, 3) + << "arpeggio must sit on the 3-note C major triad"; + delete score; +} +// =========================================================================== +// FIX: Multi-measure hairpin end tick resolved from WEDGESTART's alMezuro (cresc alMezuro=2, dim alMezuro=1). +// =========================================================================== +TEST_F(Tst_Ornaments, multi_measure_hairpin_resolved_from_almezuro) +{ + MasterScore* score = readEncoreScore("ornaments_multi_measure_hairpin.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int hairpinCount = 0; + bool foundCresc = false; + bool foundDim = false; + const Fraction wholeMeasure(4, 4); + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isHairpin()) { + continue; + } + ++hairpinCount; + Hairpin* hp = toHairpin(sp); + EXPECT_LT(hp->tick(), hp->tick2()) << "hairpin span must be positive"; + if (hp->hairpinType() == HairpinType::CRESC_HAIRPIN) { + foundCresc = true; + // start at measure 0 / tick 0, end at end of measure 2 (= 3 * 4/4) + EXPECT_EQ(hp->tick(), Fraction(0, 1)); + EXPECT_EQ(hp->tick2(), wholeMeasure * 3); + } else if (hp->hairpinType() == HairpinType::DIM_HAIRPIN) { + foundDim = true; + // start at measure 1 / beat 2 (480 enc ticks = 2/4), + // end at end of measure 2 (= 3 * 4/4) + EXPECT_EQ(hp->tick(), wholeMeasure + Fraction(2, 4)); + EXPECT_EQ(hp->tick2(), wholeMeasure * 3); + } + } + EXPECT_EQ(hairpinCount, 2); + EXPECT_TRUE(foundCresc); + EXPECT_TRUE(foundDim); + delete score; +} +TEST_F(Tst_Ornaments, bowing_marks_from_orn_c4_c5) +{ + MasterScore* score = readEncoreScore("ornaments_bowing.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector bowings; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::stringsDownBow + || a->symId() == SymId::stringsUpBow) { + bowings.push_back(a->symId()); + } + } + } + } + const std::vector expected = { + SymId::stringsDownBow, SymId::stringsUpBow, + SymId::stringsDownBow, SymId::stringsUpBow, + }; + EXPECT_EQ(bowings, expected); + delete score; +} + +// =========================================================================== +// FEATURE: In v0xC2, ORN tipo 0xC4 = accent above (not up-bow as in v0xC4). +// v0xC2 NOTE elements (size=22) have no articulation bytes; accent is in ORN. +// =========================================================================== +TEST_F(Tst_Ornaments, v0xc2_orn_c4_is_accent_not_upbow) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_orn_c4_accent.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int accentCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + EXPECT_NE(a->symId(), SymId::stringsUpBow) + << "ORN 0xC4 in v0xC2 must not produce stringsUpBow"; + if (a->symId() == SymId::articAccentAbove + || a->symId() == SymId::articAccentBelow) { + ++accentCount; + } + } + } + } + EXPECT_GE(accentCount, 5) << "Expected several accent marks in this v0xC2 score"; + delete score; +} + +// =========================================================================== +// FEATURE: In v0xC4, ORN tipo 0xBE = accent above (standalone accent glyph). +// =========================================================================== +TEST_F(Tst_Ornaments, v0xc4_orn_be_is_accent) +{ + MasterScore* score = readEncoreScore("ornaments_v0c4_orn_be_accent.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int accentCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + EXPECT_NE(a->symId(), SymId::stringsUpBow) + << "ORN 0xBE in v0xC4 must not produce stringsUpBow"; + if (a->symId() == SymId::articAccentAbove + || a->symId() == SymId::articAccentBelow) { + ++accentCount; + } + } + } + } + EXPECT_GE(accentCount, 5) << "Expected several accent marks in this v0xC4 score"; + delete score; +} + +// =========================================================================== +// FEATURE: Stand-alone fingering from ORN tipo 0xB9..0xBD (tipo = 0xB8 + finger 1..5). +// =========================================================================== +TEST_F(Tst_Ornaments, fingering_from_orn_b9_bd) +{ + MasterScore* score = readEncoreScore("ornaments_fingering_orn.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector fingerings; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* e : n->el()) { + if (e && e->isFingering()) { + fingerings.push_back(toFingering(e)->plainText()); + } + } + } + } + } + const std::vector expected = { u"1", u"2", u"3", u"4", u"5" }; + EXPECT_EQ(fingerings, expected); + delete score; +} + +// =========================================================================== +// FIX: Grand-staff FINGER ORN routing: cross-measure (Pattern A) and multi-note same-tick (Pattern B). +// =========================================================================== +TEST_F(Tst_Ornaments, fingering_grandstaff_routing) +{ + MasterScore* score = readEncoreScore("ornaments_fingering_grandstaff.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // Navigate to a measure by 0-based index (Encore measure order). + auto measureAt = [&](int idx) -> Measure* { + int n = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + if (n++ == idx) { + return toMeasure(mb); + } + } + return nullptr; + }; + + // Collect fingerings attached to the first chord on `tr` in `m`. + auto fingeringsOnFirstChord = [](Measure* m, track_idx_t tr) -> std::vector { + std::vector out; + if (!m) { + return out; + } + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(tr); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* e : n->el()) { + if (e && e->isFingering()) { + out.push_back(toFingering(e)->plainText()); + } + } + } + break; + } + return out; + }; + + // Collect all fingerings on `tr` across every chord in `m`. + auto fingeringsOnTrack = [](Measure* m, track_idx_t tr) -> std::vector { + std::vector out; + if (!m) { + return out; + } + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(tr); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* e : n->el()) { + if (e && e->isFingering()) { + out.push_back(toFingering(e)->plainText()); + } + } + } + } + return out; + }; + + const track_idx_t staff1 = 0; + const track_idx_t staff2 = VOICES; + + // Pattern A: 4 ORNs from m2's last voice=0 tick must land on m3 staff 2, not m2 staff 1. + Measure* m2 = measureAt(1); + ASSERT_NE(m2, nullptr); + Measure* m3 = measureAt(2); + ASSERT_NE(m3, nullptr); + + // Last chord of m2, staff 1: must NOT carry the cross-measure fingerings. + { + std::vector m2s1last; + Segment* lastSeg = nullptr; + for (Segment* s = m2->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + if (s->element(staff1) && s->element(staff1)->isChord()) { + lastSeg = s; + } + } + if (lastSeg) { + for (Note* n : toChord(lastSeg->element(staff1))->notes()) { + for (EngravingItem* e : n->el()) { + if (e && e->isFingering()) { + m2s1last.push_back(toFingering(e)->plainText()); + } + } + } + } + EXPECT_TRUE(m2s1last.empty()) + << "Last chord of m2 staff 1 should have no fingerings (Pattern A regression)"; + } + + // First chord of m3, staff 2: receives the 4 Pattern A fingerings. + { + auto f = fingeringsOnFirstChord(m3, staff2); + EXPECT_EQ(f.size(), 4u) << "m3 staff 2 should have 4 fingerings from Pattern A"; + if (f.size() == 4) { + EXPECT_EQ(f[0], u"1"); + EXPECT_EQ(f[1], u"1"); + EXPECT_EQ(f[2], u"3"); + EXPECT_EQ(f[3], u"4"); + } + } + + // Staff 1 m3 melody fingerings are unaffected by the fix. + { + auto f = fingeringsOnTrack(m3, staff1); + EXPECT_EQ(f, (std::vector { u"1", u"2", u"4" })); + } + + // Pattern B: more ORNs at m11 tick=0 than voice=0 notes must land on staff 2, not staff 1. + Measure* m11 = measureAt(10); + ASSERT_NE(m11, nullptr); + + // First chord of m11, staff 1: must NOT carry the Pattern B fingerings. + { + auto f = fingeringsOnFirstChord(m11, staff1); + // Staff 1 may legitimately have its own single fingering; check it has <=1. + EXPECT_LE(f.size(), 1u) + << "m11 staff 1 first chord should not carry 4 Pattern B fingerings"; + } + + // First chord of m11, staff 2: receives the 4 Pattern B fingerings. + { + auto f = fingeringsOnFirstChord(m11, staff2); + EXPECT_EQ(f.size(), 4u) << "m11 staff 2 should have 4 fingerings from Pattern B"; + if (f.size() == 4) { + EXPECT_EQ(f[0], u"1"); + EXPECT_EQ(f[1], u"2"); + EXPECT_EQ(f[2], u"4"); + EXPECT_EQ(f[3], u"4"); + } + } + + delete score; +} + +// =========================================================================== +// BUG FIX: articulationDown=0x21 on a non-tuplet note must create fermataBelow; +// on a tuplet note it must be suppressed (same dual-meaning rule as 0x20 above). +// =========================================================================== + +// =========================================================================== +// BUG FIX: articulationDown=0x21 on a non-tuplet note must create fermataBelow; +// on a tuplet note it must be suppressed (same dual-meaning rule as 0x20 above). +// =========================================================================== +TEST_F(Tst_Ornaments, fermata_below_kept_on_non_tuplet_suppressed_on_tuplet) +{ + MasterScore* score = readEncoreScore("ornaments_fermata_below_not_in_tuplet.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + int fermataCount = 0; + SymId sym = SymId::noSym; + for (Segment* s = m1->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isFermata()) { + ++fermataCount; + sym = toFermata(e)->symId(); + } + } + } + EXPECT_EQ(fermataCount, 1) << "only the non-tuplet note must produce a fermata"; + EXPECT_EQ(sym, SymId::fermataBelow) << "articDown=0x21 on non-tuplet must be fermataBelow"; + delete score; +} + +// =========================================================================== +// BUG FIX companion: when the tremolo ORN resolves to a chord with no incoming +// tie, the tremolo must stay on that chord (no spurious backwards walk). +// =========================================================================== +TEST_F(Tst_Ornaments, tremolo_orn_stays_on_untied_chord) +{ + MasterScore* score = readEncoreScore("ornaments_tremolo_orn_no_tie.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + Chord* tremoloChord = nullptr; + for (Segment* s = m1->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord() && toChord(el)->tremoloSingleChord()) { + tremoloChord = toChord(el); + } + } + ASSERT_NE(tremoloChord, nullptr) << "TremoloSingleChord not found"; + EXPECT_EQ(tremoloChord->tick(), Fraction(0, 1)) + << "tremolo must land on the quarter (tick=0); no spurious tie-back walk"; + EXPECT_EQ(tremoloChord->notes().front()->tieBack(), nullptr) + << "the chord carrying the tremolo must have no incoming tie"; + delete score; +} + +// =========================================================================== +// NEW ARTIC BYTES: brassMuteClosed (0x1B), ornamentTurnInverted (0x2E), +// brassMuteHalfClosed (0x30) +// =========================================================================== +TEST_F(Tst_Ornaments, new_artic_bytes_stopped_inverted_turn_half_stopped) +{ + MasterScore* score = readEncoreScore("ornaments_new_artic_bytes.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto articsOnNote = [&](int noteIdx) { + Segment* seg = m->first(SegmentType::ChordRest); + for (int i = 0; i < noteIdx && seg; ++i) { + seg = seg->next(SegmentType::ChordRest); + } + if (!seg) { + return std::vector {}; + } + EngravingItem* el = seg->element(0); + if (!el || !el->isChord()) { + return std::vector {}; + } + std::vector ids; + for (Articulation* a : toChord(el)->articulations()) { + ids.push_back(a->symId()); + } + for (EngravingItem* e : toChord(el)->el()) { + if (e->isOrnament()) { + ids.push_back(toOrnament(e)->symId()); + } + } + return ids; + }; + + auto s0 = articsOnNote(0); + auto s1 = articsOnNote(1); + auto s2 = articsOnNote(2); + + EXPECT_TRUE(std::find(s0.begin(), s0.end(), SymId::brassMuteClosed) != s0.end()) + << "0x1B must produce brassMuteClosed (+)"; + EXPECT_TRUE(std::find(s1.begin(), s1.end(), SymId::ornamentTurnInverted) != s1.end()) + << "0x2E must produce ornamentTurnInverted (inverted turn)"; + EXPECT_TRUE(std::find(s2.begin(), s2.end(), SymId::brassMuteHalfClosed) != s2.end()) + << "0x30 must produce brassMuteHalfClosed (half-stopped)"; + + delete score; +} + +// =========================================================================== +// BREATH MARKS AND CAESURA (ORN tipo 0xA8=breathMarkComma, 0xA7=caesura) +// =========================================================================== +TEST_F(Tst_Ornaments, breath_comma_and_caesura_from_orn_tipo) +{ + MasterScore* score = readEncoreScore("ornaments_breath_and_caesura.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + int breathCount = 0; + bool hasComma = false, hasCaesura = false; + // Breath elements live on SegmentType::Breath segments as segment elements, + // not as annotations; iterate all segments and check element slots. + for (Segment* seg = m->first(); seg; seg = seg->next()) { + for (track_idx_t v = 0; v < score->ntracks(); ++v) { + EngravingItem* el = seg->element(v); + if (el && el->isBreath()) { + ++breathCount; + SymId sid = toBreath(el)->symId(); + if (sid == SymId::breathMarkComma) { + hasComma = true; + } + if (sid == SymId::caesura) { + hasCaesura = true; + } + } + } + } + + EXPECT_EQ(breathCount, 2) << "Must have 2 Breath elements (comma + caesura)"; + EXPECT_TRUE(hasComma) << "ORN tipo 0xA8 must produce breathMarkComma"; + EXPECT_TRUE(hasCaesura) << "ORN tipo 0xA7 must produce caesura"; + + delete score; +} + +// =========================================================================== +// STANDALONE TRILL_END (tipo 0x35 without prior TRILL_START) +// Creates a Trill spanner spanning the note's duration. +// =========================================================================== +TEST_F(Tst_Ornaments, standalone_trill_end_creates_trill_spanner_on_note) +{ + MasterScore* score = readEncoreScore("ornaments_standalone_trill_end.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + int trillCount = 0; + for (auto it = score->spanner().cbegin(); it != score->spanner().cend(); ++it) { + if (it->second->isTrill()) { + ++trillCount; + Trill* tr = toTrill(it->second); + EXPECT_GE(tr->ticks().ticks(), 240) + << "Trill spanner must cover at least a quarter note"; + } + } + EXPECT_EQ(trillCount, 1) + << "TRILL_END without prior TRILL_START must create one Trill spanner"; + + delete score; +} + +// =========================================================================== +// MEASURE REPEAT (ORN tipo 0xA3) +// =========================================================================== +TEST_F(Tst_Ornaments, measure_repeat_from_orn_tipo_0xA3) +{ + MasterScore* score = readEncoreScore("ornaments_measure_repeat.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + int mrCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + for (size_t v = 0; v < VOICES; ++v) { + EngravingItem* el = seg->element(static_cast(v)); + if (el && el->isMeasureRepeat()) { + ++mrCount; + } + } + } + } + EXPECT_EQ(mrCount, 1) << "ORN tipo 0xA3 must produce one MeasureRepeat element"; + + delete score; +} + +// =========================================================================== +// TRILL WITH ACCIDENTALS: intervalAbove set from artic bytes 0x05/0x06/0x07 +// =========================================================================== +TEST_F(Tst_Ornaments, trill_accidentals_set_interval_above) +{ + MasterScore* score = readEncoreScore("ornaments_trill_with_accidentals.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + struct NoteOrnInterval { + int noteIdx; + IntervalType expected; + const char* desc; + }; + std::vector checks = { + { 0, IntervalType::AUTO, "0x04 trill: no accidental → AUTO" }, + { 1, IntervalType::MINOR, "0x05 trill+flat → MINOR (trill menor)" }, + { 2, IntervalType::AUGMENTED, "0x06 trill+sharp → AUGMENTED (trill augmented)" }, + { 3, IntervalType::MAJOR, "0x07 trill+natural → MAJOR" }, + }; + + // Collect intervalAbove values from all trill ornaments (any symId, since + // MuseScore may store the cue-note accidental as the primary glyph for some intervals). + std::vector intervals; + int noteIdx = 0; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (!el || !el->isChord()) { + continue; + } + if (noteIdx >= (int)checks.size()) { + break; + } + for (EngravingItem* sub : toChord(el)->el()) { + if (!sub->isOrnament()) { + continue; + } + Ornament* orn = toOrnament(sub); + // Only check ornaments that were created for trills (have a trill-related interval or AUTO) + if (orn->intervalAbove().type != IntervalType::AUTO + || checks[noteIdx].expected == IntervalType::AUTO) { + EXPECT_EQ(orn->intervalAbove().type, checks[noteIdx].expected) + << checks[noteIdx].desc; + } + } + ++noteIdx; + } + + delete score; +} + +// =========================================================================== +// OPEN-STRING (0x46): plain Fingering "0", NOT circled STRING_NUMBER. +// STICK (0x47): unmapped, no fingering added. +// =========================================================================== +TEST_F(Tst_Ornaments, open_string_0x46_is_plain_fingering_not_string_number) +{ + // Note 1: au=0x46 (open string) → Fingering "0", plain FINGERING style (no circle). + // Note 2: au=0x47 (stick) → no fingering added. + MasterScore* score = readEncoreScore("ornaments_open_string_and_stick.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector > fingeringsByNote; + for (Segment* seg = m->first(SegmentType::ChordRest); seg; seg = seg->next(SegmentType::ChordRest)) { + EngravingItem* el = seg->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* sub : n->el()) { + if (sub && sub->isFingering()) { + Fingering* fg = toFingering(sub); + fingeringsByNote.emplace_back(fg->plainText(), fg->textStyleType()); + } + } + } + } + + ASSERT_EQ(fingeringsByNote.size(), 1u) + << "Only note 1 (0x46) must have a fingering; note 2 (0x47=stick) is unmapped"; + EXPECT_EQ(fingeringsByNote[0].first, String(u"0")) + << "Open-string (0x46) must produce Fingering '0'"; + EXPECT_EQ(fingeringsByNote[0].second, TextStyleType::FINGERING) + << "Open-string (0x46) must use plain FINGERING style, not STRING_NUMBER (circled)"; + + delete score; +} + +// =========================================================================== +// STANDALONE TRILL_ALT (0x37) with no prior TRILL_START on same track: +// creates a Trill spanner covering the note's duration. +// =========================================================================== +TEST_F(Tst_Ornaments, standalone_trill_alt_creates_trill_spanner) +{ + MasterScore* score = readEncoreScore("ornaments_trill_alt_standalone.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "sanity check failed"; + + int trillCount = 0; + for (auto it = score->spanner().cbegin(); it != score->spanner().cend(); ++it) { + if (it->second->isTrill()) { + ++trillCount; + Trill* tr = toTrill(it->second); + EXPECT_GE(tr->ticks().ticks(), 240) + << "Standalone TRILL_ALT spanner must cover at least a quarter note"; + } + } + EXPECT_EQ(trillCount, 1) + << "TRILL_ALT (0x37) without prior TRILL_START must create exactly one Trill spanner"; + + delete score; +} + +// When several notes in a chord carry the same articulation byte, the ornament must be added once, not +// duplicated per note. +TEST_F(Tst_Ornaments, artic_byte_dedup_no_duplicate_ornament_on_chord) +{ + MasterScore* score = readEncoreScore("notes_artic_dedup_trill_on_chord.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()) << "Artic dedup must not corrupt"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + EngravingItem* el = seg->element(0); + ASSERT_NE(el, nullptr); + ASSERT_TRUE(el->isChord()); + Chord* chord = toChord(el); + ASSERT_EQ(chord->notes().size(), 2u) << "Chord must have both notes"; + + // Count ornamentTrill instances on the chord + int trillCount = 0; + for (Articulation* e : chord->articulations()) { + if (e && e->isOrnament() && toOrnament(e)->symId() == SymId::ornamentTrill) { + ++trillCount; + } + } + EXPECT_EQ(trillCount, 1) + << "Both notes carry au=0x04 (trill) but the chord must have exactly ONE " + "ornamentTrill; duplicate artic bytes on multi-note chords must be deduped"; + + delete score; +} + +// =========================================================================== +// BUG FIX: TRILL_SIMPLE (0xB6) standalone ornament glyph +// =========================================================================== + +TEST_F(Tst_Ornaments, trill_simple_tipo_b6_places_ornament_trill) +{ + // TRILL_SIMPLE (tipo=0xB6) is a 16-byte standalone trill mark. + // Case 1: ORN at same tick as a note places ornamentTrill on that note. + // Case 2: ORN at a REST tick snaps forward to the next note. + MasterScore* score = readEncoreScore("ornaments_trill_simple_on_note.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "TRILL_SIMPLE test must produce clean score: " << ret.text(); + + // M1: ORN@tick=0 co-located with first quarter note → trill on that note + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + Segment* seg1 = m1->first(SegmentType::ChordRest); + ASSERT_NE(seg1, nullptr); + EngravingItem* el1 = seg1->element(0); + ASSERT_NE(el1, nullptr); + ASSERT_TRUE(el1->isChord()); + Chord* chord1 = toChord(el1); + int trillCount1 = 0; + for (Articulation* a : chord1->articulations()) { + if (a && a->isOrnament() && toOrnament(a)->symId() == SymId::ornamentShortTrill) { + ++trillCount1; + } + } + EXPECT_EQ(trillCount1, 1) + << "TRILL_SHORT (0xB6) at note tick must place ornamentShortTrill on that note"; + + // M2 (5/8): ORN at tick=1/8 (REST position) must snap to the 2nd chord (tick=1/4) + Measure* m2 = m1->nextMeasure(); + ASSERT_NE(m2, nullptr); + Chord* trillNote = nullptr; + int chordIdx = 0; + for (Segment* s = m2->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + if (++chordIdx == 2) { + trillNote = toChord(el); + break; + } + } + } + ASSERT_NE(trillNote, nullptr) << "M2 must have a 2nd chord (target of the rest-tick snap)"; + int trillCount2 = 0; + for (Articulation* a : trillNote->articulations()) { + if (a && a->isOrnament() && toOrnament(a)->symId() == SymId::ornamentShortTrill) { + ++trillCount2; + } + } + EXPECT_EQ(trillCount2, 1) + << "TRILL_SHORT (0xB6) at REST tick must snap forward and place ornamentShortTrill on next note"; + + // M3 (4/4): TRILL_TR (0xB0) at tick=1/4 on second quarter note → ornamentTrill + Measure* m3 = m2->nextMeasure(); + ASSERT_NE(m3, nullptr); + Chord* trChord = nullptr; + int ci = 0; + for (Segment* s = m3->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + if (++ci == 2) { + trChord = toChord(el); + break; + } + } + } + ASSERT_NE(trChord, nullptr) << "M3 must have a 2nd chord"; + int trCount3 = 0; + for (Articulation* a : trChord->articulations()) { + if (a && a->isOrnament() && toOrnament(a)->symId() == SymId::ornamentTrill) { + ++trCount3; + } + } + EXPECT_EQ(trCount3, 1) << "TRILL_TR (0xB0) must place ornamentTrill on its target note"; + + // M4 (4/4): two identical TRILL_SHORT ORNs at tick=0 → dedup places exactly one glyph + Measure* m4 = m3->nextMeasure(); + ASSERT_NE(m4, nullptr); + Segment* seg4 = m4->first(SegmentType::ChordRest); + ASSERT_NE(seg4, nullptr); + EngravingItem* el4 = seg4->element(0); + ASSERT_NE(el4, nullptr); + ASSERT_TRUE(el4->isChord()); + Chord* chord4 = toChord(el4); + int trCount4 = 0; + for (Articulation* a : chord4->articulations()) { + if (a && a->isOrnament() && toOrnament(a)->symId() == SymId::ornamentShortTrill) { + ++trCount4; + } + } + EXPECT_EQ(trCount4, 1) + << "Two duplicate TRILL_SHORT ORNs at the same tick must be deduped to one glyph"; + + delete score; +} +TEST_F(Tst_Ornaments, accent_orn_attaches_to_nonzero_voice) +{ + // Single-staff file: note C4 in voice=1 at tick=0, ORN 0xBE at voice=0. + MasterScore* score = readEncoreScore("ornaments_accent_nonzero_voice.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + + // Note is in voice=1 → track = staffIdx*VOICES + 1 = 1. + EngravingItem* el = seg->element(1); + ASSERT_TRUE(el && el->isChord()) + << "Voice-1 note must be imported at track=1"; + + int accentCount = 0; + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::articAccentAbove || a->symId() == SymId::articAccentBelow) { + ++accentCount; + } + } + EXPECT_EQ(accentCount, 1) + << "ACCENT ORN at voice=0 must attach to the voice=1 note on the same staff; " + "resolver must scan all voices before falling back to sibling staff"; + + // Voice=0 track must have no spurious accent (the ORN must not have redirected + // to a phantom sibling or the voice=0 rest). + EngravingItem* v0el = seg->element(0); + if (v0el && v0el->isChord()) { + for (Articulation* a : toChord(v0el)->articulations()) { + EXPECT_NE(a->symId(), SymId::articAccentAbove) + << "Accent must not appear on the voice=0 element"; + } + } + + delete score; +} + +// An ACCENT ORN at voice 0 but a mid-measure tick must resolve by its own tick (not the measure start) so +// it accents the note actually at that beat, even when that note lives in another voice. +TEST_F(Tst_Ornaments, accent_orn_offset_tick_nonzero_voice_lands_on_correct_note) +{ + MasterScore* score = readEncoreScore("ornaments_accent_offset_tick_nonzero_voice.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + const Fraction measTick = m->tick(); + + // enc tick=240 → MuseScore tick = 240/960 * 1920 = 480 ticks from measTick. + const Fraction targetTick = measTick + Fraction(480, 1920); + Segment* seg = m->findSegment(SegmentType::ChordRest, targetTick); + ASSERT_NE(seg, nullptr) << "Segment at MuseScore tick=480 must exist"; + + EngravingItem* el = seg->element(1); + ASSERT_TRUE(el && el->isChord()) << "E4 chord must be in voice=1 at tick=480"; + + int accentOnE4 = 0; + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::articAccentAbove || a->symId() == SymId::articAccentBelow) { + ++accentOnE4; + } + } + EXPECT_EQ(accentOnE4, 1) + << "ACCENT ORN at enc tick=240 must land on E4 at MuseScore tick=480, " + "not on C4 at tick=0 (which is the bug when cumTick=0 is used as the target tick)"; + + // C4 at tick=0 must NOT have an accent. + Segment* seg0 = m->first(SegmentType::ChordRest); + if (seg0) { + EngravingItem* el0 = seg0->element(1); + if (el0 && el0->isChord()) { + for (Articulation* a : toChord(el0)->articulations()) { + EXPECT_NE(a->symId(), SymId::articAccentAbove) + << "C4 at tick=0 must not carry the accent that belongs to E4"; + } + } + } + + delete score; +} + +// An ACCENT ORN must scan all voices of its own staff before falling back to the sibling staff, so it does +// not spill to the wrong staff when the target note sits in a non-zero voice. +TEST_F(Tst_Ornaments, accent_orn_does_not_spill_to_sibling_staff) +{ + MasterScore* score = readEncoreScore("ornaments_accent_sibling_no_spillover.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* seg = m->first(SegmentType::ChordRest); + ASSERT_NE(seg, nullptr); + + EngravingItem* el0v3 = seg->element(3); + ASSERT_TRUE(el0v3 && el0v3->isChord()) + << "C4 chord must be in staff=0 voice=3 (track=3)"; + + int accentOnStaff0 = 0; + for (Articulation* a : toChord(el0v3)->articulations()) { + if (a->symId() == SymId::articAccentAbove || a->symId() == SymId::articAccentBelow) { + ++accentOnStaff0; + } + } + EXPECT_EQ(accentOnStaff0, 1) + << "ACCENT ORN on staff 0 (voice=0) must attach to the voice=3 chord on staff 0, " + "not redirect to the sibling staff"; + + EngravingItem* el1v0 = seg->element(4); + if (el1v0 && el1v0->isChord()) { + for (Articulation* a : toChord(el1v0)->articulations()) { + EXPECT_NE(a->symId(), SymId::articAccentAbove) + << "Staff 1 (the sibling-staff trap) must not receive a spurious accent"; + } + } + + delete score; +} + +// When two ACCENT ORNs in a measure share the same ornXoffset (xoffset is relative to the notehead) and one +// is at tick 0, the tick-0 ORN must stay on note 1 rather than clustering to the later ORN's note. +TEST_F(Tst_Ornaments, accent_orn_tick0_stays_on_note1_when_same_xoffset_as_later_accent) +{ + MasterScore* score = readEncoreScore("ornaments_accent_tick0_xoffset.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + struct ChordInfo { + Fraction tick; + int accents { 0 }; + }; + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + ChordInfo ci; + ci.tick = s->tick(); + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::articAccentAbove + || a->symId() == SymId::articAccentBelow) { + ++ci.accents; + } + } + chords.push_back(ci); + } + + ASSERT_GE(chords.size(), 2u) + << "Measure must have at least 2 chords (note 1 and note 3)"; + + int totalAccents = 0; + for (const auto& ci : chords) { + totalAccents += ci.accents; + } + EXPECT_EQ(totalAccents, 2) + << "Expected exactly 2 accent marks total (one on note 1 and one on note 3)"; + + EXPECT_EQ(chords.front().accents, 1) + << "Note 1 (enc-tick=0) must carry exactly 1 accent; " + "without fix both accents land on note 3 (enc-tick=480)"; + + for (const auto& ci : chords) { + EXPECT_LE(ci.accents, 1) + << "Chord at MuseScore tick " + << ci.tick.toString().toStdString() + << " carries " << ci.accents + << " accents; max 1 expected (regression: Phase 1 xoffset cluster match moved tick-0 ORN)"; + } + + delete score; +} + +// A bowing ORN at tick 0 must stay on note 1 even when its ornXoffset does not match note 1's xoffset +// (ORN and note xoffsets use different origins): a note exists on the ORN's staff at tick 0, so the raw +// tick is trusted rather than snapping the bow to a later note by xoffset. +TEST_F(Tst_Ornaments, bowing_tick0_stays_on_note1_when_xoffset_mismatches) +{ + MasterScore* score = readEncoreScore("ornaments_bowing_tick0_xoffset_mismatch.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector > > perChord; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + std::vector bows; + for (Articulation* a : toChord(el)->articulations()) { + if (a->symId() == SymId::stringsUpBow || a->symId() == SymId::stringsDownBow) { + bows.push_back(a->symId()); + } + } + perChord.push_back({ s->tick(), bows }); + } + + ASSERT_GE(perChord.size(), 3u) << "Measure must have at least 3 chords"; + + // Note 1 (earliest tick) must carry exactly the up-bow. + EXPECT_EQ(perChord.front().second, std::vector { SymId::stringsUpBow }) + << "Note 1 (enc-tick=0) must keep its up-bow; without fix the up-bow is " + "snapped onto a later note by the xoffset correction"; + + // No chord may carry more than one bowing mark (the relocated up-bow would + // otherwise pile onto the down-bow note). + for (const auto& cinfo : perChord) { + EXPECT_LE(cinfo.second.size(), 1u) + << "Chord at MuseScore tick " << cinfo.first.toString().toStdString() + << " carries " << cinfo.second.size() << " bowing marks; max 1 expected"; + } + + delete score; +} + +// Single-SymId articulation ORN tipos map to their MuseScore families (marcato, marcato-staccato, tenuto, +// mordent; guitar bend skipped). Tested by family (via subtype()) since layout flips Above/Below by stem. +TEST_F(Tst_Ornaments, new_artic_types_from_orns) +{ + MasterScore* score = readEncoreScore("ornaments_new_artic_types.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + enum class K { + Marcato, MarcatoStaccato, Tenuto, Mordent, Other + }; + auto kindOf = [](Articulation* a) -> K { + SymId s = SymId(a->subtype()); + if (s == SymId::articMarcatoAbove || s == SymId::articMarcatoBelow) { + return K::Marcato; + } + if (s == SymId::articMarcatoStaccatoAbove || s == SymId::articMarcatoStaccatoBelow) { + return K::MarcatoStaccato; + } + if (s == SymId::articTenutoAbove || s == SymId::articTenutoBelow) { + return K::Tenuto; + } + if (s == SymId::ornamentMordent || s == SymId::ornamentPrallMordent) { + return K::Mordent; + } + return K::Other; + }; + + std::vector found; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + found.push_back(kindOf(a)); + } + } + } + // 5 chords with articulations: two marcato (0xBF and 0xC6), one marcatoStaccato (0xC0), + // one tenuto (0xC8), one mordent (0xB8). 0x30 (GUITAR_BEND_V) is skipped. + const std::vector expected = { + K::Marcato, K::Marcato, K::MarcatoStaccato, K::Tenuto, K::Mordent + }; + EXPECT_EQ(found, expected); + EXPECT_EQ(found.size(), 5u) << "0x30 guitar bend must be skipped; only 5 chords get articulations"; + delete score; +} + +// ORN tipos 0x28-0x2B are guitar bends (size-28 spanners), not staccatissimo, and must be skipped without +// adding any articulation. +TEST_F(Tst_Ornaments, guitar_bend_orns_skipped) +{ + MasterScore* score = readEncoreScore("ornaments_staccatissimo_orns.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int articCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + articCount += static_cast(toChord(el)->articulations().size()); + } + } + EXPECT_EQ(articCount, 0) + << "Guitar bend ORNs 0x28-0x2B must not add articulations to chords"; + delete score; +} +TEST_F(Tst_Ornaments, tremolo_orn_r16_and_string_numbers) +{ + MasterScore* score = readEncoreScore("ornaments_tremolo_r8_r16_r64.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector tremolos; + int stringNumCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + TremoloSingleChord* t = toChord(el)->tremoloSingleChord(); + if (t) { + tremolos.push_back(t->tremoloType()); + } + for (Note* n : toChord(el)->notes()) { + for (EngravingItem* e : n->el()) { + if (e && e->isFingering() + && toFingering(e)->textStyleType() == TextStyleType::STRING_NUMBER) { + ++stringNumCount; + } + } + } + } + } + // Only 0xEE produces a tremolo (R16). + EXPECT_EQ(tremolos, std::vector { TremoloType::R16 }); + // 0xE6 and 0xE9 produce string numbers (2 and 5). + EXPECT_EQ(stringNumCount, 2) << "0xE6 and 0xE9 must produce STRING_NUMBER fingerings, not tremolos"; + delete score; +} + +// =========================================================================== +// FEATURE: ORN tipo 0x1C (GRAPHIC_LINE, user-drawn line) is silently skipped. +// No articulation is added to the chord; score loads and passes sanity check. +// =========================================================================== + +// =========================================================================== +// FEATURE: ORN tipo 0x1C (GRAPHIC_LINE, user-drawn line) is silently skipped. +// No articulation is added to the chord; score loads and passes sanity check. +// =========================================================================== +TEST_F(Tst_Ornaments, graphic_line_orn_silently_skipped) +{ + MasterScore* score = readEncoreScore("ornaments_graphic_line_skipped.enc"); + ASSERT_NE(score, nullptr) << "File must load without crash"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int articCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + articCount += static_cast(toChord(el)->articulations().size()); + } + } + EXPECT_EQ(articCount, 0) << "GRAPHIC_LINE ORN must not add any articulation to the chord"; + delete score; +} + +// Regression: single-chord tremolos encoded as size-16 ORN (tipo=0xAF), not the articulation byte. +// ORN can appear at the chord's tick or at durTicks (measure end); both must attach TremoloSingleChord. +TEST_F(Tst_Ornaments, v0c4_tremolo_orn_normal_and_barline_tick) +{ + MasterScore* score = readEncoreScore("ornaments_tremolo_orn.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_tremolo_orn.enc"; + + std::vector > trems; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + if (c->tremoloSingleChord()) { + trems.push_back({ s->tick(), c->tremoloSingleChord()->tremoloType() }); + } + } + } + ASSERT_EQ(trems.size(), 2u) + << "expected exactly 2 TremoloSingleChord: one at m1.0 (normal tick) " + "and one at m2.beat-4 (from ORN at measure end tick)"; + EXPECT_EQ(trems[0].first, Fraction(0, 1)); + EXPECT_EQ(trems[0].second, TremoloType::R32); + EXPECT_EQ(trems[1].first, Fraction(1, 1) + Fraction(3, 4)); + EXPECT_EQ(trems[1].second, TremoloType::R32); + delete score; +} + +// Regression: tremolo ORN is always in voice 0 regardless of the actual note voice. +// Resolver must widen to all staff voices when voice 0 yields no chord. +TEST_F(Tst_Ornaments, v0c4_tremolo_orn_cross_voice_attaches) +{ + MasterScore* score = readEncoreScore("ornaments_tremolo_orn_crossvoice.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_tremolo_orn_crossvoice.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + bool foundTremolo = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (int v = 0; v < static_cast(VOICES); ++v) { + EngravingItem* el = s->element(static_cast(v)); + if (!el || !el->isChord()) { + continue; + } + if (toChord(el)->tremoloSingleChord()) { + EXPECT_EQ(toChord(el)->tremoloSingleChord()->tremoloType(), TremoloType::R32); + foundTremolo = true; + } + } + } + } + EXPECT_TRUE(foundTremolo) + << "TremoloSingleChord must attach to the chord even when ORN is in a different voice"; + delete score; +} + +// A tremolo ORN whose tick resolves to a tie-continuation note must walk back via tieBack() to the +// tie-start chord, or it lands on the wrong (continuation) note. +TEST_F(Tst_Ornaments, v0c4_tremolo_orn_on_tied_from_note) +{ + MasterScore* score = readEncoreScore("ornaments_tremolo_orn_tied_from.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_tremolo_orn_tied_from.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + Chord* tremoloChord = nullptr; + for (Segment* s = m1->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + Chord* c = toChord(el); + if (c->tremoloSingleChord()) { + tremoloChord = c; + } + } + } + ASSERT_NE(tremoloChord, nullptr) << "TremoloSingleChord not found in measure 1"; + EXPECT_EQ(tremoloChord->tremoloSingleChord()->tremoloType(), TremoloType::R32); + EXPECT_EQ(tremoloChord->tick(), Fraction(0, 1)) + << "Tremolo must land on the tie-start quarter (tick=0), not the eighth continuation"; + ASSERT_FALSE(tremoloChord->notes().empty()); + EXPECT_NE(tremoloChord->notes().front()->tieFor(), nullptr) + << "The tremolo chord must have an outgoing tie (it is the quarter note)"; + EXPECT_EQ(tremoloChord->notes().front()->tieBack(), nullptr) + << "The tremolo chord must NOT have an incoming tie"; + delete score; +} + +// =========================================================================== +// BUG FIX: articulationUp=0x20 on the last note of a tuplet group is Encore's +// "tuplet bracket placement above" flag, not a fermata. +// =========================================================================== +TEST_F(Tst_Ornaments, v0c4_fermata_suppressed_on_tuplet_last_note) +{ + MasterScore* score = readEncoreScore("ornaments_fermata_not_in_tuplet.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_fermata_not_in_tuplet.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + int fermataCount = 0; + for (Segment* s = m1->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isFermata()) { + ++fermataCount; + } + } + } + EXPECT_EQ(fermataCount, 1) + << "Only the non-tuplet note must get a fermata; tuplet notes with " + "articUp=0x20 encode bracket placement, not a fermata"; + delete score; +} + +// Lightweight test macro for Tst_Ornaments +#ifndef ENC_SANITY_TEST_ORNAMENTS +#define ENC_SANITY_TEST_ORNAMENTS(testName, fileName) \ + TEST_F(Tst_Ornaments, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } +#endif + +ENC_SANITY_TEST_ORNAMENTS(articulations_extended, "ornaments_articulations.enc") +ENC_SANITY_TEST_ORNAMENTS(articulations_combo, "ornaments_articulations_combo.enc") +ENC_SANITY_TEST_ORNAMENTS(trill_mordent, "ornaments_trill_mordent.enc") +ENC_SANITY_TEST_ORNAMENTS(tremolos, "ornaments_tremolos.enc") +ENC_SANITY_TEST_ORNAMENTS(fermatas, "ornaments_fermatas.enc") +ENC_SANITY_TEST_ORNAMENTS(technical, "ornaments_technical.enc") +ENC_SANITY_TEST_ORNAMENTS(trill_spanner, "ornaments_trill_spanner.enc") +ENC_SANITY_TEST_ORNAMENTS(staccato_orn, "ornaments_staccato_orn.enc") +ENC_SANITY_TEST_ORNAMENTS(arpeggio, "ornaments_arpeggio.enc") + +TEST_F(Tst_Ornaments, encore_symbols_full_coverage) +{ + MasterScore* score = readEncoreScore("encore_symbols.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int dynamics = 0; + int fermatas = 0; + int markers = 0; + int jumps = 0; + int staccatos = 0; + int tenutos = 0; + int accents = 0; + int marcatos = 0; + int staccatissimos = 0; + int trills = 0; + int mordents = 0; + int fingerings = 0; + int arpeggios = 0; + int tremolos = 0; + int hairpins = 0; + int dotted_barlines = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (EngravingItem* e : m->el()) { + if (e && e->isMarker()) { + ++markers; + } + if (e && e->isJump()) { + ++jumps; + } + } + Segment* endBar = m->findSegment(SegmentType::EndBarLine, m->endTick()); + if (endBar) { + for (size_t s = 0; s < score->nstaves(); ++s) { + EngravingItem* el = endBar->element(s * VOICES); + if (el && el->isBarLine() && toBarLine(el)->barLineType() == BarLineType::DOTTED) { + ++dotted_barlines; + break; + } + } + } + for (Segment* s = m->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isDynamic()) { + ++dynamics; + } + if (e && e->isFermata()) { + ++fermatas; + } + } + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + if (c->arpeggio()) { + ++arpeggios; + } + if (c->tremoloSingleChord()) { + ++tremolos; + } + for (Articulation* a : c->articulations()) { + using mu::engraving::SymId; + switch (a->symId()) { + case SymId::articStaccatoAbove: case SymId::articStaccatoBelow: + ++staccatos; + break; + case SymId::articTenutoAbove: case SymId::articTenutoBelow: + ++tenutos; + break; + case SymId::articAccentAbove: case SymId::articAccentBelow: + ++accents; + break; + case SymId::articMarcatoAbove: case SymId::articMarcatoBelow: + ++marcatos; + break; + case SymId::articStaccatissimoAbove: case SymId::articStaccatissimoBelow: + ++staccatissimos; + break; + case SymId::articMarcatoStaccatoAbove: case SymId::articMarcatoStaccatoBelow: + ++marcatos; + ++staccatos; + break; + case SymId::articMarcatoTenutoAbove: case SymId::articMarcatoTenutoBelow: + ++marcatos; + ++tenutos; + break; + case SymId::articAccentStaccatoAbove: case SymId::articAccentStaccatoBelow: + ++accents; + ++staccatos; + break; + case SymId::articTenutoStaccatoAbove: case SymId::articTenutoStaccatoBelow: + ++tenutos; + ++staccatos; + break; + case SymId::articTenutoAccentAbove: case SymId::articTenutoAccentBelow: + ++tenutos; + ++accents; + break; + case SymId::ornamentTrill: + ++trills; + break; + case SymId::ornamentShortTrill: + case SymId::ornamentTremblement: + case SymId::ornamentMordent: + case SymId::ornamentPrallMordent: + ++mordents; + break; + default: break; + } + } + for (Note* n : c->notes()) { + for (EngravingItem* nel : n->el()) { + if (nel && nel->isFingering()) { + ++fingerings; + } + } + } + } + } + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isTrill()) { + ++trills; + } + if (sp->isHairpin()) { + ++hairpins; + } + } + EXPECT_GE(dynamics, 13) << "all 13 Encore dynamics expected"; + EXPECT_GE(fermatas, 2); + EXPECT_GE(markers, 3) << "Segno + Coda(s) + To Coda + Fine"; + EXPECT_GE(jumps, 1) << "at least one D.C. / D.S. variant"; + EXPECT_GE(staccatos, 7); + EXPECT_GE(tenutos, 9); + EXPECT_GE(accents, 5); + EXPECT_GE(marcatos, 6); + EXPECT_GE(staccatissimos, 6); + EXPECT_GE(trills, 6) << "trill-marks from per-note bytes + ORN 0x36/0x37"; + EXPECT_GE(mordents, 4) << "mordent + inverted-mordent"; + EXPECT_GE(fingerings, 6) << "fingering 1..5 + open-string"; + EXPECT_GE(arpeggios, 1); + EXPECT_GE(tremolos, 4); + EXPECT_GE(hairpins, 2); + EXPECT_GE(dotted_barlines, 1); + delete score; +} + +// Regression: Encore stores a run of articulations (an accent on each note of a bar) all +// at the downbeat tick, separated only by xoffset. The importer used to trust the raw +// tick when a note sat on the downbeat and stacked every accent on the first chord. It +// must spread same-tick marks across the notes so each chord gets exactly one accent. +TEST_F(Tst_Ornaments, v0c4_accents_distributed_across_notes) +{ + MasterScore* score = readEncoreScore("ornaments_accents_distributed.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_accents_distributed.enc"; + + int chordsWithAccent = 0; + int totalAccents = 0; + int maxAccentsOnOneChord = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + int n = 0; + for (Articulation* a : toChord(el)->articulations()) { + // layout may flip the accent to its below-staff glyph variant. + if (a && (a->symId() == SymId::articAccentAbove + || a->symId() == SymId::articAccentBelow)) { + ++n; + } + } + if (n > 0) { + ++chordsWithAccent; + } + totalAccents += n; + maxAccentsOnOneChord = std::max(maxAccentsOnOneChord, n); + } + } + EXPECT_EQ(totalAccents, 4) << "all four accents must import"; + EXPECT_EQ(chordsWithAccent, 4) << "each of the four notes must carry one accent"; + EXPECT_EQ(maxAccentsOnOneChord, 1) + << "accents must not stack on a single chord"; + delete score; +} + +// Regression: a simple "TR" trill whose stored tick falls between two notes (no note on +// that exact tick) used to anchor via the cumulative tick, which overshoots to the +// following note. Encore draws the TR on the preceding note; the importer must anchor +// from the raw tick and snap to the note it sits on. +TEST_F(Tst_Ornaments, v0c4_trill_between_notes_snaps_to_preceding) +{ + MasterScore* score = readEncoreScore("ornaments_trill_between_notes.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_trill_between_notes.enc"; + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + Fraction trillTick(-1, 1); + int trillCount = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Articulation* a : toChord(el)->articulations()) { + if (a && (a->symId() == SymId::ornamentTrill || a->symId() == SymId::ornamentShortTrill)) { + trillTick = s->tick() - m->tick(); + ++trillCount; + } + } + } + EXPECT_EQ(trillCount, 1) << "exactly one trill must import"; + // note@0 is beat 1 (tick 0); the following note@240(enc) is beat 2 (tick 480 in MuseScore). + EXPECT_EQ(trillTick, Fraction(0, 1)) + << "the TR must land on its own (preceding) note, not the following one"; + delete score; +} diff --git a/src/importexport/encore/tests/tst_ornaments_slurs.cpp b/src/importexport/encore/tests/tst_ornaments_slurs.cpp new file mode 100644 index 0000000000000..530ec42046976 --- /dev/null +++ b/src/importexport/encore/tests/tst_ornaments_slurs.cpp @@ -0,0 +1,780 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Slur and ottava endpoint resolution: open-slur removal, alMezuro/+16 measure-count resolution, xoffset +// pixel-span heuristics, multi-instrument routing, and grace-to-main/grace-to-later anchoring. +// See ENCORE_IMPORTER.md §Slur endpoint resolution and ENCORE_IMPORTER.md §Grace-to-main and grace-to-later slurs. + +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/textbase.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/breath.h" +#include "engraving/dom/measurerepeat.h" +#include "engraving/dom/ornament.h" +#include "engraving/dom/ottava.h" +#include "engraving/dom/slur.h" +#include "engraving/dom/trill.h" + +#include "testbase.h" +#include "../internal/importer/import-options.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_OrnamentsSlurs : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// A SLURSTART with no matching stop has no endpoints and would NaN in Bezier layout; open slurs must be +// dropped so every surviving spanner has a valid tick range. +TEST_F(Tst_OrnamentsSlurs, no_nan_crash_from_open_slurs) +{ + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr) << "Corrupted file should load without NaN crash"; + for (auto& [tick, sp] : score->spannerMap().map()) { + EXPECT_LT(sp->tick(), sp->tick2()) + << "All spanners should have tick < tick2 (valid range)"; + } + delete score; +} + +TEST_F(Tst_OrnamentsSlurs, no_nan_crash_opus27) +{ + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr); + delete score; +} + +TEST_F(Tst_OrnamentsSlurs, overfull_measure_slur_no_zero_length_arc) +{ + // Overfull-measure tick drift can make a cross-measure slur resolve start==end (a zero-length arc), + // which NaNs the Bezier layout; such slurs must be dropped. Needs the IrregularMeasure strategy (which + // extends the bar and produces the drift); the readEncoreScore default Truncate hides it. The fixture + // is a trimmed real file because a synthetic bar does not reproduce the accumulated drift. + mu::iex::enc::EncImportOptions opts; + opts.overfillMeasureStrategy = mu::iex::enc::OverfillStrategy::IrregularMeasure; + MasterScore* score = readEncoreScoreWithOpts("structure_v0c4_slur_zero_length_overfull.enc", opts); + ASSERT_NE(score, nullptr) << "must load and lay out without a NaN crash"; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + EXPECT_NE(sp->startElement(), sp->endElement()) + << "no slur may have a coinciding start and end element (zero-length arc)"; + } + delete score; +} + +// .enc has no SLURSTOP; a SLURSTART resolves its end from the alMezuro measure count after the measure pass. +TEST_F(Tst_OrnamentsSlurs, multi_measure_slur_resolved_from_almezuro) +{ + MasterScore* score = readEncoreScore("ornaments_multi_measure_slur.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int slurCount = 0; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + EXPECT_LT(sp->tick(), sp->tick2()) << "slur span must be positive"; + EXPECT_NE(sp->startElement(), nullptr) << "slur missing start element"; + EXPECT_NE(sp->endElement(), nullptr) << "slur missing end element"; + } + EXPECT_EQ(slurCount, 2); + delete score; +} + +// A v0xC2 slur whose +16 measure-count marks it cross-measure must anchor its end to the downbeat of the +// target measure; the stale xoffset2 must not be used. +TEST_F(Tst_OrnamentsSlurs, v0xc2_cross_measure_slur_ends_in_next_measure) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_cross_measure_slur.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int crossMeasureCount = 0; + int sameMeasureCount = 0; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + EXPECT_LT(sp->tick(), sp->tick2()) << "slur span must be positive"; + EXPECT_NE(sp->startElement(), nullptr) << "slur missing start element"; + EXPECT_NE(sp->endElement(), nullptr) << "slur missing end element"; + if (sp->startElement() && sp->endElement()) { + const EngravingItem* startEl = sp->startElement(); + const EngravingItem* endEl = sp->endElement(); + const Measure* startMeas = startEl->findMeasure(); + const Measure* endMeas = endEl->findMeasure(); + if (startMeas && endMeas && startMeas != endMeas) { + ++crossMeasureCount; + } else { + ++sameMeasureCount; + } + } + } + EXPECT_GT(crossMeasureCount, 0) << "expected at least one cross-measure slur"; + EXPECT_EQ(sameMeasureCount, 0) << "no same-measure slurs expected in this file"; + delete score; +} + +// Regression: in multi-instrument compact-encoded files the raw instrument index must be translated to +// the routed LINE slot before finding a slur's notes, or staves 1-3 miss and the last-chord fallback +// wrongly picks note3. Each staff's slur must end at note2. +TEST_F(Tst_OrnamentsSlurs, multiinstr_slur_endpoint_on_second_note_not_last_chord) +{ + MasterScore* score = readEncoreScore("ornaments_multiinstr_slur_routing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->nstaves(), 4); + + std::map expectedEndPitch = { { 0, 64 }, { 1, 52 }, { 2, 71 }, { 3, 59 } }; + std::map staffSeen; + + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + EXPECT_NE(sp->startElement(), nullptr) << "slur missing start"; + EXPECT_NE(sp->endElement(), nullptr) << "slur missing end"; + if (!sp->startElement() || !sp->endElement()) { + continue; + } + const int si = static_cast(sp->staffIdx()); + staffSeen[si] = true; + EXPECT_LT(sp->tick(), sp->tick2()) << "slur span must be positive, staff " << si; + + const EngravingItem* endEl = sp->endElement(); + ASSERT_TRUE(endEl->isChord()) << "slur end must be a chord, staff " << si; + const int endPitch = toChord(endEl)->notes().back()->pitch(); + auto it = expectedEndPitch.find(si); + if (it != expectedEndPitch.end()) { + EXPECT_EQ(endPitch, it->second) + << "slur on staff " << si << " must end at note2 (pitch " << it->second + << "), not note3"; + } + } + + for (const auto& [si, expected] : expectedEndPitch) { + EXPECT_TRUE(staffSeen.count(si) > 0) << "missing slur on staff " << si; + } + delete score; +} + +// v0xC2 xoffset2 is stale, so a tiny-span slur is a note-to-next-note arc ending at note2, regardless of +// a decoy note3 whose xoffset would otherwise match. +TEST_F(Tst_OrnamentsSlurs, v0xc2_slur_ends_at_note2_not_decoy_note3) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_slur_firstnote_xoff_mismatch.enc"); + ASSERT_NE(score, nullptr); + + const Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + + bool foundSlur = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + foundSlur = true; + ASSERT_NE(sp->endElement(), nullptr) << "slur must have an end element"; + ASSERT_TRUE(sp->endElement()->isChord()) << "slur end must be a chord"; + const int endPitch = toChord(sp->endElement())->notes().back()->pitch(); + EXPECT_EQ(endPitch, 64) << "slur must end at E4 (note2), not C4 (decoy note3)"; + } + EXPECT_TRUE(foundSlur) << "score must contain a slur"; + delete score; +} + +// A v0xC2 within-measure slur (+16 count = 0) must end at the next note in its own bar and not extend to +// a decoy note in the next measure that an xoffset match would prefer. +TEST_F(Tst_OrnamentsSlurs, v0xc2_same_measure_slur_not_extended_to_next_measure) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_same_measure_slur_no_cross.enc"); + ASSERT_NE(score, nullptr); + + const Measure* m0 = score->firstMeasure(); + ASSERT_NE(m0, nullptr); + + bool foundSlur = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + foundSlur = true; + EXPECT_NE(sp->startElement(), nullptr) << "slur must have start element"; + EXPECT_NE(sp->endElement(), nullptr) << "slur must have end element"; + if (!sp->startElement() || !sp->endElement()) { + continue; + } + const Measure* startMeas = sp->startElement()->findMeasure(); + const Measure* endMeas = sp->endElement()->findMeasure(); + EXPECT_EQ(startMeas, m0) << "slur must start in measure 0"; + EXPECT_EQ(endMeas, m0) << "slur must end in measure 0, not in the decoy measure 1"; + } + EXPECT_TRUE(foundSlur) << "score must contain a slur"; + delete score; +} + +// v0xC2 multi-instrument slur routing: staff-slot translation must let each staff's slur find its notes, +// and the next-note rule must end each slur at note2 rather than a decoy note3. +TEST_F(Tst_OrnamentsSlurs, v0xc2_multiinstr_slur_endpoint_on_note2_not_decoy) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_multiinstr_slur_routing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->nstaves(), 4); + + const std::map expectedPitch = { { 0, 60 }, { 1, 52 }, { 2, 71 }, { 3, 59 } }; + std::map staffSeen; + + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + EXPECT_NE(sp->startElement(), nullptr) << "slur missing start"; + EXPECT_NE(sp->endElement(), nullptr) << "slur missing end"; + if (!sp->startElement() || !sp->endElement()) { + continue; + } + const int si = static_cast(sp->staffIdx()); + staffSeen[si] = true; + ASSERT_TRUE(sp->endElement()->isChord()) << "slur end not a chord, staff " << si; + const int endPitch = toChord(sp->endElement())->notes().back()->pitch(); + auto it = expectedPitch.find(si); + if (it != expectedPitch.end()) { + EXPECT_EQ(endPitch, it->second) + << "staff " << si << ": slur must end at note2 (pitch " << it->second + << "), not note3 (decoy)"; + } + } + for (const auto& [si, _] : expectedPitch) { + EXPECT_TRUE(staffSeen.count(si) > 0) << "missing slur on staff " << si; + } + delete score; +} + +// A slur from an appoggiatura grace to its co-located main note must not be dropped: the zero-span end is +// detected as grace-to-main with the grace chord as startElement. +TEST_F(Tst_OrnamentsSlurs, grace_slur_to_main_not_dropped) +{ + MasterScore* score = readEncoreScore("ornaments_grace_slur_to_main.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isSlur()) { + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + } + } + bool endInSameMeasure = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isSlur() && sp->startElement() && sp->endElement() + && sp->startElement()->isChord() && sp->endElement()->isChord()) { + const Chord* startCh = toChord(sp->startElement()); + const Chord* endCh = toChord(sp->endElement()); + if (startCh->isGrace() && !endCh->isGrace()) { + endInSameMeasure = (startCh->measure() == endCh->measure()); + } + } + } + EXPECT_GE(slurCount, 1) << "At least one slur must be imported"; + EXPECT_TRUE(graceStart) + << "Slur from appoggiatura grace must have a grace chord as startElement"; + EXPECT_TRUE(endInSameMeasure) + << "Slur endElement must be the main chord in the same measure, not a note in the next measure"; + + delete score; +} + +// A slur from a grace to a later note must start at the grace chord, not fall back to an earlier note. +TEST_F(Tst_OrnamentsSlurs, grace_slur_to_later_note_starts_from_grace) +{ + MasterScore* score = readEncoreScore("ornaments_grace_slur_to_later.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + bool endIsQuarter = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (sp->isSlur()) { + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + if (sp->endElement() && sp->endElement()->isChord()) { + endIsQuarter = (toChord(sp->endElement())->durationType().type() + == DurationType::V_QUARTER); + } + } + } + EXPECT_GE(slurCount, 1) << "Slur from grace to later note must be imported"; + EXPECT_TRUE(graceStart) + << "Slur startElement must be the grace chord, not the half note"; + EXPECT_TRUE(endIsQuarter) + << "Slur endElement must be the quarter note that follows the graces"; + + delete score; +} + +// When an ACCIACCATURA grace and its main note share the same Encore tick, a grace-to-main slur must end +// on the co-located main chord in the same measure, not spill to a rest or note in a later measure. +TEST_F(Tst_OrnamentsSlurs, v0c4_grace_slur_to_main_coloc_correct_endpoint) +{ + MasterScore* score = readEncoreScore("ornaments_v0c4_grace_slur_to_main_coloc.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + bool endIsNonGrace = false; + bool endInSameMeasure = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + if (sp->endElement() && sp->endElement()->isChord()) { + const Chord* endCh = toChord(sp->endElement()); + endIsNonGrace = !endCh->isGrace(); + if (sp->startElement() && sp->startElement()->isChord()) { + const Chord* startCh = toChord(sp->startElement()); + endInSameMeasure = (startCh->measure() == endCh->measure()); + } + } + } + EXPECT_GE(slurCount, 1) << "Grace-to-main slur must be imported"; + EXPECT_TRUE(graceStart) << "startElement must be the grace chord"; + EXPECT_TRUE(endIsNonGrace) << "endElement must be the non-grace main chord"; + EXPECT_TRUE(endInSameMeasure) + << "endElement must be in the same measure as the grace, not in a later measure"; + + delete score; +} + +// v0xC2 counterpart: the pixel-span heuristic must detect grace+regular co-location and force a zero-span +// grace-to-main slur rather than being baited to a later note. +TEST_F(Tst_OrnamentsSlurs, v0c2_grace_slur_to_main_coloc_correct_endpoint) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_grace_slur_to_main_coloc.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + bool endIsNonGrace = false; + bool endAtSameTick = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + if (sp->endElement() && sp->endElement()->isChord()) { + const Chord* endCh = toChord(sp->endElement()); + endIsNonGrace = !endCh->isGrace(); + // Grace-to-main is a zero-span arc: end chord sits at the slur tick. + endAtSameTick = (endCh->tick() == sp->tick()); + } + } + EXPECT_GE(slurCount, 1) << "Grace-to-main slur must be imported"; + EXPECT_TRUE(graceStart) << "startElement must be the grace chord"; + EXPECT_TRUE(endIsNonGrace) << "endElement must be the non-grace main chord"; + EXPECT_TRUE(endAtSameTick) + << "endElement must be the co-located main chord (same beat as grace), " + "not the note at the following beat"; + + delete score; +} + +// In v0xC4 the main note is serialized before the grace at the same tick, so the grace arrives as a chord +// extension and must be retroactively attached to the already-placed main chord; the slur then anchors to +// the grace. See ENCORE_IMPORTER.md §Grace note ordering (multi-grace groups). +TEST_F(Tst_OrnamentsSlurs, v0c4_grace_after_main_in_binary_slur_anchors_to_grace) +{ + MasterScore* score = readEncoreScore("ornaments_v0c4_grace_after_main_in_binary.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + } + EXPECT_GE(slurCount, 1) << "Grace-to-later slur must be imported"; + EXPECT_TRUE(graceStart) + << "startElement must be the grace chord, not the regular (main) chord at same tick"; + + delete score; +} + +// v0xC4 grace-after-main where the slur targets a LATER note: retroactive grace attachment plus the +// xoffset shortcut must yield a grace-to-later slur (grace start, end at the later note), not zero-span. +TEST_F(Tst_OrnamentsSlurs, v0c4_grace_after_main_grace_to_later_slur_anchors_to_grace) +{ + MasterScore* score = readEncoreScore("ornaments_v0c4_grace_after_main_grace_to_later.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + bool endAtLaterTick = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + if (sp->endElement() && sp->endElement()->isChord()) { + // Grace-to-later ends after the grace tick, not zero-span at the co-located main chord. + endAtLaterTick = (toChord(sp->endElement())->tick() > sp->tick()); + } + } + EXPECT_GE(slurCount, 1) << "Grace-to-later slur must be imported"; + EXPECT_TRUE(graceStart) + << "startElement must be the grace chord retroactively attached to mainChord"; + EXPECT_TRUE(endAtLaterTick) + << "Slur must end at the LATER note (grace-to-later), not zero-span at main"; + + delete score; +} + +// Same grace-after-main case but with preceding notes advancing the tick cursor: the grace must still be +// retroactively attached to its main chord, not carried forward to the later note. +TEST_F(Tst_OrnamentsSlurs, v0c4_grace_after_main_preceding_notes_slur_anchors_to_grace) +{ + MasterScore* score = readEncoreScore("ornaments_v0c4_grace_after_main_preceding_notes.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + bool endAtLaterTick = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + if (sp->endElement() && sp->endElement()->isChord()) { + endAtLaterTick = (toChord(sp->endElement())->tick() > sp->tick()); + } + } + EXPECT_GE(slurCount, 1) << "Grace-to-later slur must be imported"; + EXPECT_TRUE(graceStart) + << "startElement must be the grace chord; preceding context must not prevent " + "retroactive attachment when grace follows main in binary"; + EXPECT_TRUE(endAtLaterTick) + << "Slur must end at the later note (grace-to-later), not at the main chord"; + + delete score; +} + +// The pixel-span reference xoffset must be the grace note's, not the main note's: the slur arc begins at +// the grace, so using the main's larger xoffset would inflate the target and pick a far later note. +TEST_F(Tst_OrnamentsSlurs, v0c4_grace_after_main_slur_arc_starts_at_grace_not_regular) +{ + MasterScore* score = readEncoreScore("ornaments_v0c4_grace_after_main_slur_to_main.enc"); + ASSERT_NE(score, nullptr); + + int slurCount = 0; + bool graceStart = false; + bool endAtSameTick = false; + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + ++slurCount; + if (sp->startElement() && sp->startElement()->isChord()) { + graceStart = toChord(sp->startElement())->isGrace(); + } + if (sp->endElement() && sp->endElement()->isChord()) { + // Grace-to-main: end at the co-located main chord (same tick), not the later note. + endAtSameTick = (toChord(sp->endElement())->tick() == sp->tick()); + } + } + EXPECT_GE(slurCount, 1) << "Grace-to-main slur must be imported"; + EXPECT_TRUE(graceStart) << "startElement must be the grace chord"; + EXPECT_TRUE(endAtSameTick) + << "Slur must end at the co-located main chord (grace-to-main), not the " + "later note, firstNoteXoff must use the grace xoffset, not the regular"; + + delete score; +} + +// A cross-measure slur's endpoint is resolved by comparing xoffset2 against the target measure's note +// xoffsets, so it lands on the matching interior note (D4), not the last-ChordRest fallback (F4). +TEST_F(Tst_OrnamentsSlurs, cross_measure_slur_endpoint_precision) +{ + MasterScore* score = readEncoreScore("ornaments_cross_measure_slur_precision.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + const Spanner* crossSlur = nullptr; + const Fraction firstMeasTick = score->firstMeasure()->tick(); + for (auto& [tick, sp] : score->spannerMap().map()) { + if (!sp->isSlur()) { + continue; + } + if (sp->tick() == firstMeasTick && sp->tick2() > sp->tick()) { + crossSlur = sp; + break; + } + } + ASSERT_NE(crossSlur, nullptr) << "Cross-measure slur must be created"; + ASSERT_NE(crossSlur->endElement(), nullptr) << "Slur must have a resolved end element"; + ASSERT_TRUE(crossSlur->endElement()->isChord()) << "Slur end element must be a Chord"; + + const int endPitch = toChord(crossSlur->endElement())->notes().back()->pitch(); + EXPECT_EQ(endPitch, 62) + << "slurXoffset2=15 must select D4 (pitch=62, xoff=15), not F4 (last note, xoff=35)"; + + delete score; +} +// Regression: slur end was anchored on the last ChordRest of the alMezuro measure, covering all remaining notes. +// Fix: snap firstNote.xoffset + (xoffset2 - xoffset) to the closest note xoffset in the start measure. +TEST_F(Tst_OrnamentsSlurs, v0c4_slur_pixel_span) +{ + MasterScore* score = readEncoreScore("importer_slur_pixel_span.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_slur_pixel_span.enc"; + + Slur* found = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isSlur()) { + found = toSlur(sp); + break; + } + } + ASSERT_NE(found, nullptr); + EXPECT_EQ(found->tick(), Fraction(0, 1)) + << "slur start at the SLURSTART tick (beat 1)"; + EXPECT_EQ(found->tick2(), Fraction(1, 2)) + << "slur end snaps to note 3 at tick=480 (target xoff 70 matches " + "note xoff 70 exactly); not the last note of the measure"; + delete score; +} + +// Regression: pixel-span heuristic in 6/8 (compound meter) used beatTicks*timeSigDen as whole-note +// ticks instead of durTicks*timeSigDen/timeSigNum. +TEST_F(Tst_OrnamentsSlurs, v0c4_slur_pixel_span_6_8) +{ + MasterScore* score = readEncoreScore("importer_slur_pixel_span_6_8.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_slur_pixel_span_6_8.enc"; + + Slur* found = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isSlur()) { + found = toSlur(sp); + break; + } + } + ASSERT_NE(found, nullptr) << "A slur must be created"; + EXPECT_EQ(found->tick(), Fraction(1, 8)) + << "slur start must be at the 2nd note (enc_tick=120 = 1/8 from measure start)"; + EXPECT_EQ(found->tick2(), Fraction(1, 4)) + << "slur end must be at note 3 (enc_tick=240 = 1/4); " + "with wrong formula it lands at note 4 (enc_tick=360 = 3/8)"; + delete score; +} + +// Regression: SLURSTART xoffset > 127 must be treated as unsigned for pixel-span computation. +TEST_F(Tst_OrnamentsSlurs, v0c4_slur_xoffset_unsigned) +{ + MasterScore* score = readEncoreScore("importer_slur_xoffset_unsigned.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_slur_xoffset_unsigned.enc"; + + Slur* found = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isSlur()) { + found = toSlur(sp); + break; + } + } + ASSERT_NE(found, nullptr) << "A slur must be created"; + EXPECT_EQ(found->tick(), Fraction(1, 4)) + << "slur starts at note 2 (tick=240 = 1/4)"; + EXPECT_EQ(found->tick2(), Fraction(1, 2)) + << "slur ends at note 3 (tick=480 = 1/2); with signed xoffset it lands too late"; + delete score; +} + +// Regression: pixel-span heuristic skips cross-measure slurs (alMezuro >= 1). +// Pins the fallback: alMezuro=1 slur must anchor on the last ChordRest of the target measure. +TEST_F(Tst_OrnamentsSlurs, v0c4_slur_cross_measure_fallback) +{ + MasterScore* score = readEncoreScore("importer_slur_cross_measure_fallback.enc"); + ASSERT_NE(score, nullptr) + << "Failed to load importer_slur_cross_measure_fallback.enc"; + + Slur* found = nullptr; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isSlur()) { + found = toSlur(sp); + break; + } + } + ASSERT_NE(found, nullptr); + EXPECT_EQ(found->tick(), Fraction(0, 1)) + << "slur start at the SLURSTART tick (m1 beat 1)"; + EXPECT_EQ(found->tick2(), Fraction(7, 4)) + << "cross-measure slur must fall back to the last ChordRest of " + "the alMezuro target measure (m2 beat 4 = absolute tick 7/4)"; + delete score; +} + +// Two ottava spanners: 8va in m0, 8vb in m1. +// resolveOttavas pins each endpoint to the next ottava's startTick, or scoreEnd. +TEST_F(Tst_OrnamentsSlurs, v0c4_ottava_two_spanners) +{ + MasterScore* score = readEncoreScore("ornaments_ottava_two_spanners.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_ottava_two_spanners.enc"; + + std::vector ottavas; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isOttava()) { + ottavas.push_back(toOttava(sp)); + } + } + ASSERT_EQ(ottavas.size(), 2u) << "expected exactly 2 ottava spanners"; + + std::sort(ottavas.begin(), ottavas.end(), [](Ottava* a, Ottava* b) { + return a->tick() < b->tick(); + }); + + EXPECT_EQ(ottavas[0]->ottavaType(), OttavaType::OTTAVA_8VA); + EXPECT_EQ(ottavas[0]->tick(), Fraction(0, 1)); + EXPECT_EQ(ottavas[0]->tick2(), Fraction(1, 1)); + + EXPECT_EQ(ottavas[1]->ottavaType(), OttavaType::OTTAVA_8VB); + EXPECT_EQ(ottavas[1]->tick(), Fraction(1, 1)); + EXPECT_EQ(ottavas[1]->tick2(), Fraction(6, 1)); + + delete score; +} + +// Regression: when any slur's +16 measure-count points past the last measure, the whole file's field is +// unreliable, so every slur (even plausible-looking counts) must resolve inside its own bar. +TEST_F(Tst_OrnamentsSlurs, v0c2_unreliable_slur_count_stays_in_measure) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_unreliable_slur_count.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_v0c2_unreliable_slur_count.enc"; + + int total = 0; + int crossMeasure = 0; + for (auto it : score->spanner()) { + Spanner* sp = it.second; + if (!sp || !sp->isSlur()) { + continue; + } + ++total; + Measure* m1 = score->tick2measure(sp->tick()); + Measure* m2 = score->tick2measure(sp->tick2()); + if (m1 && m2 && m1 != m2) { + ++crossMeasure; + } + } + EXPECT_EQ(total, 2) << "both slurs must import"; + EXPECT_EQ(crossMeasure, 0) + << "a plausible-looking count must not extend a slur past its bar when the file's " + "+16 field is unreliable"; + delete score; +} + +// Regression: some v0xC2 files store a per-staff CONSTANT in the slur +16 field, so every slur carries the +// same in-range value regardless of start. A repeated large span (>=3) across different start measures +// marks +16 unreliable, so each slur resolves inside its own bar instead of drawing a phantom span. +TEST_F(Tst_OrnamentsSlurs, v0c2_constant_slur_count_stays_in_measure) +{ + MasterScore* score = readEncoreScore("ornaments_v0c2_constant_slur_count.enc"); + ASSERT_NE(score, nullptr) << "Failed to load ornaments_v0c2_constant_slur_count.enc"; + + int total = 0; + int crossMeasure = 0; + for (auto it : score->spanner()) { + Spanner* sp = it.second; + if (!sp || !sp->isSlur()) { + continue; + } + ++total; + Measure* m1 = score->tick2measure(sp->tick()); + Measure* m2 = score->tick2measure(sp->tick2()); + if (m1 && m2 && m1 != m2) { + ++crossMeasure; + } + } + EXPECT_EQ(total, 2) << "both slurs must import"; + EXPECT_EQ(crossMeasure, 0) + << "a constant +16 value repeated across start measures must not extend the slurs " + "into an 11-measure phantom span"; + delete score; +} diff --git a/src/importexport/encore/tests/tst_parser_chord.cpp b/src/importexport/encore/tests/tst_parser_chord.cpp new file mode 100644 index 0000000000000..ecdcd87fae267 --- /dev/null +++ b/src/importexport/encore/tests/tst_parser_chord.cpp @@ -0,0 +1,252 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Unit tests for the numeric chord decoding in EncChordSym::chordName() and EncMidiCc::read(), +// exercised in isolation by building the parser structs directly. See ENCORE_FORMAT.md §CHORD symbol element. + +#include + +#include +#include + +#include "../internal/parser/elem.h" + +using namespace mu::iex::enc; + +// Helper: build a numeric-only chord (tipo bit 0 clear = no text). +static EncChordSym makeNumeric(quint8 toniko, quint8 radiko, quint8 baso = 0, quint8 tipo = 0) +{ + EncChordSym cs; + cs.toniko = toniko; + cs.tipo = tipo; + cs.radiko = radiko; + cs.baso = baso; + return cs; +} + +// Helper: build a text-mode chord (tipo bit 0 set). +static EncChordSym makeText(const QString& teksto) +{ + EncChordSym cs; + cs.tipo = 1; + cs.teksto = teksto; + return cs; +} + +// Root note encoding (radiko). + +TEST(Tst_EncChordSym, all_natural_roots) +{ + EXPECT_EQ(makeNumeric(0, 0x00).chordName(), "C"); + EXPECT_EQ(makeNumeric(0, 0x01).chordName(), "D"); + EXPECT_EQ(makeNumeric(0, 0x02).chordName(), "E"); + EXPECT_EQ(makeNumeric(0, 0x03).chordName(), "F"); + EXPECT_EQ(makeNumeric(0, 0x04).chordName(), "G"); + EXPECT_EQ(makeNumeric(0, 0x05).chordName(), "A"); + EXPECT_EQ(makeNumeric(0, 0x06).chordName(), "B"); +} + +TEST(Tst_EncChordSym, sharp_roots) +{ + EXPECT_EQ(makeNumeric(0, 0x10).chordName(), "C#"); + EXPECT_EQ(makeNumeric(0, 0x14).chordName(), "G#"); + EXPECT_EQ(makeNumeric(0, 0x15).chordName(), "A#"); +} + +TEST(Tst_EncChordSym, flat_roots) +{ + EXPECT_EQ(makeNumeric(0, 0x21).chordName(), "Db"); + EXPECT_EQ(makeNumeric(0, 0x22).chordName(), "Eb"); + EXPECT_EQ(makeNumeric(0, 0x26).chordName(), "Bb"); +} + +TEST(Tst_EncChordSym, invalid_root_returns_empty) +{ + EXPECT_TRUE(makeNumeric(0, 0x07).chordName().isEmpty()) << "radiko=7 has no note name"; + EXPECT_TRUE(makeNumeric(0, 0x0F).chordName().isEmpty()) << "radiko=0x0F has no note name"; + EXPECT_TRUE(makeNumeric(0, 0x17).chordName().isEmpty()) << "sharp + invalid nibble"; +} + +// Chord quality (toniko index into kChordQuality[]). + +TEST(Tst_EncChordSym, major_no_suffix) +{ + EXPECT_EQ(makeNumeric(0, 0x00).chordName(), "C"); + EXPECT_EQ(makeNumeric(0, 0x05).chordName(), "A"); +} + +TEST(Tst_EncChordSym, minor) +{ + EXPECT_EQ(makeNumeric(1, 0x05).chordName(), "Am"); + EXPECT_EQ(makeNumeric(1, 0x00).chordName(), "Cm"); +} + +TEST(Tst_EncChordSym, augmented) +{ + EXPECT_EQ(makeNumeric(2, 0x00).chordName(), "C+"); +} + +TEST(Tst_EncChordSym, diminished) +{ + EXPECT_EQ(makeNumeric(3, 0x00).chordName(), "Cdim"); +} + +TEST(Tst_EncChordSym, diminished7_index4) +{ + // toniko=4 is diminished 7 in Encore's palette (not dominant 7). + EXPECT_EQ(makeNumeric(4, 0x03).chordName(), "Fdim7"); +} + +TEST(Tst_EncChordSym, dominant7_index24) +{ + EXPECT_EQ(makeNumeric(24, 0x03).chordName(), "F7"); +} + +TEST(Tst_EncChordSym, maj7) +{ + EXPECT_EQ(makeNumeric(12, 0x00).chordName(), "Cmaj7"); +} + +TEST(Tst_EncChordSym, ninth_sharp11_index34) +{ + // toniko=34 is 9(#11) (was mislabeled 11 by an off-by-one in the table). + EXPECT_EQ(makeNumeric(34, 0x00).chordName(), "C9(#11)"); +} + +TEST(Tst_EncChordSym, minor7) +{ + EXPECT_EQ(makeNumeric(55, 0x01).chordName(), "Dm7"); +} + +TEST(Tst_EncChordSym, sus4) +{ + EXPECT_EQ(makeNumeric(47, 0x04).chordName(), "Gsus4"); +} + +TEST(Tst_EncChordSym, sus2) +{ + EXPECT_EQ(makeNumeric(45, 0x04).chordName(), "Gsus2"); +} + +TEST(Tst_EncChordSym, seventh_sus4_index48) +{ + // toniko=48 is 7sus4 (the table used to map it to 9sus4). + EXPECT_EQ(makeNumeric(48, 0x01).chordName(), "D7sus4"); +} + +TEST(Tst_EncChordSym, half_diminished) +{ + EXPECT_EQ(makeNumeric(57, 0x05).chordName(), "Am7(b5)"); +} + +TEST(Tst_EncChordSym, out_of_range_toniko_treated_as_major) +{ + // toniko=64 is beyond the 0-63 table; degrades to just the root. + EXPECT_EQ(makeNumeric(64, 0x00).chordName(), "C"); +} + +// Bass note (tipo bit 1 = bass present, baso same encoding as radiko). + +TEST(Tst_EncChordSym, slash_chord_with_bass) +{ + EncChordSym cs = makeNumeric(0, 0x00, 0x04, 0x02); + EXPECT_EQ(cs.chordName(), "C/G"); +} + +TEST(Tst_EncChordSym, slash_chord_minor_with_flat_bass) +{ + EncChordSym cs = makeNumeric(1, 0x05, 0x02, 0x02); + EXPECT_EQ(cs.chordName(), "Am/E"); +} + +TEST(Tst_EncChordSym, bass_ignored_when_tipo_bit1_clear) +{ + // baso is set but tipo bit 1 is clear: bass must not appear. + EncChordSym cs = makeNumeric(0, 0x00, 0x04, 0x00); + EXPECT_EQ(cs.chordName(), "C"); +} + +// Text mode (tipo bit 0 set): teksto is returned verbatim. + +TEST(Tst_EncChordSym, text_mode_returns_teksto) +{ + EXPECT_EQ(makeText("Am").chordName(), "Am"); + EXPECT_EQ(makeText("G7").chordName(), "G7"); + EXPECT_EQ(makeText("Cmaj7(b5)").chordName(), "Cmaj7(b5)"); +} + +TEST(Tst_EncChordSym, text_mode_ignores_numeric_fields) +{ + // Even with radiko pointing to a different note, teksto wins. + EncChordSym cs = makeText("F#m7"); + cs.toniko = 0; + cs.radiko = 0x00; // would decode to "C" without text + EXPECT_EQ(cs.chordName(), "F#m7"); +} + +TEST(Tst_EncChordSym, empty_teksto_falls_through_to_numeric) +{ + EncChordSym cs; + cs.tipo = 0; // no text flag + cs.teksto = {}; + cs.toniko = 1; + cs.radiko = 0x05; // A minor + EXPECT_EQ(cs.chordName(), "Am"); +} + +// EncMidiCc inline MIDI Control Change events. read() is entered at d[3] (caller already consumed +// tick + typeVoice), so the hand-built buffer starts at the size byte. +// See ENCORE_FORMAT.md §MIDI control change (type 11). +TEST(Tst_EncMidiCc, decodes_controller_and_value) +{ + QByteArray bytes; + bytes.append(char(12)); // d[3] size = 12 + bytes.append(char(0)); // d[4] rawStaff + bytes.append(char(0xB0)); // d[5] CC marker (channel 0) + bytes.append(4, char(0)); // d[6..9] zeros + bytes.append(char(64)); // d[10] controller = sustain pedal + bytes.append(char(127)); // d[11] value = ON + + QDataStream ds(bytes); + ds.setByteOrder(QDataStream::LittleEndian); + EncMidiCc cc(0, static_cast(EncElemType::MIDI_CC), 0); + cc.read(ds); + + EXPECT_EQ(cc.size, 12); + EXPECT_EQ(cc.controller, 64); + EXPECT_EQ(cc.value, 127); +} + +TEST(Tst_EncMidiCc, short_element_leaves_controller_value_zero) +{ + // A malformed/short element (size < 12) must not read controller/value (stream stays aligned). + QByteArray bytes; + bytes.append(char(4)); // d[3] size = 4 (too short) + bytes.append(char(0)); // d[4] rawStaff + QDataStream ds(bytes); + ds.setByteOrder(QDataStream::LittleEndian); + EncMidiCc cc(0, static_cast(EncElemType::MIDI_CC), 0); + cc.read(ds); + + EXPECT_EQ(cc.controller, 0); + EXPECT_EQ(cc.value, 0); +} diff --git a/src/importexport/encore/tests/tst_parser_ticks.cpp b/src/importexport/encore/tests/tst_parser_ticks.cpp new file mode 100644 index 0000000000000..f91866b77d328 --- /dev/null +++ b/src/importexport/encore/tests/tst_parser_ticks.cpp @@ -0,0 +1,324 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Unit tests for the rhythm math (face value / real duration / dots / implied tuplets), page-setup plist +// parsing, and parser bounds helpers, exercised without loading a score. + +#include + +#include "../internal/parser/ticks.h" +#include "../internal/parser/elem.h" +#include "../internal/parser/readers.h" +#include "../internal/importer/coords.h" +#include "../internal/importer/durations.h" +#include "../internal/importer/page-layout.h" + +#include + +using namespace mu::engraving; +using namespace mu::iex::enc; + +TEST(Tst_EncoreRhythm, faceValueToTicks) +{ + EXPECT_EQ(faceValue2ticks(1), 960); + EXPECT_EQ(faceValue2ticks(2), 480); + EXPECT_EQ(faceValue2ticks(3), 240); + EXPECT_EQ(faceValue2ticks(4), 120); + EXPECT_EQ(faceValue2ticks(5), 60); + EXPECT_EQ(faceValue2ticks(6), 30); + EXPECT_EQ(faceValue2ticks(7), 15); + EXPECT_EQ(faceValue2ticks(8), 7); + EXPECT_EQ(faceValue2ticks(0), 0); + EXPECT_EQ(faceValue2ticks(9), 0); + EXPECT_EQ(faceValue2ticks(0xFF), 0); + // Upper nibble must be ignored (fv & 0x0F). + EXPECT_EQ(faceValue2ticks(0x14), 120); + EXPECT_EQ(faceValue2ticks(0xF8), 7); +} + +TEST(Tst_EncoreRhythm, faceValueToDurationType) +{ + EXPECT_EQ(faceValue2DurationType(1), DurationType::V_WHOLE); + EXPECT_EQ(faceValue2DurationType(2), DurationType::V_HALF); + EXPECT_EQ(faceValue2DurationType(3), DurationType::V_QUARTER); + EXPECT_EQ(faceValue2DurationType(4), DurationType::V_EIGHTH); + EXPECT_EQ(faceValue2DurationType(5), DurationType::V_16TH); + EXPECT_EQ(faceValue2DurationType(6), DurationType::V_32ND); + EXPECT_EQ(faceValue2DurationType(7), DurationType::V_64TH); + EXPECT_EQ(faceValue2DurationType(8), DurationType::V_128TH); + // Invalid face values fall back to V_QUARTER. + EXPECT_EQ(faceValue2DurationType(0), DurationType::V_QUARTER); + EXPECT_EQ(faceValue2DurationType(9), DurationType::V_QUARTER); + // Upper nibble ignored. + EXPECT_EQ(faceValue2DurationType(0x14), DurationType::V_EIGHTH); +} + +TEST(Tst_EncoreRhythm, realDurationToDurationType) +{ + // Plain duration mappings: realDur == faceTicks, guard does not fire. + EXPECT_EQ(realDuration2DurationType(960, 1), DurationType::V_WHOLE); + EXPECT_EQ(realDuration2DurationType(480, 2), DurationType::V_HALF); + EXPECT_EQ(realDuration2DurationType(240, 3), DurationType::V_QUARTER); + EXPECT_EQ(realDuration2DurationType(120, 4), DurationType::V_EIGHTH); + EXPECT_EQ(realDuration2DurationType(60, 5), DurationType::V_16TH); + // Dotted mappings: realDur > faceTicks, guard does not fire. + EXPECT_EQ(realDuration2DurationType(720, 2), DurationType::V_HALF); + EXPECT_EQ(realDuration2DurationType(360, 3), DurationType::V_QUARTER); + EXPECT_EQ(realDuration2DurationType(180, 4), DurationType::V_EIGHTH); + // Multi-stream truncation: realDur < faceTicks means overlapping MIDI streams, so face value wins. + EXPECT_EQ(realDuration2DurationType(120, 3), DurationType::V_QUARTER); + EXPECT_EQ(realDuration2DurationType(240, 2), DurationType::V_HALF); + EXPECT_EQ(realDuration2DurationType(480, 1), DurationType::V_WHOLE); + // Triplet rdur falls back to face value; the 3:2 ratio is carried by the tuplet wrapper, not the type. + EXPECT_EQ(realDuration2DurationType(160, 4), DurationType::V_EIGHTH); + EXPECT_EQ(realDuration2DurationType(80, 4), DurationType::V_EIGHTH); + EXPECT_EQ(realDuration2DurationType(80, 5), DurationType::V_16TH); + EXPECT_EQ(realDuration2DurationType(40, 5), DurationType::V_16TH); + // realDur <= 0 falls back to faceValue2DurationType. + EXPECT_EQ(realDuration2DurationType(0, 5), DurationType::V_16TH); + EXPECT_EQ(realDuration2DurationType(-1, 4), DurationType::V_EIGHTH); + // Unknown realDur also falls back to faceValue2DurationType. + EXPECT_EQ(realDuration2DurationType(99, 4), DurationType::V_EIGHTH); + EXPECT_EQ(realDuration2DurationType(31000, 3), DurationType::V_QUARTER); + // Inflated rdur (gap-to-next-event spacing) that is not a real dotted multiple of the face stays + // the face value: a face=quarter with rdur=720 remains a quarter, not a dotted half. + EXPECT_EQ(realDuration2DurationType(720, 3), DurationType::V_QUARTER); + EXPECT_EQ(realDuration2DurationType(360, 4), DurationType::V_EIGHTH); + EXPECT_EQ(realDuration2DurationType(180, 5), DurationType::V_16TH); + // But rdur=360 on a face=quarter IS a real dotted quarter (calcDots>0), so the dotted mapping applies. + EXPECT_EQ(realDuration2DurationType(360, 3), DurationType::V_QUARTER); +} + +TEST(Tst_EncoreRhythm, dotCalculation) +{ + // Strict mode. + EXPECT_EQ(calcDots(180, 4), 1); // 120 * 3/2 + EXPECT_EQ(calcDots(210, 4), 2); // 120 * 7/4 + EXPECT_EQ(calcDots(225, 4), 3); // 120 * 15/8 + EXPECT_EQ(calcDots(120, 4), 0); // == base + EXPECT_EQ(calcDots(181, 4), 0); // strict mode: no snap + EXPECT_EQ(calcDots(0, 4), 0); // dur <= 0 + EXPECT_EQ(calcDots(-1, 4), 0); + EXPECT_EQ(calcDots(180, 0), 0); // base <= 0 + EXPECT_EQ(calcDots(180, 9), 0); + + // Snap mode (±1 tick tolerance). + EXPECT_EQ(calcDotsSnap(181, 4), 1); + EXPECT_EQ(calcDotsSnap(179, 4), 1); + EXPECT_EQ(calcDotsSnap(178, 4), 0); // 2 ticks off → outside tolerance + EXPECT_EQ(calcDotsSnap(211, 4), 2); + EXPECT_EQ(calcDotsSnap(226, 4), 3); + EXPECT_EQ(calcDotsSnap(121, 4), 0); // base ± 1 → 0 dots + EXPECT_EQ(calcDotsSnap(0, 4), 0); + EXPECT_EQ(calcDotsSnap(180, 0), 0); +} + +// The bit-0 dot fallback must fire only for genuine MIDI drift (rdur > faceTicks). When rdur <= faceTicks +// computeDotCount returns 0 even with the bit set; the true v0xC2 dotted-eighth dot is forced elsewhere +// (EncNote::forceDotted in emitters-note.cpp), not here. +TEST(Tst_EncoreRhythm, computeDotCount_v0c2_dotted_eighth) +{ + EXPECT_EQ(computeDotCount(0x60, 120, 4, /*useBit0Fallback=*/ true), 0) + << "v0xC2 dotted-eighth without fix: dotControl=0x60 yields 0 dots"; + + EXPECT_EQ(computeDotCount(0x61, 120, 4, /*useBit0Fallback=*/ true), 0) + << "v0xC2 dotted-eighth: computeDotCount returns 0 when rdur==faceTicks; " + "dot is forced by EncNote::forceDotted in emitters-note.cpp"; + + EXPECT_EQ(computeDotCount(0x39, 60, 5, /*useBit0Fallback=*/ true), 0) + << "Plain 16th with spurious dotControl bit0 must not be dotted (rdur==faceTicks)"; + EXPECT_EQ(computeDotCount(0x39, 60, 4, /*useBit0Fallback=*/ true), 0) + << "rdur < faceTicks: bit-0 must not force dot even when set"; + + EXPECT_EQ(computeDotCount(0x01, 160, 4, /*useBit0Fallback=*/ true), 1) + << "Genuine drift (rdur=160 > faceTicks=120): bit0 fires → 1 dot"; +} + +TEST(Tst_EncoreRhythm, impliedTuplets) +{ + int normalNotes = 0; + + // Triplet (3:2): 120 * 2/3 = 80. + EXPECT_EQ(detectImpliedTuplet(80, 4, normalNotes), 3); + EXPECT_EQ(normalNotes, 2); + + // Quintuplet (5:4): 120 * 4/5 = 96. + normalNotes = 0; + EXPECT_EQ(detectImpliedTuplet(96, 4, normalNotes), 5); + EXPECT_EQ(normalNotes, 4); + + // Exact match is not a tuplet. + normalNotes = 7; + EXPECT_EQ(detectImpliedTuplet(120, 4, normalNotes), 0); + EXPECT_EQ(normalNotes, 0); + + // Invalid base resets normalNotes to 0. + normalNotes = 7; + EXPECT_EQ(detectImpliedTuplet(120, 0, normalNotes), 0); + EXPECT_EQ(normalNotes, 0); + + // Non-positive realDur resets normalNotes to 0. + normalNotes = 7; + EXPECT_EQ(detectImpliedTuplet(0, 4, normalNotes), 0); + EXPECT_EQ(normalNotes, 0); +} + +// A dotted value that is fractional in ticks (integer division truncates base*n/d) must not be +// mistaken for a real dotted duration: e.g. a 16th's triple-dot = 60*15/8 = 112.5 truncates to 112, +// which used to be misread as a triple-dotted 16th. +TEST(Tst_EncoreRhythm, dotCalculation_noFalsePositiveForFractionalDottedValues) +{ + EXPECT_EQ(calcDots(112, 5), 0); + EXPECT_EQ(calcDotsSnap(112, 5), 0); + EXPECT_EQ(calcDotsSnap(111, 5), 0); + EXPECT_EQ(calcDotsSnap(113, 5), 0); + + EXPECT_EQ(calcDots(52, 6), 0); + EXPECT_EQ(calcDotsSnap(52, 6), 0); + + EXPECT_EQ(calcDots(56, 6), 0); + EXPECT_EQ(calcDotsSnap(56, 6), 0); + + // Exact-integer dotted durations must still resolve. + EXPECT_EQ(calcDots(90, 5), 1); + EXPECT_EQ(calcDotsSnap(90, 5), 1); + EXPECT_EQ(calcDots(105, 5), 2); + EXPECT_EQ(calcDotsSnap(105, 5), 2); + // 8th triple-dotted (225 = 120*15/8 exact). + EXPECT_EQ(calcDots(225, 4), 3); + EXPECT_EQ(calcDotsSnap(225, 4), 3); + EXPECT_EQ(calcDotsSnap(226, 4), 3); +} + +// Encore ticks are on a fixed 960-per-whole grid, so ticks-per-whole must be 960 for every meter +// including compound (the old beatTicks x timeSigDen form was wrong). No usable meter -> kEncWholeTicks. +TEST(Tst_EncoreRhythm, wholeNoteTicks) +{ + auto wnt = [](int durTicks, int num, int den) { + EncMeasure m; + m.durTicks = static_cast(durTicks); + m.timeSigNum = static_cast(num); + m.timeSigDen = static_cast(den); + return encWholeNoteTicks(m); + }; + EXPECT_EQ(wnt(960, 4, 4), 960); // 4/4 + EXPECT_EQ(wnt(480, 2, 4), 960); // 2/4 + EXPECT_EQ(wnt(720, 6, 8), 960); // 6/8 compound: 720*8/6 + EXPECT_EQ(wnt(840, 7, 8), 960); // 7/8: 840*8/7 + EXPECT_EQ(wnt(360, 3, 8), 960); // 3/8: 360*8/3 + // Missing time signature falls back to the constant. + EXPECT_EQ(wnt(0, 0, 0), kEncWholeTicks); + EXPECT_EQ(encWholeNoteTicks(EncMeasure {}), kEncWholeTicks); +} + +TEST(Tst_EncoreRhythm, dottedAdvance) +{ + EXPECT_EQ(dottedAdvance(DurationType::V_EIGHTH, 0), Fraction(1, 8)); + EXPECT_EQ(dottedAdvance(DurationType::V_EIGHTH, 1), Fraction(3, 16)); + EXPECT_EQ(dottedAdvance(DurationType::V_EIGHTH, 2), Fraction(7, 32)); + EXPECT_EQ(dottedAdvance(DurationType::V_EIGHTH, 3), Fraction(15, 64)); + // dots >= 3 clamps to 15/8 multiplier. + EXPECT_EQ(dottedAdvance(DurationType::V_EIGHTH, 4), Fraction(15, 64)); + EXPECT_EQ(dottedAdvance(DurationType::V_EIGHTH, 99), Fraction(15, 64)); + // Quarter base. + EXPECT_EQ(dottedAdvance(DurationType::V_QUARTER, 0), Fraction(1, 4)); + EXPECT_EQ(dottedAdvance(DurationType::V_QUARTER, 1), Fraction(3, 8)); +} + +// SCO5 (macOS Encore 5) stores the PREC page setup as an NSPrintInfo XML plist, not a Windows DEVMODE; +// parsePrecPlist extracts orientation, paper size and scale (no margins). See ENCORE_FORMAT.md §PREC block. +TEST(Tst_EncorePrecPlist, letter_portrait_scale_120) +{ + const QByteArray plist + ="\n" + "\n" + "com.apple.print.PageFormat.PMOrientation1\n" + "com.apple.print.PageFormat.PMScaling1.2\n" + "PMTiogaPaperNamena-letter\n" + "\n"; + EncPrintSetup ps; + EXPECT_TRUE(parsePrecPlist(plist, ps)); + EXPECT_TRUE(ps.hasData); + EXPECT_EQ(ps.orientation, 1); // portrait + EXPECT_EQ(ps.paperSize, 1); // Letter (dmPaper code) + EXPECT_EQ(ps.scale, 120); // 1.2 -> 120% +} + +TEST(Tst_EncorePrecPlist, a4_landscape) +{ + const QByteArray plist + ="\n" + "com.apple.print.PageFormat.PMOrientation2\n" + "PMTiogaPaperNameiso-a4\n" + "\n"; + EncPrintSetup ps; + EXPECT_TRUE(parsePrecPlist(plist, ps)); + EXPECT_EQ(ps.orientation, 2); // landscape + EXPECT_EQ(ps.paperSize, 9); // A4 +} + +TEST(Tst_EncorePrecPlist, rejects_non_plist) +{ + EncPrintSetup ps; + EXPECT_FALSE(parsePrecPlist(QByteArray("not a plist at all"), ps)); + EXPECT_FALSE(ps.hasData); +} + +TEST(Tst_EncoreParserBounds, clampMeasureEnd_clamps_oversized_varsize) +{ + // Normal case: end = measStart + varsize + elemBlockOffset. + EXPECT_EQ(clampMeasureEnd(100, 50, 0x36, 10000), 100 + 50 + 0x36); + // An oversized (attacker-controlled) varsize must clamp to the device size, never past EOF. + EXPECT_EQ(clampMeasureEnd(100, 0xFFFFFFFFu, 0x36, 1000), 1000); + // A varsize that fits stays unclamped even for a large device. + EXPECT_EQ(clampMeasureEnd(0, 200, 0x36, 100000), 200 + 0x36); +} + +TEST(Tst_EncoreParserDurations, computeElementDurations_gap_and_boundary) +{ + // Two notes 480 ticks apart in a 960-tick (4/4) measure: each spans the gap to the next event, + // and the last spans the gap to the measure end. + EncNote a(0, 9, 0); + EncNote b(480, 9, 0); + std::vector elems { &a, &b }; + computeElementDurations(elems, 960, /*hasGraceTimeBorrowing*/ false); + EXPECT_EQ(a.realDuration, 480); + EXPECT_EQ(b.realDuration, 480); + + // A boundary tick (e.g. a mid-measure clef change) caps the preceding note's gap. + EncNote c(0, 9, 0); + EncNote d(480, 9, 0); + std::vector elems2 { &c, &d }; + computeElementDurations(elems2, 960, false, { 240 }); + EXPECT_EQ(c.realDuration, 240); + EXPECT_EQ(d.realDuration, 480); +} + +TEST(Tst_EncorePageLayout, winiUnitsPerInch_detects_points_vs_pixels) +{ + // Degenerate page width falls back to typographic points. + EXPECT_DOUBLE_EQ(winiUnitsPerInch(500, 50, 0.0), 72.0); + // A near-72 ratio (typographic-point WINI) snaps to exactly 72: (560+35)/8.27 = 71.9. + EXPECT_DOUBLE_EQ(winiUnitsPerInch(560, 35, 8.27), 72.0); + // A clearly larger ratio (~84) is a screen-pixel WINI and is returned as the estimate. + EXPECT_GT(winiUnitsPerInch(665, 35, 8.27), 80.0); // (665+35)/8.27 = 84.6 +} diff --git a/src/importexport/encore/tests/tst_parser_v0xa6.cpp b/src/importexport/encore/tests/tst_parser_v0xa6.cpp new file mode 100644 index 0000000000000..bef0d2e3b424f --- /dev/null +++ b/src/importexport/encore/tests/tst_parser_v0xa6.cpp @@ -0,0 +1,773 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// v0xA6 (Encore 2.x) importer coverage: the format's distinct element offsets, absolute pitch, tuplet byte, +// duplicate-rest dedupe, octave-key clef compensation, grace time-borrowing, and compact lyric/text/stafftext. +// See ENCORE_FORMAT.md §v0xA6 note (size 10, on-disk slot 20). + +#include + +#include +#include + +#include "../internal/parser/elem.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/clef.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/tuplet.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_ImporterV0xa6 : public ::testing::Test, public MTest +{ +protected: + void SetUp() override + { + setRootDir(ENC_DIR); + } +}; + +TEST_F(Tst_ImporterV0xa6, very_old_format_v0xa6_sanity_check) +{ + // Regression: the v0xA6 element offset differs from v0xC4; a wrong offset drops all notes silently. + MasterScore* score = readEncoreScore("structure_v0xa6_basic.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->nmeasures(), 2); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "v0xA6 synthetic file should pass sanityCheck: " << ret.text(); + delete score; +} + +TEST_F(Tst_ImporterV0xa6, very_old_format_v0xa6_pitch_encoding) +{ + // v0xA6 pitch is an absolute MIDI value, decoded differently from v0xC4. + MasterScore* score = readEncoreScore("structure_v0xa6_basic.enc"); + ASSERT_NE(score, nullptr); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector pitches; + for (Segment* s = m->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + pitches.push_back(n->pitch()); + } + } + } + } + ASSERT_EQ(pitches.size(), 4u) << "Measure 1 should have 4 notes"; + EXPECT_EQ(pitches[0], 60) << "C4"; + EXPECT_EQ(pitches[1], 62) << "D4"; + EXPECT_EQ(pitches[2], 64) << "E4"; + EXPECT_EQ(pitches[3], 67) << "G4"; + delete score; +} + +// End-to-end regression for the full v0xA6 fix chain: 4 instruments, key transposition, tuplets, +// duplicate REST collapse, and no spurious articulation/tremolo/fingering glyphs. +TEST_F(Tst_ImporterV0xa6, v0xa6_boda_like_full_pipeline) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_boda_like.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_boda_like.enc"; + + ASSERT_EQ(score->nstaves(), 4u) << "fixture has 4 instruments"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + auto staffPitches = [m](int staffIdx) { + std::vector out; + const track_idx_t base = staffIdx * VOICES; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(base); + if (el && el->isChord()) { + for (Note* n : toChord(el)->notes()) { + out.push_back(n->pitch()); + } + } + } + return out; + }; + auto staffElementCount = [m](int staffIdx) { + int count = 0; + const track_idx_t base = staffIdx * VOICES; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + if (s->element(base)) { + ++count; + } + } + return count; + }; + auto staffTupletGroups = [m](int staffIdx) { + std::set seen; + const track_idx_t base = staffIdx * VOICES; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(base); + if (el && el->isChord()) { + if (const Tuplet* t = toChord(el)->tuplet()) { + seen.insert(t); + } + } + } + return seen.size(); + }; + + EXPECT_EQ(staffTupletGroups(0), 2u) << "B1 must hold 2 triplet groups"; + EXPECT_EQ(staffPitches(0), (std::vector { 88, 88, 89, 88, 86, 88, 86 })) + << "B1 pitches survive without Key shift"; + + EXPECT_EQ(staffElementCount(1), 3) << "B2 must hold rest + 2 chords"; + EXPECT_EQ(staffPitches(1), (std::vector { 76, 77 })) + << "B2 pitches survive without Key shift"; + + EXPECT_EQ(staffElementCount(2), 3) + << "Laud must hold exactly rest + 2 chords after duplicate-REST dedupe"; + EXPECT_EQ(staffPitches(2), (std::vector { 76 - 12, 77 - 12 })) + << "Laud pitches must drop by Key = -12"; + + EXPECT_EQ(staffElementCount(3), 3) << "Bajo holds 3 notes"; + EXPECT_EQ(staffPitches(3), (std::vector { 57 - 12, 60 - 12, 64 - 12 })) + << "Bajo pitches must drop by Key = -12"; + + int v1Count = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (size_t st = 0; st < 4; ++st) { + if (s->element(st * VOICES + 1)) { + ++v1Count; + } + } + } + EXPECT_EQ(v1Count, 0) << "no v1 spillover after dedup + correct tuplet handling"; + + int tremCount = 0; + int fingerCount = 0; + int fermataCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isFermata()) { + ++fermataCount; + } + } + for (size_t v = 0; v < score->nstaves() * VOICES; ++v) { + EngravingItem* el = s->element(v); + if (el && el->isChord()) { + Chord* c = toChord(el); + if (c->tremoloSingleChord() || c->tremoloTwoChord()) { + ++tremCount; + } + for (Note* nt : c->notes()) { + for (EngravingItem* sub : nt->el()) { + if (sub && sub->isFingering()) { + ++fingerCount; + } + } + } + } + } + } + } + EXPECT_EQ(tremCount, 0) << "no spurious tremolo glyphs"; + EXPECT_EQ(fingerCount, 0) << "no spurious fingering glyphs"; + EXPECT_EQ(fermataCount, 0) << "no spurious fermata glyphs"; + + delete score; +} + +// Regression: v0xA6 NOTE stores the tuplet byte at offset +7 (not +13 as in v0xC4). +TEST_F(Tst_ImporterV0xa6, v0xa6_triplet_byte_at_offset_7) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_triplet_byte_at_offset_7.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_triplet_byte_at_offset_7.enc"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + int tupletCount = 0; + int noteCount = 0; + std::vector pitches; + std::set seenTuplets; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + Chord* c = toChord(el); + ++noteCount; + for (Note* nt : c->notes()) { + pitches.push_back(nt->pitch()); + } + const Tuplet* t = c->tuplet(); + if (t && seenTuplets.insert(t).second) { + ++tupletCount; + EXPECT_EQ(t->ratio().numerator(), 3) << "triplet actualNotes"; + EXPECT_EQ(t->ratio().denominator(), 2) << "triplet normalNotes"; + } + } + } + EXPECT_EQ(noteCount, 6) << "6 triplet sixteenths must survive import"; + EXPECT_EQ(tupletCount, 2) << "two 3:2 triplet groups expected"; + const std::vector expected{ 64, 65, 64, 62, 64, 62 }; + EXPECT_EQ(pitches, expected) << "pitches must match the binary in order"; + + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EXPECT_EQ(s->element(1), nullptr) << "voice 1 should stay empty"; + } + delete score; +} + +// Regression: a size-11 v0xA6 NOTE carries an articulation byte (0x20 = fermata); the reader must still +// take the pitch from the v0xA6 slot (not the v0xC4 base read) and emit the fermata. +TEST_F(Tst_ImporterV0xa6, v0xa6_note_size11_fermata_pitch_and_glyph) +{ + MasterScore* score = readEncoreScore("structure_v0xa6_fermata.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_v0xa6_fermata.enc"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector pitches; + int fermataCount = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isFermata()) { + ++fermataCount; + } + } + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + for (Note* nt : toChord(el)->notes()) { + pitches.push_back(nt->pitch()); + } + } + } + const std::vector expected{ 64, 67 }; + EXPECT_EQ(pitches, expected) << "size-11 NOTE pitch comes from +11, not the +15 decoy"; + EXPECT_EQ(fermataCount, 2) << "the +18 articulation byte 0x20 must import as a fermata"; + + delete score; +} + +// Regression: v0xA6 can store two byte-identical REST elements at the same tick; importer must deduplicate. +TEST_F(Tst_ImporterV0xa6, v0xa6_duplicate_rest_collapse) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_duplicate_rest_collapse.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_duplicate_rest_collapse.enc"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector > positions; + std::vector pitches; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + positions.emplace_back(s->tick() - m->tick(), el->isRest()); + if (el->isChord()) { + pitches.push_back(toChord(el)->notes()[0]->pitch()); + } + } + ASSERT_EQ(positions.size(), 3u) << "measure must hold 3 elements after dedupe"; + EXPECT_TRUE(positions[0].second) << "beat 1: rest"; + EXPECT_EQ(positions[0].first, Fraction(0, 1)); + EXPECT_FALSE(positions[1].second) << "beat 2: chord"; + EXPECT_EQ(positions[1].first, Fraction(1, 8)); + EXPECT_FALSE(positions[2].second) << "beat 3: chord"; + EXPECT_EQ(positions[2].first, Fraction(2, 8)); + ASSERT_EQ(pitches.size(), 2u); + EXPECT_EQ(pitches[0], 64); + EXPECT_EQ(pitches[1], 64); + + int v1Count = 0; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + if (s->element(1)) { + ++v1Count; + } + } + EXPECT_EQ(v1Count, 0) << "voice 1 must be empty after dedupe"; + delete score; +} + +// Regression: v0xA6 header ends at 0xA6 (174 bytes), not 0xC2 (194). +TEST_F(Tst_ImporterV0xa6, v0xa6_header_ends_at_0xa6) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_header_ends_at_0xa6.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_header_ends_at_0xa6.enc"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Chord* firstChord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + firstChord = toChord(el); + break; + } + } + ASSERT_NE(firstChord, nullptr); + ASSERT_EQ(firstChord->notes().size(), 1u); + EXPECT_EQ(firstChord->notes()[0]->pitch(), 48) + << "TK00 at the v0xA6 file offset 0xA6 must be parsed so its " + "Key = -12 actually lowers C4 (60) to C3 (48)"; + delete score; +} + +// Regression: Key byte at TK+42 (not PRG_BASE+n*PRG_STEP) in v0xA6 format. +TEST_F(Tst_ImporterV0xa6, v0xa6_key_transposition_octave_lower) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_key_transposition.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_key_transposition.enc"; + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Chord* firstChord = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + firstChord = toChord(el); + break; + } + } + ASSERT_NE(firstChord, nullptr); + ASSERT_EQ(firstChord->notes().size(), 1u); + EXPECT_EQ(firstChord->notes()[0]->pitch(), 48) + << "v0xA6 Key = -12 must lower C4 (60) to C3 (48)"; + delete score; +} + +// Regression: an octave key shift must add a matching octave-decorated clef so the display stays at the +// written octave; v0xA6 has no LINE clef data, so the compensation was missing (Key=-12 -> G8vb). +TEST_F(Tst_ImporterV0xa6, v0xa6_octave_key_adds_compensating_clef) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_key_transposition.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_key_transposition.enc"; + ASSERT_FALSE(score->staves().empty()); + EXPECT_EQ(score->staff(0)->clef(Fraction(0, 1)), ClefType::G8_VB) + << "v0xA6 octave Key=-12 must add a compensating G8vb clef, not leave the plain template clef"; + delete score; +} + +// Regression: v0xA6 NOTE is 10 bytes but EncNote::read consumed 27, reading garbage as articulation data. +TEST_F(Tst_ImporterV0xa6, v0xa6_no_spurious_articulation_glyphs) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_no_spurious_tremolo.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_no_spurious_tremolo.enc"; + + int tremCount = 0; + int fingerCount = 0; + int articCount = 0; + int fermataCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* ann : s->annotations()) { + if (ann && ann->isFermata()) { + ++fermataCount; + } + } + for (size_t v = 0; v < score->nstaves() * VOICES; ++v) { + EngravingItem* el = s->element(v); + if (el && el->isChord()) { + Chord* c = toChord(el); + if (c->tremoloSingleChord() || c->tremoloTwoChord()) { + ++tremCount; + } + articCount += static_cast(c->articulations().size()); + for (Note* n : c->notes()) { + for (EngravingItem* sub : n->el()) { + if (sub && sub->isFingering()) { + ++fingerCount; + } + } + } + } + } + } + } + EXPECT_EQ(tremCount, 0) << "v0xA6 NOTEs do not carry tremolo data"; + EXPECT_EQ(fingerCount, 0) << "v0xA6 NOTEs do not carry fingering or open-string data"; + EXPECT_EQ(articCount, 0) << "v0xA6 NOTEs do not carry articulation glyphs"; + EXPECT_EQ(fermataCount, 0) << "v0xA6 NOTEs do not carry fermata data"; + delete score; +} + +// Regression: after a grace note, regular notes at exact face-grid ticks triggered spurious gap snap. +TEST_F(Tst_ImporterV0xa6, v0xa6_grace_ongrid_snap_suppressed) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_grace_ongrid_snap_suppressed.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_grace_ongrid_snap_suppressed.enc"; + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "sanityCheck failed: " << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + bool hasSpuriousInterNoteRest = false; + bool prevWasChord = false; + int graceCount = 0; + for (Segment* s = m1->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + if (el->isRest()) { + if (prevWasChord && s->next(SegmentType::ChordRest)) { + hasSpuriousInterNoteRest = true; + } + prevWasChord = false; + } else if (el->isChord()) { + Chord* c = toChord(el); + graceCount += static_cast(c->graceNotes().size()); + prevWasChord = true; + } + } + EXPECT_FALSE(hasSpuriousInterNoteRest) + << "spurious rest between regular notes; stolenTicks snap suppression missing"; + EXPECT_EQ(graceCount, 1) << "expected exactly 1 grace (leading 32nd)"; + delete score; +} + +// Regression: inner grace (g1=0x10) shorter than the leader (g1=0x20) was treated as a regular note. +TEST_F(Tst_ImporterV0xa6, v0xa6_inner_grace_group) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_inner_grace_group.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_inner_grace_group.enc"; + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "sanityCheck failed (would crash in GUI): " << ret.text(); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + int graceCount = 0; + bool hasSpuriousPreGraceRest = false; + std::vector regularTypes; + for (Segment* s = m1->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + if (el->isRest()) { + Segment* nx = s->next(SegmentType::ChordRest); + if (nx) { + EngravingItem* nxEl = nx->element(0); + if (nxEl && nxEl->isChord() && !toChord(nxEl)->graceNotes().empty()) { + hasSpuriousPreGraceRest = true; + } + } + } else if (el->isChord()) { + Chord* c = toChord(el); + graceCount += static_cast(c->graceNotes().size()); + regularTypes.push_back(c->durationType().type()); + } + } + EXPECT_FALSE(hasSpuriousPreGraceRest) + << "Rest found immediately before grace-note chord (crash-inducing structure)"; + EXPECT_EQ(graceCount, 2) << "expected 2 graces (32nd leader + 64th inner)"; + ASSERT_GE(regularTypes.size(), 2u); + EXPECT_EQ(regularTypes.front(), DurationType::V_EIGHTH); + EXPECT_EQ(regularTypes.back(), DurationType::V_EIGHTH); + delete score; +} + +// Regression: v0xA6 grace notes shift subsequent real notes forward; calculateRealDurations must restore face duration. +TEST_F(Tst_ImporterV0xa6, v0xa6_grace_restores_face_value) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_grace_restores_face_value.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_v0xa6_grace_restores_face_value.enc"; + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + + std::vector > elements; + for (Segment* s = m1->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el) { + continue; + } + if (el->isRest()) { + elements.push_back({ toRest(el)->durationType().type(), false }); + } else if (el->isChord()) { + Chord* c = toChord(el); + for (Chord* gc : c->graceNotes()) { + elements.push_back({ gc->durationType().type(), true }); + } + elements.push_back({ c->durationType().type(), false }); + } + } + ASSERT_EQ(elements.size(), 5u) + << "grace time-borrowing correction must restore the last 8th; got " << elements.size(); + EXPECT_EQ(elements[0].second, false); + EXPECT_EQ(elements[0].first, DurationType::V_EIGHTH); + EXPECT_EQ(elements[1].second, true); + EXPECT_EQ(elements[1].first, DurationType::V_32ND); + EXPECT_EQ(elements[2].second, false); + EXPECT_EQ(elements[2].first, DurationType::V_16TH); + EXPECT_EQ(elements[3].second, false); + EXPECT_EQ(elements[3].first, DurationType::V_16TH); + EXPECT_EQ(elements[4].second, false); + EXPECT_EQ(elements[4].first, DurationType::V_EIGHTH) + << "last note must be an eighth (face value), not a 16th from rawGap=90"; + delete score; +} + +// v0xA6 stores LYRIC elements in a compact layout the shared newer-format reader would skip past, +// dropping every 2.x lyric. See ENCORE_FORMAT.md §Lyric element. Feeds the raw bytes for "lent". +TEST_F(Tst_ImporterV0xa6, v0xa6_compact_lyric_parses_text) +{ + QByteArray buf; + buf.append(char(6)); // size (element +3) + buf.append(char(0x40)); // rawStaff (+4) + buf.append(char(0x77)); // control byte (+5) + buf.append("lent"); // text (+6..) + buf.append(char(0x00)); // NUL terminator + while (buf.size() < 6 * 2) { + buf.append(char(0)); // pad out the size*2 slot + } + + QDataStream ds(buf); + ds.setByteOrder(QDataStream::LittleEndian); + + mu::iex::enc::EncLyric lyr(0, 6, 1); + lyr.preKieSkip = 0; // v0xA6 layout values (from EncFormatReader_V0xA6) + lyr.textGapAfterKie = 0; + lyr.spacingFactor = 2; + lyr.read(ds); + + EXPECT_EQ(lyr.text, QString("lent")); +} + +// v0xA6 TEXT-block entries carry no per-entry header (text at offset 0, not the newer +14), so short +// entries read empty at the wrong offset. See ENCORE_FORMAT.md §TEXT block. Feeds a one-entry "Moderato". +TEST_F(Tst_ImporterV0xa6, v0xa6_text_block_entry_text_at_offset_0) +{ + QByteArray buf; + auto u16 = [&](int v) { buf.append(char(v & 0xff)); buf.append(char((v >> 8) & 0xff)); }; + u16(0); // sync + u16(1); // count = 1 entry + u16(0); + u16(0); // contentSize (4 bytes, unused) + u16(10); // entrySize = 10 + buf.append("Moderato"); + buf.append(char(0)); + buf.append(char(0)); // text + NUL + pad = 10 bytes + + QDataStream ds(buf); + ds.setByteOrder(QDataStream::LittleEndian); + + mu::iex::enc::EncTextBlock tb; + tb.read(ds, static_cast(buf.size()), /*textOffset*/ 0); // v0xA6 + + ASSERT_EQ(tb.entries.size(), size_t(1)); + EXPECT_EQ(tb.entries[0], QString("Moderato")); +} + +// v0xA6 compact STAFFTEXT ornaments hold the TEXT-entry index (tind) at a fixed offset, not the +// newer size-based one. See ENCORE_FORMAT.md §Ornament element. +TEST_F(Tst_ImporterV0xa6, v0xa6_stafftext_tind_at_offset_26) +{ + QByteArray buf(30, 0); // size*2 slot + buf[0] = char(15); // element size + buf[2] = char(0x1E); // tipo = STAFFTEXT (read right after size + rawStaff) + buf[25] = char(0x04); // tind: +26 from the type/voice byte, which sits one byte before the buffer + + QDataStream ds(buf); + ds.setByteOrder(QDataStream::LittleEndian); + + mu::iex::enc::EncOrnament orn(0, 5, 0); + orn.tindOffset = 26; // v0xA6 + orn.read(ds); + + EXPECT_EQ(orn.tind, 4); +} + +// End-to-end: a real v0xA6 file whose measure carries a compact lyric syllable ("loco") and a +// STAFFTEXT ornament pointing at TEXT entry 0 ("dolce") must import both. Before the compact-layout +// fix the 2.x lyric and staff text were silently dropped. +TEST_F(Tst_ImporterV0xa6, v0xa6_imports_lyrics_and_staff_text) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_lyrics_and_stafftext.enc"); + ASSERT_NE(score, nullptr); + + bool foundLyric = false; + bool foundStaffText = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* a : s->annotations()) { + if (a && a->isStaffText() && toStaffText(a)->plainText() == String(u"dolce")) { + foundStaffText = true; + } + } + for (int ti = 0; ti < static_cast(score->nstaves() * VOICES); ++ti) { + EngravingItem* el = s->element(static_cast(ti)); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + if (ly && ly->plainText() == String(u"loco")) { + foundLyric = true; + } + } + } + } + } + EXPECT_TRUE(foundLyric) << "v0xA6 compact lyric 'loco' must be imported"; + EXPECT_TRUE(foundStaffText) << "v0xA6 staff text 'dolce' must be imported"; + delete score; +} + +// v0xA6 compact STAFFTEXT stores its vertical placement at a different offset than v0xC4; reading the +// v0xC4 offset yields 0 for both texts, so "espressivo" (negative y) wrongly landed above. +TEST_F(Tst_ImporterV0xa6, v0xa6_stafftext_placement_from_offset_6) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_stafftext_placement.enc"); + ASSERT_NE(score, nullptr); + + bool sawAbove = false; + bool sawBelow = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* a : s->annotations()) { + if (!a || !a->isStaffText()) { + continue; + } + const StaffText* st = toStaffText(a); + if (st->plainText() == String(u"cresc.")) { + EXPECT_EQ(st->placement(), PlacementV::ABOVE) << "positive y must place above"; + sawAbove = true; + } else if (st->plainText() == String(u"espressivo")) { + EXPECT_EQ(st->placement(), PlacementV::BELOW) << "negative y must place below"; + sawBelow = true; + } + } + } + } + EXPECT_TRUE(sawAbove) << "staff text 'cresc.' must be imported"; + EXPECT_TRUE(sawBelow) << "staff text 'espressivo' must be imported"; + delete score; +} + +// Encore stores every verse-2 syllable at tick=0; its real position is the xoffset. Verse 2 must align +// to the same notes as verse 1 by xoffset, not collapse onto the first note. +TEST_F(Tst_ImporterV0xa6, v0xa6_second_verse_aligns_by_xoffset) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_two_verse_alignment.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector > perChord; // (verse 0 text, verse 1 text) + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + String v0, v1; + for (Lyrics* ly : toChord(el)->lyrics()) { + if (!ly) { + continue; + } + if (ly->verse() == 0) { + v0 = ly->plainText(); + } else if (ly->verse() == 1) { + v1 = ly->plainText(); + } + } + perChord.emplace_back(v0, v1); + } + + ASSERT_GE(perChord.size(), size_t(3)); + EXPECT_EQ(perChord[0].first, String(u"A")); + EXPECT_EQ(perChord[0].second, String(u"X")); + EXPECT_EQ(perChord[1].first, String(u"B")); + EXPECT_EQ(perChord[1].second, String(u"Y")); + EXPECT_EQ(perChord[2].first, String(u"C")); + EXPECT_EQ(perChord[2].second, String(u"Z")); + delete score; +} + +// Melisma word: Encore stores verse 1 at the melisma's END note, but its xoffset matches verse 2 on the +// first note, so xoffset alignment must keep both syllables on note 1 (tick matching wrongly split them). +TEST_F(Tst_ImporterV0xa6, v0xa6_melisma_word_aligns_both_verses_on_first_note) +{ + MasterScore* score = readEncoreScore("importer_v0xa6_melisma_verse_alignment.enc"); + ASSERT_NE(score, nullptr); + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector > perChord; // (verse 0 text, verse 1 text) + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + String v0, v1; + for (Lyrics* ly : toChord(el)->lyrics()) { + if (!ly) { + continue; + } + if (ly->verse() == 0) { + v0 = ly->plainText(); + } else if (ly->verse() == 1) { + v1 = ly->plainText(); + } + } + perChord.emplace_back(v0, v1); + } + + ASSERT_GE(perChord.size(), size_t(2)); + EXPECT_EQ(perChord[0].first, String(u"peace")); + EXPECT_EQ(perChord[0].second, String(u"born")); + EXPECT_TRUE(perChord[1].first.isEmpty()); + EXPECT_TRUE(perChord[1].second.isEmpty()); + delete score; +} diff --git a/src/importexport/encore/tests/tst_repeats.cpp b/src/importexport/encore/tests/tst_repeats.cpp new file mode 100644 index 0000000000000..1c1f84cd12757 --- /dev/null +++ b/src/importexport/encore/tests/tst_repeats.cpp @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Repeats, voltas, jumps and coda/segno markers: bracket coalescing, numbering, play counts and replay skipping. + +#include + +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/repeatlist.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/volta.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Repeats : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +#define ENC_SANITY_TEST(testName, fileName) \ + TEST_F(Tst_Repeats, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } + +// Regression: when a second volta bracket's bitmask contains bits already shown in the +// first bracket (e.g. raw bits {2,4} after {1,2,3}), the second bracket must display +// only the NEW endings ({4}), not the full raw set ("2, 4."). +TEST_F(Tst_Repeats, v0c4_volta_overlapping_bits_filtered) +{ + MasterScore* score = readEncoreScore("importer_volta_overlapping_bits.enc"); + ASSERT_NE(score, nullptr); + + std::vector voltas; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isVolta()) { + voltas.push_back(toVolta(sp)); + } + } + std::sort(voltas.begin(), voltas.end(), + [](Volta* a, Volta* b) { return a->tick() < b->tick(); }); + ASSERT_EQ(voltas.size(), 2u); + EXPECT_EQ(voltas[0]->beginText(), String(u"1, 2, 3.")) + << "First volta (bits 0x07) must show all three endings"; + EXPECT_EQ(voltas[1]->beginText(), String(u"4.")) + << "Second volta (raw bits 0x0A) must show only ending 4 " + "(ending 2 already covered by the first bracket)"; + EXPECT_EQ(static_cast(voltas[1]->endings().size()), 1) + << "Endings list must also contain only {4}"; + EXPECT_EQ(voltas[1]->endings()[0], 4); + delete score; +} + +// Regression: importer created one Volta per measure (3 voltas for 1/1/2 bits) and never set begin-text. +// Fix: coalesce equal-bitmask runs into one Volta and set begin-text from the endings list. +TEST_F(Tst_Repeats, v0c4_volta_coalesce_and_numbered_text) +{ + MasterScore* score = readEncoreScore("importer_volta_coalesce_and_text.enc"); + ASSERT_NE(score, nullptr) + << "Failed to load importer_volta_coalesce_and_text.enc"; + + std::vector voltas; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isVolta()) { + voltas.push_back(toVolta(sp)); + } + } + std::sort(voltas.begin(), voltas.end(), + [](Volta* a, Volta* b) { return a->tick() < b->tick(); }); + ASSERT_EQ(voltas.size(), 2u) + << "consecutive measures with the same repeatAlternative bitmask " + "must collapse into one Volta"; + EXPECT_EQ(voltas[0]->beginText(), String(u"1.")) + << "first ending must render the number '1.'"; + EXPECT_EQ(voltas[1]->beginText(), String(u"2.")) + << "second ending must render the number '2.'"; + EXPECT_GT(voltas[0]->tick2() - voltas[0]->tick(), Fraction(4, 4)) + << "first Volta must span both alt-1 measures, not just one"; + delete score; +} + +// Regression: both coda bytes (0x85 CODA1 and 0x89 CODA2) mapped to CODA, losing the TOCODA distinction. +// Fix: 0x85 -> TOCODA, 0x89 -> CODA. +TEST_F(Tst_Repeats, v0c4_to_coda_distinct_from_coda) +{ + MasterScore* score = readEncoreScore("importer_to_coda_vs_coda_marker.enc"); + ASSERT_NE(score, nullptr) + << "Failed to load importer_to_coda_vs_coda_marker.enc"; + + std::vector orderedTypes; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* el : toMeasure(mb)->el()) { + if (el && el->isMarker()) { + orderedTypes.push_back(toMarker(el)->markerType()); + } + } + } + ASSERT_EQ(orderedTypes.size(), 2u) + << "expected one marker per of the two coda-bearing measures"; + EXPECT_EQ(orderedTypes[0], MarkerType::TOCODA) + << "CODA1 (0x85) must import as TOCODA, not CODA"; + EXPECT_EQ(orderedTypes[1], MarkerType::CODA) + << "CODA2 (0x89) must import as CODA"; + delete score; +} + +// Regression: Encore has no explicit repeat play count; it is implied by the highest volta ending. +// A repeat with endings "1.-3." then "4." must play four times or the "4." ending is never reached +// (the default count of 2 stopped it early). +TEST_F(Tst_Repeats, v0c4_volta_repeat_playcount_from_endings) +{ + MasterScore* score = readEncoreScore("structure_volta_repeat_playcount.enc"); + ASSERT_NE(score, nullptr); + score->setExpandRepeats(true); + + const Measure* endRepeat = nullptr; + const Volta* fourthEnding = nullptr; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (mb->isMeasure() && toMeasure(mb)->repeatEnd()) { + endRepeat = toMeasure(mb); + break; + } + } + for (const auto& p : score->spanner()) { + const Spanner* sp = p.second; + if (sp && sp->isVolta()) { + const Volta* v = toVolta(sp); + if (v->endings().size() == 1 && v->endings()[0] == 4) { + fourthEnding = v; + } + } + } + ASSERT_NE(endRepeat, nullptr) << "expected a measure with a repeat-end barline"; + ASSERT_NE(fourthEnding, nullptr) << "expected a 4th-ending volta (endings == {4})"; + + EXPECT_EQ(endRepeat->repeatCount(), 4) + << "end-repeat barline must play 4 times (highest ending is '4.'); the default " + "of 2 stops before the 4th ending (got " << endRepeat->repeatCount() << ")"; + + const int repeatStartTick = endRepeat->tick().ticks() - endRepeat->ticks().ticks(); + const int fourthEndingTick = fourthEnding->tick().ticks(); + int bodyPlays = 0; + int fourthEndingPlays = 0; + for (const RepeatSegment* rs : score->repeatList()) { + if (rs->tick <= repeatStartTick && repeatStartTick < rs->endTick()) { + ++bodyPlays; + } + if (rs->tick <= fourthEndingTick && fourthEndingTick < rs->endTick()) { + ++fourthEndingPlays; + } + } + EXPECT_EQ(bodyPlays, 4) + << "repeated body must play 4 times (got " << bodyPlays << ")"; + EXPECT_EQ(fourthEndingPlays, 1) + << "4th ending must play exactly once, on the final pass (got " + << fourthEndingPlays << ")"; + delete score; +} + +ENC_SANITY_TEST(section_markers, "structure_section_markers.enc") +ENC_SANITY_TEST(jump_marks, "structure_jump_marks.enc") +ENC_SANITY_TEST(jump_marks_all, "structure_jump_marks_all.enc") + +// Regression: a repeat list cached during layout (before voltas were anchored) replayed the 1st +// ending on the repeat instead of skipping it; the importer must invalidate it after load. +TEST_F(Tst_Repeats, v0c4_volta_repeat_skips_first_ending_on_replay) +{ + MasterScore* score = readEncoreScore("structure_volta_repeat_playback.enc"); + ASSERT_NE(score, nullptr); + score->setExpandRepeats(true); + + const Volta* firstEnding = nullptr; + for (const auto& p : score->spanner()) { + const Spanner* sp = p.second; + if (sp && sp->isVolta()) { + const Volta* v = toVolta(sp); + if (v->endings().size() == 1 && v->endings()[0] == 1) { + firstEnding = v; + break; + } + } + } + ASSERT_NE(firstEnding, nullptr) << "expected a 1st-ending volta (endings == {1})"; + const int firstEndingTick = firstEnding->tick().ticks(); + + int firstEndingPlays = 0; + for (const RepeatSegment* rs : score->repeatList()) { + if (rs->tick <= firstEndingTick && firstEndingTick < rs->endTick()) { + ++firstEndingPlays; + } + } + EXPECT_EQ(firstEndingPlays, 1) + << "1st ending must play once and be skipped on the repeat; a stale cached " + "repeat list replays it on every pass (got " << firstEndingPlays << " plays)"; + delete score; +} + +// Regression: a volta's end hook must reflect its measure's barline (closed over a repeat-end, +// open otherwise); the importer used to hard-code every volta closed. +TEST_F(Tst_Repeats, v0c4_final_ending_volta_is_open) +{ + MasterScore* score = readEncoreScore("structure_volta_repeat_playback.enc"); + ASSERT_NE(score, nullptr); + + const Volta* firstEnding = nullptr; + const Volta* secondEnding = nullptr; + for (const auto& p : score->spanner()) { + const Spanner* sp = p.second; + if (!sp || !sp->isVolta()) { + continue; + } + const Volta* v = toVolta(sp); + if (v->endings().size() == 1 && v->endings()[0] == 1) { + firstEnding = v; + } else if (v->endings().size() == 1 && v->endings()[0] == 2) { + secondEnding = v; + } + } + ASSERT_NE(firstEnding, nullptr) << "expected a 1st-ending volta"; + ASSERT_NE(secondEnding, nullptr) << "expected a 2nd-ending volta"; + EXPECT_EQ(firstEnding->voltaType(), Volta::Type::CLOSED) + << "the 1st ending ends on a repeat barline and must be closed"; + EXPECT_EQ(secondEnding->voltaType(), Volta::Type::OPEN) + << "the final ending has no repeat barline and must be open, not a closed box"; + delete score; +} diff --git a/src/importexport/encore/tests/tst_smoke.cpp b/src/importexport/encore/tests/tst_smoke.cpp new file mode 100644 index 0000000000000..f01fc018a2fbd --- /dev/null +++ b/src/importexport/encore/tests/tst_smoke.cpp @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Smoke test: verifies the test harness initializes and the stub importEncore() is reachable. + +#include + +#include +#include + +#include "engraving/engravingerrors.h" +#include "../internal/importer/import.h" +#include "../internal/importer/import-options.h" + +using namespace mu::engraving; +using namespace mu::iex::enc; + +TEST(EncImporterSmoke, StubRejectsEveryFile) +{ + // The stub importEncore returns an error before any parser is built. + Err err = importEncore(nullptr, "nonexistent.enc"); + EXPECT_NE(err, Err::NoError); +} + +// Write a small file with the given leading bytes and return its path. +static QString writeTempFile(const QString& name, const QByteArray& bytes) +{ + const QString path = QDir::temp().filePath(name); + QFile f(path); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + f.write(bytes); + f.close(); + } + return path; +} + +// The bad-format message must identify why a file was rejected and how to recover. +TEST(EncImporterErrors, EncryptedContainerMessage) +{ + for (const char* magic : { "ZBOT", "ZBOP", "ZBO6" }) { + const QString path = writeTempFile(QStringLiteral("enc_err_%1.enc").arg(magic), + QByteArray(magic) + QByteArray(40, '\0')); + const QString msg = encoreLoadErrorMessage(path).toQString(); + EXPECT_TRUE(msg.contains("encrypted", Qt::CaseInsensitive)) + << magic << " -> " << msg.toStdString(); + EXPECT_TRUE(msg.contains(QString::fromLatin1(magic))) + << "message should name the detected container: " << msg.toStdString(); + QFile::remove(path); + } +} + +TEST(EncImporterErrors, UnreadableEncoreFileMessage) +{ + // A SCOW header with no valid body: recognizable Encore file, but not parseable. + const QString path = writeTempFile(QStringLiteral("enc_err_scow.enc"), + QByteArray("SCOW") + QByteArray(1, '\xC4') + QByteArray(8, '\0')); + const QString msg = encoreLoadErrorMessage(path).toQString(); + EXPECT_TRUE(msg.contains("could not be read", Qt::CaseInsensitive)) << msg.toStdString(); + EXPECT_TRUE(msg.contains("0xc4", Qt::CaseInsensitive)) + << "message should report the detected format version byte: " << msg.toStdString(); + QFile::remove(path); +} + +TEST(EncImporterErrors, NotAnEncoreFileMessage) +{ + const QString path = writeTempFile(QStringLiteral("enc_err_noise.enc"), + QByteArray("\x7F\x45\x4C\x46 random noise not encore", 28)); + const QString msg = encoreLoadErrorMessage(path).toQString(); + EXPECT_TRUE(msg.contains("not a recognized Encore file", Qt::CaseInsensitive)) << msg.toStdString(); + QFile::remove(path); +} diff --git a/src/importexport/encore/tests/tst_staff_size.cpp b/src/importexport/encore/tests/tst_staff_size.cpp new file mode 100644 index 0000000000000..0a9aa80a23be5 --- /dev/null +++ b/src/importexport/encore/tests/tst_staff_size.cpp @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Staff scale (MAG) import: score size header field, Encore 4.x LINE per-staff hint, and the v0xA6 global size. + +#include + +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/stafftype.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_StaffSize : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// Encore scoreSize=2 -> Staff Properties Scale = 75 % (Pid::MAG = 0.75). +TEST_F(Tst_StaffSize, score_size2_sets_staff_scale_75pct) +{ + MasterScore* score = readEncoreScore("importer_score_size2.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_score_size2.enc"; + ASSERT_FALSE(score->staves().empty()); + const double mag = score->staff(0)->staffType(mu::engraving::Fraction(0, 1))->userMag(); + EXPECT_NEAR(mag, 0.75, 1e-6) << "Expected staff scale 75 % for scoreSize=2, got " << mag * 100 << " %"; + delete score; +} + +// Encore scoreSize=3 -> Staff Properties Scale = 100 % (Pid::MAG = 1.00). +TEST_F(Tst_StaffSize, score_size3_sets_staff_scale_100pct) +{ + MasterScore* score = readEncoreScore("importer_score_size3.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_score_size3.enc"; + ASSERT_FALSE(score->staves().empty()); + const double mag = score->staff(0)->staffType(mu::engraving::Fraction(0, 1))->userMag(); + EXPECT_NEAR(mag, 1.00, 1e-6) << "Expected staff scale 100 % for scoreSize=3, got " << mag * 100 << " %"; + delete score; +} + +// Encore 4.x staff size comes from the LINE staff entry byte, not the unrelated header field. +// See ENCORE_FORMAT.md §System block (LINE). Regression guard: byte[13]=1 -> Size=2 -> 75%. +TEST_F(Tst_StaffSize, enc4x_line_staff_size_hint_size2_sets_75pct) +{ + MasterScore* score = readEncoreScore("importer_enc4x_line_size2_70pct.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_enc4x_line_size2_70pct.enc"; + ASSERT_FALSE(score->staves().empty()); + const double mag = score->staff(0)->staffType(mu::engraving::Fraction(0, 1))->userMag(); + EXPECT_NEAR(mag, 0.75, 1e-6) + << "Encore 4.x: byte[13]=1 in LINE staff entry must yield 75% scale, got " << mag * 100 << "%"; + delete score; +} + +// Encore 4.x: byte[13]=2 -> Size=3 -> 100%. The header field must not override the LINE-derived size. +TEST_F(Tst_StaffSize, enc4x_line_staff_size_hint_size3_sets_100pct) +{ + MasterScore* score = readEncoreScore("importer_enc4x_line_size3_75pct.enc"); + ASSERT_NE(score, nullptr) << "Failed to load importer_enc4x_line_size3_75pct.enc"; + ASSERT_FALSE(score->staves().empty()); + const double mag = score->staff(0)->staffType(mu::engraving::Fraction(0, 1))->userMag(); + EXPECT_NEAR(mag, 1.00, 1e-6) + << "Encore 4.x: byte[13]=2 in LINE staff entry must yield 100% scale, got " << mag * 100 << "%"; + delete score; +} + +// v0xA6 has a single global staff size applied to every staff. See ENCORE_FORMAT.md §v0xA6 staff size and clef. +// Regression guard: size=1 must yield 60% (importer previously fell back to the default 130%). +TEST_F(Tst_StaffSize, v0xa6_global_staff_size_from_header_0x8d) +{ + MasterScore* score = readEncoreScore("structure_v0xa6_score_size.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_v0xa6_score_size.enc"; + ASSERT_GE(score->staves().size(), size_t(2)); + const double mag0 = score->staff(0)->staffType(mu::engraving::Fraction(0, 1))->userMag(); + const double mag1 = score->staff(1)->staffType(mu::engraving::Fraction(0, 1))->userMag(); + EXPECT_NEAR(mag0, 0.60, 1e-6) << "v0xA6 size=1 (header 0x8D) must yield 60%, got " << mag0 * 100 << "%"; + EXPECT_NEAR(mag1, 0.60, 1e-6) << "v0xA6 size=1 (header 0x8D) must yield 60%, got " << mag1 * 100 << "%"; + delete score; +} diff --git a/src/importexport/encore/tests/tst_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp new file mode 100644 index 0000000000000..73265dea828bb --- /dev/null +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -0,0 +1,1598 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Score structure import: measures, systems and page breaks, clefs, key/time signatures, pickup measures, +// and page layout (margins, spatium, staff spacing). + +#include + +#include +#include + +#include +#include +#include + +#include "engraving/dom/system.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/tuplet.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/layoutbreak.h" +#include "engraving/dom/note.h" +#include "engraving/dom/page.h" +#include "engraving/dom/part.h" +#include "engraving/dom/clef.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/timesig.h" +#include "engraving/style/style.h" +#include "engraving/types/fraction.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +static Measure* measureAt(MasterScore* score, int n) +{ + int idx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + if (idx == n) { + return toMeasure(mb); + } + ++idx; + } + return nullptr; +} + +class Tst_Structure : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +static int firstPageBreakPage(MasterScore* score) +{ + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + bool hasPageBreak = false; + for (EngravingItem* e : mb->el()) { + if (e && e->isLayoutBreak() && toLayoutBreak(e)->isPageBreak()) { + hasPageBreak = true; + break; + } + } + if (!hasPageBreak) { + continue; + } + System* sys = toMeasure(mb)->system(); + if (sys && sys->page()) { + for (size_t i = 0; i < score->pages().size(); ++i) { + if (score->pages()[i] == sys->page()) { + return (int)i; + } + } + } + return -1; + } + return -2; +} + +TEST_F(Tst_Structure, page_break_spill_shrinks_staff_space) +{ + // structure_page_break_spill.enc: 6 staves, 3 systems. Its first two systems (LINE pageIdx + // 0 then 1) belong on the first page, but at the default staff space they do not fit on its + // short custom page, so the second system spills onto the second page (leaving a near-empty + // page). With imported page breaks the importer shrinks the staff space (by <= 0.01 inch) + // until the first page break's measure returns to the first page. + MasterScore* score = readEncoreScore("structure_page_break_spill.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + EXPECT_EQ(firstPageBreakPage(score), 0) + << "the first page break's measure must be pulled back onto the first page"; + + // A reference import with page breaks off does not run the fit pass, so its staff space is + // the untouched default. The page-break import must have reduced it, but by no more than the + // 0.022-inch budget (1 inch == 1200 engraving units). + mu::iex::enc::EncImportOptions noBreaks; + noBreaks.importPageBreaks = false; + MasterScore* ref = readEncoreScoreWithOpts("structure_page_break_spill.enc", noBreaks); + ASSERT_NE(ref, nullptr); + const double defaultSp = ref->style().styleD(Sid::spatium); + const double sp = score->style().styleD(Sid::spatium); + EXPECT_LT(sp, defaultSp) << "staff space must be reduced to make the first page fit"; + EXPECT_GE(sp, defaultSp - 0.022 * 1200.0) << "the reduction must not exceed 0.022 inch"; + + delete ref; + delete score; +} + +TEST_F(Tst_Structure, basic_measure_count) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Structure, basic_single_part) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + EXPECT_EQ(score->parts().size(), 1u); + EXPECT_EQ(score->nstaves(), 1u); + delete score; +} + +TEST_F(Tst_Structure, multipart_score) +{ + MasterScore* score = readEncoreScore("bando.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->parts().size(), 1u) << "bando.enc should have multiple parts"; + EXPECT_GT(score->nstaves(), 1u); + delete score; +} + +TEST_F(Tst_Structure, time_sig_4_4) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(4, 4)) << "First measure should be 4/4"; + delete score; +} + +TEST_F(Tst_Structure, time_sig_3_4) +{ + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(3, 4)) << "Synthetic triplet file should be 3/4"; + delete score; +} + +TEST_F(Tst_Structure, time_sig_2_4) +{ + MasterScore* score = readEncoreScore("notes_swing.enc"); + ASSERT_NE(score, nullptr); + Measure* m = measureAt(score, 0); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->timesig(), Fraction(2, 4)) << "First measure should be 2/4"; + delete score; +} + +TEST_F(Tst_Structure, key_sig_no_accidentals) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + Staff* st = score->staff(0); + ASSERT_NE(st, nullptr); + Key k = st->key(Fraction(0, 1)); + EXPECT_EQ(int(k), 0) << "bazo.enc should be in C major (0 accidentals)"; + delete score; +} + +// v0xA6 stores the key signature in the LINE staff entry, not where v0xC2/C4 keep it, and its +// staffPerSystem reads 0; the key must still be read (A major = 3 sharps on every staff), not lost. +// See ENCORE_FORMAT.md §System block (LINE). +TEST_F(Tst_Structure, key_sig_v0xa6_from_line_entry) +{ + MasterScore* score = readEncoreScore("structure_v0xa6_key_signature.enc"); + ASSERT_NE(score, nullptr); + ASSERT_GT(score->nstaves(), 0u); + for (size_t i = 0; i < score->nstaves(); ++i) { + Staff* st = score->staff(i); + ASSERT_NE(st, nullptr); + EXPECT_EQ(int(st->key(Fraction(0, 1))), 3) + << "v0xA6 staff " << i << " must import A major (3 sharps) from LINE entry offset 14"; + } + delete score; +} + +TEST_F(Tst_Structure, key_sig_no_invalid_large_values) +{ + // encKeyToFifths wrapping was broken before (key index 8 mapped to -248); verify -7..7 range. + MasterScore* score = readEncoreScore("bando.enc"); + ASSERT_NE(score, nullptr); + Fraction tick(0, 1); + for (size_t i = 0; i < score->nstaves(); ++i) { + Staff* st = score->staff(i); + int keyVal = int(st->key(tick)); + EXPECT_GE(keyVal, -7) << "Staff " << i << " key should be >= -7"; + EXPECT_LE(keyVal, 7) << "Staff " << i << " key should be <= 7"; + } + delete score; +} + +TEST_F(Tst_Structure, intermediate_time_sig_7_8) +{ + MasterScore* score = readEncoreScore("paloteos_7x8.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + EXPECT_EQ(m0->timesig(), Fraction(4, 4)) << "M0 should be 4/4"; + + Measure* m16 = measureAt(score, 16); + ASSERT_NE(m16, nullptr); + EXPECT_EQ(m16->timesig(), Fraction(7, 8)) << "M16 should be 7/8"; + EXPECT_EQ(m16->ticks(), Fraction(7, 8)) << "M16 duration should be 7/8"; + + Segment* tsSeg = m16->findSegment(SegmentType::TimeSig, m16->tick()); + EXPECT_NE(tsSeg, nullptr) << "M16 must have a TimeSig segment"; + if (tsSeg) { + bool found7_8 = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig()) { + TimeSig* ts = toTimeSig(el); + if (ts->sig() == Fraction(7, 8)) { + found7_8 = true; + } + } + } + EXPECT_TRUE(found7_8) << "TimeSig segment at M16 must contain a 7/8 element"; + } + + Measure* m15 = measureAt(score, 15); + ASSERT_NE(m15, nullptr); + EXPECT_EQ(m15->timesig(), Fraction(4, 4)) << "M15 should still be 4/4"; + + delete score; +} + +// A 6/8 <-> 3/4 change must be detected: comparing time signatures by value treats 6/8 == 3/4, so the +// change is swallowed; it must be compared by numerator/denominator (Fraction::identical). +TEST_F(Tst_Structure, time_sig_change_6_8_to_3_4_and_back) +{ + MasterScore* score = readEncoreScore("timesig_change_6_8_to_3_4.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + EXPECT_TRUE(m0->timesig().identical(Fraction(6, 8))) << "M0 should be 6/8"; + + // Measure 2: 6/8 → 3/4. Must have a visible TimeSig element. + Measure* m2 = measureAt(score, 2); + ASSERT_NE(m2, nullptr); + EXPECT_TRUE(m2->timesig().identical(Fraction(3, 4))) << "M2 should be 3/4"; + { + Segment* tsSeg = m2->findSegment(SegmentType::TimeSig, m2->tick()); + ASSERT_NE(tsSeg, nullptr) << "M2 must have a TimeSig segment (6/8 → 3/4 change)"; + bool found = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig()) { + TimeSig* ts = toTimeSig(el); + if (ts->sig().identical(Fraction(3, 4))) { + found = true; + } + } + } + EXPECT_TRUE(found) << "TimeSig at M2 must carry 3/4, not be merged silently with 6/8"; + } + + // Measure 5: 3/4 → 6/8. Must have a visible TimeSig element. + Measure* m5 = measureAt(score, 5); + ASSERT_NE(m5, nullptr); + EXPECT_TRUE(m5->timesig().identical(Fraction(6, 8))) << "M5 should be 6/8"; + { + Segment* tsSeg = m5->findSegment(SegmentType::TimeSig, m5->tick()); + ASSERT_NE(tsSeg, nullptr) << "M5 must have a TimeSig segment (3/4 → 6/8 change)"; + bool found = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig()) { + TimeSig* ts = toTimeSig(el); + if (ts->sig().identical(Fraction(6, 8))) { + found = true; + } + } + } + EXPECT_TRUE(found) << "TimeSig at M5 must carry 6/8, not be merged silently with 3/4"; + } + + delete score; +} + +// Same value-vs-identical issue for 2/2 <-> 4/4: the change must be detected, not swallowed. +TEST_F(Tst_Structure, time_sig_change_2_2_to_4_4_and_back) +{ + MasterScore* score = readEncoreScore("timesig_change_2_2_to_4_4.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + EXPECT_TRUE(m0->timesig().identical(Fraction(2, 2))) << "M0 should be 2/2"; + + Measure* m2 = measureAt(score, 2); + ASSERT_NE(m2, nullptr); + EXPECT_TRUE(m2->timesig().identical(Fraction(4, 4))) << "M2 should be 4/4"; + { + Segment* tsSeg = m2->findSegment(SegmentType::TimeSig, m2->tick()); + ASSERT_NE(tsSeg, nullptr) << "M2 must have a TimeSig segment (2/2 -> 4/4)"; + bool found = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig() && toTimeSig(el)->sig().identical(Fraction(4, 4))) { + found = true; + } + } + EXPECT_TRUE(found) << "TimeSig at M2 must carry 4/4"; + } + + Measure* m4 = measureAt(score, 4); + ASSERT_NE(m4, nullptr); + EXPECT_TRUE(m4->timesig().identical(Fraction(2, 2))) << "M4 should be 2/2"; + { + Segment* tsSeg = m4->findSegment(SegmentType::TimeSig, m4->tick()); + ASSERT_NE(tsSeg, nullptr) << "M4 must have a TimeSig segment (4/4 -> 2/2)"; + bool found = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig() && toTimeSig(el)->sig().identical(Fraction(2, 2))) { + found = true; + } + } + EXPECT_TRUE(found) << "TimeSig at M4 must carry 2/2"; + } + + delete score; +} + +// =========================================================================== +// WINI block / page margin tests +// =========================================================================== + +// bazo.enc has a WINI block: top=18 left=18 bEdge=824 rEdge=577 on A4. +TEST_F(Tst_Structure, page_margins_wini_standard_a4) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + + const double expectedIn = 18.0 / 72.0; // 0.25" + EXPECT_NEAR(score->style().styleD(Sid::pageOddTopMargin), expectedIn, 0.001); + EXPECT_NEAR(score->style().styleD(Sid::pageEvenTopMargin), expectedIn, 0.001); + EXPECT_NEAR(score->style().styleD(Sid::pageOddLeftMargin), expectedIn, 0.001); + EXPECT_NEAR(score->style().styleD(Sid::pageEvenLeftMargin), expectedIn, 0.001); + + // printableWidth = (rEdge - left) / 72 = (577 - 18) / 72 = 559 / 72 + const double expectedPrintW = 559.0 / 72.0; + EXPECT_NEAR(score->style().styleD(Sid::pagePrintableWidth), expectedPrintW, 0.001); + + delete score; +} + +// File with custom left margin (left=7 pts, ~0.097 in). +// bazo_left_100.enc: top=18 left=7 bEdge=824 rEdge=577. +TEST_F(Tst_Structure, page_margins_wini_custom_left) +{ + MasterScore* score = readEncoreScore("bazo_left_100.enc"); + ASSERT_NE(score, nullptr); + + EXPECT_NEAR(score->style().styleD(Sid::pageOddTopMargin), 18.0 / 72.0, 0.001); + EXPECT_NEAR(score->style().styleD(Sid::pageOddLeftMargin), 7.0 / 72.0, 0.001); + EXPECT_NEAR(score->style().styleD(Sid::pageEvenLeftMargin), 7.0 / 72.0, 0.001); + // printableWidth = (577 - 7) / 72 = 570 / 72 + EXPECT_NEAR(score->style().styleD(Sid::pagePrintableWidth), 570.0 / 72.0, 0.001); + + delete score; +} + +// File with WINI top=0 left=0 (zero margins, full-page printable area). +// ornaments_fingering_grandstaff.enc: top=0 left=0 bEdge=842 rEdge=595. +// Zero margins are clamped to the minimum safe values so staves stay within the page. +TEST_F(Tst_Structure, page_margins_wini_zero_margins) +{ + MasterScore* score = readEncoreScore("ornaments_fingering_grandstaff.enc"); + ASSERT_NE(score, nullptr); + + // WINI has all-zero margins: all four margins should be 0. + EXPECT_NEAR(score->style().styleD(Sid::pageOddTopMargin), 0.0, 0.005); + EXPECT_NEAR(score->style().styleD(Sid::pageEvenTopMargin), 0.0, 0.005); + EXPECT_NEAR(score->style().styleD(Sid::pageOddLeftMargin), 0.0, 0.005); + EXPECT_NEAR(score->style().styleD(Sid::pageEvenLeftMargin), 0.0, 0.005); + EXPECT_NEAR(score->style().styleD(Sid::pageOddBottomMargin), 0.0, 0.005); + // printableWidth equals full page width when both side margins are 0. + const double pageWIn = score->style().styleD(Sid::pageWidth); + EXPECT_NEAR(score->style().styleD(Sid::pagePrintableWidth), pageWIn, 0.01); + + delete score; +} + +// Verify bottom margin is correctly derived from bottomEdge. +// bazo.enc: top=18 left=18 bEdge=824 rEdge=577 on A4 (842 pts high). +// bottomMargin = (842 - 824) / 72 = 18 / 72 = 0.25" +TEST_F(Tst_Structure, page_margins_wini_bottom_margin_derived) +{ + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + + const double expectedIn = 18.0 / 72.0; + EXPECT_NEAR(score->style().styleD(Sid::pageOddBottomMargin), expectedIn, 0.005) + << "bottom margin must be derived from bottomEdge and page height"; + EXPECT_NEAR(score->style().styleD(Sid::pageEvenBottomMargin), expectedIn, 0.005); + + delete score; +} + +// WINI pts format must set the page size explicitly (from its edges), not rely on the MuseScore default, +// so an A4 file still produces an A4 score on a Letter-default machine. +TEST_F(Tst_Structure, page_size_detected_from_wini_pts_format) +{ + MasterScore* score = readEncoreScore("ornaments_fingering_grandstaff.enc"); + ASSERT_NE(score, nullptr); + + const double kA4W = 210.0 / 25.4; // 8.2677" + const double kA4H = 297.0 / 25.4; // 11.6929" + EXPECT_NEAR(score->style().styleD(Sid::pageWidth), kA4W, 0.01) + << "pts-format WINI with A4 boundary values must set A4 page width"; + EXPECT_NEAR(score->style().styleD(Sid::pageHeight), kA4H, 0.01) + << "pts-format WINI with A4 boundary values must set A4 page height"; + + delete score; +} + +// Page size from the PREC (DEVMODE) block. dmPaperSize is a direct enum, so it is the primary +// page-size source for all formats (v0xA6/v0xC2 have no WINI, and many v0xC4 files lack it). +// Unicode DEVMODE variant (32-WCHAR device name): dmPaperSize=1 (Letter). +TEST_F(Tst_Structure, page_size_from_prec_letter_unicode) +{ + MasterScore* score = readEncoreScore("structure_prec_page_letter.enc"); + ASSERT_NE(score, nullptr); + EXPECT_NEAR(score->style().styleD(Sid::pageWidth), 8.5, 0.02) + << "PREC dmPaperSize=1 must set Letter width"; + EXPECT_NEAR(score->style().styleD(Sid::pageHeight), 11.0, 0.02) + << "PREC dmPaperSize=1 must set Letter height"; + delete score; +} + +// ANSI DEVMODE variant (32-byte device name): dmPaperSize=8 (A3 = 297x420mm). +TEST_F(Tst_Structure, page_size_from_prec_ansi_a3) +{ + MasterScore* score = readEncoreScore("structure_prec_page_a3.enc"); + ASSERT_NE(score, nullptr); + EXPECT_NEAR(score->style().styleD(Sid::pageWidth), 297.0 / 25.4, 0.03) + << "ANSI PREC dmPaperSize=8 must set A3 width"; + EXPECT_NEAR(score->style().styleD(Sid::pageHeight), 420.0 / 25.4, 0.03) + << "ANSI PREC dmPaperSize=8 must set A3 height"; + delete score; +} + +// Large WINI margins must survive import (they were previously clamped to a tiny 0.6" max). The px-to-inch +// conversion uses an estimated dpi, so values are approximate; the invariant is that they are not clamped. +TEST_F(Tst_Structure, page_margins_wini_large_not_clamped) +{ + MasterScore* score = readEncoreScore("structure_wini_large_margins_a3.enc"); + ASSERT_NE(score, nullptr); + const double topIn = score->style().styleD(Sid::pageOddTopMargin); + const double leftIn = score->style().styleD(Sid::pageOddLeftMargin); + EXPECT_GT(topIn, 1.5) << "large top margin must not be clamped to a tiny maximum"; + EXPECT_GT(leftIn, 1.5) << "large left margin must not be clamped to a tiny maximum"; + EXPECT_NEAR(topIn, 2.14, 0.2) << "top margin ~2.1 inches (176 px at ~82 dpi)"; + EXPECT_NEAR(leftIn, 2.54, 0.2) << "left margin ~2.5 inches (209 px at ~82 dpi)"; + delete score; +} + +// Some WINI files store page coordinates in monitor pixels, not points, so the right edge exceeds the +// point-based page width. That must be detected (rightEdge > pageWidth) and symmetric margins computed, +// rather than clamping the right and bottom margins to near zero. +TEST_F(Tst_Structure, page_margins_wini_screen_pixel_a4_detected) +{ + // structure_wini_screen_pixel_a4.enc: bazo.enc with WINI patched to + // screen-pixel coordinates: top=28, left=28, bEdge=962, rEdge=672. + // Expected: A4 page (8.2677" x 11.6929"), all margins ~0.331" (8.4mm). + MasterScore* score = readEncoreScore("structure_wini_screen_pixel_a4.enc"); + ASSERT_NE(score, nullptr); + + // Page dimensions must be detected as A4. + const double kA4W = 210.0 / 25.4; // 8.2677" + const double kA4H = 297.0 / 25.4; // 11.6929" + EXPECT_NEAR(score->style().styleD(Sid::pageWidth), kA4W, 0.01) + << "Screen-pixel WINI: page must be detected as A4 width"; + EXPECT_NEAR(score->style().styleD(Sid::pageHeight), kA4H, 0.01) + << "Screen-pixel WINI: page must be detected as A4 height"; + + // Margins must be symmetric at ~0.331" = 28 / 84.67 DPI. + // (Old code: L=T=0.389", R=0.030", B=0.10", all wrong.) + const double kExpectedM = 28.0 / (700.0 / kA4W); // ≈ 0.331" + EXPECT_NEAR(score->style().styleD(Sid::pageOddLeftMargin), kExpectedM, 0.005) + << "Screen-pixel WINI: left margin must be ~0.33\""; + EXPECT_NEAR(score->style().styleD(Sid::pageEvenLeftMargin), kExpectedM, 0.005); + EXPECT_NEAR(score->style().styleD(Sid::pageOddTopMargin), kExpectedM, 0.005) + << "Screen-pixel WINI: top margin must be ~0.33\""; + // Right margin: symmetric (pageWidth - left - printableWidth ≈ kExpectedM). + const double printW = score->style().styleD(Sid::pagePrintableWidth); + const double rightM = kA4W - kExpectedM - printW; + EXPECT_NEAR(rightM, kExpectedM, 0.01) + << "Screen-pixel WINI: right margin must be ~0.33\""; + + delete score; +} + +// =========================================================================== +// WINI / page margin: no-WINI file must leave MuseScore default margins intact. +// =========================================================================== + +// File with no WINI block must leave MuseScore default margins intact. +// text_tempo_orn_compound_68.enc has no WINI block. +TEST_F(Tst_Structure, page_margins_no_wini_uses_defaults) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_compound_68.enc"); + ASSERT_NE(score, nullptr); + + const double defaultLeftIn = 15.0 / INCH; + EXPECT_NEAR(score->style().styleD(Sid::pageOddLeftMargin), defaultLeftIn, 0.001) + << "no-WINI file must keep default left margin"; + EXPECT_NEAR(score->style().styleD(Sid::pageEvenLeftMargin), defaultLeftIn, 0.001); + EXPECT_NEAR(score->style().styleD(Sid::pageOddTopMargin), defaultLeftIn, 0.001) + << "no-WINI file must keep default top margin"; + + delete score; +} + +// A no-WINI file with a landscape PREC page must recompute the printable width so the right margin equals +// the left, rather than keeping the portrait default and leaving a lopsided right margin. +TEST_F(Tst_Structure, page_margins_no_wini_landscape_right_matches_left) +{ + MasterScore* score = readEncoreScore("structure_prec_landscape_no_wini.enc"); + ASSERT_NE(score, nullptr); + + const double pageW = score->style().styleD(Sid::pageWidth); + const double pageH = score->style().styleD(Sid::pageHeight); + EXPECT_GT(pageW, pageH) << "PREC orientation=2 must yield a landscape page"; + + const double leftM = score->style().styleD(Sid::pageOddLeftMargin); + const double printW = score->style().styleD(Sid::pagePrintableWidth); + const double rightM = pageW - leftM - printW; + EXPECT_NEAR(rightM, leftM, 0.01) + << "landscape no-WINI: right margin must match the left, not leave the extra page width"; + delete score; +} + +// =========================================================================== +// A KEYCHANGE to C major (tipo=0) must be emitted; the previous guard silently dropped it. +TEST_F(Tst_Structure, keychange_to_c_major_emitted) +{ + MasterScore* score = readEncoreScore("structure_keychange_to_c.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int keySigCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::KeySig); s; s = s->next(SegmentType::KeySig)) { + if (s->element(0)) { + ++keySigCount; + } + } + } + // Initial key sig (m0 G major) + tipo=0 modulation sig (m1); both must be present. + EXPECT_GE(keySigCount, 2); + delete score; +} + +// =========================================================================== +// v0xC2 stores the MIDI pitch in the tuplet field, not semiTonePitch; it must be swapped back on import. +// See ENCORE_FORMAT.md §v0xC2 note (size 22 or 24). +TEST_F(Tst_Structure, old_format_v0c2_correct_pitches) +{ + MasterScore* score = readEncoreScore("structure_v0c2_pitches.enc"); + ASSERT_NE(score, nullptr); + + std::vector pitches; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + pitches.push_back(n->pitch()); + } + } + } + } + } + ASSERT_EQ(pitches.size(), 4u) << "Should have 4 notes"; + EXPECT_EQ(pitches[0], 60) << "First note should be C4 (60)"; + EXPECT_EQ(pitches[1], 64) << "Second note should be E4 (64)"; + EXPECT_EQ(pitches[2], 67) << "Third note should be G4 (67)"; + EXPECT_EQ(pitches[3], 72) << "Fourth note should be C5 (72)"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "v0xC2 pitch-fixed score should pass sanityCheck: " << ret.text(); + delete score; +} + +TEST_F(Tst_Structure, old_format_v0c2_triplets_detected) +{ + // v0xC2: 6 eighth notes at 80-tick spacing (2/3 of an eighth) → detectImpliedTuplet returns 3:2. + MasterScore* score = readEncoreScore("structure_v0c2_triplets.enc"); + ASSERT_NE(score, nullptr); + + bool foundTriplet = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (e->isTuplet() && toTuplet(e)->ratio() == Fraction(3, 2)) { + foundTriplet = true; + break; + } + } + if (foundTriplet) { + break; + } + } + EXPECT_TRUE(foundTriplet) << "v0xC2 implied triplets should be detected"; + delete score; +} + +TEST_F(Tst_Structure, old_format_v0c2_triplet_pitch_in_semitone) +{ + // When a v0xC2 note already has its pitch in semiTonePitch, the tuplet slot holds a real ratio (0x32), + // so the pitch-swap must not fire, or a triplet's notes all import as MIDI 50 with the ratio lost. + MasterScore* score = readEncoreScore("structure_v0c2_triplet_pitch_in_semitone.enc"); + ASSERT_NE(score, nullptr); + + std::vector pitches; + bool foundTriplet = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : toMeasure(mb)->el()) { + if (e->isTuplet() && toTuplet(e)->ratio() == Fraction(3, 2)) { + foundTriplet = true; + } + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + pitches.push_back(n->pitch()); + } + } + } + } + } + ASSERT_EQ(pitches.size(), 4u) << "Should have 4 notes"; + EXPECT_EQ(pitches[0], 60) << "triplet note 1 must be C4 (60), not the tuplet byte 50"; + EXPECT_EQ(pitches[1], 64) << "triplet note 2 must be E4 (64)"; + EXPECT_EQ(pitches[2], 67) << "triplet note 3 must be G4 (67)"; + EXPECT_EQ(pitches[3], 72) << "quarter note must be C5 (72)"; + EXPECT_TRUE(foundTriplet) << "explicit 3:2 tuplet must survive the pitch fix"; + delete score; +} + +TEST_F(Tst_Structure, old_format_v0c2_spurious_semitone_flag_uses_pitch_at_13) +{ + // A small stray flag (1 or 3) in the semiTonePitch slot is not a pitch: the discriminator must treat + // +15 as a pitch only when it holds a plausible MIDI value, else notes import as MIDI 1 and collapse. + MasterScore* score = readEncoreScore("structure_v0c2_spurious_semitone_flag.enc"); + ASSERT_NE(score, nullptr); + + std::vector pitches; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->elist()) { + if (e && e->isChord()) { + for (Note* n : toChord(e)->notes()) { + pitches.push_back(n->pitch()); + } + } + } + } + } + ASSERT_EQ(pitches.size(), 4u) << "Chord must keep all three notes, not collapse to one"; + EXPECT_EQ(pitches[0], 60) << "chord note 1 must be C4 (60), not the +15 flag"; + EXPECT_EQ(pitches[1], 64) << "chord note 2 must be E4 (64)"; + EXPECT_EQ(pitches[2], 67) << "chord note 3 must be G4 (67)"; + EXPECT_EQ(pitches[3], 72) << "C5 (72) must read from +13 even though +15 == 3"; + delete score; +} + +// =========================================================================== +// A pickup measure imports shortened (actual ticks < nominal) while still displaying the nominal time +// signature; the following measure starts right after it. +TEST_F(Tst_Structure, pickup_measure_shortened) +{ + MasterScore* score = readEncoreScore("structure_pickup_measure.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + Measure* m1 = measureAt(score, 1); + ASSERT_NE(m0, nullptr); + ASSERT_NE(m1, nullptr); + + EXPECT_EQ(m0->timesig(), Fraction(4, 4)) << "Pickup m0 must display the nominal 4/4 time signature"; + EXPECT_EQ(m0->ticks(), Fraction(1, 4)) << "Pickup m0 must be shortened to the pickup duration"; + EXPECT_EQ(m1->tick(), Fraction(1, 4)) << "m1 must start immediately after the shortened m0"; + + // The pickup note must be at offset 0 within the short measure. + Fraction noteOffset { -1, 1 }; + for (Segment* s = m0->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (el && el->isChord()) { + noteOffset = s->tick() - m0->tick(); + break; + } + } + EXPECT_EQ(noteOffset, Fraction(0, 1)) << "Pickup note must be at offset 0 within the shortened m0"; + + delete score; +} + +// Case B (pure cumTick): same timeSig=4/4, 8 32nd notes from tick=0. +// No gap-snap (notes at exact cumTick positions). cumTick = 8/32 = 1/4. +// Measure 0 must be shortened to 1/4 based purely on cumTick, no barline needed. +TEST_F(Tst_Structure, pickup_caseb_reduces_to_max_content) +{ + MasterScore* score = readEncoreScore("structure_pickup_caseb_reduces.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + Measure* m1 = measureAt(score, 1); + ASSERT_NE(m0, nullptr); + ASSERT_NE(m1, nullptr); + + EXPECT_EQ(m0->timesig(), Fraction(4, 4)) << "Pickup m0 must display nominal 4/4"; + EXPECT_EQ(m0->ticks(), Fraction(1, 4)) << "Pickup m0 must be shortened to cumTick=8/32=1/4"; + EXPECT_EQ(m1->tick(), Fraction(1, 4)) << "m1 must start immediately after the shortened m0"; + + delete score; +} + +// Case B: whole note (fv=1) at tick=0 fills the measure completely. +// cumTick = 1 = measure->ticks() -> NOT less than -> no shortening. +TEST_F(Tst_Structure, pickup_caseb_no_reduce_when_full_content) +{ + MasterScore* score = readEncoreScore("structure_pickup_caseb_no_reduce_full.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + + EXPECT_EQ(m0->ticks(), Fraction(4, 4)) << "Measure 0 must NOT be shortened: whole note cumTick=1=measure->ticks()"; + + delete score; +} + +// A Case A pickup (short ts[0], full ts[1]) whose content is shorter than the pickup must not be +// double-shortened by the Case B path; measure 0 stays at the pickup length. +TEST_F(Tst_Structure, pickup_casea_guard_prevents_double_shortening) +{ + MasterScore* score = readEncoreScore("structure_pickup_casea_sparse.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + Measure* m1 = measureAt(score, 1); + ASSERT_NE(m0, nullptr); + ASSERT_NE(m1, nullptr); + + EXPECT_EQ(m0->timesig(), Fraction(4, 4)) << "Case A pickup must display nominal 4/4"; + EXPECT_EQ(m0->ticks(), Fraction(2, 4)) << "Case A pickup must stay at its explicit 2/4, not be further shortened by Case B"; + EXPECT_EQ(m1->tick(), Fraction(2, 4)) << "m1 must start at 2/4, not be shifted by a spurious Case B delta"; + + delete score; +} + +// =========================================================================== +// A v0xC2 4/4 whose time-sig glyph byte marks common time must import as TimeSigType::FOUR_FOUR (the "C" +// symbol), not a numeric 4/4. +TEST_F(Tst_Structure, timesig_v0c2_common_time_glyph_preserved) +{ + MasterScore* score = readEncoreScore("notes_v0c2_common_time_glyph.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + + Segment* tsSeg = m0->findSegment(SegmentType::TimeSig, m0->tick()); + ASSERT_NE(tsSeg, nullptr) << "Measure 0 must have a TimeSig segment"; + + bool foundFourFour = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig()) { + TimeSig* ts = toTimeSig(el); + if (ts->timeSigType() == TimeSigType::FOUR_FOUR) { + foundFourFour = true; + } + } + } + EXPECT_TRUE(foundFourFour) << "TimeSig glyph 0x63 must produce TimeSigType::FOUR_FOUR (common time C), not NORMAL"; + + delete score; +} + +// Same as above but for glyph=0x43 ('C', uppercase ASCII), the variant produced by +// older Encore versions (e.g. Encore 3.x/4.x files vs. 5.x files with 0x63). +TEST_F(Tst_Structure, timesig_v0c2_common_time_glyph_uppercase_preserved) +{ + MasterScore* score = readEncoreScore("notes_v0c2_common_time_glyph_uc.enc"); + ASSERT_NE(score, nullptr); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + + Segment* tsSeg = m0->findSegment(SegmentType::TimeSig, m0->tick()); + ASSERT_NE(tsSeg, nullptr) << "Measure 0 must have a TimeSig segment"; + + bool foundFourFour = false; + for (EngravingItem* el : tsSeg->elist()) { + if (el && el->isTimeSig()) { + TimeSig* ts = toTimeSig(el); + if (ts->timeSigType() == TimeSigType::FOUR_FOUR) { + foundFourFour = true; + } + } + } + EXPECT_TRUE(foundFourFour) << "TimeSig glyph 0x43 must produce TimeSigType::FOUR_FOUR (common time C), not NORMAL"; + + delete score; +} + +// All ten Encore navigation options (Segno/Coda/ToCoda/Fine + 6 DC/DS variants) survive import. +TEST_F(Tst_Structure, all_encore_navigation_options) +{ + MasterScore* score = readEncoreScore("structure_jump_marks_all.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int segnoMarkers = 0; + int codaMarkers = 0; + int toCodaMarkers = 0; + int fineMarkers = 0; + std::set jumpTypes; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (EngravingItem* e : mb->el()) { + if (e && e->isMarker()) { + MarkerType mt = toMarker(e)->markerType(); + if (mt == MarkerType::SEGNO) { + ++segnoMarkers; + } else if (mt == MarkerType::CODA) { + ++codaMarkers; + } else if (mt == MarkerType::TOCODA) { + ++toCodaMarkers; + } else if (mt == MarkerType::FINE) { + ++fineMarkers; + } + } else if (e && e->isJump()) { + jumpTypes.insert(toJump(e)->jumpType()); + } + } + } + // Segno comes from ORN 0xA2 AND coda byte 0x88; both add a Marker. + EXPECT_GE(segnoMarkers, 1) << "ORN 0xA2 must produce a Segno Marker"; + // Coda from ORN 0xA6 + byte 0x89; byte 0x85 produces TOCODA instead. + EXPECT_GE(codaMarkers, 1) << "ORN 0xA6 must produce a Coda Marker"; + // "To Coda" comes from ORN 0xA5 AND coda byte 0x85 (CODA1). + EXPECT_GE(toCodaMarkers, 1) << "ORN 0xA5 must produce a TOCODA Marker"; + // Fine comes from coda byte 0x86. + EXPECT_EQ(fineMarkers, 1) << "coda byte 0x86 must produce a FINE Marker"; + // Every Jump variant must appear at least once. + const std::set expectedJumps = { + JumpType::DC, JumpType::DS, + JumpType::DC_AL_FINE, JumpType::DS_AL_FINE, + JumpType::DC_AL_CODA, JumpType::DS_AL_CODA, + }; + for (JumpType j : expectedJumps) { + EXPECT_TRUE(jumpTypes.count(j) > 0) + << "missing Jump variant for the Encore-UI option"; + } + delete score; +} + +// Jump marks come from the MEAS coda byte and To Coda from an ORN tipo; both must import as markers/jumps. +TEST_F(Tst_Structure, jump_marks_dc_ds_tocoda) +{ + MasterScore* score = readEncoreScore("structure_jump_marks.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector > seen; // measure number (1-based), text + int measIdx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + ++measIdx; + for (EngravingItem* e : mb->el()) { + if (e && e->isMarker()) { + seen.emplace_back(measIdx, toMarker(e)->plainText()); + } else if (e && e->isJump()) { + seen.emplace_back(measIdx, toJump(e)->plainText()); + } + } + } + // Marker (TOCODA) lands on m1; Jumps land on m2 and m3. + ASSERT_EQ(seen.size(), 3u); + EXPECT_EQ(seen[0].first, 1); + EXPECT_TRUE(seen[0].second.contains(u"Coda")) + << "expected To Coda Marker on m1"; + EXPECT_EQ(seen[1].first, 2); + EXPECT_TRUE(seen[1].second.contains(u"D.S.")) + << "expected D.S. al Coda Jump on m2"; + EXPECT_EQ(seen[2].first, 3); + EXPECT_TRUE(seen[2].second.contains(u"D.C.")) + << "expected D.C. Jump on m3"; + delete score; +} + +// Section markers (Segno/Coda) from ORN tipos and a dotted end barline must import. +TEST_F(Tst_Structure, section_markers_and_dotted_barline) +{ + MasterScore* score = readEncoreScore("structure_section_markers.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seenMarkers; + BarLineType m3Bar = BarLineType::NORMAL; + int measIdx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + ++measIdx; + for (EngravingItem* e : mb->el()) { + if (e && e->isMarker()) { + seenMarkers.push_back(toMarker(e)->markerType()); + } + } + if (measIdx == 3) { + Measure* m3 = toMeasure(mb); + Segment* seg = m3->findSegment(SegmentType::EndBarLine, m3->endTick()); + if (seg) { + if (EngravingItem* el = seg->element(0)) { + if (el->isBarLine()) { + m3Bar = toBarLine(el)->barLineType(); + } + } + } + } + } + const std::vector expectedMarkers = { + MarkerType::SEGNO, MarkerType::CODA, + }; + EXPECT_EQ(seenMarkers, expectedMarkers); + EXPECT_EQ(m3Bar, BarLineType::DOTTED) + << "m3 end barline must be DOTTED (barTypeEnd=0x08)"; + delete score; +} + +// Regression: after a Case B pickup shift, a hairpin's search boundary must use the post-shift tick, or a +// stale (too-large) boundary resolves its endpoint in the wrong measure. +TEST_F(Tst_Structure, pickup_caseb_hairpin_maxendtick_not_stale) +{ + MasterScore* score = readEncoreScore("structure_pickup_caseb_hairpin.enc"); + ASSERT_NE(score, nullptr); + + Measure* m1 = measureAt(score, 1); + ASSERT_NE(m1, nullptr); + + int hairpinCount = 0; + bool hairpinEndsInM1 = false; + for (const auto& kv : score->spanner()) { + Spanner* sp = kv.second; + if (sp && sp->isHairpin()) { + ++hairpinCount; + const Fraction tick2 = sp->tick2(); + if (tick2 >= m1->tick() && tick2 <= m1->endTick()) { + hairpinEndsInM1 = true; + } + } + } + + EXPECT_GE(hairpinCount, 1) << "At least one hairpin must be imported"; + EXPECT_TRUE(hairpinEndsInM1) << "Hairpin must end within measure 1 (stale maxEndTick would push it past)"; + + delete score; +} + +// LINE block data becomes SystemLocks so each Encore system keeps its measures together regardless of spatium. +TEST_F(Tst_Structure, system_breaks_from_line_data) +{ + MasterScore* score = readEncoreScore("structure_system_break.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // Measure 2 is the last measure of the first Encore system → end of a SystemLock. + Measure* m2 = measureAt(score, 2); + ASSERT_NE(m2, nullptr); + EXPECT_TRUE(m2->isEndOfSystemLock()) + << "measure 2 (end of system 0) must be the end of a SystemLock"; + + // Measure 0 is the start of the first system → start of a SystemLock. + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + EXPECT_TRUE(m0->isStartOfSystemLock()) + << "measure 0 (start of system 0) must be the start of a SystemLock"; + + delete score; +} + +// =========================================================================== +// SCO5 (big-endian macOS Encore 5): page size + orientation come from the PREC +// macOS plist (Letter portrait here); document margins are not stored anywhere +// importable, so the importer applies a clean, symmetric 0.25" margin (better UX +// than edge-to-edge 0 or the A4-tuned default, which is asymmetric on Letter). +// =========================================================================== +TEST_F(Tst_Structure, sco5_macos_page_letter_default_margins) +{ + MasterScore* score = readEncoreScore("structure_sco5_macos.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + const MStyle& st = score->style(); + EXPECT_NEAR(st.styleD(Sid::pageWidth), 8.5, 1e-3) << "Letter width from PREC plist"; + EXPECT_NEAR(st.styleD(Sid::pageHeight), 11.0, 1e-3) << "Letter height from PREC plist"; + EXPECT_NEAR(st.styleD(Sid::pageOddLeftMargin), 0.25, 1e-6) << "SCO5 uses a uniform 0.25\" margin"; + EXPECT_NEAR(st.styleD(Sid::pageOddTopMargin), 0.25, 1e-6); + EXPECT_NEAR(st.styleD(Sid::pageOddBottomMargin), 0.25, 1e-6); + EXPECT_NEAR(st.styleD(Sid::pagePrintableWidth), 8.0, 1e-3) << "printable width = page width - 2 x 0.25\""; + + delete score; +} + +// When per-line measureCount reads 0 (e.g. SCO5), each system's span must be derived from the LINE start +// deltas so system locks still apply. +TEST_F(Tst_Structure, system_breaks_from_line_start_deltas_when_count_zero) +{ + MasterScore* score = readEncoreScore("structure_system_break_mcount_zero.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + Measure* m0 = measureAt(score, 0); + ASSERT_NE(m0, nullptr); + EXPECT_TRUE(m0->isStartOfSystemLock()) + << "measure 0 must start a SystemLock derived from the line start delta"; + Measure* m2 = measureAt(score, 2); + ASSERT_NE(m2, nullptr); + EXPECT_TRUE(m2->isEndOfSystemLock()) + << "measure 2 (start[1]-start[0]=3 measures later) must end the first SystemLock"; + + delete score; +} + +// Page-break detection must use the same start-delta fallback as system locks when measureCount reads 0, +// or every page break is dropped. +TEST_F(Tst_Structure, page_break_from_line_start_deltas_when_count_zero) +{ + MasterScore* score = readEncoreScore("structure_page_break_mcount_zero.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + bool foundPageBreak = false; + for (Measure* m = score->firstMeasure(); m && !foundPageBreak; m = m->nextMeasure()) { + for (EngravingItem* e : m->el()) { + if (e && e->isLayoutBreak() && toLayoutBreak(e)->isPageBreak()) { + foundPageBreak = true; + break; + } + } + } + EXPECT_TRUE(foundPageBreak) + << "page break must be recovered from line start deltas when measureCount is 0"; + + delete score; +} + +// SystemLocks lock each Encore system to exactly its LINE measureCount. +TEST_F(Tst_Structure, fit_spatium_first_system_measure_count) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_compound_68.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + int firstSystemMeasureCount = 0; + for (const System* sys : score->systems()) { + int mc = 0; + for (const MeasureBase* mb : sys->measures()) { + if (mb->isMeasure()) { + ++mc; + } + } + if (mc > 0) { + firstSystemMeasureCount = mc; + break; + } + } + EXPECT_GE(firstSystemMeasureCount, 3) + << "first system must fit at least enc.lines[0].measureCount (3) measures"; + + delete score; +} + +TEST_F(Tst_Structure, fit_spatium_multiple_systems_measure_count) +{ + // All 8 lines have measureCount=3; verify the first 4 systems each have exactly 3 measures. + MasterScore* score = readEncoreScore("text_tempo_orn_compound_68.enc"); + ASSERT_NE(score, nullptr); + + std::vector sysCounts; + for (const System* sys : score->systems()) { + int mc = 0; + for (const MeasureBase* mb : sys->measures()) { + if (mb->isMeasure()) { + ++mc; + } + } + if (mc > 0) { + sysCounts.push_back(mc); + } + } + + // The fixture has 8 lines; we require at least the first 4 to be present. + ASSERT_GE(sysCounts.size(), 4u) << "fixture must produce at least 4 music systems"; + + for (int j = 0; j < 4; ++j) { + EXPECT_GE(sysCounts[j], 3) + << "system " << j << " must fit at least 3 measures (enc.lines[" << j << "].measureCount)"; + } + + delete score; +} + +// A mid-measure CLEF anchors to the note that physically follows it in the stream, not to its own stored +// tick, so it lands before the next note rather than mid-beat. +TEST_F(Tst_Structure, mid_measure_clef_change_imported) +{ + MasterScore* score = readEncoreScore("structure_clef_change_mid_measure.enc"); + ASSERT_NE(score, nullptr); + + bool foundC4 = false; + bool foundEarly = false; + for (Measure* m = score->firstMeasure(); m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::Clef); s; s = s->next(SegmentType::Clef)) { + if (s->tick() <= m->tick()) { + continue; // skip header clef at measure start + } + EngravingItem* el = s->element(0); + if (!el || !el->isClef() || toClef(el)->clefType() != ClefType::C4) { + continue; + } + if (s->tick() == m->tick() + Fraction(1, 4)) { + foundC4 = true; + } else if (s->tick() == m->tick() + Fraction(3, 16)) { + foundEarly = true; + } + } + } + EXPECT_TRUE(foundC4) + << "mid-measure CLEF(C4L) must anchor to the following note at beat-2 offset (1/4)"; + EXPECT_FALSE(foundEarly) + << "CLEF must not be placed at its own stored tick (3/16); it follows the next note"; + delete score; +} + +// A trailing CLEF (last element of a measure, no note after it) is cautionary: it takes effect on the next +// measure's downbeat, not before the current measure's final note. +TEST_F(Tst_Structure, trailing_clef_change_moves_to_next_measure) +{ + MasterScore* score = readEncoreScore("structure_clef_trailing_cautionary.enc"); + ASSERT_NE(score, nullptr); + + Measure* m1 = score->firstMeasure(); + ASSERT_NE(m1, nullptr); + Measure* m2 = m1->nextMeasure(); + ASSERT_NE(m2, nullptr); + const Fraction barline = m2->tick(); // = end of measure 1 = downbeat of measure 2 + + // A cautionary clef on the m1/m2 barline is serialized as a trailing Clef segment of m1, + // so check by absolute tick rather than by measure ownership. + bool clefAtBarline = false; + bool clefMidM1 = false; + for (Measure* m = m1; m; m = m->nextMeasure()) { + for (Segment* s = m->first(SegmentType::Clef); s; s = s->next(SegmentType::Clef)) { + EngravingItem* el = s->element(0); + if (!el || !el->isClef() || toClef(el)->clefType() != ClefType::F) { + continue; + } + if (s->tick() == barline) { + clefAtBarline = true; + } else if (s->tick() > m1->tick() && s->tick() < barline) { + clefMidM1 = true; + } + } + } + EXPECT_TRUE(clefAtBarline) + << "trailing CLEF must take effect on the downbeat of the next measure"; + EXPECT_FALSE(clefMidM1) + << "trailing CLEF must not land inside measure 1"; + delete score; +} + +// Malformed-input robustness: a .enc file is untrusted binary and must never crash, hang, or read out of +// bounds. These tests derive corrupt variants from a good fixture and assert the importer returns a bounded +// result (a valid score or a clean null). On a debug build, running to completion is itself the assertion. + +static QByteArray readFixtureBytes(const QString& name) +{ + QFile f(ENC_DIR + name); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); +} + +// A grand-staff WEDGESTART on the bass sub-staff, paired with a note whose voice nibble is far +// above VOICES, made the wedge resolver derive an out-of-range hairpin track. Before the validTrack +// guard the Hairpin was created at a track beyond ntracks() and crashed at layout. The import must +// drop that hairpin, produce no out-of-range spanner, and lay out cleanly. +TEST_F(Tst_Structure, grandstaff_wedge_out_of_range_voice_does_not_crash) +{ + MasterScore* score = readEncoreScore("structure_grandstaff_wedge_out_of_range_voice.enc"); + ASSERT_NE(score, nullptr); + + const size_t ntracks = score->ntracks(); + for (const auto& pair : score->spanner()) { + const Spanner* sp = pair.second; + ASSERT_NE(sp, nullptr); + EXPECT_LT(sp->track(), ntracks) << "spanner track out of range"; + EXPECT_LT(sp->track2(), ntracks) << "spanner track2 out of range"; + } + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "out-of-range-wedge score should pass sanityCheck: " << ret.text(); +} + +TEST_F(Tst_Structure, malformed_truncated_input_does_not_crash) +{ + const QByteArray good = readFixtureBytes("bando.enc"); + ASSERT_GT(good.size(), 0); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + setRootDir(tmp.path()); + + // Every prefix of a valid file, cut at many points, must import without crashing/hanging. + for (int frac = 1; frac < 10; ++frac) { + const QByteArray cut = good.left(good.size() * frac / 10); + const QString name = QString("trunc_%1.enc").arg(frac); + QFile f(tmp.path() + "/" + name); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(cut); + f.close(); + // May be null (clean reject) or a bounded score; the point is it returns at all. + delete readEncoreScore(name); + } + + setRootDir(ENC_DIR); +} + +TEST_F(Tst_Structure, malformed_oversized_block_size_does_not_crash) +{ + QByteArray data = readFixtureBytes("bando.enc"); + ASSERT_GT(data.size(), 0); + + // Overwrite the 4-byte little-endian size that follows the first MEAS magic with a value + // far larger than the file. The parser must clamp it to the device instead of seeking past + // EOF or wrapping the size negative when skipping. + const int idx = data.indexOf("MEAS"); + ASSERT_GE(idx, 0); + ASSERT_LE(idx + 8, data.size()); + for (int k = 0; k < 4; ++k) { + data[idx + 4 + k] = static_cast(0xFF); + } + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + setRootDir(tmp.path()); + QFile f(tmp.path() + "/oversized.enc"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(data); + f.close(); + + delete readEncoreScore("oversized.enc"); + setRootDir(ENC_DIR); +} + +TEST_F(Tst_Structure, malformed_truncated_at_byte_boundaries_does_not_crash) +{ + // Finer-grained companion to malformed_truncated_input_does_not_crash: truncate a known-good + // file at every 64-byte boundary so a cut landing in the middle of any block header, size + // field, or element stream exercises the parser's bounds guards. Every prefix must import + // (as a null or a bounded score) without crashing, aborting, or hanging. + const QByteArray good = readFixtureBytes("bazo.enc"); + ASSERT_GT(good.size(), 0); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + setRootDir(tmp.path()); + + for (int len = 0; len <= good.size(); len += 64) { + const QByteArray cut = good.left(len); + const QString name = QString("trunc_at_%1.enc").arg(len); + QFile f(tmp.path() + "/" + name); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(cut); + f.close(); + delete readEncoreScore(name); + } + + setRootDir(ENC_DIR); +} + +// Hostile but structurally complete fixtures: a zero tuplet nibble (zero-term ratio), an out-of-range +// staff index, an out-of-range voice nibble, and a zero-size element (advance-by-one guard). Each must +// import without crashing and produce a score that passes sanityCheck (garbage is dropped, not emitted). +TEST_F(Tst_Structure, hostile_fixtures_import_and_pass_sanity_check) +{ + static const char* kHostileFixtures[] = { + "structure_grandstaff_wedge_out_of_range_voice.enc", + "structure_hostile_zero_tuplet_nibble.enc", + "structure_hostile_out_of_range_staff.enc", + "structure_hostile_out_of_range_voice.enc", + "structure_hostile_zero_size_element.enc", + }; + for (const char* name : kHostileFixtures) { + MasterScore* score = readEncoreScore(name); + ASSERT_NE(score, nullptr) << "hostile fixture should import to a bounded score: " << name; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << name << " should pass sanityCheck: " << ret.text(); + delete score; + } +} + +TEST_F(Tst_Structure, malformed_truncated_at_block_boundaries_does_not_crash) +{ + // Cut a known-good file a few bytes past each 4-char block magic (SCOW/TK00/PAGE/LINE/MEAS/PREC/ + // TITL/TEXT/WINI/...), so a truncation lands inside every block type's header or size field. Each + // prefix must import (null or bounded score) without crashing. + const QByteArray good = readFixtureBytes("bando.enc"); + ASSERT_GT(good.size(), 0); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + setRootDir(tmp.path()); + + int caseIdx = 0; + for (int i = 0; i + 4 <= good.size(); ++i) { + const char c0 = good[i], c1 = good[i + 1], c2 = good[i + 2], c3 = good[i + 3]; + const bool looksLikeMagic = std::isupper(static_cast(c0)) + && (std::isupper(static_cast(c1)) || std::isdigit(static_cast(c1))) + && (std::isupper(static_cast(c2)) || std::isdigit(static_cast(c2))) + && (std::isupper(static_cast(c3)) || std::isdigit(static_cast(c3))); + if (!looksLikeMagic) { + continue; + } + // Truncate a few bytes into the block: inside the size field (magic + 0..8 bytes). + for (int extra : { 2, 6 }) { + const int len = std::min(i + 4 + extra, static_cast(good.size())); + const QString name = QString("trunc_block_%1.enc").arg(caseIdx++); + QFile f(tmp.path() + "/" + name); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(good.left(len)); + f.close(); + delete readEncoreScore(name); + } + } + + setRootDir(ENC_DIR); +} + +TEST_F(Tst_Structure, malformed_v0xa6_truncation_does_not_crash) +{ + // v0xA6 (Encore 2.x) uses fixed-offset absolute seeks; a prefix cut must exercise those bounds + // checks. Every truncation must import (null or bounded score) without crashing. + const QByteArray good = readFixtureBytes("structure_v0xa6_basic.enc"); + ASSERT_GT(good.size(), 0); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + setRootDir(tmp.path()); + + // Dense in the header/absolute-seek region (first 512 bytes), coarse thereafter to keep runtime low. + for (int len = 0; len <= good.size(); len += (len < 512 ? 8 : 256)) { + const QString name = QString("trunc_a6_%1.enc").arg(len); + QFile f(tmp.path() + "/" + name); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(good.left(len)); + f.close(); + delete readEncoreScore(name); + } + + setRootDir(ENC_DIR); +} + +// A live-recorded chord's notes carry a small per-note tick "strum"; since they share one notated column +// (xoffset), same-column runs must collapse onto the anchor tick into a single chord, not split into extras. +TEST_F(Tst_Structure, chord_strum_xoffset_collapses_to_single_chord) +{ + MasterScore* score = readEncoreScore("notes_chord_strum_xoffset.enc"); + ASSERT_NE(score, nullptr); + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "chord-strum score should pass sanityCheck: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + + ASSERT_EQ(chords.size(), 8u) << "Expected exactly 8 chords (one per notated column)"; + + const std::vector expectedDur = { + DurationType::V_EIGHTH, DurationType::V_16TH, DurationType::V_16TH, DurationType::V_EIGHTH, + DurationType::V_16TH, DurationType::V_16TH, DurationType::V_EIGHTH, DurationType::V_EIGHTH + }; + for (size_t i = 0; i < chords.size(); ++i) { + Chord* c = chords[i]; + std::vector pitches; + for (Note* n : c->notes()) { + pitches.push_back(n->pitch()); + } + std::sort(pitches.begin(), pitches.end()); + ASSERT_EQ(pitches.size(), 4u) << "Chord " << i << " must have 4 notes"; + EXPECT_EQ(pitches[0], 59) << "Chord " << i; + EXPECT_EQ(pitches[1], 62) << "Chord " << i; + EXPECT_EQ(pitches[2], 67) << "Chord " << i; + EXPECT_EQ(pitches[3], 71) << "Chord " << i; + EXPECT_EQ(c->durationType().type(), expectedDur[i]) << "Chord " << i << " duration"; + } + + delete score; +} + +// Inverse of the collapse: two notes a few ticks apart in different columns must stay separate events, not +// merge into one chord, so tightly played tuplet members remain distinct. +TEST_F(Tst_Structure, diff_column_notes_do_not_merge) +{ + MasterScore* score = readEncoreScore("notes_diff_column_no_merge.enc"); + ASSERT_NE(score, nullptr); + + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "diff-column score should pass sanityCheck: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + std::vector chords; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChord()) { + chords.push_back(toChord(e)); + } + } + + ASSERT_GE(chords.size(), 2u) << "The two eighths must not collapse into a single chord"; + EXPECT_EQ(chords[0]->notes().size(), 1u) << "First eighth must be a single note (60)"; + EXPECT_EQ(chords[0]->notes().front()->pitch(), 60); + EXPECT_EQ(chords[1]->notes().size(), 1u) << "Second eighth must be a single note (64)"; + EXPECT_EQ(chords[1]->notes().front()->pitch(), 64); + + delete score; +} + +// A 3:2 triplet whose 2nd and 3rd positions were played a few ticks apart in different columns must keep +// all three members separate; without the column check they merge into a two-member triplet. +TEST_F(Tst_Structure, tuplet_diff_column_keeps_all_members) +{ + MasterScore* score = readEncoreScore("notes_tuplet_diff_column_keeps_members.enc"); + ASSERT_NE(score, nullptr); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + + Tuplet* tuplet = nullptr; + for (Segment* s = m->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (e && e->isChordRest() && toChordRest(e)->tuplet()) { + tuplet = toChordRest(e)->tuplet(); + break; + } + } + ASSERT_NE(tuplet, nullptr) << "Expected a tuplet on staff 0"; + EXPECT_EQ(tuplet->ratio(), Fraction(3, 2)) << "Expected a 3:2 triplet"; + + // Count note-bearing members. Merging positions 2 and 3 would give 2 members + // (one of them a two-note chord [64,67]) instead of the three single notes. + std::vector memberPitches; + int noteMembers = 0; + for (DurationElement* de : tuplet->elements()) { + if (de->isChord()) { + ++noteMembers; + EXPECT_EQ(toChord(de)->notes().size(), 1u) + << "Each triplet member must be a single note, not a merged chord"; + memberPitches.push_back(toChord(de)->notes().front()->pitch()); + } + } + ASSERT_EQ(noteMembers, 3) + << "Triplet must keep all 3 members; merging positions 2 and 3 leaves 2"; + std::sort(memberPitches.begin(), memberPitches.end()); + EXPECT_EQ(memberPitches[0], 60); + EXPECT_EQ(memberPitches[1], 64); + EXPECT_EQ(memberPitches[2], 67); + + delete score; +} + +// Regression: a double barline between two measures is stored by Encore as the SECOND +// measure's start barline (byte 0x0C = DOUBLEL), not the first measure's end barline. +// The importer handled only end barlines, so the divider was dropped. It must map a +// special start barline onto the previous measure's end barline. +TEST_F(Tst_Structure, v0c4_start_double_barline_maps_to_previous_end) +{ + MasterScore* score = readEncoreScore("structure_start_double_barline.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_start_double_barline.enc"; + Measure* first = score->firstMeasure(); + ASSERT_NE(first, nullptr); + EXPECT_EQ(first->endBarLineType(), BarLineType::DOUBLE) + << "the double bar before measure 2 must appear as measure 1's end barline"; + delete score; +} + +// A voice that carries a real note can also carry a redundant placeholder REST stored at the SAME +// tick (Encore writes a rest slot for the voice even where the note sits). The non-tuplet rest must +// be dropped so the note keeps beat 1; with the bug the rest was emitted first and pushed the note +// off the beat, overflowing the bar. Fixture: 2/4, voice 0 = eighth rest @0 + quarter @0 + quarter. +TEST_F(Tst_Structure, coincident_placeholder_rest_dropped_note_keeps_beat) +{ + MasterScore* score = readEncoreScore("structure_rest_coincident_with_note.enc"); + ASSERT_NE(score, nullptr) << "Failed to load structure_rest_coincident_with_note.enc"; + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); + + Measure* m = score->firstMeasure(); + ASSERT_NE(m, nullptr); + Segment* first = m->first(SegmentType::ChordRest); + ASSERT_NE(first, nullptr); + EngravingItem* e = first->element(0); + ASSERT_NE(e, nullptr); + EXPECT_TRUE(e->isChord()) + << "beat 1 must be the note, not a placeholder rest pushed ahead of it"; + EXPECT_EQ(first->tick(), m->tick()); +} diff --git a/src/importexport/encore/tests/tst_tempo.cpp b/src/importexport/encore/tests/tst_tempo.cpp new file mode 100644 index 0000000000000..8339dce3f0a94 --- /dev/null +++ b/src/importexport/encore/tests/tst_tempo.cpp @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Tempo import and no-crash smoke loads: ghost-measure truncation and v0xC2 tempo beat-unit decoding. + +#include + +#include + +#include "engraving/dom/masterscore.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/tempotext.h" + +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Tempo : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// Files can carry stale MEAS blocks past the rendered count; only the real ones must import. +// See ENCORE_FORMAT.md §Header. +TEST_F(Tst_Tempo, v0c4_header_measure_count_truncates_ghost_measures) +{ + MasterScore* score = readEncoreScore( + "importer_header_measure_count_truncates_ghost_measures.enc"); + ASSERT_NE(score, nullptr) + << "Failed to load importer_header_measure_count_truncates_ghost_measures.enc"; + int measureCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (mb->isMeasure()) { + ++measureCount; + } + } + EXPECT_EQ(measureCount, 2) + << "ghost MEAS blocks past header.measureCount must be dropped"; + delete score; +} + +TEST_F(Tst_Tempo, beethoven_no_crash) +{ + MasterScore* score = readEncoreScore("notes_corrupted.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +TEST_F(Tst_Tempo, twelve_instrument_score_no_crash) +{ + MasterScore* score = readEncoreScore("notes_triplets.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +// No sanityCheck: swing timing files produce slight measure shortfalls (by design). +TEST_F(Tst_Tempo, swing_timing_file_no_crash) +{ + MasterScore* score = readEncoreScore("notes_swing.enc"); + ASSERT_NE(score, nullptr); + EXPECT_GT(score->nmeasures(), 0); + delete score; +} + +// Regression: older-layout v0xC2 tempo marks carry the beat unit in a different slot; without it +// an "eighth = 240" mark in 6/8 was read as a dotted quarter and played 3x too fast. +// See ENCORE_FORMAT.md §Note element (Tempo beat unit). +TEST_F(Tst_Tempo, v0c2_older_layout_tempo_beat_unit_at_plus26) +{ + MasterScore* score = readEncoreScore("tempo_v0c2_eighth_beat_unit.enc"); + ASSERT_NE(score, nullptr) << "Failed to load tempo_v0c2_eighth_beat_unit.enc"; + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + Measure* m = toMeasure(mb); + for (Segment* s = m->first(SegmentType::ChordRest); s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "no TempoText emitted for the v0xC2 tempo mark"; + + EXPECT_TRUE(tt->xmlText().contains(u"metNote8thUp")) + << "expected eighth beat unit, got: " << tt->xmlText().toStdString(); + EXPECT_FALSE(tt->xmlText().contains(u"metAugmentationDot")) + << "beat unit must not be dotted: " << tt->xmlText().toStdString(); + // eighth = 240 -> 120 quarter/min -> 2 beats/sec. + EXPECT_EQ(std::lround(tt->tempo().val), 2) + << "playback tempo must be 2 beats/sec, got " << tt->tempo().val; + + delete score; +} diff --git a/src/importexport/encore/tests/tst_text.cpp b/src/importexport/encore/tests/tst_text.cpp new file mode 100644 index 0000000000000..afdd3d57bd4df --- /dev/null +++ b/src/importexport/encore/tests/tst_text.cpp @@ -0,0 +1,1634 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Text import: lyrics (syllable matching, verses, hyphen/melisma, encodings) and staff/rehearsal/tempo text, +// including rich-text runs. See ENCORE_FORMAT.md §Lyric element and ENCORE_FORMAT.md §TEXT block. + +#include + +#include "engraving/dom/arpeggio.h" +#include "engraving/dom/articulation.h" +#include "engraving/dom/barline.h" +#include "engraving/dom/chord.h" +#include "engraving/dom/dynamic.h" +#include "engraving/dom/fermata.h" +#include "engraving/dom/fingering.h" +#include "engraving/dom/hairpin.h" +#include "engraving/dom/jump.h" +#include "engraving/dom/keysig.h" +#include "engraving/dom/lyrics.h" +#include "engraving/dom/marker.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/stafftext.h" +#include "engraving/dom/tempotext.h" +#include "engraving/dom/measure.h" +#include "engraving/dom/note.h" +#include "engraving/dom/part.h" +#include "engraving/dom/rest.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/spanner.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/textbase.h" +#include "engraving/dom/tie.h" +#include "engraving/dom/tremolosinglechord.h" +#include "engraving/dom/timesig.h" +#include "engraving/dom/harmony.h" +#include "engraving/dom/tuplet.h" + +#include "../internal/importer/emitters-internal.h" +#include "testbase.h" + +static const QString ENC_DIR(QString(iex_encore_tests_DATA_ROOT) + "/data/"); + +using namespace mu::engraving; + +class Tst_Text : public ::testing::Test, public MTest +{ +protected: + void SetUp() override { setRootDir(ENC_DIR); } +}; + +// A sung syllable always belongs to a note: one whose stored tick falls between notes (beyond the match +// window) with no rest available must attach to the nearest chord rather than being dropped. +TEST_F(Tst_Text, lyrics_offgrid_syllable_attaches_to_nearest_chord) +{ + MasterScore* score = readEncoreScore("text_lyrics_offgrid_nearest_chord.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + int pitchWithLyric = -1; + int lyricCount = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e || !e->isChord()) { + continue; + } + Chord* c = toChord(e); + for (Lyrics* ly : c->lyrics()) { + if (ly->plainText() == u"ge") { + pitchWithLyric = c->upNote()->pitch(); + ++lyricCount; + } + } + } + } + EXPECT_EQ(lyricCount, 1) << "the off-grid syllable must be kept, not dropped"; + EXPECT_EQ(pitchWithLyric, 62) << "it must attach to the nearest chord (note at tick 120, pitch 62)"; + + delete score; +} + +// A hyphen opening a measure must promote the previous measure's last syllable (SINGLE -> BEGIN) so the +// connecting hyphen survives across the barline. +TEST_F(Tst_Text, lyrics_hyphen_renders_across_barline) +{ + MasterScore* score = readEncoreScore("text_lyrics_hyphen_across_barline.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + LyricsSyllabic sofSyll = LyricsSyllabic::SINGLE; + LyricsSyllabic tlySyll = LyricsSyllabic::SINGLE; + bool sofSeen = false, tlySeen = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* e = s->element(0); + if (!e || !e->isChord()) { + continue; + } + for (Lyrics* ly : toChord(e)->lyrics()) { + if (ly->plainText() == u"sof") { + sofSyll = ly->syllabic(); + sofSeen = true; + } else if (ly->plainText() == u"tly") { + tlySyll = ly->syllabic(); + tlySeen = true; + } + } + } + } + ASSERT_TRUE(sofSeen && tlySeen); + EXPECT_EQ(sofSyll, LyricsSyllabic::BEGIN) << "first syllable must become BEGIN so the hyphen renders"; + EXPECT_EQ(tlySyll, LyricsSyllabic::END) << "continuation syllable after the barline is END"; + + delete score; +} + +TEST_F(Tst_Text, lyrics_hyphen_separators_dropped_and_set_syllabic) +{ + MasterScore* score = readEncoreScore("text_lyrics_hyphenated_words.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + struct Entry { + String text; + LyricsSyllabic syll; + }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + seen.push_back({ ly->plainText(), ly->syllabic() }); + } + } + } + ASSERT_EQ(seen.size(), 3u); + EXPECT_EQ(seen[0].text, String(u"JU")); + EXPECT_EQ(seen[0].syll, LyricsSyllabic::BEGIN) + << "JU is followed by a hyphen continuation"; + EXPECT_EQ(seen[1].text, String(u"LIO")); + EXPECT_EQ(seen[1].syll, LyricsSyllabic::END) + << "LIO closes the JU-LIO word"; + EXPECT_EQ(seen[2].text, String(u"RO")); + EXPECT_EQ(seen[2].syll, LyricsSyllabic::BEGIN) + << "RO is followed by a hyphen continuation past the bar"; + delete score; +} + +TEST_F(Tst_Text, lyrics_two_verses_on_voice_0_chord) +{ + MasterScore* score = readEncoreScore("text_lyrics_two_verses.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector verse0; + std::vector verse1; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + if (ly->verse() == 0) { + verse0.push_back(ly->plainText()); + } else if (ly->verse() == 1) { + verse1.push_back(ly->plainText()); + } + } + } + } + std::vector expectedV0 = { u"JU", u"LIO", u"RO", u"ME" }; + std::vector expectedV1 = { u"Co", u"mo", u"ca", u"pa" }; + EXPECT_EQ(verse0, expectedV0); + EXPECT_EQ(verse1, expectedV1); + delete score; +} + +TEST_F(Tst_Text, lyrics_variable_length_with_empty_placeholder) +{ + MasterScore* score = readEncoreScore("text_lyrics_variable.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector expected = { u"JU", u"LIO", u"RO" }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + seen.push_back(ly->plainText()); + } + } + } + EXPECT_EQ(seen, expected); + delete score; +} + +// A lyric whose tick is slightly after its note's (visual offset) must still attach to that note; the +// note-tick mapping must not halve the Encore tick, which pushed offset lyrics past the match threshold. +TEST_F(Tst_Text, lyrics_offset_ticks_still_attach_correctly) +{ + MasterScore* score = readEncoreScore("text_lyrics_6_8_offset_ticks.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector expected = { u"do", u"re", u"mi", u"fa" }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + seen.push_back(ly->plainText()); + } + } + } + EXPECT_EQ(seen, expected) + << "All 4 lyrics must attach to their correct note even when lyric encTick " + "is +50 ticks after the note encTick"; + delete score; +} + +TEST_F(Tst_Text, lyrics_attached_to_chords) +{ + MasterScore* score = readEncoreScore("text_lyrics.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector expected = { u"do", u"re", u"mi", u"fa" }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + for (Lyrics* ly : c->lyrics()) { + seen.push_back(ly->plainText()); + } + } + } + EXPECT_EQ(seen, expected); + delete score; +} + +// Lyric encoding is detected per element: a Latin-1 (one byte/char) lyric must not be read as UTF-16 LE, +// which produced spurious CJK code units. +TEST_F(Tst_Text, lyrics_latin1_text_decoded_as_one_byte_per_char) +{ + MasterScore* score = readEncoreScore("text_lyrics_latin1.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(0); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + seen.push_back(ly->plainText()); + } + } + } + ASSERT_EQ(seen.size(), 2u); + EXPECT_EQ(seen[0], String(u"txã")); + EXPECT_EQ(seen[1], String(u"nã")); + delete score; +} + +// Lyrics on a grand-staff bottom staff must be matched against that staff's routed notes, not the raw +// encStaff (which grabs another instrument's notes and reverses the syllables). +TEST_F(Tst_Text, lyrics_grandstaff_match_routed_staff_notes) +{ + MasterScore* score = readEncoreScore("text_lyrics_grandstaff_routed_notes.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + // Collect (pitch, syllable, syllabic) for lyrics on MuseScore staff 1 (track 4 = staff 1, voice 0). + struct Hit { + int pitch; + String text; + LyricsSyllabic syll; + }; + std::vector hits; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + EngravingItem* el = s->element(4); // staff 1, voice 0 + if (!el || !el->isChord()) { + continue; + } + Chord* c = toChord(el); + for (Lyrics* ly : c->lyrics()) { + hits.push_back({ c->upNote()->pitch(), ly->plainText(), ly->syllabic() }); + } + } + } + ASSERT_EQ(hits.size(), 2u) << "Both syllables must land on the bottom-staff notes"; + EXPECT_EQ(hits[0].text, String(u"Sal")); + EXPECT_EQ(hits[0].pitch, 55) << "First syllable must be on the first bottom-staff note (pitch 55), not reversed"; + EXPECT_EQ(hits[0].syll, LyricsSyllabic::BEGIN); + EXPECT_EQ(hits[1].text, String(u"ve")); + EXPECT_EQ(hits[1].pitch, 57) << "Second syllable must be on the second bottom-staff note (pitch 57)"; + EXPECT_EQ(hits[1].syll, LyricsSyllabic::END); + + delete score; +} + +// STAFFTEXT matching an Italian tempo term is promoted to TempoText (relative markings like "a tempo" get +// no absolute BPS); non-tempo strings stay StaffText. +TEST_F(Tst_Text, staff_text_promoted_to_tempo_for_italian_terms) +{ + MasterScore* score = readEncoreScore("text_stafftext_tempo_promotion.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector tempoTexts; + std::vector staffTexts; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (!e) { + continue; + } + if (e->isTempoText()) { + tempoTexts.push_back(toTempoText(e)->plainText()); + } else if (e->isStaffText()) { + staffTexts.push_back(toStaffText(e)->plainText()); + } + } + } + } + EXPECT_EQ(tempoTexts, (std::vector { u"Allegro", u"a tempo" })) + << "Allegro and 'a tempo' must reach the score as TempoText, not StaffText"; + EXPECT_EQ(staffTexts, (std::vector { u"ten." })) + << "Non-tempo words remain plain StaffText"; + delete score; +} + +TEST_F(Tst_Text, staff_text_promoted_to_tempo_sets_tempo_map) +{ + MasterScore* score = readEncoreScore("text_stafftext_tempo_promotion.enc"); + ASSERT_NE(score, nullptr); + + // Allegro at measure 0 must use the palette default of 144 BPM (= 2.4 BPS). + const Fraction tick0(0, 1); + EXPECT_NEAR(score->tempo(tick0).val, 144.0 / 60.0, 1e-6) + << "Allegro at tick 0 must set the tempo to 144 BPM"; + delete score; +} + +// =========================================================================== +// TITL slots of the same category join with newlines (headers/footers stack by alignment byte), and Encore +// #P/#D/#T tokens are rewritten to the MuseScore macros $P/$D/$m. +TEST_F(Tst_Text, multi_slot_text_joined_with_newlines) +{ + MasterScore* score = readEncoreScore("text_multi_slot_stacked_text.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + EXPECT_EQ(score->metaTag(u"composer"), + u"Vicente Paiva e Jararáca\nAdapt.: Sgt Solano\nBanda de Música do CRPO/VRS") + << "the three author slots must join into one newline-separated string"; + // Two center-aligned header lines must stack into a single Sid value. + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::oddHeaderC), + u"Top line one\nTop line two"); + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::evenHeaderC), + u"Top line one\nTop line two"); + // The two footers have different alignments (left + right) and must + // therefore stay on separate Sids. + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::oddFooterL), u"Left footer"); + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::oddFooterR), u"Right footer"); + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::evenFooterL), u"Left footer"); + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::evenFooterR), u"Right footer"); + delete score; +} + +// =========================================================================== +// When a file writes the TITL block twice, the second pass must replace the first, not double the +// composer/header/footer lines. +TEST_F(Tst_Text, duplicate_titl_block_does_not_double_lines) +{ + MasterScore* score = readEncoreScore("text_duplicate_titl_block.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // The fixture writes the same TITL block twice; the composer must + // appear exactly once. + EXPECT_EQ(score->metaTag(u"composer"), u"Sole Composer") + << "second TITL block must replace the first instead of appending"; + EXPECT_EQ(score->metaTag(u"workTitle"), u"Duped TITL"); + delete score; +} + +// =========================================================================== +// A later empty TITL block (Encore writes one per page, page 2+ often blank) must not overwrite the first +// block's title and author with empty strings. +TEST_F(Tst_Text, empty_second_titl_block_preserves_first_block_data) +{ + MasterScore* score = readEncoreScore("text_titl_empty_second_block.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // The fixture has TITL1 = {title="Multi TITL First", author="Real Author"}; + // TITL2 is completely empty. The first block's data must survive. + EXPECT_EQ(score->metaTag(u"workTitle"), u"Multi TITL First") + << "empty second TITL block must not overwrite the title from the first block"; + EXPECT_EQ(score->metaTag(u"composer"), u"Real Author") + << "empty second TITL block must not overwrite the author from the first block"; + delete score; +} + +// The MEAS header BPM must drive the tempo: a post-pass emits TempoText for the first measure and each BPM +// change (and sets Score::setTempo), rather than defaulting every import to 120. +TEST_F(Tst_Text, measure_header_bpm_drives_initial_tempo_and_changes) +{ + MasterScore* score = readEncoreScore("text_tempo_changes.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + struct Found { + int measureIdx; + double bps; + String xmlText; + }; + std::vector seen; + int mi = -1; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + ++mi; + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + TempoText* tt = toTempoText(e); + seen.push_back({ mi, tt->tempo().val, tt->xmlText() }); + } + } + } + } + // [100,60,100,60,200,200]: emit TempoText for m1..m5 (initial + changes); m6 same as m5, no mark. + ASSERT_EQ(seen.size(), 5u); + EXPECT_EQ(seen[0].measureIdx, 0); + EXPECT_NEAR(seen[0].bps, 100.0 / 60.0, 1e-6); + EXPECT_EQ(seen[0].xmlText, u"metNoteQuarterUp = 100"); + EXPECT_EQ(seen[1].measureIdx, 1); + EXPECT_NEAR(seen[1].bps, 60.0 / 60.0, 1e-6); + EXPECT_EQ(seen[2].measureIdx, 2); + EXPECT_NEAR(seen[2].bps, 100.0 / 60.0, 1e-6); + EXPECT_EQ(seen[3].measureIdx, 3); + EXPECT_NEAR(seen[3].bps, 60.0 / 60.0, 1e-6); + EXPECT_EQ(seen[4].measureIdx, 4); + EXPECT_NEAR(seen[4].bps, 200.0 / 60.0, 1e-6); + + // Tempo map must reflect the same changes (sampled at each measure start). + Measure* m = score->firstMeasure(); + std::vector expected = { 100, 60, 100, 60, 200, 200 }; + for (int i = 0; i < 6 && m; ++i, m = m->nextMeasure()) { + EXPECT_NEAR(score->tempo(m->tick()).val, expected[i] / 60.0, 1e-6) + << "measure " << i << " expected " << expected[i] << " BPM"; + } + delete score; +} + +// =========================================================================== +// The ORN TEMPO value is beat-unit BPM, so in a compound meter (dotted-quarter beat) it must be scaled by +// 3/2 to quarter-note BPM, not used directly. +TEST_F(Tst_Text, orn_tempo_compound_meter_dotted_quarter_bpm) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_compound_68.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "No TempoText found in score"; + EXPECT_NEAR(tt->tempo().val, 120.0 / 60.0, 1e-6) + << "ORN TEMPO=80 in 6/8 must produce quarterBpm=120 (BPS=2.0), not 80/60"; + EXPECT_EQ(tt->xmlText(), + u"metNoteQuarterUpspacemetAugmentationDot = 80") + << "Compound-meter tempo must use dotted-quarter sym tags; displayed value must be 80"; + + delete score; +} + +// A tempo ORN anchored to a note's tick but drawn (smaller xoffset) over the earlier downbeat rest must +// snap to the chord-rest matching its drawn position, like dynamics do, not sit on the later note. +TEST_F(Tst_Text, orn_tempo_snaps_to_downbeat_by_xoffset) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_xoffset_downbeat.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + TempoText* tt = nullptr; + Segment* host = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + host = s; + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "No TempoText found in score"; + EXPECT_TRUE(host->rtick().isZero()) + << "Tempo ORN must snap to the downbeat rest (rtick 0), not the later note; got rtick " + << host->rtick().toString().toStdString(); + EXPECT_NEAR(tt->tempo().val, 63.0 / 60.0, 1e-6) + << "5/8 is not compound: ORN TEMPO=63 means quarter=63 (BPS 1.05)"; + EXPECT_EQ(tt->xmlText(), u"metNoteQuarterUp = 63") + << "Simple-meter tempo must display quarter=63"; + + delete score; +} + +// =========================================================================== +// The tempo mark's beat unit comes from the ORN noto byte, not the meter: a "quarter = 198" mark in 6/8 +// must stay a quarter, not be rewritten as the compound-default dotted quarter. +TEST_F(Tst_Text, tempo_beat_unit_from_noto_overrides_compound_meter) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_explicit_quarter_unit.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "No TempoText found in score"; + EXPECT_EQ(tt->xmlText(), u"metNoteQuarterUp = 198") + << "noto=2 (quarter) must override the 6/8 compound default; not dotted-quarter=132"; + EXPECT_NEAR(tt->tempo().val, 198.0 / 60.0, 1e-6) + << "Playback unchanged: 198 quarter/min = 3.3 BPS"; + + delete score; +} + +// v0xC2 stores a tempo mark's BPM in a different ORN slot than v0xC4, so reading the v0xC4 slot gets a +// constant, not the real BPM. See ENCORE_FORMAT.md §Note element (Tempo beat unit). +TEST_F(Tst_Text, tempo_orn_v0c2_reads_bpm_from_offset_28) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_v0c2_bpm_offset.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "No TempoText found in score"; + EXPECT_EQ(tt->xmlText(), u"metNoteQuarterUp = 80") + << "v0xC2 tempo BPM is at ORN +28 (80), not +30 (the constant 52)"; + EXPECT_NEAR(tt->tempo().val, 80.0 / 60.0, 1e-6); + + delete score; +} + +// Some v0xC2 files store the tempo the v0xC4 way (beat-unit code at +28, BPM at +30); the reader must keep +// the +30 BPM when +28 is a valid beat-unit code, or a quarter=158 mark imports as quarter=2. +TEST_F(Tst_Text, tempo_orn_v0c2_keeps_bpm_at_offset_30_when_28_is_beat_unit) +{ + MasterScore* score = readEncoreScore("text_tempo_orn_v0c2_v0c4_layout.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "No TempoText found in score"; + EXPECT_EQ(tt->xmlText(), u"metNoteQuarterUp = 158") + << "v0xC2 with a beat-unit code at +28 keeps the BPM at +30 (158), not the unit (2)"; + EXPECT_NEAR(tt->tempo().val, 158.0 / 60.0, 1e-6); + + delete score; +} + +// Tempo texts must use note tags (not a raw Unicode note glyph) so TempoText::updateTempo matches, +// and numeric BPM marks get followText=true so editing the displayed BPM keeps the tempo map in sync. +TEST_F(Tst_Text, tempo_text_uses_sym_tags_and_follow_text_enabled) +{ + MasterScore* score = readEncoreScore("ornaments_tempo_sym_followtext.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + struct Found { + String xmlText; + bool followText; + double bps; + }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + TempoText* tt = toTempoText(e); + seen.push_back({ tt->xmlText(), tt->followText(), tt->tempo().val }); + } + } + } + } + // The fixture has 2 custom measures plus fill measures; at least 2 TempoTexts expected. + ASSERT_GE(seen.size(), 2u); + + // m1: 4/4 bpm=100 -> simple meter -> quarter sym + EXPECT_EQ(seen[0].xmlText, u"metNoteQuarterUp = 100") + << "Simple-meter tempo must use metNoteQuarterUp sym tag, not raw unicode"; + EXPECT_TRUE(seen[0].followText) + << "MEAS-header TempoText must have followText=true so BPM edits update playback"; + EXPECT_NEAR(seen[0].bps, 100.0 / 60.0, 1e-6); + + // m2: 6/8 bpm=80 beat-unit (dotted quarter) -> compound meter + EXPECT_EQ(seen[1].xmlText, + u"metNoteQuarterUpspacemetAugmentationDot = 80") + << "Compound-meter tempo must use dotted-quarter sym tags, not raw unicode"; + EXPECT_TRUE(seen[1].followText) + << "Compound-meter TempoText must have followText=true"; + // MEAS header bpm=120 (quarter-note BPM); BPS = 120/60 = 2.0 + EXPECT_NEAR(seen[1].bps, 120.0 / 60.0, 1e-6); + + delete score; +} + +TEST_F(Tst_Text, header_footer_tokens_translated_to_mscore_macros) +{ + MasterScore* score = readEncoreScore("text_header_footer_tokens.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // header[0] right-aligned -> oddHeaderR + evenHeaderR. + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::oddHeaderR), u"Page $P") + << "#P must be rewritten to $P (page number on every page)"; + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::evenHeaderR), u"Page $P"); + // header[1] center-aligned -> oddHeaderC + evenHeaderC. + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::oddHeaderC), u"Created $D") + << "#D must be rewritten to $D (creation date)"; + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::evenHeaderC), u"Created $D"); + // footer[0] left-aligned -> oddFooterL + evenFooterL. + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::oddFooterL), u"Time $m") + << "#T must be rewritten to $m (best MuseScore equivalent for 'time')"; + EXPECT_EQ(score->style().styleSt(mu::engraving::Sid::evenFooterL), u"Time $m"); + delete score; +} + +// A STAFFTEXT's tind byte indexes into the TEXT block for its display string; the importer resolves the +// StaffText via that index. See ENCORE_FORMAT.md §TEXT block. +TEST_F(Tst_Text, staff_text_resolved_via_text_block) +{ + MasterScore* score = readEncoreScore("text_staff_text.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + // "Allegretto" is promoted to TempoText; the remaining entries stay as StaffText. + std::vector expected = { u"cresc.", u"dimin.", u"ten." }; + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + seen.push_back(toStaffText(e)->plainText()); + } + } + } + } + EXPECT_EQ(seen, expected); + delete score; +} + +// A rich-text TEXT entry stores its text after a variable-length run header, so the text offset must be +// derived from the run count; assuming the single-run offset resolves a multi-run entry to garbage. +// See ENCORE_FORMAT.md §TEXT block. +TEST_F(Tst_Text, staff_text_multirun_header) +{ + MasterScore* score = readEncoreScore("text_staff_text_multirun.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + seen.push_back(toStaffText(e)->plainText()); + } + } + } + } + std::vector expected = { u"TAN TRAN" }; + EXPECT_EQ(seen, expected) + << "A multi-run TEXT entry must resolve its text via the run-count-derived offset"; + delete score; +} + +// A rich-text TEXT entry can carry more than one formatting descriptor (count at the header), so the text +// offset must account for the descriptor count; assuming a single descriptor reads into a descriptor and +// decodes garbage. See ENCORE_FORMAT.md §TEXT block. +TEST_F(Tst_Text, staff_text_two_descriptors_header) +{ + MasterScore* score = readEncoreScore("text_staff_text_two_descriptors.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + seen.push_back(toStaffText(e)->plainText()); + } + } + } + } + std::vector expected = { u"Cajas y Tambores" }; + EXPECT_EQ(seen, expected) + << "A two-descriptor TEXT entry must resolve its text via the descriptor-count-derived offset"; + delete score; +} + +// Multi-part files write one TEXT block per part view in different order, but tind indices match only the +// first (score) block, so the importer must keep the first block, not overwrite it with each one read. +TEST_F(Tst_Text, staff_text_uses_first_text_block) +{ + MasterScore* score = readEncoreScore("text_staff_text_first_block_wins.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + String seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + seen = toStaffText(e)->plainText(); + } + } + } + } + EXPECT_EQ(seen, String(u"Alpha")) + << "tind=0 must resolve against the FIRST TEXT block ('Alpha'), not the last ('Beta')"; + delete score; +} + +// Encore separates lines inside a TEXT-block entry with U+0004; a multi-line staff text must keep all lines +// (joined with newlines), not stop at the first separator. +TEST_F(Tst_Text, staff_text_multiline_preserved) +{ + MasterScore* score = readEncoreScore("text_staff_text_multiline.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + String seen; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + seen = toStaffText(e)->plainText(); + } + } + } + } + EXPECT_EQ(seen, String(u"Notes + change duration\n(third quarter to half)")) + << "multi-line staff text must keep both lines (was truncated at first U+0004)"; + delete score; +} + +// STAFFTEXT placement comes from the ORN yoffset: positive keeps ABOVE, negative (Cartesian below) maps to BELOW. +TEST_F(Tst_Text, staff_text_placement_from_yoffset) +{ + MasterScore* score = readEncoreScore("text_staff_text_placement.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::map placements; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isStaffText()) { + StaffText* st = toStaffText(e); + placements[st->plainText()] = st->placement(); + } + } + } + } + ASSERT_EQ(placements.size(), 2u) << "two STAFFTEXT elements expected"; + EXPECT_EQ(placements[u"Above"], PlacementV::ABOVE) + << "yoffset=+10 must keep default ABOVE placement"; + EXPECT_EQ(placements[u"ten"], PlacementV::BELOW) + << "yoffset=-10 (Cartesian below) must map to PlacementV::BELOW"; + delete score; +} + +// Pure-function tests for tempoXmlText(): the note symbol is always a tag (not raw Unicode), the +// beat-unit variant follows beatTicks, and displayBpm is the beat-unit BPM verbatim (no conversion). +// =========================================================================== +TEST(Tst_TempoXmlText, simple_meter_quarter_sym) +{ + using namespace mu::iex::enc; + // beatTicks=240 (quarter beat): quarter sym + EXPECT_EQ(tempoXmlText(120, 240), + String(u"metNoteQuarterUp = 120")); + EXPECT_EQ(tempoXmlText(80, 240), + String(u"metNoteQuarterUp = 80")); + EXPECT_EQ(tempoXmlText(100, 240), + String(u"metNoteQuarterUp = 100")); +} + +TEST(Tst_TempoXmlText, dotted_quarter_beat_sym) +{ + using namespace mu::iex::enc; + // beatTicks=360 (dotted-quarter beat): dotted-quarter sym; displayBpm is already the beat-unit value. + // (For MEAS BPM the caller converts QPM to displayBpm via bpm*2/3 before calling tempoXmlText.) + EXPECT_EQ(tempoXmlText(80, 360), + String(u"metNoteQuarterUpspacemetAugmentationDot = 80")); + // beatTicks=360 for 3/8 (dotted-quarter beat, previously incorrectly treated as quarter) + EXPECT_EQ(tempoXmlText(80, 360), + String(u"metNoteQuarterUpspacemetAugmentationDot = 80")); +} + +TEST(Tst_TempoXmlText, beat_ticks_select_the_note_symbol) +{ + using namespace mu::iex::enc; + // beatTicks is the beat duration in display ticks (quarter=240) and selects the note + // symbol: 120=eighth, 480=half, plus the dotted variants (base x 3/2). + EXPECT_EQ(tempoXmlText(156, 120), + String(u"metNote8thUp = 156")); + EXPECT_EQ(tempoXmlText(120, 240), + String(u"metNoteQuarterUp = 120")); + EXPECT_EQ(tempoXmlText(90, 480), + String(u"metNoteHalfUp = 90")); + EXPECT_EQ(tempoXmlText(60, 720), + String(u"metNoteHalfUpspacemetAugmentationDot = 60")); + // Unknown/zero falls back to a plain quarter note. + EXPECT_EQ(tempoXmlText(80, 0), + String(u"metNoteQuarterUp = 80")); +} + +// An ORN TEMPO must not be suppressed just because its value disagrees with the MEAS header BPM when the +// beat is not a quarter (the two are in different units); the genuine ORN mark must be used. +TEST_F(Tst_Text, orn_tempo_5_8_not_suppressed_and_uses_quarter_bpm) +{ + MasterScore* score = readEncoreScore("text_orn_tempo_eighth_beat_not_suppressed.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s && !tt; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "ORN TEMPO=63 in 5/8 must create a TempoText"; + EXPECT_NEAR(tt->tempo().val, 63.0 / 60.0, 1e-5) + << "ORN tempo=63 is quarter-note BPM regardless of beatTicks=120; BPS=63/60"; + EXPECT_EQ(tt->xmlText(), String(u"metNoteQuarterUp = 63")) + << "Display must show quarter note symbol (negra) matching Encore's 'negra = 63'"; + + delete score; +} + +// A 3/8 piece with a dotted-quarter beat (beatTicks=360) must get the same 1.5x BPS adjustment as 6/8; +// the compound check must key on beatTicks, not just numerator > 3 (which excludes 3/8). +TEST_F(Tst_Text, orn_tempo_3_8_dotted_quarter_bps_correct) +{ + MasterScore* score = readEncoreScore("text_orn_tempo_3_8_dotted_quarter.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s && !tt; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "No TempoText found in score"; + EXPECT_NEAR(tt->tempo().val, 80.0 * 1.5 / 60.0, 1e-5) + << "ORN TEMPO=80 in 3/8 (beatTicks=360) must give BPS=2.0 (dotted-quarter 80), " + "not 1.333 (plain quarter 80)"; + EXPECT_EQ(tt->xmlText(), + u"metNoteQuarterUpspacemetAugmentationDot = 80") + << "Display must show dotted-quarter=80, not quarter=80"; + + delete score; +} + +// =========================================================================== +// An ORN TEMPO that is not a misplaced ornament (no later measure carries its BPM) is the genuine score +// tempo and takes precedence over the MEAS header BPM. The header-BPM guard must scan the whole measure so +// an ORN placed at a later tick (on the first note, not the first rest) does not produce a second mark. +TEST_F(Tst_Text, orn_tempo_wins_over_meas_bpm_when_not_misplaced) +{ + MasterScore* score = readEncoreScore("text_meas_bpm_suppressed_by_orn_tempo_later_tick.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + TempoText* tt = nullptr; + for (MeasureBase* mb = score->first(); mb && !tt; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s && !tt; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + tt = toTempoText(e); + break; + } + } + } + } + ASSERT_NE(tt, nullptr) << "ORN TEMPO=63 must create a TempoText"; + EXPECT_NEAR(tt->tempo().val, 63.0 / 60.0, 1e-5) + << "ORN TEMPO=63 (quarter BPM) must give BPS=63/60; " + "ORN overrides MEAS header when it is not a misplaced ornament"; + + delete score; +} + +// Regression: importTempoTextSemantic=false must suppress ORN TEMPO (visual score +// marking) just like Italian text; only the MEAS header BPM creates a TempoText. +TEST_F(Tst_Text, orn_tempo_suppressed_when_semantic_disabled) +{ + mu::iex::enc::EncImportOptions opts; + opts.importTempoTextSemantic = false; + MasterScore* score = readEncoreScoreWithOpts("text_meas_bpm_suppressed_by_orn_tempo_later_tick.enc", opts); + ASSERT_NE(score, nullptr); + + std::vector bpsValues; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + bpsValues.push_back(toTempoText(e)->tempo().val); + } + } + } + } + EXPECT_EQ(bpsValues.size(), 1u) << "importTempoTextSemantic=false: ORN TEMPO must be suppressed"; + if (!bpsValues.empty()) { + EXPECT_NEAR(bpsValues[0], 160.0 / 60.0, 1e-5) + << "With semantic=false only MEAS header BPM=160 must apply"; + } + delete score; +} + +// An ORN TEMPO whose BPM conflicts with its measure's header but matches a later measure is a misplaced +// ornament; it must be suppressed so the later measure gets the correct TempoText from the header path. +TEST_F(Tst_Text, orn_tempo_mismatch_with_header_bpm_suppressed) +{ + MasterScore* score = readEncoreScore("text_orn_tempo_mismatch_suppressed.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + struct TempoAtTick { + Fraction tick; + double bps; + }; + std::vector found; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; + s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText()) { + found.push_back({ s->tick(), toTempoText(e)->tempo().val }); + } + } + } + } + + // No TempoText at measure 1 (tick=0) with BPM=80, the misplaced ornament must be suppressed + for (const auto& t : found) { + if (t.tick == Fraction(0, 1)) { + EXPECT_FALSE(std::abs(t.bps - 80.0 / 60.0) < 1e-4) + << "ORN TEMPO=80 at M1 (header BPM=249) must be suppressed (misplaced ornament)"; + } + } + + // TempoText BPM=80 must appear somewhere after tick=0 (at measure 2) + bool foundM2 = false; + for (const auto& t : found) { + if (t.tick > Fraction(0, 1) && std::abs(t.bps - 80.0 / 60.0) < 1e-4) { + foundM2 = true; + } + } + EXPECT_TRUE(foundM2) + << "Header BPM=80 must create TempoText at M2 when misplaced ORN TEMPO at M1 is suppressed"; + + delete score; +} + +// Same misplacement quirk, but the tempo change is several measures after the ORN (the old check +// only looked one measure ahead, so it missed this and placed the tempo too early). +TEST_F(Tst_Text, orn_tempo_misplaced_multi_measure_suppressed) +{ + // text_orn_tempo_misplaced_multi_measure.enc: + // M1: header BPM=249, ORN TEMPO=80 (misplaced); M2-M3: header BPM=249; M4: header BPM=80. + // Expected: TempoText BPM=80 at M4 (tick=3 whole notes), NOT at M1. + MasterScore* score = readEncoreScore("text_orn_tempo_misplaced_multi_measure.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + Fraction tickOf80; + bool found80 = false, eightyAtStart = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText() && std::abs(toTempoText(e)->tempo().val - 80.0 / 60.0) < 1e-4) { + found80 = true; + tickOf80 = s->tick(); + if (s->tick() == Fraction(0, 1)) { + eightyAtStart = true; + } + } + } + } + } + EXPECT_TRUE(found80) << "header BPM=80 at M4 must create a TempoText"; + EXPECT_FALSE(eightyAtStart) << "misplaced ORN TEMPO=80 must not place a tempo at M1"; + EXPECT_GT(tickOf80, Fraction(2, 1)) << "tempo 80 must land at M4 (several measures in), not at M1"; + + delete score; +} + +// An ORN TEMPO whose BPM equals its own measure's header BPM (e.g. an initial "= 230" Encore +// stores at the end of measure 1) is redundant: it must be suppressed so the header places the +// tempo at the MEASURE START, where it actually drives playback, not left on the ORN's late +// segment (which does not set the tempo, leaving the default). +TEST_F(Tst_Text, orn_tempo_equal_to_header_placed_at_measure_start) +{ + MasterScore* score = readEncoreScore("text_orn_tempo_equals_header.enc"); + ASSERT_NE(score, nullptr); + EXPECT_TRUE(score->sanityCheck()); + + int count230 = 0; + bool atStart = false, late = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + for (EngravingItem* e : s->annotations()) { + if (e && e->isTempoText() && std::abs(toTempoText(e)->tempo().val - 230.0 / 60.0) < 1e-4) { + ++count230; + (s->tick() == Fraction(0, 1) ? atStart : late) = true; + } + } + } + } + EXPECT_EQ(count230, 1) << "exactly one tempo 230 (no duplicate)"; + EXPECT_TRUE(atStart) << "tempo 230 must be placed at the measure start"; + EXPECT_FALSE(late) << "tempo 230 must not stay at the ORN's late tick"; + + delete score; +} + +// End-to-end v0xC2 6/8 lyric fixture exercising three fixes together: the shorter post-kie text gap so +// syllables are not truncated (see ENCORE_FORMAT.md §Lyric element), lyrics-first matching, and a +// compound-meter encTicksPerQuarter (beatTicks * 2/3). All 56 syllables must import intact. + +static std::vector collectAllLyrics(MasterScore* score) +{ + std::vector lyrics; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + for (track_idx_t t = 0; t < score->ntracks(); ++t) { + EngravingItem* el = s->element(t); + if (!el || !el->isChord()) { + continue; + } + for (Lyrics* ly : toChord(el)->lyrics()) { + lyrics.push_back(ly->plainText()); + } + } + } + } + return lyrics; +} + +TEST_F(Tst_Text, lyrics_v0xc2_text_offset_full_words) +{ + // lyrics_v0c2_compound_meter.enc: v0xC2 6/8, 3 measures × 6 eighth notes = 18 notes, + // each with a lyric. Syllables: "La","ro","sol","es","mi","do" (each >=2 chars). + // Wrong +20 offset would decode each as a single char ("L","r","s","e","m","d"). + MasterScore* score = readEncoreScore("lyrics_v0c2_compound_meter.enc"); + ASSERT_NE(score, nullptr); + muse::Ret ret = score->sanityCheck(); + EXPECT_TRUE(ret) << ret.text(); + + std::vector all = collectAllLyrics(score); + // 3 measures × 6 syllables = 18 lyrics after v0xC2 +18 offset fix. + EXPECT_GE(all.size(), 18u) + << "expected 18 lyrics after v0xC2 offset fix"; + + auto contains = [&](const String& s) { + return std::find(all.begin(), all.end(), s) != all.end(); + }; + EXPECT_TRUE(contains(u"La")) << "'La' must be present (wrong offset gives 'L')"; + EXPECT_TRUE(contains(u"ro")) << "'ro' must be present (wrong offset gives 'r')"; + EXPECT_TRUE(contains(u"sol")) << "'sol' must be present (wrong offset gives 's')"; + EXPECT_TRUE(contains(u"es")) << "'es' must be present (wrong offset gives 'e')"; + EXPECT_TRUE(contains(u"mi")) << "'mi' must be present (wrong offset gives 'm')"; + EXPECT_TRUE(contains(u"do")) << "'do' must be present (wrong offset gives 'd')"; + + // Single-char garbled fragments must not appear after the fix. + EXPECT_FALSE(contains(u"L")) << "garbled fragment 'L' must not appear after offset fix"; + EXPECT_FALSE(contains(u"s")) << "garbled fragment 's' must not appear after offset fix"; + + delete score; +} + +TEST_F(Tst_Text, lyrics_compound_meter_all_syllables_matched) +{ + // lyrics_v0c2_compound_meter.enc: v0xC2 6/8 (beatTicks=360), 3 measures. + // In 6/8 the segEncTick formula must use encTicksPerQuarter = beatTicks*2/3 = 240. + // Using 360 inflates note positions, placing beat-2 syllables out of range. + // Each of the 6 syllables appears 3 times (once per measure). + MasterScore* score = readEncoreScore("lyrics_v0c2_compound_meter.enc"); + ASSERT_NE(score, nullptr); + + std::vector all = collectAllLyrics(score); + int countLa = 0, countSol = 0, countEs = 0; + for (const String& s : all) { + if (s == u"La") { + ++countLa; + } + if (s == u"sol") { + ++countSol; + } + if (s == u"es") { + ++countEs; + } + } + EXPECT_GE(countLa, 2) + << "'La' must appear at least twice; before compound-meter fix: 0 or 1 occurrences."; + EXPECT_GE(countSol, 3) + << "'sol' must appear at least three times; before compound-meter fix: 0 occurrences."; + EXPECT_GE(countEs, 3) + << "'es' must appear at least three times; before compound-meter fix: 0 occurrences."; + + delete score; +} + +TEST_F(Tst_Text, lyrics_rest_does_not_shift_note_assignment) +{ + // Two lyric-matching invariants: rests must not consume note-tick entries (which would shift every + // note's encTick), and proximity matching must prefer a note at or before the lyric tick rather than a + // closer later note. Here LYRIC@140 must attach to NOTE@120, not NOTE@240. + MasterScore* score = readEncoreScore("lyrics_rest_does_not_shift_notes.enc"); + ASSERT_NE(score, nullptr); + + std::vector all = collectAllLyrics(score); + ASSERT_GE(all.size(), 1u) << "fixture must have at least one lyric"; + EXPECT_EQ(all[0], u"ma") << "lyric text must be 'ma'"; + + delete score; +} + +TEST_F(Tst_Text, title_frame_created) +{ + // kordorkestro has title "String Orchestra w/Piano" + MasterScore* score = readEncoreScore("kordorkestro.enc"); + ASSERT_NE(score, nullptr); + MeasureBase* first = score->first(); + ASSERT_NE(first, nullptr); + EXPECT_TRUE(first->isVBox()) << "Score with title should start with a VBox frame"; + delete score; +} + +TEST_F(Tst_Text, no_title_frame_when_empty) +{ + // bazo.enc has no title, should not have a VBox frame + MasterScore* score = readEncoreScore("bazo.enc"); + ASSERT_NE(score, nullptr); + MeasureBase* first = score->first(); + ASSERT_NE(first, nullptr); + EXPECT_TRUE(first->isMeasure()) << "Score without title should start with a measure"; + delete score; +} + +TEST_F(Tst_Text, title_frame_instruction_and_copyright) +{ + MasterScore* score = readEncoreScore("text_title_instruction_copyright.enc"); + ASSERT_NE(score, nullptr); + + MeasureBase* first = score->first(); + ASSERT_NE(first, nullptr); + ASSERT_TRUE(first->isVBox()) << "TITL with content must produce a VBox frame"; + + std::map texts; + for (const EngravingItem* el : first->el()) { + if (el->isText()) { + const TextBase* tb = toTextBase(el); + texts[tb->textStyleType()] = tb->plainText(); + } + } + + EXPECT_EQ(texts[TextStyleType::TITLE], String(u"Test Title")); + EXPECT_EQ(texts[TextStyleType::SUBTITLE], String(u"Test Subtitle")); + EXPECT_EQ(texts[TextStyleType::LYRICIST], String(u"Test Instruction")) + << "instruction[0] must be added as LYRICIST text"; + EXPECT_EQ(texts[TextStyleType::COMPOSER], String(u"Test Composer")); + + EXPECT_EQ(score->metaTag(u"workTitle"), String(u"Test Title")) + << "title must be stored in workTitle metadata"; + EXPECT_EQ(score->metaTag(u"subtitle"), String(u"Test Subtitle")) + << "subtitle[0] must be stored in subtitle metadata"; + EXPECT_EQ(score->metaTag(u"lyricist"), String(u"Test Instruction")) + << "instruction[0] must be stored in lyricist metadata"; + EXPECT_EQ(score->metaTag(u"composer"), String(u"Test Composer")) + << "author[0] must be stored in composer metadata"; + EXPECT_EQ(score->metaTag(u"copyright"), String(u"(c) 2026 Test")) + << "copyright[0] must be stored in copyright metadata"; + + delete score; +} + +TEST_F(Tst_Text, title_frame_headers_footers) +{ + MasterScore* score = readEncoreScore("text_titl_headers_footers.enc"); + ASSERT_NE(score, nullptr); + + auto styleText = [score](Sid sid) -> String { + return score->style().styleSt(sid); + }; + + EXPECT_EQ(styleText(Sid::oddHeaderR), String(u"Header Right")); + EXPECT_EQ(styleText(Sid::evenHeaderR), String(u"Header Right")); + EXPECT_EQ(styleText(Sid::oddHeaderC), String(u"Header Center")); + EXPECT_EQ(styleText(Sid::evenHeaderC), String(u"Header Center")); + EXPECT_NE(styleText(Sid::oddHeaderL), String(u"Header Right")); + EXPECT_NE(styleText(Sid::oddHeaderL), String(u"Header Center")); + EXPECT_NE(styleText(Sid::evenHeaderL), String(u"Header Right")); + EXPECT_NE(styleText(Sid::evenHeaderL), String(u"Header Center")); + + EXPECT_EQ(styleText(Sid::oddFooterC), String(u"Footer Center")); + EXPECT_EQ(styleText(Sid::evenFooterC), String(u"Footer Center")); + EXPECT_EQ(styleText(Sid::oddFooterR), String(u"Footer Right")); + EXPECT_EQ(styleText(Sid::evenFooterR), String(u"Footer Right")); + EXPECT_NE(styleText(Sid::oddFooterL), String(u"Footer Center")); + EXPECT_NE(styleText(Sid::oddFooterL), String(u"Footer Right")); + EXPECT_NE(styleText(Sid::evenFooterL), String(u"Footer Center")); + EXPECT_NE(styleText(Sid::evenFooterL), String(u"Footer Right")); + + delete score; +} + +TEST_F(Tst_Text, chord_symbols_present) +{ + // akordo.enc has chord symbols (Am, G7, etc.) + MasterScore* score = readEncoreScore("akordo.enc"); + ASSERT_NE(score, nullptr); + bool foundHarmony = false; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + if (segmentHarmony(s)) { + foundHarmony = true; + break; + } + } + if (foundHarmony) { + break; + } + } + EXPECT_TRUE(foundHarmony) << "akordo.enc should contain chord symbols"; + delete score; +} + +// Regression: chord symbols stored without text (tipo bit0 == 0) were silently skipped. +TEST_F(Tst_Text, numeric_chord_symbols) +{ + MasterScore* score = readEncoreScore("chord_parsing.enc"); + ASSERT_NE(score, nullptr); + + std::map harmonyByMeasure; + int measureIdx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s; s = s->next(SegmentType::ChordRest)) { + if (Harmony* h = segmentHarmony(s)) { + harmonyByMeasure[measureIdx] = h->harmonyName(); + } + } + ++measureIdx; + } + + EXPECT_FALSE(harmonyByMeasure.empty()) << "numeric chord symbols must be imported (were silently dropped)"; + EXPECT_EQ(harmonyByMeasure[0], String(u"C")) << "toniko=0 (major)"; + EXPECT_EQ(harmonyByMeasure[1], String(u"Cm")) << "toniko=1 (minor)"; + EXPECT_EQ(harmonyByMeasure[2], String(u"C+")) << "toniko=2 (augmented)"; + EXPECT_EQ(harmonyByMeasure[4], String(u"C7")) << "toniko=24 (dom7 alternate)"; + EXPECT_EQ(harmonyByMeasure[8], String(u"Cdim")) << "toniko=3 (diminished)"; + EXPECT_EQ(harmonyByMeasure[9], String(u"CMaj7")) << "toniko=12 (maj7)"; + + delete score; +} + +// Regression: the numeric chord-quality table (toniko -> suffix) was wrong from index 4 on. +// Encore's real palette has "dim7" at 4 (the importer had dominant "7"), fills the slots the +// importer left blank (maj7#11, 7#11, the multi-alteration dominants), and from 34 onward the +// importer's entries were all shifted by one, so e.g. toniko 48 rendered as 9sus4 instead of +// 7sus4. The fixture carries one numeric C chord per measure at the affected toniko values. +TEST_F(Tst_Text, numeric_chord_quality_table) +{ + MasterScore* score = readEncoreScore("text_chord_quality_table.enc"); + ASSERT_NE(score, nullptr); + + std::map byMeasure; + int measureIdx = 0; + for (MeasureBase* mb = score->first(); mb; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + if (Harmony* h = segmentHarmony(s)) { + byMeasure[measureIdx] = h->harmonyName(); + } + } + ++measureIdx; + } + + EXPECT_EQ(byMeasure[0], String(u"Cdim7")) << "toniko=4 is diminished 7, not dominant 7"; + EXPECT_EQ(byMeasure[1], String(u"CMaj7#11")) << "toniko=16 was blank, is maj7(#11)"; + EXPECT_EQ(byMeasure[2], String(u"C9#11")) << "toniko=34 is 9(#11), not 11"; + EXPECT_EQ(byMeasure[3], String(u"C13#11")) << "toniko=40 is 13(#11), not +7"; + EXPECT_EQ(byMeasure[4], String(u"C7sus")) << "toniko=48 is 7sus4, not 9sus4"; + EXPECT_EQ(byMeasure[5], String(u"Cm13")) << "toniko=63 was blank, is m13"; + + delete score; +} + +// Regression: numeric chord with bass note (tipo bit 1 set) should produce a slash chord. +TEST_F(Tst_Text, numeric_chord_with_bass_note) +{ + MasterScore* score = readEncoreScore("akordo.enc"); + ASSERT_NE(score, nullptr); + + Harmony* slashChord = nullptr; + for (MeasureBase* mb = score->first(); mb && !slashChord; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); + s && !slashChord; s = s->next(SegmentType::ChordRest)) { + if (Harmony* h = segmentHarmony(s)) { + if (h->harmonyName().contains(u"/")) { + slashChord = h; + } + } + } + } + + ASSERT_NE(slashChord, nullptr) << "akordo.enc must have a slash chord (tipo=2, bass note present)"; + const String name = slashChord->harmonyName(); + EXPECT_TRUE(name.startsWith(u"Ab")) + << "root should be Ab (radiko=0x25): " << name.toStdString(); + EXPECT_TRUE(name.contains(u"/F#")) + << "bass should be F# (baso=0x13): " << name.toStdString(); + + delete score; +} + +// Regression: CHORD symbol (type=7) text decoded unconditionally as UTF-16 LE. +TEST_F(Tst_Text, v0c4_chord_sym_latin1) +{ + MasterScore* score = readEncoreScore("text_chord_sym_latin1.enc"); + ASSERT_NE(score, nullptr) << "Failed to load text_chord_sym_latin1.enc"; + + Harmony* found = nullptr; + for (MeasureBase* mb = score->first(); mb && !found; mb = mb->next()) { + if (!mb->isMeasure()) { + continue; + } + for (Segment* s = toMeasure(mb)->first(SegmentType::ChordRest); s; s = s->next(SegmentType::ChordRest)) { + if (Harmony* h = segmentHarmony(s)) { + found = h; + break; + } + } + } + ASSERT_NE(found, nullptr) << "expected one Harmony from the chord-symbol element"; + EXPECT_EQ(found->harmonyName(), String(u"Am")) + << "Latin-1 chord text must decode as 'Am', not as UTF-16 gibberish"; + delete score; +} + +// Regression: TITL encoding inherited TK00 charSize; files with large TK offset but Latin-1 TITL mis-decoded. +TEST_F(Tst_Text, v0c4_titl_latin1_small_varsize) +{ + MasterScore* score = readEncoreScore("text_titl_latin1_small_varsize.enc"); + ASSERT_NE(score, nullptr) << "Failed to load text_titl_latin1_small_varsize.enc"; + + EXPECT_EQ(score->metaTag(u"workTitle"), String(u"Romeria")) + << "small-varsize TITL must decode as Latin-1, not as TWO_BYTES UTF-16"; + delete score; +} + +// Regression: formula-offset name recovery probed UTF-16 only; Latin-1 names were discarded silently. +TEST_F(Tst_Text, v0c4_recovered_name_latin1) +{ + MasterScore* score = readEncoreScore("text_recovered_name_latin1.enc"); + ASSERT_NE(score, nullptr) << "Failed to load text_recovered_name_latin1.enc"; + + ASSERT_GE(score->parts().size(), 1u); + const Part* part = score->parts()[0]; + ASSERT_NE(part, nullptr); + EXPECT_EQ(part->partName(), String(u"Tropa")) + << "Latin-1 name at NAME_BASE must be recovered when TK block name is empty"; + delete score; +} + +// Lightweight test macro for Tst_Text +#ifndef ENC_SANITY_TEST_TEXT +#define ENC_SANITY_TEST_TEXT(testName, fileName) \ + TEST_F(Tst_Text, testName) { \ + MasterScore* score = readEncoreScore(fileName); \ + ASSERT_NE(score, nullptr) << "Failed to load " << fileName; \ + EXPECT_GT(score->nmeasures(), 0); \ + muse::Ret ret = score->sanityCheck(); \ + EXPECT_TRUE(ret) << "Corrupted: " << ret.text(); \ + delete score; \ + } +#endif + +ENC_SANITY_TEST_TEXT(staff_text, "text_staff_text.enc") +ENC_SANITY_TEST_TEXT(titl_headers_footers, "text_titl_headers_footers.enc") +ENC_SANITY_TEST_TEXT(staff_text_placement, "text_staff_text_placement.enc") +ENC_SANITY_TEST_TEXT(keychange_to_c, "structure_keychange_to_c.enc") diff --git a/src/preferences/qml/MuseScore/Preferences/CMakeLists.txt b/src/preferences/qml/MuseScore/Preferences/CMakeLists.txt index 227df8c295e5f..aad86d018c976 100644 --- a/src/preferences/qml/MuseScore/Preferences/CMakeLists.txt +++ b/src/preferences/qml/MuseScore/Preferences/CMakeLists.txt @@ -99,6 +99,7 @@ qt_add_qml_module(preferences_qml internal/KeyboardLayoutsSection.qml internal/LanguagesSection.qml internal/MeiSection.qml + internal/EncoreSection.qml internal/MnxSection.qml internal/MidiDevicesSection.qml internal/MidiSection.qml diff --git a/src/preferences/qml/MuseScore/Preferences/ImportPreferencesPage.qml b/src/preferences/qml/MuseScore/Preferences/ImportPreferencesPage.qml index ae85f8d25ca02..7b86dbe14a49f 100644 --- a/src/preferences/qml/MuseScore/Preferences/ImportPreferencesPage.qml +++ b/src/preferences/qml/MuseScore/Preferences/ImportPreferencesPage.qml @@ -170,6 +170,98 @@ PreferencesPage { } } } + + SeparatorLine { } + + EncoreSection { + importPageLayout: importPreferencesModel.encoreImportPageLayout + importPageBreaks: importPreferencesModel.encoreImportPageBreaks + importSystemLocks: importPreferencesModel.encoreImportSystemLocks + importStaffSize: importPreferencesModel.encoreImportStaffSize + importTempoTextSemantic: importPreferencesModel.encoreImportTempoTextSemantic + importUnsupportedArticulationsAsText: importPreferencesModel.encoreImportUnsupportedArticulationsAsText + + instrumentSearchModeModel: importPreferencesModel.encoreInstrumentSearchModeModel() + currentInstrumentSearchMode: importPreferencesModel.encoreInstrumentSearchMode + + underfillStrategyModel: importPreferencesModel.encoreUnderfillStrategyModel() + currentUnderfillStrategy: importPreferencesModel.encoreUnderfillStrategy + overfillStrategyModel: importPreferencesModel.encoreOverfillStrategyModel() + currentOverfillStrategy: importPreferencesModel.encoreOverfillStrategy + + firstMeasureIsPickup: importPreferencesModel.encoreFirstMeasureIsPickup + mergeVoices: importPreferencesModel.encoreMergeVoices + + navigation.section: root.navigationSection + navigation.order: root.navigationOrderStart + 6 + + onImportPageLayoutChangeRequested: function(value) { + importPreferencesModel.encoreImportPageLayout = value + } + + onImportPageBreaksChangeRequested: function(value) { + importPreferencesModel.encoreImportPageBreaks = value + } + + onImportSystemLocksChangeRequested: function(value) { + importPreferencesModel.encoreImportSystemLocks = value + } + + onImportStaffSizeChangeRequested: function(value) { + importPreferencesModel.encoreImportStaffSize = value + } + + onImportTempoTextSemanticChangeRequested: function(value) { + importPreferencesModel.encoreImportTempoTextSemantic = value + } + + onImportUnsupportedArticulationsAsTextChangeRequested: function(value) { + importPreferencesModel.encoreImportUnsupportedArticulationsAsText = value + } + + onInstrumentSearchModeChangeRequested: function(value) { + importPreferencesModel.encoreInstrumentSearchMode = value + } + + onUnderfillStrategyChangeRequested: function(value) { + importPreferencesModel.encoreUnderfillStrategy = value + } + + onOverfillStrategyChangeRequested: function(value) { + importPreferencesModel.encoreOverfillStrategy = value + } + + onFirstMeasureIsPickupChangeRequested: function(value) { + importPreferencesModel.encoreFirstMeasureIsPickup = value + } + + onMergeVoicesChangeRequested: function(value) { + importPreferencesModel.encoreMergeVoices = value + } + + onResetToDefaultRequested: { + importPreferencesModel.encoreImportPageLayout = true + importPreferencesModel.encoreImportPageBreaks = true + importPreferencesModel.encoreImportSystemLocks = true + importPreferencesModel.encoreImportStaffSize = true + importPreferencesModel.encoreImportTempoTextSemantic = true + importPreferencesModel.encoreImportUnsupportedArticulationsAsText = false + importPreferencesModel.encoreInstrumentSearchMode = 0 // NameAndMidi + importPreferencesModel.encoreUnderfillStrategy = 2 // IrregularMeasure + importPreferencesModel.encoreOverfillStrategy = 2 // IrregularMeasure + importPreferencesModel.encoreFirstMeasureIsPickup = true + importPreferencesModel.encoreMergeVoices = true + } + + onFocusChanged: { + if (activeFocus) { + if (!suppressEnsureVisible) { + root.ensureContentVisibleRequested(Qt.rect(x, y, width, height)) + } + suppressEnsureVisible = false + } + } + } } function reset() { diff --git a/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.cpp b/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.cpp index 94a769cea3607..21d955d8bfcfd 100644 --- a/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.cpp +++ b/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.cpp @@ -75,6 +75,50 @@ void ImportPreferencesModel::load() musicXmlConfiguration()->needAskAboutApplyingNewStyleChanged().onReceive(this, [this](bool val) { emit needAskAboutApplyingNewStyleChanged(val); }); + + encoreConfiguration()->importPageLayoutChanged().onReceive(this, [this](bool val) { + emit encoreImportPageLayoutChanged(val); + }); + + encoreConfiguration()->importPageBreaksChanged().onReceive(this, [this](bool val) { + emit encoreImportPageBreaksChanged(val); + }); + + encoreConfiguration()->importSystemLocksChanged().onReceive(this, [this](bool val) { + emit encoreImportSystemLocksChanged(val); + }); + + encoreConfiguration()->importStaffSizeChanged().onReceive(this, [this](bool val) { + emit encoreImportStaffSizeChanged(val); + }); + + encoreConfiguration()->importTempoTextSemanticChanged().onReceive(this, [this](bool val) { + emit encoreImportTempoTextSemanticChanged(val); + }); + + encoreConfiguration()->importUnsupportedArticulationsAsTextChanged().onReceive(this, [this](bool val) { + emit encoreImportUnsupportedArticulationsAsTextChanged(val); + }); + + encoreConfiguration()->firstMeasureIsPickupChanged().onReceive(this, [this](bool val) { + emit encoreFirstMeasureIsPickupChanged(val); + }); + + encoreConfiguration()->mergeVoicesChanged().onReceive(this, [this](bool val) { + emit encoreMergeVoicesChanged(val); + }); + + encoreConfiguration()->instrumentSearchModeChanged().onReceive(this, [this](iex::enc::InstrumentSearchMode val) { + emit encoreInstrumentSearchModeChanged(static_cast(val)); + }); + + encoreConfiguration()->underfillMeasureStrategyChanged().onReceive(this, [this](iex::enc::UnderfillStrategy val) { + emit encoreUnderfillStrategyChanged(static_cast(val)); + }); + + encoreConfiguration()->overfillMeasureStrategyChanged().onReceive(this, [this](iex::enc::OverfillStrategy val) { + emit encoreOverfillStrategyChanged(static_cast(val)); + }); } QVariantList ImportPreferencesModel::charsets() const @@ -278,3 +322,197 @@ void ImportPreferencesModel::setMnxRequireExactSchemaValidation(bool value) mnxConfiguration()->setMnxRequireExactSchemaValidation(value); emit mnxRequireExactSchemaValidationChanged(value); } + +QVariantList ImportPreferencesModel::encoreInstrumentSearchModeModel() const +{ + return { + QVariantMap { { "title", muse::qtrc("preferences", "Try Name first, then MIDI") }, + { "value", static_cast(iex::enc::InstrumentSearchMode::NameAndMidi) } }, + QVariantMap { { "title", muse::qtrc("preferences", "Use MIDI program only") }, + { "value", static_cast(iex::enc::InstrumentSearchMode::MidiOnly) } }, + QVariantMap { { "title", muse::qtrc("preferences", "Set Grand Piano for all staves") }, + { "value", static_cast(iex::enc::InstrumentSearchMode::Piano) } }, + }; +} + +QVariantList ImportPreferencesModel::encoreUnderfillStrategyModel() const +{ + QVariantList result = { + QVariantMap { { "title", muse::qtrc("preferences", "Reduce measure time (irregular)") }, + { "value", static_cast(iex::enc::UnderfillStrategy::IrregularMeasure) } }, + QVariantMap { { "title", muse::qtrc("preferences", "Fill with rests") }, + { "value", static_cast(iex::enc::UnderfillStrategy::VisibleRests) } }, + QVariantMap { { "title", muse::qtrc("preferences", "Fill with invisible rests") }, + { "value", static_cast(iex::enc::UnderfillStrategy::InvisibleRests) } } + }; + + return result; +} + +QVariantList ImportPreferencesModel::encoreOverfillStrategyModel() const +{ + QVariantList result = { + QVariantMap { { "title", muse::qtrc("preferences", "Enlarge measure time (irregular)") }, + { "value", static_cast(iex::enc::OverfillStrategy::IrregularMeasure) } }, + QVariantMap { { "title", muse::qtrc("preferences", "Remove last notes") }, + { "value", static_cast(iex::enc::OverfillStrategy::Truncate) } }, + QVariantMap { { "title", muse::qtrc("preferences", "Stretch last notes") }, + { "value", static_cast(iex::enc::OverfillStrategy::StretchLastNote) } } + }; + + return result; +} + +bool ImportPreferencesModel::encoreImportPageLayout() const +{ + return encoreConfiguration()->importPageLayout(); +} + +void ImportPreferencesModel::setEncoreImportPageLayout(bool value) +{ + if (value == encoreImportPageLayout()) { + return; + } + + encoreConfiguration()->setImportPageLayout(value); +} + +bool ImportPreferencesModel::encoreImportPageBreaks() const +{ + return encoreConfiguration()->importPageBreaks(); +} + +void ImportPreferencesModel::setEncoreImportPageBreaks(bool value) +{ + if (value == encoreImportPageBreaks()) { + return; + } + + encoreConfiguration()->setImportPageBreaks(value); +} + +bool ImportPreferencesModel::encoreImportSystemLocks() const +{ + return encoreConfiguration()->importSystemLocks(); +} + +void ImportPreferencesModel::setEncoreImportSystemLocks(bool value) +{ + if (value == encoreImportSystemLocks()) { + return; + } + + encoreConfiguration()->setImportSystemLocks(value); +} + +bool ImportPreferencesModel::encoreImportStaffSize() const +{ + return encoreConfiguration()->importStaffSize(); +} + +void ImportPreferencesModel::setEncoreImportStaffSize(bool value) +{ + if (value == encoreImportStaffSize()) { + return; + } + + encoreConfiguration()->setImportStaffSize(value); +} + +bool ImportPreferencesModel::encoreImportTempoTextSemantic() const +{ + return encoreConfiguration()->importTempoTextSemantic(); +} + +void ImportPreferencesModel::setEncoreImportTempoTextSemantic(bool value) +{ + if (value == encoreImportTempoTextSemantic()) { + return; + } + + encoreConfiguration()->setImportTempoTextSemantic(value); +} + +bool ImportPreferencesModel::encoreImportUnsupportedArticulationsAsText() const +{ + return encoreConfiguration()->importUnsupportedArticulationsAsText(); +} + +void ImportPreferencesModel::setEncoreImportUnsupportedArticulationsAsText(bool value) +{ + if (value == encoreImportUnsupportedArticulationsAsText()) { + return; + } + + encoreConfiguration()->setImportUnsupportedArticulationsAsText(value); +} + +int ImportPreferencesModel::encoreInstrumentSearchMode() const +{ + return static_cast(encoreConfiguration()->instrumentSearchMode()); +} + +void ImportPreferencesModel::setEncoreInstrumentSearchMode(int value) +{ + if (value == encoreInstrumentSearchMode()) { + return; + } + + encoreConfiguration()->setInstrumentSearchMode(static_cast(value)); +} + +int ImportPreferencesModel::encoreUnderfillStrategy() const +{ + return static_cast(encoreConfiguration()->underfillMeasureStrategy()); +} + +void ImportPreferencesModel::setEncoreUnderfillStrategy(int value) +{ + if (value == encoreUnderfillStrategy()) { + return; + } + + encoreConfiguration()->setUnderfillMeasureStrategy(static_cast(value)); +} + +int ImportPreferencesModel::encoreOverfillStrategy() const +{ + return static_cast(encoreConfiguration()->overfillMeasureStrategy()); +} + +void ImportPreferencesModel::setEncoreOverfillStrategy(int value) +{ + if (value == encoreOverfillStrategy()) { + return; + } + + encoreConfiguration()->setOverfillMeasureStrategy(static_cast(value)); +} + +bool ImportPreferencesModel::encoreFirstMeasureIsPickup() const +{ + return encoreConfiguration()->firstMeasureIsPickup(); +} + +void ImportPreferencesModel::setEncoreFirstMeasureIsPickup(bool value) +{ + if (value == encoreFirstMeasureIsPickup()) { + return; + } + + encoreConfiguration()->setFirstMeasureIsPickup(value); +} + +bool ImportPreferencesModel::encoreMergeVoices() const +{ + return encoreConfiguration()->mergeVoices(); +} + +void ImportPreferencesModel::setEncoreMergeVoices(bool value) +{ + if (value == encoreMergeVoices()) { + return; + } + + encoreConfiguration()->setMergeVoices(value); +} diff --git a/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.h b/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.h index 0f2b1df7d77d9..1b270568451c3 100644 --- a/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.h +++ b/src/preferences/qml/MuseScore/Preferences/importpreferencesmodel.h @@ -34,6 +34,7 @@ #include "importexport/midi/imidiconfiguration.h" #include "importexport/mei/imeiconfiguration.h" #include "importexport/mnx/imnxconfiguration.h" +#include "importexport/encore/ienc-importconfiguration.h" #include "notation/inotationconfiguration.h" namespace mu::preferences { @@ -56,6 +57,24 @@ class ImportPreferencesModel : public QObject, public muse::Contextable, public Q_PROPERTY(bool mnxRequireExactSchemaValidation READ mnxRequireExactSchemaValidation WRITE setMnxRequireExactSchemaValidation NOTIFY mnxRequireExactSchemaValidationChanged) + Q_PROPERTY(bool encoreImportPageLayout READ encoreImportPageLayout WRITE setEncoreImportPageLayout NOTIFY encoreImportPageLayoutChanged) + Q_PROPERTY(bool encoreImportPageBreaks READ encoreImportPageBreaks WRITE setEncoreImportPageBreaks NOTIFY encoreImportPageBreaksChanged) + Q_PROPERTY( + bool encoreImportSystemLocks READ encoreImportSystemLocks WRITE setEncoreImportSystemLocks NOTIFY encoreImportSystemLocksChanged) + Q_PROPERTY(bool encoreImportStaffSize READ encoreImportStaffSize WRITE setEncoreImportStaffSize NOTIFY encoreImportStaffSizeChanged) + Q_PROPERTY(bool encoreImportTempoTextSemantic READ encoreImportTempoTextSemantic WRITE setEncoreImportTempoTextSemantic + NOTIFY encoreImportTempoTextSemanticChanged) + Q_PROPERTY(bool encoreImportUnsupportedArticulationsAsText READ encoreImportUnsupportedArticulationsAsText + WRITE setEncoreImportUnsupportedArticulationsAsText NOTIFY encoreImportUnsupportedArticulationsAsTextChanged) + Q_PROPERTY(int encoreInstrumentSearchMode READ encoreInstrumentSearchMode WRITE setEncoreInstrumentSearchMode + NOTIFY encoreInstrumentSearchModeChanged) + Q_PROPERTY( + int encoreUnderfillStrategy READ encoreUnderfillStrategy WRITE setEncoreUnderfillStrategy NOTIFY encoreUnderfillStrategyChanged) + Q_PROPERTY(int encoreOverfillStrategy READ encoreOverfillStrategy WRITE setEncoreOverfillStrategy NOTIFY encoreOverfillStrategyChanged) + Q_PROPERTY(bool encoreFirstMeasureIsPickup READ encoreFirstMeasureIsPickup WRITE setEncoreFirstMeasureIsPickup + NOTIFY encoreFirstMeasureIsPickupChanged) + Q_PROPERTY(bool encoreMergeVoices READ encoreMergeVoices WRITE setEncoreMergeVoices NOTIFY encoreMergeVoicesChanged) + Q_PROPERTY(int currentShortestNote READ currentShortestNote WRITE setCurrentShortestNote NOTIFY currentShortestNoteChanged) Q_PROPERTY(bool roundTempo READ roundTempo WRITE setRoundTempo NOTIFY roundTempoChanged) @@ -68,6 +87,7 @@ class ImportPreferencesModel : public QObject, public muse::Contextable, public muse::GlobalInject midiImportExportConfiguration; muse::GlobalInject meiConfiguration; muse::GlobalInject mnxConfiguration; + muse::GlobalInject encoreConfiguration; muse::GlobalInject notationConfiguration; public: @@ -77,6 +97,9 @@ class ImportPreferencesModel : public QObject, public muse::Contextable, public Q_INVOKABLE QVariantList charsets() const; Q_INVOKABLE QVariantList shortestNotes() const; + Q_INVOKABLE QVariantList encoreInstrumentSearchModeModel() const; + Q_INVOKABLE QVariantList encoreUnderfillStrategyModel() const; + Q_INVOKABLE QVariantList encoreOverfillStrategyModel() const; Q_INVOKABLE QStringList stylePathFilter() const; Q_INVOKABLE QString styleChooseTitle() const; Q_INVOKABLE QString fileDirectory(const QString& filePath) const; @@ -97,6 +120,18 @@ class ImportPreferencesModel : public QObject, public muse::Contextable, public bool meiImportLayout() const; bool mnxRequireExactSchemaValidation() const; + bool encoreImportPageLayout() const; + bool encoreImportPageBreaks() const; + bool encoreImportSystemLocks() const; + bool encoreImportStaffSize() const; + bool encoreImportTempoTextSemantic() const; + bool encoreImportUnsupportedArticulationsAsText() const; + int encoreInstrumentSearchMode() const; + int encoreUnderfillStrategy() const; + int encoreOverfillStrategy() const; + bool encoreFirstMeasureIsPickup() const; + bool encoreMergeVoices() const; + public slots: void setStyleFileImportPath(QString path); void setCurrentOvertureCharset(QString charset); @@ -114,6 +149,18 @@ public slots: void setMeiImportLayout(bool import); void setMnxRequireExactSchemaValidation(bool value); + void setEncoreImportPageLayout(bool value); + void setEncoreImportPageBreaks(bool value); + void setEncoreImportSystemLocks(bool value); + void setEncoreImportStaffSize(bool value); + void setEncoreImportTempoTextSemantic(bool value); + void setEncoreImportUnsupportedArticulationsAsText(bool value); + void setEncoreInstrumentSearchMode(int value); + void setEncoreUnderfillStrategy(int value); + void setEncoreOverfillStrategy(int value); + void setEncoreFirstMeasureIsPickup(bool value); + void setEncoreMergeVoices(bool value); + signals: void styleFileImportPathChanged(QString styleFileImportPath); void currentOvertureCharsetChanged(QString currentOvertureCharset); @@ -126,5 +173,17 @@ public slots: void needAskAboutApplyingNewStyleChanged(bool needAskAboutApplyingNewStyle); void meiImportLayoutChanged(bool importLayout); void mnxRequireExactSchemaValidationChanged(bool value); + + void encoreImportPageLayoutChanged(bool value); + void encoreImportPageBreaksChanged(bool value); + void encoreImportSystemLocksChanged(bool value); + void encoreImportStaffSizeChanged(bool value); + void encoreImportTempoTextSemanticChanged(bool value); + void encoreImportUnsupportedArticulationsAsTextChanged(bool value); + void encoreInstrumentSearchModeChanged(int value); + void encoreUnderfillStrategyChanged(int value); + void encoreOverfillStrategyChanged(int value); + void encoreFirstMeasureIsPickupChanged(bool value); + void encoreMergeVoicesChanged(bool value); }; } diff --git a/src/preferences/qml/MuseScore/Preferences/internal/EncoreSection.qml b/src/preferences/qml/MuseScore/Preferences/internal/EncoreSection.qml new file mode 100644 index 0000000000000..49d5ec722497d --- /dev/null +++ b/src/preferences/qml/MuseScore/Preferences/internal/EncoreSection.qml @@ -0,0 +1,267 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Layouts + +import Muse.Ui +import Muse.UiComponents + +BaseSection { + id: root + + // Suppress BaseSection default title; we render it ourselves in the header row. + title: "" + + // Layout group + property alias importPageLayout: importPageLayoutBox.checked + property alias importPageBreaks: importPageBreaksBox.checked + property alias importSystemLocks: importSystemLocksBox.checked + property alias importStaffSize: importStaffSizeBox.checked + + // Text / content group + property alias importTempoTextSemantic: importTempoTextSemanticBox.checked + property alias importUnsupportedArticulationsAsText: importUnsupportedArticulationsAsTextBox.checked + + // Instrument search + property alias instrumentSearchModeModel: instrumentSearchModeBox.model + property int currentInstrumentSearchMode: 0 + + // Measure correction group + property alias underfillStrategyModel: underfillStrategyBox.model + property int currentUnderfillStrategy: 0 + + property alias overfillStrategyModel: overfillStrategyBox.model + property int currentOverfillStrategy: 0 + + property alias firstMeasureIsPickup: firstMeasureIsPickupBox.checked + + property alias mergeVoices: mergeVoicesBox.checked + + // When a dropdown popup opens it steals the section's activeFocus; on close the + // section regains it and would otherwise snap the scroll back to the section top. + // This flag suppresses that single ensure-visible so the scroll position is kept. + property bool suppressEnsureVisible: false + + signal importPageLayoutChangeRequested(bool value) + signal importPageBreaksChangeRequested(bool value) + signal importSystemLocksChangeRequested(bool value) + signal importStaffSizeChangeRequested(bool value) + signal importTempoTextSemanticChangeRequested(bool value) + signal importUnsupportedArticulationsAsTextChangeRequested(bool value) + signal instrumentSearchModeChangeRequested(int value) + signal underfillStrategyChangeRequested(int value) + signal overfillStrategyChangeRequested(int value) + signal firstMeasureIsPickupChangeRequested(bool value) + signal mergeVoicesChangeRequested(bool value) + signal resetToDefaultRequested() + + // Header: section title + reset button + RowLayout { + width: parent.width + spacing: 6 + + StyledTextLabel { + text: qsTrc("preferences", "ENCORE") + font: ui.theme.bodyBoldFont + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + + FlatButton { + Layout.alignment: Qt.AlignVCenter + width: 24 + height: 24 + + icon: IconCode.UNDO + toolTipTitle: qsTrc("preferences", "Reset to default") + + navigation.name: "EncoreResetToDefaultButton" + navigation.panel: root.navigation + navigation.row: 10 + + onClicked: root.resetToDefaultRequested() + } + } + + // Instrument search — first option + ComboBoxWithTitle { + id: instrumentSearchModeBox + + title: qsTrc("preferences", "Instrument search") + columnWidth: root.columnWidth + + currentIndex: indexOfValue(root.currentInstrumentSearchMode) + + textRole: "title" + valueRole: "value" + + navigationName: "EncoreInstrumentSearchModeBox" + navigationPanel: root.navigation + navigationRow: 0 + + onIsOpenedChanged: if (isOpened) { root.suppressEnsureVisible = true } + + onValueEdited: function(newIndex, newValue) { + root.instrumentSearchModeChangeRequested(newValue) + } + } + + // Two-column grid for checkboxes + GridLayout { + width: parent.width + columns: 2 + columnSpacing: 12 + rowSpacing: 0 + + CheckBox { + id: importPageLayoutBox + Layout.fillWidth: true + text: qsTrc("preferences", "Apply page size and margins") + navigation.name: "EncoreImportPageLayoutBox" + navigation.panel: root.navigation + navigation.row: 1 + onClicked: root.importPageLayoutChangeRequested(!checked) + } + + CheckBox { + id: importStaffSizeBox + Layout.fillWidth: true + text: qsTrc("preferences", "Apply staff size scaling") + navigation.name: "EncoreImportStaffSizeBox" + navigation.panel: root.navigation + navigation.row: 2 + onClicked: root.importStaffSizeChangeRequested(!checked) + } + + CheckBox { + id: importSystemLocksBox + Layout.fillWidth: true + text: qsTrc("preferences", "Import line breaks") + navigation.name: "EncoreImportSystemLocksBox" + navigation.panel: root.navigation + navigation.row: 3 + onClicked: root.importSystemLocksChangeRequested(!checked) + } + + CheckBox { + id: importPageBreaksBox + Layout.fillWidth: true + text: qsTrc("preferences", "Import page breaks") + navigation.name: "EncoreImportPageBreaksBox" + navigation.panel: root.navigation + navigation.row: 4 + onClicked: root.importPageBreaksChangeRequested(!checked) + } + + CheckBox { + id: importTempoTextSemanticBox + Layout.fillWidth: true + text: qsTrc("preferences", "Prefer tempo marks (♩=80) to BPM") + navigation.name: "EncoreImportTempoTextSemanticBox" + navigation.panel: root.navigation + navigation.row: 5 + onClicked: root.importTempoTextSemanticChangeRequested(!checked) + } + + CheckBox { + id: importUnsupportedArticulationsAsTextBox + Layout.fillWidth: true + text: qsTrc("preferences", "Show unrecognized articulations as text") + navigation.name: "EncoreImportUnsupportedArticulationsAsTextBox" + navigation.panel: root.navigation + navigation.row: 6 + onClicked: root.importUnsupportedArticulationsAsTextChangeRequested(!checked) + } + + // First measure — before measure correction dropdowns + CheckBox { + id: firstMeasureIsPickupBox + Layout.fillWidth: true + text: qsTrc("preferences", "Treat first measure as pickup") + navigation.name: "EncoreFirstMeasureIsPickupBox" + navigation.panel: root.navigation + navigation.row: 7 + onClicked: root.firstMeasureIsPickupChangeRequested(!checked) + } + + // Collapse voices that never overlap back into a single voice + CheckBox { + id: mergeVoicesBox + Layout.fillWidth: true + text: qsTrc("preferences", "Combine non-overlapping voices into one") + navigation.name: "EncoreMergeVoicesBox" + navigation.panel: root.navigation + navigation.row: 8 + onClicked: root.mergeVoicesChangeRequested(!checked) + } + } + + // Short/Long measures kept tighter together than the section's default row spacing. + Column { + width: parent.width + spacing: 6 + + ComboBoxWithTitle { + id: underfillStrategyBox + + title: qsTrc("preferences", "Short measures") + columnWidth: root.columnWidth + + currentIndex: indexOfValue(root.currentUnderfillStrategy) + + textRole: "title" + valueRole: "value" + + navigationName: "EncoreUnderfillStrategyBox" + navigationPanel: root.navigation + navigationRow: 8 + + onIsOpenedChanged: if (isOpened) { root.suppressEnsureVisible = true } + + onValueEdited: function(newIndex, newValue) { + root.underfillStrategyChangeRequested(newValue) + } + } + + ComboBoxWithTitle { + id: overfillStrategyBox + + title: qsTrc("preferences", "Long measures") + columnWidth: root.columnWidth + + currentIndex: indexOfValue(root.currentOverfillStrategy) + + textRole: "title" + valueRole: "value" + + navigationName: "EncoreOverfillStrategyBox" + navigationPanel: root.navigation + navigationRow: 9 + + onIsOpenedChanged: if (isOpened) { root.suppressEnsureVisible = true } + + onValueEdited: function(newIndex, newValue) { + root.overfillStrategyChangeRequested(newValue) + } + } + } +} diff --git a/src/project/internal/projectactionscontroller.cpp b/src/project/internal/projectactionscontroller.cpp index 4ef1a3e6d79fa..9819701ca3e2b 100644 --- a/src/project/internal/projectactionscontroller.cpp +++ b/src/project/internal/projectactionscontroller.cpp @@ -1902,7 +1902,7 @@ void ProjectActionsController::printScore() async::Promise ProjectActionsController::selectScoreOpeningFile() const { std::string allExt = "*.mscz *.mxl *.musicxml *.xml *.mid *.midi *.kar *.md *.mgu *.sgu *.cap *.capx " - "*.ove *.scw *.bmw *.bww *.gtp *.gp3 *.gp4 *.gp5 *.gpx *.gp *.ptb *.mei *.mnx *.json *.tef *.mscx *.mscs *.mscz~"; + "*.ove *.scw *.bmw *.bww *.gtp *.gp3 *.gp4 *.gp5 *.gpx *.gp *.ptb *.mei *.mnx *.json *.tef *.enc *.mscx *.mscs *.mscz~"; std::vector filter { muse::trc("project", "All supported files") + " (" + allExt + ")", muse::trc("project", "MuseScore files") + " (*.mscz)", @@ -1918,6 +1918,7 @@ async::Promise ProjectActionsController::selectScoreOpeningFile() co muse::trc("project", "Power Tab Editor files (experimental)") + " (*.ptb)", muse::trc("project", "MEI files") + " (*.mei)", muse::trc("project", "TablEdit files (experimental)") + " (*.tef)", + muse::trc("project", "Encore files") + " (*.enc)", muse::trc("project", "Uncompressed MuseScore folders (experimental)") + " (*.mscx)", muse::trc("project", "MuseScore developer files") + " (*.mscs)", muse::trc("project", "MuseScore backup files") + " (*.mscz~)" };