From 691eaf45f161eba6dbf52a0bf8a5fa1ed6352205 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 23 May 2026 10:00:00 +0200 Subject: [PATCH 01/33] enc-doc: document the Encore binary file format --- src/importexport/encore/ENCORE_FORMAT.md | 1313 ++++++++++++++++++++++ 1 file changed, 1313 insertions(+) create mode 100644 src/importexport/encore/ENCORE_FORMAT.md 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. From 62842ffeab3bfed0c99f49674c376e7579dfaf75 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 23 May 2026 13:00:00 +0200 Subject: [PATCH 02/33] enc-feat: open Encore (.enc) scores in MuseScore --- CMakeLists.txt | 1 + src/app/CMakeLists.txt | 1 + src/app/app_config.h.in | 1 + src/app/appfactory.cpp | 9 + src/importexport/CMakeLists.txt | 3 + src/importexport/encore/CMakeLists.txt | 36 +++ src/importexport/encore/enc-module.cpp | 63 ++++ src/importexport/encore/enc-module.h | 47 +++ .../encore/ienc-importconfiguration.h | 84 ++++++ .../internal/enc-importconfiguration.cpp | 272 ++++++++++++++++++ .../encore/internal/enc-importconfiguration.h | 93 ++++++ .../encore/internal/importer/import-options.h | 81 ++++++ .../encore/internal/importer/import.cpp | 152 ++++++++++ .../encore/internal/importer/import.h | 50 ++++ .../encore/internal/notationencreader.cpp | 54 ++++ .../encore/internal/notationencreader.h | 43 +++ .../internal/projectactionscontroller.cpp | 3 +- 17 files changed, 992 insertions(+), 1 deletion(-) create mode 100644 src/importexport/encore/CMakeLists.txt create mode 100644 src/importexport/encore/enc-module.cpp create mode 100644 src/importexport/encore/enc-module.h create mode 100644 src/importexport/encore/ienc-importconfiguration.h create mode 100644 src/importexport/encore/internal/enc-importconfiguration.cpp create mode 100644 src/importexport/encore/internal/enc-importconfiguration.h create mode 100644 src/importexport/encore/internal/importer/import-options.h create mode 100644 src/importexport/encore/internal/importer/import.cpp create mode 100644 src/importexport/encore/internal/importer/import.h create mode 100644 src/importexport/encore/internal/notationencreader.cpp create mode 100644 src/importexport/encore/internal/notationencreader.h 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..d6cd5cc25d9c8 --- /dev/null +++ b/src/importexport/encore/CMakeLists.txt @@ -0,0 +1,36 @@ +# 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/notationencreader.cpp + internal/notationencreader.h + internal/importer/import.cpp + internal/importer/import.h + internal/importer/import-options.h +) + +target_link_libraries(iex_encore PRIVATE engraving) 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/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..749bee5faf5fe --- /dev/null +++ b/src/importexport/encore/internal/importer/import.cpp @@ -0,0 +1,152 @@ +/* + * 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 "import.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 { + +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; + } + } + + return Err::FileBadFormat; +} +} // 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/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/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~)" }; From 2022b1420b2fa4fcdc3450c87513603f0408a818 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 23 May 2026 16:00:00 +0200 Subject: [PATCH 03/33] enc-test: add the Encore importer test infrastructure --- src/importexport/encore/CMakeLists.txt | 4 + src/importexport/encore/tests/CMakeLists.txt | 37 +++++ src/importexport/encore/tests/environment.cpp | 49 ++++++ src/importexport/encore/tests/testbase.cpp | 154 ++++++++++++++++++ src/importexport/encore/tests/testbase.h | 60 +++++++ src/importexport/encore/tests/tst_smoke.cpp | 90 ++++++++++ 6 files changed, 394 insertions(+) create mode 100644 src/importexport/encore/tests/CMakeLists.txt create mode 100644 src/importexport/encore/tests/environment.cpp create mode 100644 src/importexport/encore/tests/testbase.cpp create mode 100644 src/importexport/encore/tests/testbase.h create mode 100644 src/importexport/encore/tests/tst_smoke.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index d6cd5cc25d9c8..cacd5fae30238 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -34,3 +34,7 @@ target_sources(iex_encore PRIVATE ) target_link_libraries(iex_encore PRIVATE engraving) + +if (MUE_BUILD_IMPORTEXPORT_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt new file mode 100644 index 0000000000000..9d1f8649cb4f7 --- /dev/null +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +# 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 +) + +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/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_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); +} From 5c02ace3d2dccb4a103c46d28608e4881d9799b0 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 24 May 2026 10:00:00 +0200 Subject: [PATCH 04/33] enc-feat: model the Encore tick and duration system --- src/importexport/encore/CMakeLists.txt | 4 + .../encore/internal/importer/durations.cpp | 208 +++++++++++++++++ .../encore/internal/importer/durations.h | 53 +++++ .../encore/internal/parser/ticks.cpp | 63 +++++ .../encore/internal/parser/ticks.h | 46 ++++ src/importexport/encore/tests/CMakeLists.txt | 1 + .../encore/tests/tst_parser_ticks.cpp | 220 ++++++++++++++++++ 7 files changed, 595 insertions(+) create mode 100644 src/importexport/encore/internal/importer/durations.cpp create mode 100644 src/importexport/encore/internal/importer/durations.h create mode 100644 src/importexport/encore/internal/parser/ticks.cpp create mode 100644 src/importexport/encore/internal/parser/ticks.h create mode 100644 src/importexport/encore/tests/tst_parser_ticks.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index cacd5fae30238..9793322a127d6 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -26,9 +26,13 @@ target_sources(iex_encore PRIVATE ienc-importconfiguration.h internal/enc-importconfiguration.h internal/enc-importconfiguration.cpp + internal/parser/ticks.cpp + internal/parser/ticks.h internal/notationencreader.cpp internal/notationencreader.h internal/importer/import.cpp + internal/importer/durations.h + internal/importer/durations.cpp internal/importer/import.h internal/importer/import-options.h ) 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/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 index 9d1f8649cb4f7..84a05e9e2cd20 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -25,6 +25,7 @@ set(MODULE_TEST_SRC ${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 ) set(MODULE_TEST_LINK 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..ad13472b8d76b --- /dev/null +++ b/src/importexport/encore/tests/tst_parser_ticks.cpp @@ -0,0 +1,220 @@ +/* + * 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/importer/durations.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); +} + +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)); +} From e726d2825aaead4f4f028d0e918db66bbe7d35c7 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 24 May 2026 13:00:00 +0200 Subject: [PATCH 05/33] enc-feat: model Encore score contents --- src/importexport/encore/CMakeLists.txt | 1 + .../encore/internal/parser/elem-enums.h | 198 ++++++++++++++++ .../encore/internal/parser/elem-measure.h | 67 ++++++ .../encore/internal/parser/elem-note.h | 187 +++++++++++++++ .../encore/internal/parser/elem-ornament.h | 57 +++++ .../encore/internal/parser/elem-root.h | 222 ++++++++++++++++++ .../encore/internal/parser/elem-text.h | 71 ++++++ .../encore/internal/parser/elem.h | 35 +++ 8 files changed, 838 insertions(+) create mode 100644 src/importexport/encore/internal/parser/elem-enums.h create mode 100644 src/importexport/encore/internal/parser/elem-measure.h create mode 100644 src/importexport/encore/internal/parser/elem-note.h create mode 100644 src/importexport/encore/internal/parser/elem-ornament.h create mode 100644 src/importexport/encore/internal/parser/elem-root.h create mode 100644 src/importexport/encore/internal/parser/elem-text.h create mode 100644 src/importexport/encore/internal/parser/elem.h diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 9793322a127d6..917f8ee13d5e3 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -26,6 +26,7 @@ target_sources(iex_encore PRIVATE ienc-importconfiguration.h internal/enc-importconfiguration.h internal/enc-importconfiguration.cpp + internal/parser/elem.h internal/parser/ticks.cpp internal/parser/ticks.h internal/notationencreader.cpp 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 From 77072bec54fe8e91309b1ab857e8d5b921d6c512 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 24 May 2026 16:00:00 +0200 Subject: [PATCH 06/33] enc-feat: parse the Encore header and block structure --- src/importexport/encore/CMakeLists.txt | 4 + .../internal/parser/parsers-encoding.cpp | 125 +++++++++++ .../encore/internal/parser/parsers-encoding.h | 44 ++++ .../encore/internal/parser/readers.cpp | 69 ++++++ .../encore/internal/parser/readers.h | 196 ++++++++++++++++++ .../encore/tests/tst_parser_ticks.cpp | 11 + 6 files changed, 449 insertions(+) create mode 100644 src/importexport/encore/internal/parser/parsers-encoding.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-encoding.h create mode 100644 src/importexport/encore/internal/parser/readers.cpp create mode 100644 src/importexport/encore/internal/parser/readers.h diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 917f8ee13d5e3..85a0fc2e05edc 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -26,6 +26,10 @@ target_sources(iex_encore PRIVATE ienc-importconfiguration.h internal/enc-importconfiguration.h internal/enc-importconfiguration.cpp + internal/parser/parsers-encoding.h + internal/parser/parsers-encoding.cpp + internal/parser/readers.h + internal/parser/readers.cpp internal/parser/elem.h internal/parser/ticks.cpp internal/parser/ticks.h 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/readers.cpp b/src/importexport/encore/internal/parser/readers.cpp new file mode 100644 index 0000000000000..007ec22323f39 --- /dev/null +++ b/src/importexport/encore/internal/parser/readers.cpp @@ -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 . + */ + +// Shared block-skip/clamp helper implementations and the EncFormatReader factory (version to reader). + +#include "readers.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); +} +} // 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/tests/tst_parser_ticks.cpp b/src/importexport/encore/tests/tst_parser_ticks.cpp index ad13472b8d76b..658b07a4b476b 100644 --- a/src/importexport/encore/tests/tst_parser_ticks.cpp +++ b/src/importexport/encore/tests/tst_parser_ticks.cpp @@ -26,6 +26,7 @@ #include #include "../internal/parser/ticks.h" +#include "../internal/parser/readers.h" #include "../internal/importer/durations.h" #include @@ -218,3 +219,13 @@ TEST(Tst_EncoreRhythm, dottedAdvance) EXPECT_EQ(dottedAdvance(DurationType::V_QUARTER, 0), Fraction(1, 4)); EXPECT_EQ(dottedAdvance(DurationType::V_QUARTER, 1), Fraction(3, 8)); } + +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); +} From b47ec73c4dc111dd0634ff8f5a032c893a4441ff Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 30 May 2026 10:00:00 +0200 Subject: [PATCH 07/33] enc-feat: parse measures, notes and rests --- src/importexport/encore/CMakeLists.txt | 6 + .../internal/parser/parsers-measure.cpp | 391 ++++++++++++ .../encore/internal/parser/parsers-note.cpp | 122 ++++ .../internal/parser/readers-v0xc4-base.cpp | 584 ++++++++++++++++++ .../internal/parser/readers-v0xc4-base.h | 42 ++ .../encore/internal/parser/readers-v0xc4.cpp | 75 +++ .../encore/internal/parser/readers-v0xc4.h | 38 ++ .../encore/internal/parser/readers.cpp | 18 + .../encore/tests/tst_parser_ticks.cpp | 21 + 9 files changed, 1297 insertions(+) create mode 100644 src/importexport/encore/internal/parser/parsers-measure.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-note.cpp create mode 100644 src/importexport/encore/internal/parser/readers-v0xc4-base.cpp create mode 100644 src/importexport/encore/internal/parser/readers-v0xc4-base.h create mode 100644 src/importexport/encore/internal/parser/readers-v0xc4.cpp create mode 100644 src/importexport/encore/internal/parser/readers-v0xc4.h diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 85a0fc2e05edc..6f3b8585e3218 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -26,10 +26,16 @@ target_sources(iex_encore PRIVATE ienc-importconfiguration.h internal/enc-importconfiguration.h internal/enc-importconfiguration.cpp + internal/parser/parsers-note.cpp + internal/parser/parsers-measure.cpp internal/parser/parsers-encoding.h internal/parser/parsers-encoding.cpp internal/parser/readers.h internal/parser/readers.cpp + internal/parser/readers-v0xc4-base.h + internal/parser/readers-v0xc4-base.cpp + internal/parser/readers-v0xc4.h + internal/parser/readers-v0xc4.cpp internal/parser/elem.h internal/parser/ticks.cpp internal/parser/ticks.h 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..e8eedf4e2ad37 --- /dev/null +++ b/src/importexport/encore/internal/parser/parsers-measure.cpp @@ -0,0 +1,391 @@ +/* + * 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::KEYCHANGE: + 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-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/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 index 007ec22323f39..8ec7a5b8dafb5 100644 --- a/src/importexport/encore/internal/parser/readers.cpp +++ b/src/importexport/encore/internal/parser/readers.cpp @@ -23,6 +23,7 @@ // Shared block-skip/clamp helper implementations and the EncFormatReader factory (version to reader). #include "readers.h" +#include "readers-v0xc4.h" #include #include @@ -66,4 +67,21 @@ qint64 clampMeasureEnd(qint64 measStart, quint32 varsize, qint64 elemBlockOffset 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::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/tests/tst_parser_ticks.cpp b/src/importexport/encore/tests/tst_parser_ticks.cpp index 658b07a4b476b..3293d2e994021 100644 --- a/src/importexport/encore/tests/tst_parser_ticks.cpp +++ b/src/importexport/encore/tests/tst_parser_ticks.cpp @@ -26,6 +26,7 @@ #include #include "../internal/parser/ticks.h" +#include "../internal/parser/elem.h" #include "../internal/parser/readers.h" #include "../internal/importer/durations.h" @@ -229,3 +230,23 @@ TEST(Tst_EncoreParserBounds, clampMeasureEnd_clamps_oversized_varsize) // 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); +} From 1ee008606a6b153a79546a2bf335d18abdda7996 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 30 May 2026 13:00:00 +0200 Subject: [PATCH 08/33] enc-feat: parse ornaments, text, chords and metadata --- src/importexport/encore/CMakeLists.txt | 6 + .../encore/internal/parser/parsers-chord.cpp | 164 ++++++++ .../internal/parser/parsers-lyric-tie.cpp | 86 +++++ .../internal/parser/parsers-measure.cpp | 19 + .../internal/parser/parsers-metadata.cpp | 269 +++++++++++++ .../internal/parser/parsers-ornament.cpp | 78 ++++ .../encore/internal/parser/parsers-root.cpp | 361 ++++++++++++++++++ .../encore/internal/parser/parsers-text.cpp | 178 +++++++++ src/importexport/encore/tests/CMakeLists.txt | 1 + .../encore/tests/tst_parser_chord.cpp | 252 ++++++++++++ .../encore/tests/tst_parser_ticks.cpp | 39 ++ 11 files changed, 1453 insertions(+) create mode 100644 src/importexport/encore/internal/parser/parsers-chord.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-lyric-tie.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-metadata.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-ornament.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-root.cpp create mode 100644 src/importexport/encore/internal/parser/parsers-text.cpp create mode 100644 src/importexport/encore/tests/tst_parser_chord.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 6f3b8585e3218..f3388a47c2b2e 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -27,7 +27,13 @@ target_sources(iex_encore PRIVATE 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 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-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 index e8eedf4e2ad37..43051e5b963bc 100644 --- a/src/importexport/encore/internal/parser/parsers-measure.cpp +++ b/src/importexport/encore/internal/parser/parsers-measure.cpp @@ -43,8 +43,27 @@ static std::unique_ptr createMeasureElement( 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); 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-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/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 84a05e9e2cd20..ff8b3fcc14b21 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -26,6 +26,7 @@ set(MODULE_TEST_SRC ${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_chord.cpp ) set(MODULE_TEST_LINK 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 index 3293d2e994021..a29dfdaacd04a 100644 --- a/src/importexport/encore/tests/tst_parser_ticks.cpp +++ b/src/importexport/encore/tests/tst_parser_ticks.cpp @@ -221,6 +221,45 @@ TEST(Tst_EncoreRhythm, dottedAdvance) 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. From 2ddadd771ac69c2451f1da1816ae6011e06fbe57 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 30 May 2026 16:00:00 +0200 Subject: [PATCH 09/33] enc-feat: import instruments and staves --- src/importexport/encore/CMakeLists.txt | 7 + .../internal/importer/builders-parts.cpp | 396 +++++++++ .../encore/internal/importer/builders.h | 35 + .../encore/internal/importer/ctx.h | 72 ++ .../encore/internal/importer/debug-dump.cpp | 221 +++++ .../encore/internal/importer/debug-dump.h | 30 + .../encore/internal/importer/import.cpp | 40 +- .../internal/importer/mappers-instruments.cpp | 534 ++++++++++++ .../encore/internal/importer/mappers.h | 85 ++ src/importexport/encore/tests/CMakeLists.txt | 1 + .../tests/data/importer_transp_oboe_jota.enc | Bin 0 -> 25940 bytes .../instruments_abbreviated_name_bandurr.enc | Bin 0 -> 25912 bytes .../instruments_compact_no_tk_midi_oboe.enc | Bin 0 -> 25912 bytes ...struments_compact_short_header_no_midi.enc | Bin 0 -> 23480 bytes ...nstruments_compact_tk_ignores_key_byte.enc | Bin 0 -> 25940 bytes .../data/instruments_fuzzy_name_match.enc | Bin 0 -> 25940 bytes .../data/instruments_gm_perc_range_taiko.enc | Bin 0 -> 25912 bytes .../instruments_instr_bass_midi_tiebreak.enc | Bin 0 -> 25912 bytes ...instruments_instr_clarinet_midi72_key0.enc | Bin 0 -> 25912 bytes ...ruments_instr_clarinet_midi72_key_neg2.enc | Bin 0 -> 25912 bytes .../instruments_instr_drums_name_drumset.enc | Bin 0 -> 25912 bytes ...nstruments_instr_empty_name_midi_cello.enc | Bin 0 -> 25912 bytes ...ruments_instr_empty_name_midi_clarinet.enc | Bin 0 -> 25912 bytes .../data/instruments_instr_laud_accent.enc | Bin 0 -> 25912 bytes .../instruments_instr_perc_clef_drumset.enc | Bin 0 -> 25912 bytes .../instruments_instr_percussion_drumset.enc | Bin 0 -> 25912 bytes ...uments_instr_recorder_midi75_trackname.enc | Bin 0 -> 25912 bytes .../instruments_instrument_count_padding.enc | Bin 0 -> 25912 bytes .../data/instruments_name_dash_separator.enc | Bin 0 -> 25912 bytes .../tests/data/instruments_name_recovery.enc | Bin 0 -> 25912 bytes .../data/instruments_name_trailing_number.enc | Bin 0 -> 25912 bytes .../instruments_no_tk_blocks_midi_key.enc | Bin 0 -> 25940 bytes .../instruments_no_tk_large_tk_two_names.enc | Bin 0 -> 25912 bytes .../data/instruments_no_tk_name_fallback.enc | Bin 0 -> 25940 bytes .../data/instruments_no_tk_name_latin1.enc | Bin 0 -> 25940 bytes .../data/instruments_no_tk_name_recovered.enc | Bin 0 -> 25940 bytes .../data/instruments_rhythm_staff_snare.enc | Bin 0 -> 25912 bytes .../tests/data/instruments_sco5_tk_names.enc | Bin 0 -> 591 bytes .../tests/data/instruments_small_tk_key6.enc | Bin 0 -> 25912 bytes .../data/instruments_small_tk_midi49.enc | Bin 0 -> 25912 bytes .../tests/data/instruments_staff_hidden.enc | Bin 0 -> 25912 bytes .../instruments_tab_clef_keeps_tablature.enc | Bin 0 -> 25912 bytes ...struments_tab_template_forced_standard.enc | Bin 0 -> 25912 bytes ...nstruments_tk_empty_name_authoritative.enc | Bin 0 -> 23906 bytes .../data/instruments_tk_onebyte_name.enc | Bin 0 -> 25912 bytes .../tests/data/instruments_tk_probe_utf16.enc | Bin 0 -> 25912 bytes .../tests/data/instruments_tk_utf16_name.enc | Bin 0 -> 25912 bytes .../instruments_total_size_tk_two_instrs.enc | Bin 0 -> 23906 bytes .../instruments_unique_name_beats_midi.enc | Bin 0 -> 25940 bytes .../instruments_weak_name_defers_to_midi.enc | Bin 0 -> 25912 bytes .../encore/tests/data/kordorkestro.enc | Bin 0 -> 37246 bytes .../encore/tests/tst_instruments.cpp | 817 ++++++++++++++++++ 52 files changed, 2237 insertions(+), 1 deletion(-) create mode 100644 src/importexport/encore/internal/importer/builders-parts.cpp create mode 100644 src/importexport/encore/internal/importer/builders.h create mode 100644 src/importexport/encore/internal/importer/ctx.h create mode 100644 src/importexport/encore/internal/importer/debug-dump.cpp create mode 100644 src/importexport/encore/internal/importer/debug-dump.h create mode 100644 src/importexport/encore/internal/importer/mappers-instruments.cpp create mode 100644 src/importexport/encore/internal/importer/mappers.h create mode 100644 src/importexport/encore/tests/data/importer_transp_oboe_jota.enc create mode 100644 src/importexport/encore/tests/data/instruments_abbreviated_name_bandurr.enc create mode 100644 src/importexport/encore/tests/data/instruments_compact_no_tk_midi_oboe.enc create mode 100644 src/importexport/encore/tests/data/instruments_compact_short_header_no_midi.enc create mode 100644 src/importexport/encore/tests/data/instruments_compact_tk_ignores_key_byte.enc create mode 100644 src/importexport/encore/tests/data/instruments_fuzzy_name_match.enc create mode 100644 src/importexport/encore/tests/data/instruments_gm_perc_range_taiko.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_bass_midi_tiebreak.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key0.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_clarinet_midi72_key_neg2.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_drums_name_drumset.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_empty_name_midi_cello.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_empty_name_midi_clarinet.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_laud_accent.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_perc_clef_drumset.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_percussion_drumset.enc create mode 100644 src/importexport/encore/tests/data/instruments_instr_recorder_midi75_trackname.enc create mode 100644 src/importexport/encore/tests/data/instruments_instrument_count_padding.enc create mode 100644 src/importexport/encore/tests/data/instruments_name_dash_separator.enc create mode 100644 src/importexport/encore/tests/data/instruments_name_recovery.enc create mode 100644 src/importexport/encore/tests/data/instruments_name_trailing_number.enc create mode 100644 src/importexport/encore/tests/data/instruments_no_tk_blocks_midi_key.enc create mode 100644 src/importexport/encore/tests/data/instruments_no_tk_large_tk_two_names.enc create mode 100644 src/importexport/encore/tests/data/instruments_no_tk_name_fallback.enc create mode 100644 src/importexport/encore/tests/data/instruments_no_tk_name_latin1.enc create mode 100644 src/importexport/encore/tests/data/instruments_no_tk_name_recovered.enc create mode 100644 src/importexport/encore/tests/data/instruments_rhythm_staff_snare.enc create mode 100644 src/importexport/encore/tests/data/instruments_sco5_tk_names.enc create mode 100644 src/importexport/encore/tests/data/instruments_small_tk_key6.enc create mode 100644 src/importexport/encore/tests/data/instruments_small_tk_midi49.enc create mode 100644 src/importexport/encore/tests/data/instruments_staff_hidden.enc create mode 100644 src/importexport/encore/tests/data/instruments_tab_clef_keeps_tablature.enc create mode 100644 src/importexport/encore/tests/data/instruments_tab_template_forced_standard.enc create mode 100644 src/importexport/encore/tests/data/instruments_tk_empty_name_authoritative.enc create mode 100644 src/importexport/encore/tests/data/instruments_tk_onebyte_name.enc create mode 100644 src/importexport/encore/tests/data/instruments_tk_probe_utf16.enc create mode 100644 src/importexport/encore/tests/data/instruments_tk_utf16_name.enc create mode 100644 src/importexport/encore/tests/data/instruments_total_size_tk_two_instrs.enc create mode 100644 src/importexport/encore/tests/data/instruments_unique_name_beats_midi.enc create mode 100644 src/importexport/encore/tests/data/instruments_weak_name_defers_to_midi.enc create mode 100644 src/importexport/encore/tests/data/kordorkestro.enc create mode 100644 src/importexport/encore/tests/tst_instruments.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index f3388a47c2b2e..acd897143771e 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -43,11 +43,18 @@ target_sources(iex_encore PRIVATE internal/parser/readers-v0xc4.h internal/parser/readers-v0xc4.cpp internal/parser/elem.h + internal/importer/mappers-instruments.cpp + internal/importer/mappers.h internal/parser/ticks.cpp internal/parser/ticks.h internal/notationencreader.cpp internal/notationencreader.h internal/importer/import.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/durations.h internal/importer/durations.cpp internal/importer/import.h 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..f404e99fcd6a0 --- /dev/null +++ b/src/importexport/encore/internal/importer/builders.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 . + */ + +// 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); +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_BUILDERS_H diff --git a/src/importexport/encore/internal/importer/ctx.h b/src/importexport/encore/internal/importer/ctx.h new file mode 100644 index 0000000000000..44468acda220d --- /dev/null +++ b/src/importexport/encore/internal/importer/ctx.h @@ -0,0 +1,72 @@ +/* + * 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 "engraving/types/fraction.h" +#include "engraving/dom/masterscore.h" + +using namespace mu::engraving; + +namespace mu::iex::enc { +// 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 BuildCtx +{ + mu::engraving::MasterScore* score; + const EncRoot& enc; + EncImportOptions opts; + + // Populated by buildParts(): + int totalStaves = 0; + std::vector staffPitchOffset {}; + std::vector staffTemplateConcertClef {}; + +}; +} // 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..86d65f7699be9 --- /dev/null +++ b/src/importexport/encore/internal/importer/debug-dump.cpp @@ -0,0 +1,221 @@ +/* + * 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 "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(); + } + + + + 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; + 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/import.cpp b/src/importexport/encore/internal/importer/import.cpp index 749bee5faf5fe..4c7eed4948bbe 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -23,9 +23,13 @@ // 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 "debug-dump.h" #include "import.h" +#include "../parser/elem.h" #include #include @@ -88,6 +92,20 @@ using namespace mu::engraving; namespace mu::iex::enc { +static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOptions& opts) +{ + ScoreLoad sl; // import edits run outside any undo transaction; see mergeNonOverlappingVoices + + BuildCtx ctx{ score, enc, opts }; + buildParts(ctx); + // 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(); +} muse::String encoreLoadErrorMessage(const QString& path) { @@ -147,6 +165,26 @@ Err importEncore(MasterScore* score, const QString& path, const EncImportOptions } } - 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/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.h b/src/importexport/encore/internal/importer/mappers.h new file mode 100644 index 0000000000000..46d2f58ea8dfa --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers.h @@ -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 . + */ + +// 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 { +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); + +} // namespace mu::iex::enc + +#endif // MU_IMPORTEXPORT_ENC_IMPORT_MAPPING_H diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index ff8b3fcc14b21..88f06f5efe723 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -27,6 +27,7 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/tst_smoke.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_parser_ticks.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_parser_chord.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_instruments.cpp ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..52e2fe2298b1fea0611f0469ae1c890c59caf989 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85nM{KAc#`g*qdlm+iR^iiX{%7)|X|1nDlbBK)kqoSC+)@S^`QUPA zsbD6sBaO%CnaSjMuP?P4hN)lPG{bQ1=@$@&i(kDe_dA~##B$@dZF*NOTnH8c2UpRA zKRy995+DH*AOR8}f!QV?@dzFnEioZwpjiVUs>7&En30Z3HYzw9>Mje&ynZ>qQLNP} zxeet)A5#HJ2h!L^8x3?N za%yYK$LITBZdqxUU{*q0p%bj#!pNqn8e!uP0|rW{WLXYlD#W^qTPv#80TH}oCI>HQw;-ow^|m|oTD z45(Ut8%@qzu*+M|A!QWJWqkeJAN@l$*(uV`4DEy2hXx9BLLk2LDN(8=Z&NKjB}eRG zzEA9+bfdaanmax&X0Enc|8{GY4=&XT+lFxQY_IsI8y|Uu6|J6+8!3|j36Ovgpnu>o zAOR9Mi2(h>Nn~gz0TMWZ{vD9|NsNUANPq;!2+%)_;h;hSBoHG&{}6*gkpxI!i~#+^ z7!E2VKmsuW^bau@6iI*t#t6_qjNza{0wfS4K>rYfL6HPVKojVHs;QojQc^-cX*xpl zygY}^LINZ(?*te>%=-!8`bpqq0uPQKPVUbZ5+H&3A;9=ye)gIxC4titxGR6Tt;*7A z2eLm2kig6npnsV8E#?AAU}^&N4^yM#KoXdF0`w0vzr|c22~16Z{$Xl#97qB)Pk{bm z=C_y&B!N>BIEp^CSdN~bRGB(QpLC9VhO((Vv8pwtv&EjnE9mD=-o&Fihp?%iwMx zm7ciQ>*4V55TU#d<)tzfiz>;B<Rsxb`O6)b?8Ijbe#IG0hSzC2Pxe zO5u|KpeIfo+H1uDDYqVd;J}X)oH-!WLxm8^0ig;ZZQqPH@ouRFNz29l&T4nwy!jE& zXJ((CwVUU9(cSnO|DLSu0uGRY0mGOXHWxAb`|=MrOiPy+<3UWxjaUX2R&OYU^n7@_ zHCHk-*p$ix49sjc-Rc`%jH2AH@0n4w^!zJ`qS z%1FED%JDiZIO)MyA^{R00TLhq5|}&!;*TUJm64PzC*>$kNu83dI4#Esrz72D2Kl!y z6j#fQMy;@FKc{u0!AFv+C_7bqNw=`@PoL-m@t0KlXQAAp8Sz*?$Gf-hWL9|ynP zz4P;^KeS&2X{lW(J?6FVb${dOxW9!9nJ*11vy?gPU$F~y^%oxWscwocFXzyw0#sRM zj1BRYTIeH?9O7BA|D*A%d3G@`er!dkUJ<5k^yK_V2wP~NhPvb>(OeNvbw{X&x+c8} zYGsTpW`07$G-V4KIE63f^0S*Fokl}8wxO>;L9eN5|2Wd&4Wx3lhg7a^l?TTzIo0)- zkTc5WJihz!PqlHzrc*~Xhoc`UC`<`~IOX}IblEOZU@b)t#AwO$_kvF=2qZ;+6k~$vsWv@Cfsf-2>Z=` zs~w`|dcIe1d=%T;+s%HUyLdsby}hgJ-Hyu6q1NmK-FoY@0A<_vZBMmtcPs790O#`` zb~^ucY3HS3rBT{2gp2R^<-fdS&wVWD;tNS7WfC9(5)cBk58MVMKmy|k&_0YKLp=$Q zz&_OX5B*JyFi3y|NFYUk_92CX1ri{E1OeKI1PqEKKmsWOv=1p9ERX;RBnZ$xBw$b^ z0TM_NpnXW;V1WckAVGlkApwIT36Ov$Fnp@1-j7mJM!#t~Li4h`hs{I+Brxp+=s!&R z4&eMrU_60``w!#WvxWpnV0sA9f0&-N=1fW8xCCy?e{QQZcifI_PXZ(`@dRieCVq)I zK@vDI0osQXqhm)Bn0NxT4->z{oFECDm;mj=iP5nm2~0cz+J}i>Vos0*j!9rYerhov zzdxzs!;GGEj%Aa3I>)Q=qe$3%0t+-WxkPIJsRP%ob1&PMnlz6>;eQn8gnv*XPj8%l< z9xPNP|3q?6A{X!IU)o5M@kRG>T$ipHmWpoKyDsAFa!v&=evC$O3!*b{ EAK!g2K>z>% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..90a39cd94122d0d834a7ae65201eaa354e8fdbb5 GIT binary patch literal 25912 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 R5b&5-)wL!`x(i}6@E1d1FvS1> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d3bca6cf89708816c9761f701214807022eb0a07 GIT binary patch literal 25912 zcmeI5&u<$=6vw}7n~;)-TF^rTNUT6Y5lE;M13^F)V{f8OZLhW7D3&<1rdfg|XKmR| zDZS)B=!p}D_F8d3%B@EqIPl{HXATH)KnS565ULQ;@@BGjc1?m?hVx&&F4sGs7e#aZww?Aaoj)JU19`j* z6J<2emGcJn;G-pyiC5)#EjchtBtQZrKmsH{0@Fc2{E_%XQj&({{!obWFiK-;m{D4d z63&LY$sBTTUM#Ga>h)@V-FiyvMuU&}HYK}at>_je{^=845r0Ybd=knnnnQq3@HVdD zCjPqj`{A!Y|MbIge`vo5(o(xndd%zJ=>EpzQGXK`Q=b`TY9%%7U$*i!^%ur{s@tN= z$$8{c0jdn~j<&>KYN9XRQdi1h$o>z;uj*QbocOV2p?X!Aw$YRG10igpjw))B7e#Yb zJk(vG9_X6nDyWq)GMM`w^>kXcpn)^^bRjptEz()kWn&xqs=QvcrVlF(Zy=ScJ*0AV ztBj9dv@4s>A#0S8R`#R2v%}cYxOlc#`qPc~JiwAJJ`-0`CIJ#40U<#9z->SRBru5p z?ZYH8)RO=S96){l(BH%eg9J!`1QG;j9}+lNAORAH5ukmD!JtS2B#mN2A7*E*Ia3liDS^B4pW7-eoU|j`lK=@! zJptN>sb6AFkOWRmfcD|k=-812rk()p!_+S^CrARPCP4deYIN*K0#i?b_F?Lmm=h#{ z6B0Oxo?0wM?@y}uFvm|ik8Ot1sW`E!Hmjq>o|E5NIEs(Ffk$-=;zZqS0Weh3YTCEj3>Geqq?ywT`V{y_ohhmt63Gi_#q}0o_H`A zV1Iufp&a{isEEZO83!s*CPsS$&i(yJEj6x!TJrLiUd`=sh{#5=HG1;0e*H Mb43Di=S64WZ})mKJOBUy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6b65f7a0f560ddea5863044b0109b4681a224b6c GIT binary patch literal 25912 zcmeI5&u<$=6vw~oH6bMtwP+6!AhCdiBDl20f*_!ZYj2`WZLhW7D3&<1rdfieWNq0_ zDO~a&^u&ond#yMi<<_GQ9Qbj9GY5n?AcRm32vrEleKT1*yQWr2+FtDMTkX!9H!~a0 zXU0#@^5(gkcQ?Mq!^KK3-~cHYFpQbQ?xLpuT>jyfY3cf6bQ4obBhrC|)mzFSBOfex z<_cyCo6>lIo|#T3dwrv;{eI^6_sxEP>4n$O@6UewmR#?ARS?UK+m7j9K6fsh1umhE zjvN#cAOR8}0TLjADIy^8NPM;_kwN|CffAMdNJh-CB3X_Er~A6g46^TB%&!(}wMuT) zeqP&#Hy?>@icZ;H(mgEvHzv9&@sb*O1}ZF?L5R=s9T*EGQgnFo3lB=Lr#zRqnyUVfLyYJqs#JSOmFle$nTJKeDX+hRj8Qb_@!gMq ztBo^OP7PZIcOM$4ObLPb%4eifE%|F|>KWN$kMcfo!q#s0QX|BQ>y@hRx|g@%+0{a^ zi1o114mwJ6!fw#s4(it%;TGJL6*xQft-$}Z6=1Dmua<&Mxa~#|cI(|nGepJpd@twt z$TzpQ>)k*P@q$isdsnx+EmfRHrQQnK)y8K5inj0Dp6cIhmztXaE@VG!wf^tY&P&Hq zt*~JT7vJ%V|9J6{`&iJ`7vo0iBtQZrAOz?ixD7~v1jZ4de;7xGb`l_geW=eL`jZ%8 zkN^pgK!O1MLjngYBtQZ&0`w0t7*t7s1QG=39}+lNApsJI5uksF!JtY4B#wAtm1N?!aJMWrY1^?KOb+e2TDJvmgyVyQ~S%W_5izx`-*I5$V9v`fX*9kq?%; z3q>=9ZD~Bjz)Yu;y}s1dFwFe&mKlaC&%T5(od4=|Ip6)fD3+UdY}31P{(LYG6wpEg zT{IEEM_&#K36KB@kN^pgz|<3vcqBf@l*q9D&QOWUFp@DdvXQJtg0rFSGKbuy%Z2q) zySe&nv+=N6TFEVxP`y&|9<%E&p-We zJRUkOg7nlOl)mQ8Z}fQM$!L5Qms6h^+0;sEIKFJf-BRAz)geC0Dzsg=DAHT8^av4^=&?4YyPf4doA&2h^$XVWcM zaIIRgRKjM^?D{>W*+Jj$w*1D8X0QWiZ4LHrW5@SC>iF2GTI*$h8&0?B2mMCB*$z;3 zT+hwh9t!PNtI_xM5ZCXuTYI|Q>8RpWR2v6 zp+AWc1__V=2_y*6KO}IlLINZZBS8NUgF%%9NFYIg{vm;b6%rtU7yu)RbR0+mQ%`{YVd|Hd6C{CC6QF-MH98I?fvG1z|1kAS%n6df2?-oT-&!n2&rhoS zFvs6?9{UVsQ+Z-lYer{_J%`tDlplEmkLw)7rhe8kCH>Uv9-h$eMV+629znF2NWWPi)ttwy`E%EjT6jmT2><8I`>F5sFGrJQxhHzrT-AUi>uT#_vLs)47JCAr^Kp`6-mUM7n^~<0d%J@ Ay8r+H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3a0e8d7e490779612fec2062eba22c803765a1a5 GIT binary patch literal 25912 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..333c386925a9e78ce3febeeb3d387cae7d6091ee GIT binary patch literal 25912 zcmeI5&u<$=6vw~oH6bMtwV;OxkXV6)BDj=dK@d>I*qdn6wbxp26iYpTX_jCqSzESK z3YYu`J#pgDUMmhrx%KD+2Y#I3%mE<|2qBaMLKQ+<-b^;}Zm0!G%f47&C|6Ma}=V{Ov8%()Gn;5>rYe(Se24Tgo6K?=N@e zDrOFw(s+QOna^i?eW|N)T=?ZJGme*@egSbj`_-#*zw>!TEH`dDW^m==#b_3A(Za6G zWTBLugf$W%0TLhq5+H%eB_Q!gdT}|CbUm%diKJ~xw-Q-S1m|PjWd_C9FPB%{X0u*e zwV%?q(c~jdHMmaAUeY}*{4*wcSK=kr|4FE@Xa*5J!JD{_oA~SA?+3sB{L>Gk@z8M* zq^Ay{^qe=o(c_KBlkpZV=RPy6+*0mneAO;B)K54WQ{5I@QLe+73Q%=fF*eXc8v}$= zLLw`Ue=vS^-!2y=j;#vSYr?dHzFhAMVGB*v(U7ttmTMBN?g;frw`6xg?TnGf%=c)T zrW`>7=kV!VaduO*^JvP!HuN15>#99{SaS3RQl&aWs#I^4lk-=c+WK=S7_K>wufO|4 z9h|Xp?zrV>_G1H;DIt*F`GQm$)nH96y&y;ILEa}$)ZHDt-Hx#0`PGKE?w4)&cBA6D zSdZGhu&*>H8ic*=uyws1ZNXbvfwR-v3WJZjA=c{lYBk)1*K3E-pfzZBBGf%U@JmjB za%X$HH3;<(KkRq5cXhkhRmIDwx4L1k(f&AuYX^bttNxu{wX+%GQt`cR_rGrKymYKK zD;tJz37o+F(@&4w$AYdtlQvQ(0TLhqAwd7YV?Y8VFpdEI!#FaulK=_qLw)|xpTr1* z1W14cG6d)!GB{Wv0TM_Npnpigph^NHkRd?-kio$U36MaF0R2M>22~OufeZoqhYSu@ zNPq-V1n3`9FsPCM31|X`-)gGoqm-7@PnwRgKb z4kUqzCqVx&@mtIVlE9e>&_A3R9S4%Y#1o)@nD{N`0!iSM1oo3}E#{NwCslr!(KnqV zpP_6jPpoP!=xnhU@Cpv|Bd_66orBoa&syfBpL*TJWBPYd_h(;vt?XUh>Mt%YUU=uq zC7oq9u$zJWGlyeY@oK>^oj&xq<^NDhl8v_|`FaQX-j@3`OCTAHRfgg|EYzg@P)c7i z7w-s{=meNRY#$_TCz^D%?799;(PozmDtYlk6qTNMI2>YcZx6A&_T;597E4u<7t5u~ vtl`L9@z<;BSJ`lC&Wlp2v#c^?|0t8*lj{vJG#&w-5UaYDBoTK>YzF=Y4iYZI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c28b5db2491cc8c418248d608c01d48cc067c22f GIT binary patch literal 25912 zcmeI5&u<$=6vw~2YtocN)Pf!&K*9nEMQ|y_f*_!Zu{Y7Cw%1y36iXbyG)u6QtS#Fq zg-iZ}o;Y!6uN4QR+OBp$Z`_Z^mn9H`Icp2 zN|uRjX*|Nf%4E{LzSL?IWq*0oilUXLo<|hTfAxyo?|fbo%Z=NPV*oC@!Zzp4_+hWVhHH@hMr2{!^ zNwm~LU*e{&=uyP+55}+N*~PrXu@#|uO-G_ViALWS!Z|b~O07#-63aF0V^64ux+T2} zYG;fL=DtV6vg8OFIFC;k^7GqLyMTrqY(w9Hg5Fcr|6$1JbD~OhhE%CODktYIIn~W) zku}QJBEJ6a4|Q-R%K352X!fyzl^G$B-1)31^@_itmY$U(_8^}VC+zI^-)eIB%R+3S^H8*aB1g#Bi} z)ecc}J>M%hK8o$#-DW?~L%g8Z-rd*jZbua_q1NmK-FoZe0A<_vZBO-YcPs7f0GIRc zbvplbYZs+srBT{4gp2R^_xncVSeNdoYOgoE&Z&eDgD&zE*{nIMctoy@ztXH z_D*kkb@}2uS1;=WzDVngJk;9am)WvDM&WnCVG;QSKV83pJnldfXYzZ zLk?9bKa|pw%*A{9KW(JR_+tAYZcDcewW3@0Zc252HLH>rKSWXKi3fuL4h{|w$?HH~ zDr2#zlDt?hU8W5qbIIGRsJF7^RGk;3RA*Ub$o^3#y(ib}VyHg?JT6vstwVc7Y{;jd(6RuKTdk~AmTwpq@c(m!uY&zC+SSKMcZ93$@d**-uHRlAIkQ~h|9pj>Mf;^p2w#< zb0sr_O{qMr(Zx6&3^T&TY6#H9 zHSD5?7Jf&V9*PnPkN^pg011%5BoYvRBsr~&q@kRu5M>mnVf`?-;#NYS+IQaGF zpME&%5A7F0T51<=xgsukzxM_<5%+k;Tj@KvUb0c9m`bfdTln_X+d`^UV#a~lX&&d{hko&|5JG=e2TOn3luTpo{ zy`l}zu9wPXtcR^`&{HKR><8WLpn1I&ZoyqyfwR-x3jB{c0oH2vY9-i&+ieA5zu9lK zL)2W)_X>`WVtaeL*$;FVFX*+mcXhqnQQ6C=H9JAK-ugH|+4g0x1Ht4=EfJNPq+q1ZW=;Fjyo3 z5=arCeMsS;KmsI?AVB+&fWaaOkbov|_*7H9AEine{if*=n&;&`Y$g&QfoUf||6$s9 z0OwBvClff{e>k~4Ye;|uriTFihv`{s&XfdBOW>~j=eA07r|ro0BtQZaPk{De;+L2c zB!M#%pnW(qI(8(1i6=n&F!4*w36j8>3D7>A867*4z{C@veVF(q<^)OLlmzzUrxx?^ z`;#g@%+Zt1Bb%XgDo(7b&FN^d7w`%W<0G%(Q5}QW)bCnmq@6nM;xYYM)b-hyUMsrq zZ1onG7cabf<&usv>)Q=qe#~K%R@|yTNQMs`)%+iYf<)tOO1$2_zP98#&B>Pt#wtQ_ z4;HGDe<-;pk&AbP%lNNr_@etDt~-ud(Jgy7rDAqDr-Bzh#H7L#4+aD5?d>6wV^0nh zu~UAGn_Ef@PctKE6?=0`lA znSFZJZl0@ock@g9d$Q6CI6?{r3}fcFxrpiCm%qDhTDrU#4Pr`eL^80jdRr-^=fl(8 zxq_L(mQ)_0XQtE1R$u92zn}T_U9;a`dj1vk`?FuaDc8GS6hyOs$1(l&ix< zPdhE4xzq( z=x<_#K>{Q|0to`N4+$JBkN^q92+%&nU{E9h5=aoBeMsP7fdoh(Mu7Gq27@9AkU)X} z?Lz_w3nV}SF#@y?F&Gp{fCMywOoGA&MmcTvv&ux|FPTP^~Nq__KiJbnHk16HkElVd9sV6C{B%6QF%KGdgx8fr%$T`!MlK%n6dfDG3}#Pc7!7_a{|+ zn3E@+CpJUrRGe5yi= zix=Kszoetg`gYBiA9EO{6}Rm7;^9MwHUEc@lW4qk{TmYM+7Nk~lP?5RgyIe?lqLU2 za!(=`?+TZ21dK1b8&Tazl`iJpqIXNA+2xE1Ui=u73QyeY^>A=-fW90Da;S*KQk3Au xa_T&(7^n;0MoE2?O{eU`k<(?X}h$#S#ZF%@QmnYs+>@ z;gbKLCr%vNYsCR6w;p}qz>gE0IUvLVA%t>3s6t5Qy@^xrhFajXC;L0r?!0;PX5;zH z?9;QneyJ6_tuOK4C7WKt5mK;_vgVG>Em-N_SHHVyXH9!~coAD_YbXOt>o-*)Bkx~s zFBI(*wx#n3eLI~_T76}jgF)ukx9q`S<>?nN7|ehDs+@0sQ54PfTdo~kxp*;}PmW`Y z1W14cNPq-LU`7Z?JQAO2N=m#Pmj}ZV*QuNxmepaw`GGN+L+PNnI4pRqSI=s7IzJxeB^6RvL3Zpl;i;1udMz zXA8OcZIRBSE*sl2XCQCZRP7%|Ik|zf);*-P-YTcBU34p(&mm)#>_vR@{hzvV#+GyU zdQPr>PeEZu2*g)DBaK=)*wCqGWQ#q>ed0##{odQn2y32Su6dh&!GZ78ilq`Zqh=@U zs^mt!u(KOBt~H|_cx!8L_ZmB4@NqlDM%7s_huiQv%`oaUdd*gZs^u zp&8G#@Ny^Lz39d>HXPePQOAaH!OZ*|J8?GTr8@3-6kcWD>Juv{;0S;8f7 zgVJAqeB>dPO!Jwzk}?U900{^I`Uh?U5+H$b1n3{ek)fUhNZ=6q`NMn?BMcHC0TM_M zpnpi1@NNq__r1n3_UIM^To5{MC? ze~7`LNCG5a2poT_sn18Llrm46j?g?W&tbEW013=G0mcurz5_Ua5*Sb5{_(?jd-jk3 z3Cs=w#t*Zz)|@E`OiJL6{Bm2%!lVOfPXZ(`^#tf2rhbVzK@vDK0s4nCqvJpln0f;A z4^zLyoFECDnE?I6nbC0|2~0f!`iH4sVos0*CM0k;{MKS|`23{ihZ%j-Ir15*(>$@- zn=#p9FW?m%=SSYaqb3KjZJxDEiJiXg;4$-F^!)5guNAy^cDl=}%NO3ga>--iyh%~>N(d5Msv1oeYe!q`{g98lYbs#UzSS(FRUMz>HlZq2{(cdiV nTiJ3e?u%0EUe*lRKgmV!%JGIMYL5U3^^%Pabx!iwB{%J?7xSA16I~5b+=)(u2q%3*+;?ouo6hLEBv~$@d8}@B2LOk7hpa zn};XN^hv$otbd7rmuz|g2S~wyVayzMmumX=np8>VdmGj%rIPf@;QXz?ANc#`Suq@v0T4po8Fa+7lT=#gf4sp z*uYi1gm=&uJxESRjRZ)51W14cNMO zw_kq#@n}4BTm6if8`;!SYB;`PKYlgWD&!=Ntq9c(VcJGdjt_-!77f%;m$E394eVk^N35zP zxe97!j5KC`K*Ka;3mQ0w&*pNon^HTEhHPv@pMktyQ`P@Lh!IVqO0|bnsXi)^c~lhb z>e@5N7-e%F-+ce4+Bjq7+^A(Z`^dn`ln{uod`6Ud#amUEo{=qfKc5pj=9+i! z-|V;A0cwuxxp~_|p}oD`?E89%>-XB*ySm-!sN!YRnjODeZ++sUYCn zQtpFJ=f5uPymYKIitC1O@ocaBmm43sj|Hth6*sa@0wh2JLV*5(+kgZ}U>pJZhjC&qf{-WpENx}^Q=6FO(6jin05k; zAEtc=@cKz$Jb{zrhw=T{LINZ(Jp>p(OwU^LN=e|f1n$UxZY!BP?LhV?0TP&a0`w0P zzr?&i5;!vf`iC>4<3JLacmnhf6Tif~KoU4J0s4nCqvJpln0Ny84->z{yg(8-C4v3u zuEl)x{G`ecb9ATk$Y&^<$`h+vGdf%B1-yd8{K%_#Smz)%^|O{K>8DiqPJ zuN9nkw|a}qix=Lza!F^I^{j>`Kjtu!l2i2t@${jimj9!Ymwe1k$=BQ0=a!tOSv<*L ztTGh$kVRF>kEC=ZbMcOFIsVHUp4hHNZ6`4+IA!;SG|Vn%RPy2nC@MYiU@*Ym-X21E z?a51JEEZLg7t5i`q+w_-x@#5nR@UvR{eqNgFRKjMKgpzb<#I*c)q8J8P{silrXFZI)muSzESK z3YYu`J#pgDUMmhrx%KD+2Y#I3%mE<|2qBaMLKQ+X?@czzI@E%s?E(8ctKE6?X5QNK zneo%Jym_e=ytOaz?Lt>sZc7{>(zY8c}8?4 zY@>-D8tBN;Fr)S_Wp~9{Fk%+}jEUZpcuC*Z)6lSJ3K2fRTeyy!`0L*9d%ynt(+@}X zFmVxNqzR$SJ#Tzt?5!tb`z$V{KC`l^h17w4#mU$76^_SLw?&teb3dj6v@J8nn!JCV zNhmcWvZDQi@vHhyAt!NcMW|jArY&^kd|wD>QAZUusf(hyCOc?Ls7Iz}colSItTd*+ zN8Pq%3tBjXPiJz|>mr>+T{gC5u0YV=)nuyMT^ZNOVvg1gn&2!oH>A(pF7sS>Wk>omit*XT7{5vraa z_<1)#p|!c$=!M3_54)|+ZPV|ywR#oRMmy}(njeQKJ3-+1I=|LfAu z%D_s!xMm5LzzxcO`bo=O%$eqMNhM_xAOR8(0`w2u1|&cNqX^JHj3Prl36Q`p^!tbT zCPo+}KmsH%M1cNb2nQP^KmrK@^bZLb6iI*th6vC<4B=pd1V|u3fc_x?gCYr#zz_lY zhantnkN^oJ2+%(yU{E9h5-gQd8j`@+6QF+>`z7WCN#N83=pRmvj)o*K_5|o3#(s%8K@vD2f!+95i`n@3NzD&) z^h@WF&rqG_iPheW$rgJK7oqu)fn_{yauD0*S<94+)8j6lFui(x{*~7Y-j$8+{Nnt% zcQ0QsS!M&L9wh&n!;-fW^pojBjrF6toU!PSN3m;G`*P7Gxp-9=6Rw*2iKGpp;1(IlpnMx+A^t9O(^Mm}8b z%@xfQHl^_ZeKVa-_WDv+!!Yy9+h!OpJ^K>EaQ3Uqa=-U^Q7pIa+NO8)!i8WK@X*07 zcF=~89?s)paw^tHfCNZ@1W14cCXs-|Bk^UWL(%_K^^CR+Cm)GzN_NFs(mk^HXH4{-#7k=A(@bysXTxejA0K-GaP zHY9FpO2p(#35l#Y{?YhVU8|6jIJPWQuM5)_x^jIWgtMrlikg%~v0T^DsxK!%wIp{z z?TnGe%=f69rW`>7r}61rZgx|&GpNhKHuN3H>pfNdABPyxB&t+rNR{fX5}AiZ(XOn& zfQ(Tx=kfJ-f2e~qR!$FF2D1+hRHlSLeCIP#sg=Dowe*Y}u}68I*g<=@_f9jwisP1R z&bnK$;99j}sf6{Q+3~wdvxA=B+4dVZo52>Gl@-`KjV<5%sO@8|YOR+2O*oyVAM_f% zW-CC|aXmM0dnmNFw;MfQ4{`l&YkOC>J8e~5M77cOJGJJ=K1!D7S+45e>XciXJ}%|n zZ@2&J*3L`Ea=o}=2p7-xN`JcXk^5NC)#u_y>Lfq{Bp?LnA9xH%fCR=7pnn)ghISGl zfqkgYANrFRVUPd`kU)X}{X+r=Dv`y!3qhGK#TzWLktF05+DIh;P6{b^?a1lQu;~LBQ!6{bJ!FTAc1Ko!1!U> zPXO0X0^+U9k%=*N?x+@HY8tfPv4tzpJwqSgYk~! zh3+DYij*Hp=}P9}9pMs;fbqn3BWfFI($#`fa&L<^yPQ$Uiyxz?^u+ysAA5Uy2<5dW zFO{)as*=1|E?p)K19Q<`FRNc=!>-t`NU6@U%8>n|O!`2sH^oqU0yryHbuCFE?!4Fx F{0$4zFjN2l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5cc358d435f9e8d495dc33009e8963c36cce11c0 GIT binary patch literal 25940 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 E0lvg7g8%>k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cb5c733b8a8ad9ce19e7b659ed441baaf194e0a7 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85u7L%1W_s*dlPN2z1Di8SmFR~vjj`d+OnNe zxa2>mIB??7UMmhrx%KD+2Y#I3%mE<|2qBaMLKQ+<-i$Z#u4x1@hT+Dfr~k>Gr&n=Byr>XqU~xzVTqYOU!OS^WJ>^p2dDRQt!F4lWA_@G&mqI&R|6d%qq1^3#vskNZQPiy$ra zAxDpS<7?gDcr@yt#g){jMmDvUI__Vw3iYR??c|y2w)k>#jm}hnvSr5DMh`6v;7h6G zql=?|ZdnO z-TlEEtpMweTd6x+Zqb5k)l20vwt`mA?<>s?27a&YH?OyXT{!FOu=kp~zV|`b$7api zsQ5c@dM!T~GzYCtfSTiaZo&3Y?6lj>fv>x`e!tV+*Y!?U6)&RJ?E1ZW>q8%9%kwN( zweR#QogE*Sa#y?Ef4j6Q23H!TZ9}+twpaecjrTmniZ(CCozzKy1V}&#Fg|b_kN^ps zMu73*G&1y)00|sI{|-p~Bt{_t5+H#D0mg>}4jLpt0x<%N4>1^2Nq__r1Q;I@IB1Xn z3B(96KEz;9B>@sh5MX>r;GjVQBoHIO_z;6Zl>|sY6BvD~sh*EgT1r1@I!5!XJcmso z0TP&Z0z5y=`wrmzN#Jw>_dh?J-kvoiKmzkafaiz#S!>Re1kOs}uKeY;lBKhDWP1`I zfte@3_%QQJ%n6df)C3qGrbfq(Brx*?7$0VSi8(=^V!lqyEjE!%x6^c;s+=yJ@Ifj z#KFM6Wf+&@Z?L-^=OY4ndi36Bs36`9-Wjm$x zlK-IMz==b9tvDd%)}s#`_;G?W2ZT5vgisC$RS0RBdEUfp(+Fb9#r}@9`{vF2QNPc7 z9!=}*xR$GIe~y2aRAL23h{1woEeyMhn)o&I&8>LK)K|kv;!;|n4yCBR8NJH2?gf^(`5naSFhwYiuHOmyHR+; z*hZ6&G;Ju_<-(fjk;30&qIV=-(*BP_1D6GfR6fRKT*FQLdGEKQUw-=W`_XuqxCqkI z1UY8T>tCDk)}!J06t2WRwNkOQ*zx#MAzOP&`cB4Fx5bv0YZy}jT9*}L8(oQ*`tYPw z`RL;Ck6YHtWyGr)RaAmN=hk;W-P{~sbqcw(#JD9PepPt4(Ua>vp`St>Rn(-+i{+}s zvU{>4zG<1(31DZRKO+IiIF&|BsXWX*n?t$P5s;}4gXdIq%A zkJ04RdAq#z43btczKpNF{e5_-COd`ob0_xW*-s2q=7d0W=aW*YmE28TdQy(qfBBr) zerLb`M$^Z7#VOS)TTZS3r%=lmi`ep;U9YE_?f1QI%WGU~`n#yCufyJJ?0W8p9S@t; z!bZv4L8aUD{C=a~Z2PEI9M{R(E^_TwtI_w&5XbAaTl=QH($V5YR2vl;RhgdPyi%}zW5+DH*5CZfMJO(5{ z0;dt6e>jZ{?Ib_~hcN#Jq<<5mkN^pgz!(AghcO(ikN^op2+%)7U{ECi5*Q;u|1gGw z6%rtU2m$(s2n?zuKmua~=pV*#utEYP5FtSS5P?CJ1W3RT7=Ej%&qt{iGf$e1&^#;8 zVN*ze1m>Lpsi`Iu0a(nI}O1F!Ni?1(Lwj1n3{8M#q69F!KcHA7*}w zxj+&)BZ0&4TZ`rJ`AN;xIr^q^M6+gr`7UU>V>OD4;#Td2G8Lk}miUMahSX!_8Tmj6S^nqQUZNk-m= zWbJLv`7cbkLO?SV_mDza$`7P;By;heaPcSbyJCAkY@28@)m)|M+!SpwlhowJ4^T8c z@nA5(!NCCnc^$}0GZsr#k{8Qm%5lT7Iqz(h^sQ{$W&3$4^(<@l>>p&(dvd)dhT22G PV`9~7O_FqH#b)6zR^l+b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a5fb55072b0d1b007591499fa4256d75e67cc533 GIT binary patch literal 25912 zcmeI5&u<$=6vw~oH6|qywV;Qnt%L;hmpi>O3y{I6bVj;+9iYhdsm7pf_6MFs&r#U zc=sa%l_?<*pZTm*>J@KQ4LvJM>}lR7cF@`BUu^|gcHBzcS#ygPT&rFxm$4SKx_(b- zcF^~`TYmFaE7*jyybOE0x#@d%J3dxx)=I_SfYWXHLBH8=wFA@~*K-TDhhlqctJ(K; z7uWB#w{~>9(^17+s5Lu&x8C~1N7?c`%T@mEZl%59<6{0sr}KZOc1|2CjncXyTs+$= z|K-Me?qOb6&&Q3_Nq_`MKnO5Ca2b#Q35+7Z_%MnL?Ib_~dr+T0^d~XGAOR8}fdm1@ zhXf8*NPq-l1Q;J;FsPCM2_y(GJ|u9kLINZZBf$6&gF%%9NFYIg@gaeO6%rtU7y-tI z7!0Z;KmwY;!MB>~`6#8O^pmDTG_T2X*c1{VfoUhe{lm1c0FIvoMiY4Q{$aE~TS$Ne zriTFc57RT(94QHmOW=|Ga$CvlxE<+F0wgf;1Q;JCeu_Cj5;!pd#)lK5V@DF0cmj+M z6Fm0APi#p4!XEnU| zKXX`is@@=;K6K2HjOSjEY`jg$*W1_EmRzS6Z+S!KxnNpAXB&iBPoe-3y_tm<5pMBD|j8Tbdaku2T- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3d59c555c675932e3364d497a29a750363ebce60 GIT binary patch literal 591 zcmWG`_BUl90IZC z1wf9oe~_PRh@*lbOoB!<&^|+ueIY^qz5yT$jc8;M)L36v$6z2+jv)m|eqcVpLSl?f RkYQkkyU+&C0y6&p2LL0;5d{DM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..70b20b1dee4eb9ebe7577dca8727e3ae710a0546 GIT binary patch literal 25912 zcmeI4&u<$=6vw~oH6bMtwP+7fLBawFMQ|y_f*_!Zu{Y5sw%6L-D3&;Y+bpStv$kxf zKe*&S=!p}D_F8d3%B@EqIPl{HXATH)KnS565ULQ8`)0DKchg#sG>2k;$J(7YZ{AFH zJ~R6?tKB@W;H-aze^1%;9QKfc0mGQuHy1Je$Kv<5%&acYN1d3G8<7mmE#FcKY5C}M zXQpVTupyOu=$q+u(&}qn91JqQy=M*v3(voT!C?BEH|2ci%c5wm-?mME_5As^*m+y^ zX4C&C~v36KB@kih?$fb2)&m!FapFURFUl;S!iXQQ+jC7d2; zlPTohzF1f;)oRuJvUN`DhMf;3RZ+4l)`GUk;^95fN3vg1_Gh5BMN6#ncx@HnosCXkWJSp85-i_f)qYC&%sF^X+nEDYl)08D>;1oWe$xUyFbQ(2T*oHm>c|E7f{v^`D4Wx3lhE%R@mBW1( z?aJCq$QUJa7T^Bxms&U@)2RnF2faT~P?!(`@tMy^!7KYKYUmkRVvlp5*kOCS_kJV9 zlH-;=XU#2Ga4oM`Dq$^bbb_vu?64PfnnC?)Biw|uv;=#rz8Uy8+5uLo)^a)6fYWIN zVXxk6v_e!J*LU-_k3y^2toH)l#0|Qw=C-bP+A4b;)p|SVc#Tg3lq}!3TxH+tlv^7C zF61t?)g$SDO0!~Bt`*k};o{qV>2EjQY!`F7_(EJsnFL6H1cU(n1D62_kiaMc^be!R zP)`CRunYD4p`XMEg9J!`1QG=39}+lNAORAH5uksF!JtS2B#dG!KTOVCbEG72R04P9KettyIch`NlK=^fJpuZM zv7cfNkOYoRfd1jw=-7}1#-0HE!`M$T2S@_PCP4piY;ipts zZxx&mHoNnS^JhO?y`ZDa`c}=Kj$Q4NQ}O%p@S(A~lNa+6jkhlGdVBiZkn-}}8+ zzt4OA=~;GvZpmKzn*KfIvvagV8PZ78rgqI)%>J?X{T)58%JXq2I_Fv}6U{B(5e2t= zdb&4L)-%-P${iZ$*=*YCfhtB(?za#1C|Y>+4T_@aZ{Feay|2owxqjEwy-R1$cG%9{ zXtY}1XI9V*(w#g9rE|~0HR2LM00ck)1VG?_Nr3kw$?M8+N|uvy6sM$4Yvl2la8si( zgQGg_9Wex$mt^AgnP7~P&JOgC9NL3Li(nmQ8|)trj`QKY>aNOD<2lFO~K zzwfeHT|GlNt)kD;w?F(P3uk0H_PA!R_s0qd6G9+4^EocKmbW59&+!s_mixpEJDdHB zK}busW4ZRKQ!>ag+;XKtt6|Xfdm@=(-|x2k#>Sk^9lz@aH+-rXo@Y2>-|kxN zrcdV!pLFDr^gpFpwzBHwHI2D=rdRpfNjBT2IaNHJR6+&;5C8!t0RMnx00JN|iU9n> zC^FE400?Z8Jb$PsF~9%;5CDM`0r-a$4irED1QG<`9}+Mif&d7l2*5w2aG(GJAdnyc z|B!$I5d=UWMF9RGg#!f;0D%Mn_=f}xh#&w0ioot$P5C}bq>TE~bcp74{tg=n1VCWY z31I&)=_>%^2Z7N9p1yw=ZI2od0D;LNfc?Yd%r!;|0tY2;2*|*iZMVCI5YwHheM-7Ll78y0`L!GKgAdz2po{W zcKp_2HvWE6;=>HzbPj!n=p;_8)aF#Q*i-a2?Z!u5p%+yQVqJY}nPEHm-KXQ~TIBrN zTkn?akJfwhi}R;GzI0wine~jiH=VfJCA;bklHo%Wbw@82IT~++`6lPGmL)>=&)n%lKHg`A`vTF+td(Pd192Bw Gr_m#tY%xLr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e898e40eb568ece7c67b630db7c0c8427e9e2ddb GIT binary patch literal 25912 zcmeI5&u<$=6vw~oH6|qywV;Qnt%L;hmpi>O3y{I6bVj;+9iYhdsm7pf`xBZB~+;u3D*9{zs($Nq1>{QTpvKlHu`Fy%OuJKyU5 z#*5mPK@L|^pBuTQa`(auYk)7V3d+NzQ_r86%C%4``UCEI|XO@Y!sB zW<#{oXvo4g^c5)RIaU78LX7Aps#I%8mFle$nU9K+U0r(>S)**u;hXROR10UMoH}Yb zy!(-X%9Id@&wN%Y^@_KuhMtur_B8JkJLv56ueJg#J8q@!thq%Cu2nCU%UBCqUB9O^ zJLvn}Ex&oI6>P#;UWUEh-1NP>9UrSTYo+3E!0ER9px^Ab+5u{g>$wHnL$STJ)$IGa zi|hB=TRXbl>8Ro@)S4Z?TW@{hqilJeBQBb`l_gJ*dwg`jZ%8kN^pgK!O0{ zLjngYBtQZ&0*nta7*t7s1QG-o9}+lNApsJI5nz0X!JtY4B#jQC3JH)vi~!?9 z3Z1o0TP&a0*nt6KgAp%37nV!ZpH%r_hTn7!V}`P+Jh7@Z ztFy(P!P_{nm@*TXa6y>@6%UocZwDMV)2Vvl?Ff zpE)c$Rc{baA3Ekp#&a)7Hr}S>>+S1nORm!_o@6jq8H#(zp(^EVDP752ye(XUV_-b7 zeH^tNYtq%CQ+Dr(HnWsf$%~(%sPx2x!2r9vy9njjl|yALmZ~H#mQ$BW!=bt4u2s}a mS+}e98&ayZtTJT(BsYC5=lf!)KL@-dR&_2)BJP6N4EzJ?zbx4R literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..685f4f3f1bce9b65a1243979cec4f1a94b892c81 GIT binary patch literal 25912 zcmeI5&u<%55XZmkH6bMtwV;OxkXV6)BDj=dK@d>I*c)q8+iR^iiX{%zZI)okSzESK z3YYu`J#pgDUMmhrx%KD+2Y#I3%mE<|2qBaMLKQ+<=Dlp<-OvC@+XMFZt=4a5-jDcw z-tK5DZ^n(HyZ$-;y;#`=>>&dKhB33>UBv9K%ir8IEv+xclbBK)u?#GfZz_X~ymz@h zS28o$kj6cX%xpH@>kF+$QSRrr%qUuV`UOPM?3b^~>+R15goKJ zkfV*OB6VP)jBU||qV>}gQzHQqAOR8}0TP%D0uqlT7m|^Z)RS@)m!wU}R$P|jg0qqC zGK2i<7mMXevso{c?WeSDJo#8s4Hc(mFXnB1lgiLg_iLe=XxBzfaMrOS%3F0qk{kpdkfMG?o}AHTY17xNOwR&^ZOM^_@!F5VNu7MiG|A$3VCS0y&x z66&FDN$-N%86%6C@6a?&xdaWI!6$S1*$t7-qA3@)plt=rw6s$NFD)eHKK_D2CKw(r}X>fh~G zyBh&6(ZSkN^n?0s04C1|&cN#}S}^ zIF1bMBtQbYP@g~aCo#ex0TLjA6ao5&6b>pRKmrK@^bZLb6iI*tQUvH9QaGrP00|@r z&_5(#P$U5oND-iaNa3JD0wjv_{L6HPVKoi*iR#QD6rKF60(sYdGd3g?-g#<`o z+6gdznD#4x>nDNZ3EVq=IKDqyNPq;UhXCV;>DggQd4kUqzCqVx&@mtIVlEA46&_A3S9S4%Y#1o)@nD{N`0!iS6 z1a{+ZE#~9rCslr!@i(1gpP_6jPpoRq>1?s*@Cx?xBd_97orBoa&st`rpE~Z~G5ucD z>$5MtR&?Ll94sy`o_qJw1)XKqx0}ACM?NeoZp|Mh(}y0mJPe^A*?84&NT^?oP`@M5 zdKiD?e>0~q1XPCN0W8#{{y=I^G8b$Ks(T=Lec>aDChHRnaC)wQfLWd9(O-j(wWF*F_l Qo)D`#mn0E)L2L&80?zI-{{R30 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a3764937303e815c7c180aef05cfd290484d3f91 GIT binary patch literal 25912 zcmeI5&u<$=6vw}7n~;)-TF^rTNLV1D0GCoM2m-1Ydt+^Cd#&|GvBUw~W(k&@wPib{ zaLIqr6DJPswc>!3TaP|);KvEh91!Aw5JEX1R3Rkyy|ELoqgs%(J=FfrYCZ40nOS>2 zGk$uJH*YIhck^rfcS$9daDW&rSk~O2yNHQDSAVz>PZ@oAc#*i&)=&nP@;B5VBkx^q zE#%@cY)Rt*dhtYJyw^8I?e~+vzZ>uOSDt+d{r>#7Z^-MduX19!cGHRbm(HIL=YcF5 zsG%cA9altZAcZ`3MeB;z7H!!`mrcK}w6qc61!w!F%N){gUCibS)oMADx1TY#(ZxrSHWZway<&Q3#4#wNnCL@^ zm$d)WP}>|re2#Z;71#0aoxcwL`0dx9N8@4QB1lgYLYYfl``(PVo*a%(;bQDdD-~Ob z9gZ*BnTo!{@tEqS*wS(iVk$s&nK3pc4$>GYkQx$Mas2)9D|>b}Epcqo#G!38B_eI& zBO#nZ6=hVU&WYuUM4&rDJv1%jtDq}mB{26Bs`0pNK?`T_#X@?1OQf@?%Eq?LE08g3 zs{J2CIs5`?t$Rpo{i+0 zc52&!|7k10dfCnwgDtr2dJuMN-Fh=b+4X!cslHOUwz9zzGTL4}Y~-9Ns^v`C&%CbdG$6+B8qBwkA!s*mHOtgZ#+rc--V5 zj+=WeW71EL+jzo!7JYsG)i<;5``ex6)#Y;^T)JSg%=&iKmxnnVl{L5I_oC@Tk6Ipv zkdbV>`V0wuVuU_NqV_P}8jDGFpx;D^`HoXAbs4wU-fpo%%F3A2N;8K4())(Uvi?V4fJ5-{Bfi9aJjC1A z|Lpwr$Ez1@dnke+O$A%c`QTS&uYK6EkKu;?vli2*^gM1+N$K#iE%)}I(@oA!Zmzb7Oqx~Kcj-h}oa#SZtau;h@CF;H!>GlG9 zMvGwRISPgm@RfHOKaETcFH!3(3OZl0d<5dkr>uVxMQcsMHP6m1|i;J#(+`EML^wnfqA#eZzi4O-r5s?rO z0*Zjd2RRXhfDq_KU<>^FL;Vu#r6O`6AOu26K;lD49WTok0(}sW_|OL=Q4sh(m3xPfeNPOr6lBfuQkP?vi5K_m>vV}k&1SCH60ZCMZK*$I*pK1cl_@JvdP1^`Q zrT4I<2mv7wb^>z$5cV@b)-MEl6FB((p|`$_2mv7w9s+Xz5FTI4N`=5-32f6px4DcQ z)=>0?fDjmX0umnv-o>&&A#h{@5+9C?P7H;>z!Q-8Fz_yx1qy*96Oj0DWOQOE1O}df z#D{@*u`Eys9Fo9R>#4

-|a257U0q*^U`3<2)dg(J{=$r3}WLp}2+^GE{#@wL_VUSBZ8lH)A^5PRL zoSwMRXkdMP9RY3Yv~k8_>QeGz+Ev{x*cB(8g*1Payp^#oQ_bfxXUKj@lfI_?3lii$ Q0M3z=_i0MR9VeNFcMaAe)Bpeg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cc6cde787e16d231d3d7b44cfaff6d04adac44ff GIT binary patch literal 25912 zcmeI4&u<$=6vw~oH6bMtwV;Oxkgz~P5nS40K@d>IwKvhGw%1y36iXby)Jw3GtS#Fq zg-iZ}o;Y!6uN4QR+OBp$Z|H_a;ug8yZ2A=G3Z8b>Ghs)i$ zqMbrh8uu}<)9IwwSEf1~W`2F!9uAkDeF?+i?ANc!{q7e^d(Ua=~A)G}WRn(*`isd?Xur1Uh(~?{TT^TEl znIBNMZP|hr&f&AU+-y^{^QgAJcvyj6Nke0fKwA7Ev(XoqeW&H(Ytdc#C zZ@&LiH_owg?m^4p=no83ri4IzsI$|*+KRB^`Q@6o?iU>R zPOVrfVLfVf!=7qx)DOE`VdF+C+Jv{V0(ZNy83rGBLabGt)pFQ`*KLJSztL~CBUC*< z@bhkfLVIhg(GN`*KkT))c1*k1(c%)SjZWCDwLS?^a)Q9|)xX^>x0@j@=icw=SJM9_ z^Ws&m7dI^761YL>FF)RG4-2OHT-->V1W14cgaG3Mw*d)|z;Of^AC4nKI|-1$9`yT% z`6fmfBtQZrkRZVLkifwT36MaH0OLap22~Oufdm1@hXf8*NPq-l1Q;J;FsPCM2_y(G zJ|u9kLINZZBf$6&gF%%9NWc&{_*GM%k5VmVo-`e!c~PFjrjP&$OgjOdAEtc=aQ-B4 zJb{OwACC8D3ki_G^bp|rVS3h@GbMqO61XS-xvesH(vI{e0TP&a0*nt6zr>s%37nb$ zB3-iGAs?VEc`?$a!RWH8nY#XV$Ek@6!c zeaT$BEnK1_U;?pyIBGl6WU2+PA6i#1zW@LL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f6f6a2cc43a63282c93233a0e3552fad2f2c65a1 GIT binary patch literal 25912 zcmeI5zi$&+5XWb|CKpHq)?JDSkgz~P5j6a;?sB>V#n_u@gYC7}8^sa@XUJa8QnI#e zhhI(p11c&CXc5Ip=_p-XaR(LD6bMlugis2E5JFmJo;P@x!-D*%?C)FczIpS0WZ&mK zkH*@~xRkNx|DkWEWPF^~DMlJ;+EAxCi}5#8kMHP7Rh|e2(K*)wnP`0Gjwrb2*VDa` ztRABUu3V?K9*;*`{i}+;pLlgf_x;HON6Ghx|NEJ*_x{eZ=Gt9Tcg~+UQRjB&s6rl( zv_cI&s$4<>0T2KI5C8!X=z9X}kA&wK;}kB3Wj{z^oyf@`O$CYe`KrkfrG7n|naP#Q z#q^A^U+KDocO|LFnFV7~wMf$ceWE|uUy}Aek+?-e?6KUU-{}h7q>u04uD^cy;#s#p zDZhxgrE;OvnAaYu{@UK4f0E9|?rX`|WNf2<-bk0^FMR1!-DO>h&yG(;BxRm4%`0!I zMK0&Wo)!9Uk6+O?GAZ_B^X!9OW~Mc2^7$vVn01+oRN_3#n#=5|E;Dsc)kIf8Rz{1{ z&{HbwI&VRZcGK;V)bIjJ`>4ztTT@q}v|3YXzY}TW10=cZA<5;V^5xiBvoLp<5?W3l zrH4=6$;RoKc7Lwf82z&X!hjG6uY7_FrMxpMQ%~>~+s^02^y(|Eb5)O~EjwSb=Io3? zwo%IFax~{v8*Wo1(`&hnCAV^=>MfErJx%6vWzlu6*Ik+|8Z&u!fviT=^;(rywdPUL zvK>2ZI+Up`Emc~s>SDXi+R}=ux9XCeq++G+HcHhSF69izF>GmHYvgMSE}cqUs>@f> z?@FWGDqqgdYs|$lo!kdI+-;4;tv|2!KE@0=2!KF@0PI5q2MQnn0wDsh4!3I z1YjQ`I8Xoq5C{=~eF(vT2m&CW2y~um%KK3w#nhXoT{K7dJ!~Wp0D(a#0RLgocL3%O z0=)@*-GAua9yK5U0)s;U{=?v`HD(F|n`Nt{@zO{i$GV|1E2@sVfg2Ni=@SMOTJxSbpiXpg!VxgI+HOUAme*qoS} z7`u4>l!`Lz7-eTTbhp!1!D)xXhlc8QUQBZ|-U`R-ZK>;^|AvIa5sW25ag&l%;QT7* zHb*XAR=>0nrSGurub?hk(!w={yJ zvro_3&2z2bZG3@$PucVW4v>Nc%bFQB7cu?Y^0&9_tSK*!2C*f#Ml!IldP^13^TFxP zT+vQpQz{S8x6|olt1nG)Fv$G!jy)JGJ^K;{gW0cMm+PI+i=w%4+qHws=g&v8z$G*g z$w<5C$kCJ%6A6$236KB@kif(f5Pu{-$CRXaIW7-IDXvpFJ4(x=gtG(FWCpo6FBVoy z^?EhG>O5m~SV!D4J{HsqP5%$kZfPL085~ zW9EC*ZCkdWg){hcE;qX=(pl7HV_W75)0v~1!_kiv6sCkgeC0DzsFi~?oq9&L*u&f>Zq(lGU1~;H@%(blTlWhN ze5Y0{m9QQ)J7HHPH|m9*?XYpZ8EwH^S%JIL*b0M>+9B4e&T2W_gx6_?QLoW!wjxwL zKk)NzfI@3~yU`0x7eDN_ws%dv*VgP+R2%KEQ)_-4qT~dD<7@j?r`*~MaUplL-Ttpj zJ1-5(_2PylTmm;J{prVh?qk6epNlIglK=^jfDoX4;5Hxu5;%?k?Za_os3!pu*oS`q zFyF)og9J!`1QG;j9}+lNAORAH5ukmD!JtS2B#5}1ew0cn^QP$-&5QCLHWLYuz_b&f|1j-4fb%DT z;|V<2e>lE9Ye;|uriTFihv`{s&XfdBO5m>i=eDM~lXhf#5+H$zCqVl!@k`7JlEA46 z&_0|R9Xpc1#1o)>nD`~;1WDl31ZW>ljgB2jVB!hTK1}=)bAlvrLIV4vrxx?0_a`+z z%=k&?*k-6s-iyia5KR(cs08Fll(=e!q{sy*&)%*pove7E4ir y7t3k#q~cIr^w-P!Q8wI)`?BP^mo-B6k22^zIo}dR?J?jf(dxM*fw=Rcv+y_IYcO;G literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..141939490c694b818a872878c5a16513d562add8 GIT binary patch literal 23906 zcmeI4&u<$=6vw~2YeGsQuu2aRAYl~=MQ~}_2qN)Aac!sC)b?8Ijbf<>FwGJyC2Pxe zO5u|KpeIfo+H1uDDYqVd;J}X)oH-!G0U?BPK&V1U=Dmp%?}l15X-@WctlfF@=Dl&| zGqX=??dDZ;-uhSg|B^|~VIMXu*w)m6xdkir$MW}gos4PE4+e3hwgxgVckQk!#PiYR z{!HGnu_2xN=sBrW(&}r|?Dx~Zz2o%zOE12Pet-I#H{^W(%e-iA-*cVd`lU-1v2&wN zx1CM;KW=LwW+>w=9F2E1)^P&yV#0Pf_o1W)MYmj7GL{+qm)`qAR_&jM0-QpG&+s;G z;STw~%#L~bvW8VJG_*>5o{4;ph{=&-GOZK6EsgSMeHyo#bPjriN z9MA`}4P>y6HX7(6lp3-I^nWsb6~B;MlpS9ZsyBsc6CF7|6v7$QP(fAdyl8G>2irnD zGCj#v(3P=LnEDYl$LZ^pcMhM=EKYBT^c-roUa@)xvSv-y{w#_$7({CwUF*J-f%>S* zyXCcuNLxi`7T^Bxmu{Sq<=j!v;pj&S3KK#gzVc~lR7=6CPCYGK>`AT@H)`#4uQnpA zcz&tst@*hE{6aNfEMhHcw8M@{ZqyCiTVefHBie+wvI2Lzz8MCev_h;_3fD^E2E2A7 zjJox1qZy&%`GKEx1LT@pTlH>eT>P-p+}bhyUQ4S5RO+p;U2S|CqF4xmg0J??cB#1$ z;_~7Lt=8i%?W`DPdhE_MpE%%r7y*AOR8}fdm1@hXf8bNPq-l1Q;J;Fes7$2_y(GJ|u9kK>{QYBf$6& zgF%r5NFYIg@gaeO4H6)M7y-tI7z~OeKmvxq!Bb6rKT0LrylFZ_^NPHO%|ZesFzEz% z|1jx0fb%DT(F7iS|1jE~JtRN^lS6>_50kUjoGA&Ml)wY|&uuL;Cv~Jf36Q|p6JUH8 z`z7WCN#N837#~iJj*cWS_5>Io#(s%8K@vDM0mg?@qoX4Uj6DIyhp}H`PLKpnNMLX9 z)M9q<{-oxI89wP8#thYIo>=Won{2Tca0M@zZ(GIFCI_)&-nF#FPCpOujOo?$%dfwg z^KNW*=9lL$ym$Sw$ub)hYQeOzI5@3%<)9Z&9~$dBdoe57cuGHpO+FFZ6z#ILrhBCE@0jR)iI>#Kb5McHfhZ>t(fBkjCq6gQiM7OVe96jHpOwCoG1YCcW#kydRDh}jX>6k-5mOJo zluACiF!*uHs&WZtC94!AVW{R-?|!4%<3pjJMhz8Ir7Vc$ zio~*evLbuaj8#0X5Tqi8PUoA3V|Y^uplQT@!wKAe4IpfV=};wzt$O10!|s!LDF7JHEU z#17i~y|)_y)*ZK0b++8R1=p$;ibZS%jgH?{njQ4~PSdYnYXrM+*4JV0)pvdGSR zBybV|`iGOq&`ts*a0vZ3AoZIVg#<`|1jY!^KaAmEg#<_-Mu7ey27@XIkiZxL`iC(b ztdIZ+#0bzo#9&Y*0TLJ^K>sj?gB21Wffxb$hZqd1BtQb1z~EC&^?a1l68cHgV>BL}ilEBOppnsV8 zCFTW^z$pnFMxRCTDGz&}C%F#rGn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e061d6b1b1e0750de663238f2f41bc6ed7418255 GIT binary patch literal 25912 zcmeI5&u<$=6vw~oH6bMtwP+6!Ah7}oMQ|ytRe}mtjJ=6AwY}DQqgd*Jy3G7~~ZMYG>rlRh zTCCM7xmEi`Z5!NtB(^C!WqV2Yu<&q9bWP$VHS!EpSTrLM%V&5OH*p*PJoxkA_g{bc z>1aH3TmhMq{eGV#~^T7*heN4p`Wb zh^ZlQQy?WIvf}tB<5%(Qd{*Mvl2E-NOq&viHu0elwopR_RVfQ%xq)5m2z3>+j;?}Q z86%CEA5k++*@6a6;q$rd?51d^QIm~r=qr%ZYpVJ`4Kd=Is8a1ARjRj2Y#tN^r@a0u zGDgvy$G1QHr8dr3IW=hM-+f@9G9?6(E1!`{wdAjidRO+puU2S|CplJKP?Wz9FcB#1;;C%La ztM#}`J1-qewZeuWTztnb{_Q14?qfk$UrrjSlK=^jfDoX6;5Hxu5*SB-{$U&$+DU)} z_Mtw1=ucvVK>{Q|0z(AoABJ$SLINa^AVB|+fI*c6NMMKn{lgFrR!D#Z5(MZU5-_Ne z00|5cpnn*`!3qhGK!O1MLjnd>5+DIh;P6{b^?a1lQu;~LBQ&qcbJ!FTAc1Ko!1!U> zcL3*40^k8$w@X|^~(`meQn5fn#Gq4#wtT` z2Nudwek`RYnTvOXOE?0?7u!d1+enkH=G~%qTeR8bj7nbo6h);c?)7@u+uK7V$DSN2 zW3f~vd9j?j95(dL1#i8iUdo13cHWRu?PZlA`)9f71352=q52H)f>_nLB#F3lVl(hB DH!LwH literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/data/kordorkestro.enc b/src/importexport/encore/tests/data/kordorkestro.enc new file mode 100644 index 0000000000000000000000000000000000000000..ef63012c99b87577e5ecb0dc4f035b3f69358765 GIT binary patch literal 37246 zcmeI5Uu+ab9LK-AdshmSQVb17ixw5FprO>##8gSk_1YZV^^Uu%y~GDS+AUrz*V+~- z`cOd)SX^se^yd_C|Oa+tt4edp1NNb!MWPY4JBAs_^VfDouo0-D|ISnhbIlev!G z@0hz^Dr|ME$1$w7sAU1vq4VMGp@C>7Q>wKg?Tu1QC0z&z0U;m+gg_M$a9SH~PY1U) zG{^qC)<(990E#O@KnMr{As_^VKp6tQ)&_TKaBIW0O^%g00xIv@2*ITHDC6`tMN9|? z0U;m+gn$sJ83Ml62KR4pOT%z%vt#90RMOh$*vjKJ`ly|;AA6|1VbZvb9mryuKC7^z z+)s!u1cZPP5CTF#2>gEuaBJf-5F`)W)<(dw2OKNsTqUiI_HEqSNYMDuER7JAzKwEB zMHd1>KnMr{Ay7pGcnS;I3ZBG5G49(4I_8ewDCg~US{tf}H%c_oI}pB2r4@#tqOs_( z(a2zUQKjrgo1=I$XJ%!~_)R&|}#pw@*SV0o$rgH^*|wVTxS+BU6lWDNmU1A#i4Q}Ya|DeY-brG^@-!5{;C zo79)Iw>*`m2Q|V#xQ>tfHL0hyFAM7Y)sHTEemCG%btMeS@7nBd%I{_T3q0ypM@S#T zM*UH}aCSgFtG3?<^)5WFPkXA4<*eF%zf0;X`Uy|fu!2?F@9#Y!5+wW7S4wITQUMQ&ST`b(2`h>z!jJPwM9^~{( zv>l@D3~g-h*@5%T6zn_bXp0?b=5})Uq?YJeR>*m@MN^rYK@HrUtR41g_9*CPQ2y=L zYU(CKGa7(LuGIq0F;@cJ#oFsFVMn9sT3cB2pG&aBxmXr6VA01wohSRehg>aYz&RCh zx(|S{Ev3u9Px?B_?w03$&|TG2xbyf}*DZes=lq4XZra@PUvT0-?7HPuF3!KncmDqJ z9~6#LqC8#B3zX-oul#3)GJwB)A{h>K67m>5!;D~xo?)hG8^a7*k)bD>6ouTIIzs)a z`=~cHuasfRnWLCVp>0|KbzdQ47#3H78s1#lS)ZVAH8OOwXzCm2MW^pPZZ=BpDdUi@ z28R}zO7wm8MFxlNLX#FX8gTBy@9ru4a(Gd9q`!SMFI{4_L%_YS*yASJNyXATRh({$ zoB2D5lpPMqLITp#!m{a3s_Ssy1FXH|Ei0sSm zHPZ(tP3%mB2V&+Z;!`;@Ka-uwP2`b?C)3H^NE)Gu@$u}8sfr}c>51|EN{>%+uoa2y zq&bz!J!c{sPN&03&Ob2~n;13G(Ya@GveK(dwK`&%zG02Hq$BC*A4x8`K@~ru&Ep>R zSGZa2yI(1)DY(AKV=r0}r}zk-qPk;RrDrMa0Jcakii=A^AyAbBO4Scl>8v;<1cbm1 z5s>=fhHyz{Ay8uk=Fy~{^w-vy-{P(i5CY{1Nc~Wrk>n5pwMszhhg!Yz;t(N@G<)T2 zj@_zCw9s0{qZBjfO$U#_v_I~V8)&8L_MT|^$^7hGTTk2CCw6tHRc6!SOq$k5E?9qj zF#XO=zgq28t7T^Czn6)XCy=Ib%fU+#FDxuzZf=h59}9Ez;nj(0mli6fO@+k?ZgO98D8|1s92tyk;)pWKx. + */ + +// 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: 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 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; +} + +// 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; +} + +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; +} + +// 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; +} + +// 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; +} + +// =========================================================================== +// 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; +} From 50dacb590c97ddb5c8bece70e716d8d1eaf309c5 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 31 May 2026 10:00:00 +0200 Subject: [PATCH 10/33] enc-feat: import notes, rests and durations --- src/importexport/encore/CMakeLists.txt | 13 + .../internal/importer/builders-measures.cpp | 313 +++ .../encore/internal/importer/builders.h | 3 + .../encore/internal/importer/coords.cpp | 84 + .../encore/internal/importer/coords.h | 78 + .../encore/internal/importer/ctx.h | 253 +++ .../internal/importer/emitters-fill.cpp | 427 ++++ .../internal/importer/emitters-internal.h | 168 ++ .../internal/importer/emitters-note.cpp | 669 ++++++ .../internal/importer/emitters-overfill.cpp | 552 +++++ .../internal/importer/emitters-rest.cpp | 182 ++ .../importer/emitters-tuplets-groups.cpp | 530 +++++ .../internal/importer/emitters-tuplets.cpp | 119 + .../internal/importer/emitters-tuplets.h | 93 + .../encore/internal/importer/emitters.cpp | 1060 +++++++++ .../encore/internal/importer/import.cpp | 84 + .../internal/importer/mappers-clefs.cpp | 231 ++ .../encore/internal/importer/mappers.h | 25 +- src/importexport/encore/tests/CMakeLists.txt | 2 + src/importexport/encore/tests/data/akordo.enc | Bin 0 -> 25753 bytes src/importexport/encore/tests/data/bando.enc | Bin 0 -> 69402 bytes src/importexport/encore/tests/data/bazo.enc | Bin 0 -> 25912 bytes .../encore/tests/data/chord_parsing.enc | Bin 0 -> 38042 bytes .../encore/tests/data/encore_symbols.enc | Bin 0 -> 35048 bytes .../importer_2_2_beatticks240_gap_snap.enc | Bin 0 -> 26158 bytes .../importer_2_2_beatticks480_correct.enc | Bin 0 -> 26128 bytes .../data/importer_full_voice_skipped.enc | Bin 0 -> 26130 bytes .../data/importer_gap_snap_eighth_meter.enc | Bin 0 -> 25940 bytes .../tests/data/importer_perc_bateria.enc | Bin 0 -> 27832 bytes .../importer_rest_caps_in_open_tuplet.enc | Bin 0 -> 26070 bytes .../tests/data/importer_rest_in_tuplet.enc | Bin 0 -> 26016 bytes .../data/importer_rest_not_chord_anchor.enc | Bin 0 -> 26098 bytes .../data/importer_v0c2_multi_stream_drift.enc | Bin 0 -> 26126 bytes ...uments_bass_enckey0_no_octave_transpos.enc | Bin 0 -> 25940 bytes ...struments_bass_guitar_transposing_clef.enc | Bin 0 -> 25940 bytes .../data/instruments_gm_perc_chord_notes.enc | Bin 0 -> 25968 bytes .../data/notes_16th_rdur112_no_triple_dot.enc | Bin 0 -> 25968 bytes .../tests/data/notes_chord_duplicate.enc | Bin 0 -> 25996 bytes .../data/notes_chord_duplicate_no_ext_bit.enc | Bin 0 -> 25996 bytes ...notes_chord_inflated_rdur_keeps_eighth.enc | Bin 0 -> 26082 bytes .../tests/data/notes_chord_strum_xoffset.enc | Bin 0 -> 26898 bytes .../encore/tests/data/notes_corrupted.enc | Bin 0 -> 26057 bytes .../tests/data/notes_diff_column_no_merge.enc | Bin 0 -> 26026 bytes .../data/notes_dotted_ctrl_bit0_drift.enc | Bin 0 -> 26054 bytes .../encore/tests/data/notes_dotted_note.enc | Bin 0 -> 26016 bytes .../encore/tests/data/notes_dotted_rest.enc | Bin 0 -> 25976 bytes .../notes_dual_rests_same_tick_routing.enc | Bin 0 -> 26060 bytes .../data/notes_grace1_cascade_filter.enc | Bin 0 -> 25996 bytes .../notes_grandstaff_bit6_second_staff.enc | Bin 0 -> 23465 bytes .../notes_grandstaff_high_voice_own_staff.enc | Bin 0 -> 23381 bytes ...tes_grandstaff_staffwithin_four_voices.enc | Bin 0 -> 23465 bytes ...staff_staffwithin_rest_on_second_staff.enc | Bin 0 -> 23399 bytes ...otes_grandstaff_staffwithin_sequential.enc | Bin 0 -> 23465 bytes .../data/notes_implicit_leading_rest.enc | Bin 0 -> 25968 bytes .../data/notes_implicit_trailing_gap.enc | Bin 0 -> 26110 bytes .../notes_inflated_rdur_quarter_chord.enc | Bin 0 -> 25968 bytes .../data/notes_multiinstr_compact_routing.enc | Bin 0 -> 23525 bytes .../data/notes_no_spurious_string_numbers.enc | Bin 0 -> 26024 bytes .../tests/data/notes_offbeat_canonical.enc | Bin 0 -> 25968 bytes .../tests/data/notes_perc_clef_positions.enc | Bin 0 -> 26024 bytes ...es_perc_clef_standard_drumset_notehead.enc | Bin 0 -> 25940 bytes .../data/notes_perc_notehead_all_nibbles.enc | Bin 0 -> 26228 bytes .../data/notes_perc_shared_pitch_nibbles.enc | Bin 0 -> 25968 bytes .../tests/data/notes_rdur_80_stays_16th.enc | Bin 0 -> 25998 bytes .../notes_rdur_non_chord_ext_filtered.enc | Bin 0 -> 25986 bytes .../encore/tests/data/notes_rdur_snap.enc | Bin 0 -> 25958 bytes .../data/notes_rest_before_note_midi_slop.enc | Bin 0 -> 26070 bytes .../data/notes_scale_no_anchor_no_circles.enc | Bin 0 -> 26024 bytes .../notes_singlestaff_voice4_second_voice.enc | Bin 0 -> 26136 bytes .../encore/tests/data/notes_swing.enc | Bin 0 -> 26016 bytes .../data/notes_transposing_respell_melody.enc | Bin 0 -> 26030 bytes .../data/notes_transposing_written_tpc.enc | Bin 0 -> 25940 bytes .../data/notes_triple_dotted_advance.enc | Bin 0 -> 26054 bytes .../encore/tests/data/notes_triplets.enc | Bin 0 -> 26338 bytes ...notes_tuplet_diff_column_keeps_members.enc | Bin 0 -> 26054 bytes .../data/notes_v0c2_common_time_glyph.enc | Bin 0 -> 25956 bytes .../data/notes_v0c2_common_time_glyph_uc.enc | Bin 0 -> 25956 bytes .../notes_v0c2_multiinstr_compact_routing.enc | Bin 0 -> 23501 bytes .../notes_v0c2_near_simultaneous_chord.enc | Bin 0 -> 25974 bytes ...s_v0c2_plain_sixteenth_no_spurious_dot.enc | Bin 0 -> 26082 bytes .../data/notes_v0c2_size24_semitonepitch.enc | Bin 0 -> 25960 bytes .../data/notes_voice_overflow_dropped.enc | Bin 0 -> 26052 bytes .../encore/tests/data/opeco_vochoj.enc | Bin 0 -> 26680 bytes .../data/ornaments_fingering_grandstaff.enc | Bin 0 -> 2297 bytes .../encore/tests/data/paloteos_7x8.enc | Bin 0 -> 13372 bytes .../tests/data/rest_dotted_before_notes.enc | Bin 0 -> 26014 bytes .../encore/tests/data/ripetoj.enc | Bin 0 -> 27324 bytes .../data/structure_f_clef_8va_from_key.enc | Bin 0 -> 25940 bytes .../data/structure_f_clef_8vb_from_key.enc | Bin 0 -> 25940 bytes .../data/structure_g_clef_8va_from_key.enc | Bin 0 -> 25940 bytes ...re_grandstaff_wedge_out_of_range_voice.enc | Bin 0 -> 23397 bytes .../tests/data/structure_key_per_staff.enc | Bin 0 -> 27888 bytes .../structure_non_octave_key_keeps_clef.enc | Bin 0 -> 25940 bytes .../structure_octave_bassa_clef_override.enc | Bin 0 -> 25940 bytes ...ructure_octave_lower_implicit_silences.enc | Bin 0 -> 26024 bytes .../data/structure_pickup_casea_sparse.enc | Bin 0 -> 26110 bytes .../structure_pickup_caseb_no_reduce_full.enc | Bin 0 -> 26080 bytes .../data/structure_pickup_caseb_reduces.enc | Bin 0 -> 26278 bytes .../tests/data/structure_pickup_measure.enc | Bin 0 -> 26052 bytes .../structure_rest_coincident_with_note.enc | Bin 0 -> 25986 bytes .../data/structure_start_double_barline.enc | Bin 0 -> 25968 bytes .../tests/data/structure_v0c2_pitches.enc | Bin 0 -> 26000 bytes .../structure_v0c2_spurious_semitone_flag.enc | Bin 0 -> 26000 bytes .../tests/data/text_tempo_orn_compound_68.enc | Bin 0 -> 27459 bytes .../tests/data/timesig_change_2_2_to_4_4.enc | Bin 0 -> 25912 bytes .../tests/data/timesig_change_6_8_to_3_4.enc | Bin 0 -> 25976 bytes .../encore/tests/tst_instruments.cpp | 310 +++ src/importexport/encore/tests/tst_notes.cpp | 1922 +++++++++++++++++ .../encore/tests/tst_parser_ticks.cpp | 22 + .../encore/tests/tst_structure.cpp | 898 ++++++++ 110 files changed, 8037 insertions(+), 1 deletion(-) create mode 100644 src/importexport/encore/internal/importer/builders-measures.cpp create mode 100644 src/importexport/encore/internal/importer/coords.cpp create mode 100644 src/importexport/encore/internal/importer/coords.h create mode 100644 src/importexport/encore/internal/importer/emitters-fill.cpp create mode 100644 src/importexport/encore/internal/importer/emitters-internal.h create mode 100644 src/importexport/encore/internal/importer/emitters-note.cpp create mode 100644 src/importexport/encore/internal/importer/emitters-overfill.cpp create mode 100644 src/importexport/encore/internal/importer/emitters-rest.cpp create mode 100644 src/importexport/encore/internal/importer/emitters-tuplets-groups.cpp create mode 100644 src/importexport/encore/internal/importer/emitters-tuplets.cpp create mode 100644 src/importexport/encore/internal/importer/emitters-tuplets.h create mode 100644 src/importexport/encore/internal/importer/emitters.cpp create mode 100644 src/importexport/encore/internal/importer/mappers-clefs.cpp create mode 100644 src/importexport/encore/tests/data/akordo.enc create mode 100644 src/importexport/encore/tests/data/bando.enc create mode 100644 src/importexport/encore/tests/data/bazo.enc create mode 100644 src/importexport/encore/tests/data/chord_parsing.enc create mode 100644 src/importexport/encore/tests/data/encore_symbols.enc create mode 100644 src/importexport/encore/tests/data/importer_2_2_beatticks240_gap_snap.enc create mode 100644 src/importexport/encore/tests/data/importer_2_2_beatticks480_correct.enc create mode 100644 src/importexport/encore/tests/data/importer_full_voice_skipped.enc create mode 100644 src/importexport/encore/tests/data/importer_gap_snap_eighth_meter.enc create mode 100644 src/importexport/encore/tests/data/importer_perc_bateria.enc create mode 100644 src/importexport/encore/tests/data/importer_rest_caps_in_open_tuplet.enc create mode 100644 src/importexport/encore/tests/data/importer_rest_in_tuplet.enc create mode 100644 src/importexport/encore/tests/data/importer_rest_not_chord_anchor.enc create mode 100644 src/importexport/encore/tests/data/importer_v0c2_multi_stream_drift.enc create mode 100644 src/importexport/encore/tests/data/instruments_bass_enckey0_no_octave_transpos.enc create mode 100644 src/importexport/encore/tests/data/instruments_bass_guitar_transposing_clef.enc create mode 100644 src/importexport/encore/tests/data/instruments_gm_perc_chord_notes.enc create mode 100644 src/importexport/encore/tests/data/notes_16th_rdur112_no_triple_dot.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_duplicate.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_duplicate_no_ext_bit.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_inflated_rdur_keeps_eighth.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_strum_xoffset.enc create mode 100644 src/importexport/encore/tests/data/notes_corrupted.enc create mode 100644 src/importexport/encore/tests/data/notes_diff_column_no_merge.enc create mode 100644 src/importexport/encore/tests/data/notes_dotted_ctrl_bit0_drift.enc create mode 100644 src/importexport/encore/tests/data/notes_dotted_note.enc create mode 100644 src/importexport/encore/tests/data/notes_dotted_rest.enc create mode 100644 src/importexport/encore/tests/data/notes_dual_rests_same_tick_routing.enc create mode 100644 src/importexport/encore/tests/data/notes_grace1_cascade_filter.enc create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_bit6_second_staff.enc create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_high_voice_own_staff.enc create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_staffwithin_four_voices.enc create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_staffwithin_rest_on_second_staff.enc create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_staffwithin_sequential.enc create mode 100644 src/importexport/encore/tests/data/notes_implicit_leading_rest.enc create mode 100644 src/importexport/encore/tests/data/notes_implicit_trailing_gap.enc create mode 100644 src/importexport/encore/tests/data/notes_inflated_rdur_quarter_chord.enc create mode 100644 src/importexport/encore/tests/data/notes_multiinstr_compact_routing.enc create mode 100644 src/importexport/encore/tests/data/notes_no_spurious_string_numbers.enc create mode 100644 src/importexport/encore/tests/data/notes_offbeat_canonical.enc create mode 100644 src/importexport/encore/tests/data/notes_perc_clef_positions.enc create mode 100644 src/importexport/encore/tests/data/notes_perc_clef_standard_drumset_notehead.enc create mode 100644 src/importexport/encore/tests/data/notes_perc_notehead_all_nibbles.enc create mode 100644 src/importexport/encore/tests/data/notes_perc_shared_pitch_nibbles.enc create mode 100644 src/importexport/encore/tests/data/notes_rdur_80_stays_16th.enc create mode 100644 src/importexport/encore/tests/data/notes_rdur_non_chord_ext_filtered.enc create mode 100644 src/importexport/encore/tests/data/notes_rdur_snap.enc create mode 100644 src/importexport/encore/tests/data/notes_rest_before_note_midi_slop.enc create mode 100644 src/importexport/encore/tests/data/notes_scale_no_anchor_no_circles.enc create mode 100644 src/importexport/encore/tests/data/notes_singlestaff_voice4_second_voice.enc create mode 100644 src/importexport/encore/tests/data/notes_swing.enc create mode 100644 src/importexport/encore/tests/data/notes_transposing_respell_melody.enc create mode 100644 src/importexport/encore/tests/data/notes_transposing_written_tpc.enc create mode 100644 src/importexport/encore/tests/data/notes_triple_dotted_advance.enc create mode 100644 src/importexport/encore/tests/data/notes_triplets.enc create mode 100644 src/importexport/encore/tests/data/notes_tuplet_diff_column_keeps_members.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_common_time_glyph.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_common_time_glyph_uc.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_multiinstr_compact_routing.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_near_simultaneous_chord.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_plain_sixteenth_no_spurious_dot.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_size24_semitonepitch.enc create mode 100644 src/importexport/encore/tests/data/notes_voice_overflow_dropped.enc create mode 100644 src/importexport/encore/tests/data/opeco_vochoj.enc create mode 100644 src/importexport/encore/tests/data/ornaments_fingering_grandstaff.enc create mode 100644 src/importexport/encore/tests/data/paloteos_7x8.enc create mode 100644 src/importexport/encore/tests/data/rest_dotted_before_notes.enc create mode 100644 src/importexport/encore/tests/data/ripetoj.enc create mode 100644 src/importexport/encore/tests/data/structure_f_clef_8va_from_key.enc create mode 100644 src/importexport/encore/tests/data/structure_f_clef_8vb_from_key.enc create mode 100644 src/importexport/encore/tests/data/structure_g_clef_8va_from_key.enc create mode 100644 src/importexport/encore/tests/data/structure_grandstaff_wedge_out_of_range_voice.enc create mode 100644 src/importexport/encore/tests/data/structure_key_per_staff.enc create mode 100644 src/importexport/encore/tests/data/structure_non_octave_key_keeps_clef.enc create mode 100644 src/importexport/encore/tests/data/structure_octave_bassa_clef_override.enc create mode 100644 src/importexport/encore/tests/data/structure_octave_lower_implicit_silences.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_casea_sparse.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_caseb_no_reduce_full.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_caseb_reduces.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_measure.enc create mode 100644 src/importexport/encore/tests/data/structure_rest_coincident_with_note.enc create mode 100644 src/importexport/encore/tests/data/structure_start_double_barline.enc create mode 100644 src/importexport/encore/tests/data/structure_v0c2_pitches.enc create mode 100644 src/importexport/encore/tests/data/structure_v0c2_spurious_semitone_flag.enc create mode 100644 src/importexport/encore/tests/data/text_tempo_orn_compound_68.enc create mode 100644 src/importexport/encore/tests/data/timesig_change_2_2_to_4_4.enc create mode 100644 src/importexport/encore/tests/data/timesig_change_6_8_to_3_4.enc create mode 100644 src/importexport/encore/tests/tst_notes.cpp create mode 100644 src/importexport/encore/tests/tst_structure.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index acd897143771e..9249660e79117 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -43,10 +43,14 @@ target_sources(iex_encore PRIVATE internal/parser/readers-v0xc4.h internal/parser/readers-v0xc4.cpp internal/parser/elem.h + internal/importer/mappers-clefs.cpp internal/importer/mappers-instruments.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 @@ -55,8 +59,17 @@ target_sources(iex_encore PRIVATE 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-rest.cpp + internal/importer/emitters-fill.cpp + internal/importer/emitters-overfill.cpp + internal/importer/emitters.cpp internal/importer/import.h internal/importer/import-options.h ) 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.h b/src/importexport/encore/internal/importer/builders.h index f404e99fcd6a0..1b2c9bde84124 100644 --- a/src/importexport/encore/internal/importer/builders.h +++ b/src/importexport/encore/internal/importer/builders.h @@ -30,6 +30,9 @@ 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 index 44468acda220d..0820634d7eff6 100644 --- a/src/importexport/encore/internal/importer/ctx.h +++ b/src/importexport/encore/internal/importer/ctx.h @@ -38,12 +38,36 @@ #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) @@ -55,6 +79,146 @@ inline const EncLineStaffData* lineStaffDataAt(const EncRoot& enc, int idx) 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; @@ -66,6 +230,95 @@ struct BuildCtx 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 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..0926b12ddce00 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -0,0 +1,168 @@ +/* + * 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); +void handleRest(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); + + +// 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); +// 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); + + +// Render tempo text (beatTicks in display ticks: 240=quarter, 360=dotted-quarter, ...). (emitters-tempo.cpp) +mu::engraving::String tempoXmlText(int displayBpm, int beatTicks); + +} // namespace mu::iex::enc + +#endif // MU_IEX_ENCORE_NOTELOOP_INTERNAL_H 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..5f0ff97fb954d --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -0,0 +1,669 @@ +/* + * 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; +} + +// 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 (!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; + } + } + + + 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); +} +} // 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-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..156f8505c12c8 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -0,0 +1,1060 @@ +/* + * 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)); + } + } 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); + } + 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; + 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); + + + + + 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; + } + + + // 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.cpp b/src/importexport/encore/internal/importer/import.cpp index 4c7eed4948bbe..26c9b67a84659 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -30,6 +30,9 @@ #include "import.h" #include "../parser/elem.h" +#include "mappers.h" +#include "../parser/ticks.h" +#include "emitters-tuplets.h" #include #include @@ -92,12 +95,90 @@ 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(); + } + } + } + } +} + 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"); + + + // 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); + + + + EditEnharmonicSpelling::spell(score); + respellTransposingStaves(score); // 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 @@ -105,6 +186,9 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp score->rebuildMidiMapping(); score->setUpTempoMap(); score->doLayout(); + + + } muse::String encoreLoadErrorMessage(const QString& path) 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.h b/src/importexport/encore/internal/importer/mappers.h index 46d2f58ea8dfa..028424528f2bc 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -44,6 +44,30 @@ 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); +// 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 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); + QString normalizeEncoreInstrName(const QString& name); // Sentinel for findEncoreInstrumentTemplate: skip the transposition compatibility filter. @@ -79,7 +103,6 @@ const mu::engraving::InstrumentTemplate* findTemplateByMidiFamily(int encMidiPro // musicXmlId, then by track name with any trailing "(...)" variant suffix removed. const mu::engraving::InstrumentTemplate* findInstrumentVariant( const mu::engraving::InstrumentTemplate* base, bool wantTab); - } // namespace mu::iex::enc #endif // MU_IMPORTEXPORT_ENC_IMPORT_MAPPING_H diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 88f06f5efe723..d505e308aee1e 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -28,6 +28,8 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/tst_parser_ticks.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_parser_chord.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_instruments.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_structure.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes.cpp ) set(MODULE_TEST_LINK diff --git a/src/importexport/encore/tests/data/akordo.enc b/src/importexport/encore/tests/data/akordo.enc new file mode 100644 index 0000000000000000000000000000000000000000..387c7bbfd828bba072a0c041c3c06b5e97a9c6d2 GIT binary patch literal 25753 zcmeI5Uu;uV9LK-6x0{>bSY$>ZL@wZBqj8owwqX|1+D+E6Keg1bM5D}YjK$F|c2^#J zXrq$wL}tRH$#|Rk;sZYTAhQ%rmY|6bJo=!-ASh}Rh#Eqyzu&p{uBWbpz|e>8_iN6* zzwD}pp$k4$rac8mon^Axd1`MOtsZOf@w?jz_mdXd*i9su~uSuZgm>UgG)<;B`_hk~oDfyoHmv_1BHUwJSe#DYa4Z)>LzBtRS;n9gdsu0ML8LjD@mptzR%5 zbk=rHpax+Cu?wwumae)R?dU)!_AJ@hrQRqOZ(hIfD5+V)XvLW>;BqMXK^7CK^h_!} znLLte8BdM^eW-&Uzb{-4J#_Y`P(#C$Zp7uM38aw5j56ke^7gKRR zYo9k~>rdS=?z|iKBK639<6JEAU}~XeX@g0%Rt+9{+{y>j_`d`w4JKNl_vwRahcVrV zU5qn`=8d~)sBo-Ag;=M_HmAknd?oQ>ZKk1w2*>hj6)@fI4BUVkxG@?qZ_+SI(Xg4s z1f>~_z`|8ZS14Ulj?NwN9crytBkUWpHBs_f|B0&qBHqSxc%2?*=TWTauWEArAC~z8 zXSg&FgmdRB_}2jJKWmiYf5d*6!a8#tzxrxSFMcfE6PcoV1JuG%YB4`LDIBKOS{S19 zVPzkuTsPTAVdJQJl(;-f`UtUMG5qjNa+y76F zO^zLHq;Px(p8QRUm-qU}EBmj{iD;t#W?YYw!6ud6svAWY0zyCt5CNGVWEcnmA+Qnw znIBdnBX%Jm1Tt8!An;#7B0@k22mzgd%nv#Z(S?8z&w z5YP$8{GihiT?hyPoq)^_It|fF$@t3xV4bsJwo-t&0>20U@wP2*~<{VS~#0;?e)^TTTBiLVf-ZUQntRCoVM>x95+2*~`f8hYX@1ge{W z%n#MwztTD(aQ6f>3odNaoUHK_gzhvH9A2S-sqT1R+7k2fBYmB)`c4R zS32&a&{o;V<#NbmGAPm~Lmx6xo`P_Xt8=-*Gk3>d8sJ+w7#)oArp`Rl_(wc0(M4}j WGH8?I_h`G`hsnyRlk#DzWZ)lJA$$}7 literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/data/bando.enc b/src/importexport/encore/tests/data/bando.enc new file mode 100644 index 0000000000000000000000000000000000000000..2c5165db6e8f49aefdd1aa8ffa4447831ec722d9 GIT binary patch literal 69402 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 VA}gvK`}scBN?zhX+&R`6^bh0eCddE) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b40e53e1342852399a9ad11fbe08379363dc269 GIT binary patch literal 38042 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b91f5d69b279a09ab8f3df1b65d571c897ad204 GIT binary patch literal 35048 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9b7c440bcee9826da3825cec49e4c12d00d2e2ae GIT binary patch literal 26158 zcmeI5&u<$=6vyB0nvjx+TBU~ws$zkJBDl20f*?w;u{Y_aw%1y(6-zx((=3UlWNq0_ z3Au!SpcMyB9NKHe0V%g0ec-^aL*dLJLL3l6C);=jCQMbXT2FHsbYfAt#Y_dd_EX5+47`ByJo2*(fF zp#}mV00JNY0w6FV1o%9n&(!3k*L6ASC0!?SqL)&=M5iOwWsH(<%x30ul}a%^Z$GPa zgTV)qG~}FuJ)?Rg=$~hz>wI34{?Cvci^eFVPv|n;q}%k@-tW7={`}L?nH&6=MX33JBm+vn|cX^;E%&s2R1-?A>rSNBXsbXSd+phap@joK7& zDe0p#q)uUz^rWh9(8uSxH*j9+mpfEUsRqtg3~p1sGXj71y4JR1q|Ogkq)aQ^zhZJP zgC>b~lx3WCWjPzBbjykx$JM^})X7rrcIWx4dHYN4-T) z5ZQWL6bUt1m&4TFX5FKy!M^C(nIt36^Ip2aJE=}BzTW43m7oe0sl;WLH8*IT)_AXl zs^#$BXncaKU^%IH#xgHTE> z%Q}Vf3lukU)+BxX{U6=8i`K4-r}iuT(f1V)MudRA@^P+|^8SKMojsfJU*0E9*jR7B zQwwR%_3|aR>}727>{2$DqjFel1}%}CupKm4gX+y%xJ>Tc964*%<-q^25zs=>p3etM zI86p81)^1`GdgG1Rj2VIJ!StKmY_rhXBqGqqEkSDF_^wzAaG&=@DC?O2SX4TdIIndL%+nBAP5|j zz;^Fbi^<;elM)|h@JZ*uXNXSX#7b*iMT7Qw6(QG z5x=(hB@v6c%E62IQsqHKU!C>JdAXHEr{KKIrR-&i3;rVyy2sa>tSE7a>@%#DYes@_ Iv(BKu0n5-r^#A|> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..64c68d437fa8cef2d8d49016edcf8fcfaf0c62cd GIT binary patch literal 26128 zcmeI5&u<$=6vy9sO-M;Zt$Kr0TMIJDP_15$21`oMu77vP9EM2G`I2<1?r3L$OZ8?WO@XjBr~i~SvIXWqOw zZ?pTE@zccKyp*%f;#c(el8jH$4#mhI!x-xgXEFX~`iI+QQuU{!lbGBZkxVo-e_Isn z`RHxUi)fB*=900@ALrE_z>=!>H&wk&{tLM}>}uYRDL+-kHtL=d0CHX5M;H z=>{hsNMgv_MQcWlNYcOeL|1sfB;#Ko*%pmaK%dchx=y#~p9lAM{`l?J?SVh3eG&0U zwS`jW-1tuU8_!4nNt%s)VI*TSvA%!7%9MZNv9hPyHo9h?!X56X_~`Aah@{VVOpDZ_ zI(5kBR>~(QNbSNV=~>m^pifWrF5tBE&v&VmRtBzC47RD!S%E)$MYC-i>C=N5snAUx zf5GHYCQZtfiD!a!719#NwE<#?o z$X>J0<$dlyo(ud-u9ZzO@&d1xtGrek)aK(Fubd=RsYGRNbF8^a>$JwJDNvUEtBZd( zh|}25R5eY0YZ-KmKA%X9FR^r-syrozs(I4Yobc2_3ej;~x?zuj0}S9Yf<%~>kdntrQXzu{Bf@;u9x@r_oYvEHni77ga&*X+Q=65YP!AKIk+cg8&HV1P~u|8jwK%1atz34>}FV zAOHe70mKKL24oNb0i6KigH8i72!MbP=zXgx&qs+AQ%{->$h^wWVWWTm2#h)b>>oyb z1>pWc;9vre-ai~X9u^P)fzcs={ln82Ty3 z4T8Xt2_QZk866x!VCV@TJ`DX7;|4+C$OI4{j*JeDATaa<5FdtqigAM=a7Y4M(YF?p z(esm%A7=1P=OAW?PV&TxHKDS_o}@Ram-l&r^CC+QVN*S88RK#Cx<}8cYmxfpH{Q-V zmzLYp>FJY~FP>9bW<9Iw>Hnl+&MA6bJ$qAUvEd%b*@Lbc$~plG8DHd zNkwiy;@0KN#cRwZ*ayaA-AZKJXHwm)lXq{iG@edK^5Q30Bt3Dr+ojFTO$zz7$uG%R z%w0}i%!g|CD*Ec2TPet;EZRl;b#7%XOXlxi_@oc`c$XDrPLX|rwQ|fznr+q@^bkq@ BKq3GD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..69e3d79197b928f35cf88af2699de36df3522803 GIT binary patch literal 26130 zcmeI5&u<$=6vy9sO-PAFty!nxx z&&<9|;>}CNDs6sE|1OE>BK0Xk1{ucOpgOD3-%~%_F%v4k6m(*8Y6Lpb;`$viaLdQb zy@iY!p)D@#Q_qY>!?nIqS>KQU`i|-QD=)l4zCZu%8+^U@Rfa7GcXOt5{mK<@KHLsD z5C8!X009sHff*sd`w@Ml5f(kK>wX}*P4q+{sX*wwud2*Z@~u^CJzuMp)9cyim2K4d zNRxtmu9#g>H4^mCJ<$!`FG>C9NVY|D{&Z^r1mAO9RY5?Z;go;%45 zdp|KinGpi|%*Q!XDL5N4boPh z=j8vg^_EApsIr%IBXke|0T5sU@DEr9AOHeq5rBUP^!TnwR-KYzPQ|z^oI%{$bWv0LBjjXA^k*{^9KUC;mMxKg`ZtW27K3E`j^}m)jx><2FQn5CDOxCjkF2^;3)ig22QC;2$PNhlU_9^#tG_ zrhbYsKoFRi0Q|$m=+F=Zrk()&!_-eP1_%OU5;zJzwTK1pPfDcD(I=fFpCLAh6Dy^0 z6)pB6y-tJ3&l~i#iXm*OcP%4aPmcTajJg-OzWCakR_VR%&QfaW;``UHswlHgw&v*n zredvBbb5OD&@o3cUiCCbKgQ=20f?=cte6c~qX zJ3-m0B9*mDdHXhN^QpK5FMfg|;fZ^_9vvPYlF!E>9}=;cvmCsbPnCuZLvzNi7UWho kbH&`NoXT33*xx^Krw{pjn+=txh%T^I&J_u+%{GJn2CA7ofB*mh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..061c92da523d2e8649114b4828cd745d4c323518 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85nS40K@d=E>`mCz_FC(WVu=Ga&5~M5)|Ty* zz$N?z6$efn+H1uDDYqVd;J}X)oH-!G0U?BPK&V1U%bW40-VL=%Lb=%Av3BRpn>Q2B zXU0!2^5&&pbk@JX{Yy5zfCHppz%XWp<|3wl%YS>r%Iv-5y`;9$_=HEk@qgQ z=1OJ?8`61zftgMxt-jRFFwFe&x*3K`PdGh{O3p?l9~GPqwaE-}uUssylpBp&Va0kv>qe80 zB-K&2tJadX$l{+d(PfF3l>OsSVbKf%e1g|-4cGD4o!<|B{rRUKM&qI5B8a69q4b=) z-{|qiqtW;*E~Y*+vZBV{Nh4AtD~-4Eln(cEu{?*4Oge9g6rIf-#ALi~#GZlfpH zdqO{p25P8FT@uX|>|k4VB+xy{9a1}Cq%re78m1}7)4(ZwI+vT>5a~1;a_9|x2MT&m zl>LJ!haWCgt23Z#^)Wg+cge1yLpys%qTd+M8+gn@BzORS4ey_c?qx+qXs?MX< z?D*Y!>q8%9%kwN(*|)ou_J)rOxpzCAf4jBwVpwUE)(zp}*rYfL6HPVKob~#tErxk zQc_AkX*xpltUQO!LINZ(?F1M-*k?AhSI4#v8p$tv&Ejlix}okUX|R! zDu=MCpS4VhoqFBIBl^9l`_nJHTy);v>@DUO&%AT#g3dDQSq(4#j~4l(46wJihfrR7@=_U#r76ja<MO|r literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1eed2c054ef2b06df26da1d7fe224ed6c7372145 GIT binary patch literal 27832 zcmeI5&u`mg7{|Z1L)X!YMX*B!NJM~81($6^Py|d3iCei#;>dB;NE}#LO^MVxQId|q zrT@WBoH%UPi33t^JLbTFA163-Kxl^vA=Cpx6GAf2`_^6TZqZ2D0VUtpvft-@-uJcq zeqQJCwDjC7h@#nV-;nLCuS%l1cGI!^OXtpov*~^; zkpKyh011!)2}}tA@kf#)H6NjZvBQm5oxoR;H+Gm&mGgZx_;i>u{EqgGh8pVhkI z&WDn!C_7bqNw>)1L7(Wd_)F#~;29{lXa*rZ$2+)+>-hWL9|ynPz4P<1KeS&2X{lW( zz0Ygk>;A@5asM1HnqL|@bICmFU$F~ybwtN~s+*$A%X;Wj0jew`#s<1*p^rdvh-bzA z^YVA3ig-0w#HVSaC+kPz;pEUj4Ry&&qPc=y z?1YB6{q&y=dgPEVuuq<%_4V=aobNSg#k<7Aw7xdcOySm=(sO&sy%}&s*w>}F{wte6B zRQqN!x%S-m$$AT_CpHxyN0TLhqAwc`U(|`m> zU=#t`hf!pxCjk=Jhr0jJcVdJ=0wh2JDFU<)DI6@200|@r&^{z!P$U5oND-iYNa0|C z1V|u3fc7B)gCYr#K#BnELkb5ABtQZQ0<;ea7!*l>1T=xer<&^hC?%QtP17Nom*hQc zCK4clX(vGcVcO3Cj-LcZ6L`4)FuFZ!NPq;UhXDPD>6vSelmx~la9jR!Tcx>iJF-0q zkif(fpnaJ5Ddqr4;KT%IA5M&p9Z6u~3D7=F{1kJ5ByeH^v=1jn$Bra0@dRieCVq-J zKoS^}z<&JHVm^L<5=F=qR(k-SCqiQ(AGW{va7Xbj*>A*SsLnc$*Thx39M? z*-mrvC4#YvP~1ZfRmne*+>^+~JHjPA2F4fN$8p`UDqSqPW$(I3v&&f(y!bID6`pu7 z7+`O250NZ;vZ#o~Qk3Auvg$moI8v9q^@=)_4X5h7D!DqBRfOqZWT*FJeOnauCxE9# OtJWp?5Jo|C2L1t!@?2j4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d1f098e96038bebe1933cf05a4877fbc2c962cea GIT binary patch literal 26070 zcmeI5&u<$=6vw~obxlelYLy-$sDuR)ir`X=1wqir#&)7j{Y&eOVu=HoW(k&(wPib{ za7q7yiUTJO4abTDQf@u^z=0nZ;LHIb4hSKX1431Ww7eOw>$TemYQ1Q#)-HUb$~-ngX{GVjzpJ|qaRZNsx33dHri;Q3s-7YKEBxh zaZ5l_-kMQKYqgxx#=v$NMiOgb+l|P;<{^?qI&ABi1KSbIBr;(e&J1kdA(})sY)i*( zobLyYgP7Cz)$A0%>1N0A6+53zNt9ibHNPq=-b6=^_hkJOsG)+Y)H%^y#R2wZPk7og zzDFd&k0SOXYNjbSh=H^CbTPHCBhoq4P8M4%B$33btxV=@a_JFF@jXZw+oK@O- z7IC9sF5#OW{_1b*@lHYe?0x;<>h~2C=7d0a<>S(*7M)Es^|)-Y|8t*MUhAOyX2ZjJ z*)CSgTXr@NJ73Ke3fS@*ZMUN&%j>%BJ-2?X;q9Wlz7A`@zUw+4wp?sh@*72W2jzCd z^}6+Lqv@eiwjDcTImkBm_Uc_%53$`&bMHXg%Pm#Cgi5{TwyTYgTom$-lebm>X1mzj zagj{D+iLySrIp_TDAsb@hH!B#r*O{>k37b*Ze9#4DU$#Rkbn@Nf8aJC0TMWk0R6*h zWT+%5+H#Q0`w0fIM^To5(p8X ze+a>#NCG4lyzcj z|1k4Q%n6df)CA}srbfqsBrx*?=pSZ&i8(v5-NEDfy{Pju zFTR>BzqQ+0Nv~XZ`$|%0nRW6tC;YD_*2^WQ7fv5K;Y=p$o{?<4b;;M;)#rws=eanN z!B}M|?jV7Z)E`J~OXlKz;o^;faYXliU>h^(X0}|gZ;G^#j;rLwkFcop#Jye*hlhvo z<#i}8m9bcwlDt?BU5_dT>YTk*RBvV5Dp@Z`t@g6Y-~F>(^sXGQi=ui4I4@dttV){j IjOYyf1H)K4j{pDw literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bd4b1b1faa2f0b3a5c674579548170a9b81ef568 GIT binary patch literal 26016 zcmeI5&u<$=6vw~2HYOzzwMq{WRA7OGBDfS|K@d%4V{f8OZLhUnE0#D=(=5R#SzESK z3YYK~R5@_s&|WJJNV)as0|$Pb;LM>y91ub%2ZX8$X?btFi6?F&u*=DQ&)S_gZ)P?- zpEvt7iZ?ISytDB&{#{b>MI6F}0mDcPx|16JBm2XxSW4BGqKm|+G$I{XT)V{v8u@6s zGhc|A*rdio^keb($zI>6Y8WPeTZx6?@^ddE4ClUmgU)xpDv;&IZ9C>&xp*;{J2_5j zLO=)z0U;m+gusjtp#6wGQv|Z8Zeu+aNj4Ik4ON!}GH+eZuNCX{N^Z@1R@sIZ zA8OoCw9D4A>XE|3d!nnfU*i7HfVV{n1o#}6a2+@C_x(Q)fB*HDd&BWi`yxSvHnCw1fe8si$8S-t3ymyU!*Fu+$56F`#)KNi|$^u!gVHZ2(-9WXBdYjxBN8%^c zV=-Fl2F~D%`OMrVX=hQV2gp!oAg9(&|FJGspI$s~;OgnGpiI=aW>amb`VII>jgBah?-9Xz%vkX$Dwz+)~x4xp@n& zRV@^Ys0GcA-(}4XdVXiyZ(MH%TX0raVed4yeD9OCkM)YRR`NIDbeew9YxJ6}02Rme z+??$p-`d`8^n5kM^}DU@UDfWixp)ngM%(XHo1gk9TApXQ+`rW+wKjdEGw-+C|JAh= ztWl~LHaK*9wpV=M>LU-ZsHzuqqv%3F2nYcpAn`$#fe;V^rxB3&a2gr03jrZ;z`raa z;olUA2mv7=1atxtA9Na`3jraZ6Oj0z(-2(<2mzgd#0Q;*=t4jU=maD_=rlwZ0zyD1 zAn`$`A-WI{0y+VS4>}Feg@6!X0)uZg`TZ!?O!cPekjzW;9=3=O5CXGKK=u!_z5-Q2Gl2Au#m>BtA^Ni)DgB zU}6FiA0|d81BJlU6Oi~Y^)8kP3W13UNPL(WoeUHLQ%^wR!_>Q2CMX2PBybRYYq1c$ zKgs!FhTn7!V+PwePb{}4RkqmkcpZa0%eUwHM+yz}l> zcPYDc{=F+{m1Wkm>Yn~jBUYWV*VogBjyaR@y5}exZ-esn_SCsa=UEnyG8l7);x1As zQ~5EKE@dv>AuhoP7>{fpMr|Was+xC-?oHC>vPn)}{67k(C+_$A*xTDfNS{6WaK>V) zQu1OtRC&^HWG=Y165q;(UAAALlGie4;{KT~dY_KB$xwX)I7e1KmMP(Pj%)`00Ui@H AC;$Ke literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ca1ec373ae3b975fe454a2e09419d04c0cbb564 GIT binary patch literal 26098 zcmeI5&u<$=6vw~2>zI^8)G9qhPzehp6v3sK2!f!IjlGGS+Of4>E0#Eb+bqFSvbJoe z6fWssP;ubIp}AHZkaFvRgAW||aRJU8D#QUHgmOTrs*sj9<8{4u8$m)(_IIq^dGp?z z&CX|LpCS_F{MuOKKyOfrYhON+CTT zU2f0iEE8K|JVMWk#YVlp*3BSD{C3F-g5_slL=en=^O~G*e~}Z-^@F_SUcPwIpB?ST z7736536KB@kie7>ko`z>q^6W;J1PfZiRzS`3`;sJI2&k}IF{a6$*vV@wQ^?7en#tt z7avN>P{?oC%i1G}hxbJ9$$m-sKMl1lio?gJcnjBX6Mx_P^XT_qf4MvC54|sfcb zzUKAsbbsTiuzwOO=I2JzTs8;&i*}~+rTF&uRCf(C+*4IWwm{*?HX7){ zlUkJ<=lVZx9`Te`;FAjj4N`KmMmgQr18HMmy96VJm8k88Ik5RKQ%FZ`J!@b)fR#cf zYQxOH_B~=LWTUopd|)$-nG|dT!+Qw=HFCxFbR%c~vSVkLWVIA!JzSO5&_qX$_hoG) zQ9~IOsdJ*aiaqSgdhxYoeErDoKZf{Es9BagJO<9-v$>_&Es@TmCXb|{&yY!1tMPG_ z!K1HgwFXqJ9`f<)=kgoX=aDc9);zxb@vr`pA9o7dXCCMWSAU?OFeL<{GoO$~rRc7! zp(kXC{h#L~@3;24Z#R6bmYiaxRCTg89J`V$6j1dWZLg!Gyx;ZOJ6`=-!{0_}brt#D z`nKnO)bg-iw%3Z@7E0}g=XdMfM$<>R6gh2u%KmsEK7#~J(ut5SO5Fx<$5P?CF z1V~_n0OP|54mL=D1R?|&A0jX)k^l*e5MX>5!NCRzkU)e0<3j`nMG_zZO`!j+rg}e0 zNv3|&bcp5!c@LX~1V~`o32^@~?JI!eCxPP$JbM3dygw}@KmyZ4fcuB(nQM-e1Wroe zj{N1eDsw08NPiL_fr%%;_%QKP%mI?XsR=MXoEjZFlEB0hV0@VPDdqr4;M4>dA5M*q z9Z6u~2{1lP{1kJ5Byd6khvByt^WpoGDnHEdo6cd(P&$<-R#_7|TkLtfihgG2bv&VS z2wVDHOH=&R=MJ9K_oB|vz5IH%^v-r?F}-;H-ODMRW!AN8ZuH+!td=(1UNn8^2}g1= z_l#uYtxLY%u0A*9Jk74ZvN{1}T$Pu%PEu)n{LKtB8OQ5lP+DaniF(DkTdpw2nfqPmsM{6_vIsnuFmxxIgu Yi{6vtO;J>4fODc%$Figl&xp>zKSL%vk^lez literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d91078218c9459383f4b65e87256c5315b0c537a GIT binary patch literal 26126 zcmeI5&u<%55XWb|Zb(Q(tx_Qis$xJwX}OfvYJzAYyY|M~)b?8IwPL9UFwHi#l&me= zDTRyq3n~trIJDP_15$21T3pJH6P!43s1OH)5XzxK6+$vI&l`Ik8iC!O?C)5+Z{EBg zyYKVfPF|ADq?occzM}t@SU5`i6e5i@ZKhY9)$kvQ@9*d_m0$Eb(K*$8ohX{QBL;5y zWVtt=)5iL`QDdlw%odF>duvO=e*hB zcF2JM2!H?xfB*-P3k54DiLrz){6&PQ)gMRZs77o!bo zP?cKbaw?KIJ>a-NXW8;V3}IT3(obKaaD-Epx%3(p5=3XjdQ7Y*q~_`cWv%wDr)2k+ zm{ZnP-+Ed`ki4v{5BmL{hv zOPMzJ^a<}5g^F+=j|$osY$FxtsmbwLxyCD{Mom7h^SX&qnF>_oG|iT4v`ag@mONE* zcy002g=yv|D(gBwSQ^dI=kxK|P1cT6nIBh8oq3kkO6BQ#Cc-O5iKMayBozmsjDb_y z%$HuLh?dnC=-VIu>^%;F@}N4`&m8pLH$a&Z0>PP&aHg1Z)@A7I3ALwrPE4=9+qzu! zXw9;7MXO||46==4I-8}ES8cdW(M+%9Hn!c$&8oLW*4i4GJC!Zh`Ka#Fdcnx#+)c6? zRo81(TGg6I1 zQDk5T0T9^h{d`Kk6N7*N2!Oy40r-a@9LRtG2m}bgKLlVv1pyEkA^`s|gaa860D%Aj z_=f-ts2~6WLj>R-hHxMQ0w53|0RIqx0Tl#5KoL-H-Luy74W|e{2n$01VCWg z31I&)?JEG|2Z5srJbC|cbbXY700>ME0qh^9XRa|)5Ezxfeg4aBk@-;@qCNQ-|xv3T;mE9X^|S;r_l!GBS)X62nuFns8UBN?rFlB4lfI9_i{ovVDFW^p)z zu|z0tQjGGPe#oiKk&Abji+2c&!?qiK*`XqpO<7s{Hfytqhy*Wwh9cpKJDm=-+imLd zr_CRUSj<@tUd)F|hYbhjv|Y-{t!$WiQ{s2aT9&xqKXa##`FNKNMGl#Lf~|5aOCWBx GY4i`v{Xb0r literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..19ca6aa51ee2a8db85c04b7118057b4ec6caaeeb GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwMq{WRA4|t5eTUi3xa^kuDyviwY}DQqgdilOtS=|WNq0_ zDO~a&R2(>QXs;Cqq}+P+fdfBIaOQvz2ZRvH0imiwTHcJ;&e}ABxNxz*W9`hFH$UR} z%=qb9yMC$Woy{-s&ncZ;!XXkcU>I}#?jk0C%YJ*)OzZk`G>Ivt5y`;P+D)a9k@rux z7Yb$qThe%lo|#OJ_WDv+!!Y&Bn`RiUJn@TB)|Q6K{VHI*`{~-{P|!WxP&_1 zM{{%n)<}Q^NPq-LfCQ$SfcPWv1tug7>Q4`Zs0^btrbaqS*(l+3sJqM|^V-GyTCrBE zOG4Yv^POKyb<4abq`lR$7_o;4)E+bdprvg+RNMjRi zG|+`FrAi;4>;Jh$RlEqZlGSR-!cfhv);-zW9A9y*d`5iSk`TWtyqoCA^{&uQqlOBq zQWivW6?@o~9SL;H_zubTCo%UuYNjd2)4(Zwx{#UQ66rK*a_9}613A6lgWY@3QVy+7 zs#Iq{mFi=pm9eZ~mp7h4$|#zP`1-p)`iCmk9v4pyD}&jG3JNnqAinb{sZ>kex>|Zl zj@W~IPVAty*S*vTul$6j< znjWEfR-VIVA^{SZbprGsX8iu{y ztF&;^f$UELBrx>^XdkA2i@87&n3w?V!^G$~kOZcl0PVxnZ!s500uvLUeV7;>2a>?l z6QF&V`Yq-HN#KM84x+mji_!CwDn887oz5ehp>!%vtZGf^Xt8JUGWxNT*YU89A#Cbr zEfdmDz3$)<{a#f5)C;fXowv3-%h~0#S1w=BQD!}>=E*;L7^YRH?DgW|Lx(N@hmezK zymg7!+tqnP@>x!vL@-tniaSW7EaeAMx)Ql~SGWXYU_8;i7qyKw>1y67x;I3c&!$xH z;{PzI@Wj1d5BvN32<5deFBP#^suH|dE?tf)2I_*lQBrSZ(=OZ3OR3JXik|(WOnO(Y V*F{l%2sk5Jb*)Ia?40Nf`~@+CFcbg) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0a293f3a220ad1a257c6f99406e2a900de65754a GIT binary patch literal 25940 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 ZP4Dyd78`1uMf(}{$+g4@yz^``=mA$(F{uCm literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..00e960f45203a4e4d5e761820c23cdabaf26f8bd GIT binary patch literal 25996 zcmeI5&u<%55XWbCO-M;ZE$AVFN?0JF2rg~0Xevr&*WN^%+FonDA(l9_ZnI4-C2Pxe zO6ev4LB)X+hxS@=sFYieKJ>tk6P!69!~r3MazLm;NZXmW8+$ix1a^C}zt7rzGxOeT z_I=*-=sDg@Y6W}!5&d_`q!wtObka!EX2R|)rvA+RaM#EveKER-!L=62L<=i-#lW7= zF8Ak(hE5yYxKDi}l}h&dMyWxN{{3Sk2$tS_mx5sS+YkAC|3Q&0x9(Ynd+F3Ee>Uj{ z4Fo^{1V8`;KwwG;@O~sdQk_d&kIO++;x>^pQOQMxjt8pC3}w$R6V=obWXPcrK9?g{=?$1G%dZ(Qb?y2kU= zqAnlTdDb#irz+LBF0$ns?a(&Qq_0{I&n)lDQ#A7v)eVD}wMNJ2tGVp#2201O&Tke? zor&^lorE*~{AVq?gw(PIq?Wf|WFAyStFrbsrL~eVPv8Ccceoe_tx4h7Ks~tnzyM)N z2*hVT%}TB8uFBBUyu_a8ePa3To!-T!Ps_GbuGwo&!6e756-y;r^P3&7E0X2+yv~-_ zxY_hK$zEP2YrC=Oxu3T^TCJKZWp9J*PSf*yjb5|mQ`L4{CvUk_Xl-pZdY*D|yl!i2 zN448+sZLY1(e^sE=53Ekrt6xH^lx>_tqqUPWUsc{|95I7K9}pob&a{WmRtJAiF@wR zf>KY$jgUbA1VDfZAUPC^E2v00``bzaNrMVkjU00w9nefcTKW0R;p= zAVvW3AqE2?2!KF>0OCUe2NVzhffxbAhZqcqAOHdh0*DU@98f?21Y!gbA7U^df&d68 z0_v%Emcsa;t0zr|Xx`!Huu(t&1g4z;_7Bs(0x*6MIGVt-_YX(+M+*pm!1NHn{$YCN z8Y2aPQ3*WczucBGH>xB0g8&FjJORXqiJxK&5Cq01fcP*rIyi#B#1lY#nD{Bi06}1E z0*DV|qk|&|OgsU^hl!tJ3=jlHB(N8KYcU@^KPj0yhu?G#V}{ryPpq`2Rkqj@^Z|uA zmsja!l|$H2&syr-PhJn{6?HFie(U|S1^d&@?qY87#N|t8RF+xSth@1l5V34m+_@e4ggwat33`P~4>qRk;3)YlkxzZ!;JF5Ez$jH=?#fO-e1; zCFc%Hv$?b+FMfeV(i8XlecIjKrGQ_%{F02tta9>VK2)7F9GHvFT3K#o-KtpcaV=|E dvTpyv7k$jfM{KCQMD!Y4# z(-toI52_w|;?Q0z4oJE6=mQ6SoZ!p>Ar1&3lmkK)LRw~?*YU2Y1$H^v-)HT-}f3;OqxN-WVK>7({t+9|{uvY9*te45?gKmGo81@XB&ro}jt!scIO!tu;DMpDv{5w^%wsReo4BbtcNH zebQg^7p?mbrPQ(qq?X5T__}$kwDCG6wSuuoUw`*UZ!->e3iabh_Wsq63=n37Ky>Ak ztW=8bx=cOETkJ)i6U%Szb>6T0v}!xWioN0FOmfUhzEGeIzuxlNB3XXNYwdWoJ9U4X z?A2AWc5B<7`*G8w^|HBE^tQ-u)jhvc>(m=Qm2Jm$vX)D^#?DTyAI#P{Tr=fW6Prp=?|OD|GKo|yNlKQrp8=c%PsurL`NRbl2Xq` zjgUbA1VDfZz&~IcfB*=LA^`s|iVW-^00IZ3es?54iJ^c12!KG00Q^G?2NVzhfd~Qk zhX@RaAOHd}0`Lzp98f?21R@0BA0jXyf&d7_2*5wYa6kb85Qq?fe~7?<2m&CW2=uIuL$40r-cB(P1D6Og#bkhpAs;Ob`SnCIJ61 zF**zcfvG0||1kASj0u9km;?^OZ!H$X_a`M&=ir;pfzJ?|);`&gWS!E@v>748?6qQHkrjTsxe(c$c~OLttFC-45G^nv|Ne3(kF( z<}*o2Ui?26Nl)DEc4>cqp922u^G7lkv&zYf`A~J-&^PCujiTJjrd6`u;#&5yWZnLe ZFZzs+_t;Q*iRdI-i53bsMZumA zFSqCNmPzYu+@rpgNbDc=mFh-O^4IfL6fHmZGDXqs*XOvt{Y9QN*KRqMfAP$jaCW~R zdLRG-AOHd&00L7&fX^fPOieC&Uzek}=sJ;8ammDmjz?<945eSckSi4H^-8v2KdW@3 zn~x+h6rHlYtVX2hpJ$@?_`D?JPmmmoW+&(5WJsY<;5mw5GC)aC0k?}ZfAsX|q5 z^Q^f<+qA{|BUF}y`-PW3K{G#6-Lm-2V$d=AbS^!+&eCzJ^IOnRHE$)g`_!^NhKhHc zD$>dxkXGK*N~u+Or(AoHl19;*r*D4vbFjrVyDlDk&>7zSfdaym5YSgX$(?G+UzMqI zTr>X5`@{*`+r77%A+5Mxsp{6eoK2ox%@>PQ3!9ywE0PoTg3e~pxY7(a$X!_>XREOh z_#d_dTCLcHQm{^Lrx}F3Mz7flsp5LRmvwx~wKg{!y+FBmLASNJt?X`FnkA|<+Cit< zydF@|_I=xv@vTm&wI0yf^!x4hqb@B6c`DWOYX)=i9l!XOr+e!4F z0*DVf4agt>0y+W22b~6F5C8$40OEsA12PDJfKCAML8k#31VBIt48GNr=c7b2)sv

zFg*ltewd!M#!Nxrs08ltUv5j8 zJE|kbg8&FjJORXqiCep2$ojK1j{#SGC&o>;LaRkqlZ^ePQ9Ij?eVVaXwEsb?)s z9w)Cm^pyHtq<-P_YdQCwjqYM*@#MP~&#El5zFqhAe><__mi@k-K2&-CUsaZ~@isVL zZ%@@tuBW;9oWWQ!6n7~_Wp1x>>v87dE#?v)0OPangV=V!q`En`=-psxHj|X(#gDN_ zdg6Y+Pdht16!B|^Uy`wyyPUk3FV*f>4AptBR+3+3%_%#ta4UORa(Vy6H@(N#8?2~u RYVBuOE7!6l?`EAr_W>+GL6ZOg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..58cbe237b4bfed16c3d906e9cb6e1bf7ba427e51 GIT binary patch literal 26898 zcmeI5O>7%Q6vyB0I&Mm$#DW|msK5e*B6=w$f^)(e`&o@tO5$AUV>4wwrnS~ zxugd!xl|lDacHF;Pz4eqhoVYce4r8^7vS6jil{&cp{PPop-RhplTG}*ZUhPCV!w%Y zXLkNG^JafLc{_^T*L*Cy@CE#H3I|4E2XxRt(}KIru^9L*^4%pPtggqrK@7|_uM99c zbBQXj=e^U_fw-YV3B?^~8i7E2t1s21>xO=L$#C6?N1g)L?f>dIJYW4Jjy2~lCk<=v z)G4RGy`NkO0U;m+gn$qb0zEaPuX{lhS&f24)=34O1BIuXs^#16ZAs&93}+f%J$|8YEa_f!Cv z)p%i8fEtvc0XF6UeGqxH#c>EGvF-*{48S0%@11}^2>)Owx0^3 zrUX0UY5#bc1PRkB|8-)BN@}nMU@WWMh7| zuT6H?`SMSHGeHh(hBKbyNu^Up@pT4T~| zEOz&Alcs2wI_y=L=C{{in%~}erupyw!+mY7!`{bC^V|EBX?}axnC7>)t?{+J30IKz zqK=~CcZS(>UGfa%drElhz+pXu^v{`z*f>6pH2TZ4=q)Qy$Ky@(ona_I4)U1CvF0qS z!V3CRM=9<8l~#=wfZ$Ci7zVmD4UWSH1LOTAEDb{eUs;+u14Na#ruO$E;Yv}7a#{nF z(@RiQvQs>nnSUHYTFMxNufP9;UI_=La9 zq*GmOyj*r*I%}r$*?BXT05g$~r&2KQlxudKN=c_-*Ou+#TG?5G?DRAwSBguvb-rrD zY%VdAwo8z$m2Ia{Y?Lbw!Kk`C!7HUK1cZPPKmy_) zWEltnA#e}@@ec=)k$NE@1h(jpTA1jsr;>z#5D)^KfcOVaL&`!x2yg=8A2L_)uiu7QAt6hXNeBpmUMC>?hhAR+a{of$U;_8vKOEd%6d@o4dWV4QAA09m%bf~=!xFfT z|8kqsz+pQ|dm$hMx}JdehpvCba)UzP$OObc92uQ-6armOK>S13KVrE-A#h{@;vbHT zPC5#Kt|uV=q3a*9+@KIRB!MmOQ;R|G`;!zOru9i@%V$s>#fc>~q@u-+z_YL$`Fs`w zCQ}SyLw(m$$9D8{9Uf3~q4Q%WpO0l|E6U+o3WZae5mUE zf1RQjjkk#LdK>Co#&a(x3nLg)gyK4cA%poln41{6cm=sQJ}?&6z2#{>NnOUWDf1$h z`XeCV!Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..66f820f23900b5dfaf0724d8faed9fed1dfd7cb7 GIT binary patch literal 26057 zcmeI5&u<$=6vw~oH6bMtwV;OxD&c~JB6?|yRe~s$jlGFBwY}DQtytm!rdd);$=b4= zQhG`Mf(nVAIJDP_15$21`oMu77vRjHLL3l6CQ?`^MUNGjD#_ z^O@PFXL<8d&08B^;@?v`xr75Gph45-2HmNc{5|{qO+BsZ%fTc%Ni8S?OKUe-K_efY zZZ8z{1U9Mh0DV1~9Pjm&s``HF*SB@wUwQFW`2PIYXDPq^MS(O|Zkf7s;nXQ_etewN zgn$qb0zyCt2!WUoAb(_Zr3p%-_0h5)q){E0(?QAx2}ga^We%A)&*#^Q^?D_@X1t(u z!^wvwZYY{%V@35yFVZHr(534`+VV8&sGv%+K$?r##SR?< zPqjpk1v&Le%>9VEuG5BVID*d>GV_~MI*K~oX`0G`oH}H|<~^~{f*Dxy8DPoxZlFFC z1+!dx87Zx(FXEdY{v4d-!`AV_k;7^@`=LS#F(ELz^C_xSOU^nkJw-?CN$wNVYwz~n zX?j?->{8XL*?9xDQ7sgUsCmtf+vSq!_1w<3+qm5HwqUKU!rWkXP z>NH)i*XT7{9x9gY*g4ZdzO}vG=(%c$?RHz+yQ=trBREChr=%n68ni1`7K78eG`j030b}n5CTyGVjrSJq%H)6KuAFBLr6lT5D)@U0%9Mc zM5HbRgg{6@>_bRGq!17SQ37HgqC})F1cX3HK@mEKLBQs9Jgs5~>*`s{ z1oh+B9Xz8x3+FGLd?RnYyVYIJE+2dE!Wk81)-meN=zo`3waQL^G<@iUE19f&j-v54 zC|++*hBa${nF5V$7UIdIox=TS@q)AotR?)srrTJ`%gBSmg ziNh23`+e-~?NJ16AA9uTh{aUpklIvWIj#uR1-n+_kFsHw&DTisS>}-4KhdQ3=z5D3 S)u({xNXyp>MF!82PQwF3(m~k( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b7de5e9fa22e0a9a47442660719388438ebd1e5 GIT binary patch literal 26026 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0bef02a7ed29a8cdd470fb75be56ec894f4d124b GIT binary patch literal 23465 zcmeI4&uA{#L61_Bf(MqC;7Z4WR3M^TcK;pq*S?eZrAwV^H!N0)x z76crYG4d-5;n$rvv<^MJBTN@{820lu?&J8#~+x4idx+sU?Ur;s#CU+8Fw zE^wdy0PztF9Nqmt_Q>ctO&|Nb!)AC`n;NDZ0g4Z=k<}7~CL2{Q`_v6B$kJIpbBS^J zDh7+#L{TbHlN@T3&06H$DuqOc=^W8R3gzQs_2vD6Z;2=)zBfY`eOQeT#+@O$>Gyp+ z@bNWcUSF@5H??Gp=a}VLZ1NoH)Z+4tXB4F}<*C3r#Vwn(L)$z{S9!)~O%zz5vp=XD z3iY^7OY~_uwpiub1u6%FtmRCUPz(+xeZq=j&({zwR~Ox5VdayQo-1CZNFWmm)3@)x z`k;M1Mh24|$nfez8+u*{cy~U+Mj>l$$l4=(i=E`>M0XoI?fZ^PX~WDGjG~#;$kYm{ zOoocC)3jSs)7`e+tl56fNv2-i*$MlZerAx|O^){jo0w4eaOaSo#w*d%%z$5~Q50l8C9RxsNm;Wvz!oP_@ zKmY_lz()Y_!G{9|2!Mcx0OErO2C5(c0zLwW4?Y|)KmY_h1P~uQFi-^n5bzN|eDL9b z0RkZ4A%OVcfq^OrfPf;<|5j7pkCIwYy=gi^bB*7_hJXMF%sT-*Kg|0bfc1mGWCDjj zKTPfq4+wz3{1CwN!~El`VFKZqsG;X&anxS911-)Vr2J?kC3{omXBtU%7E7X*}L)t;N??o;+Gt zS!OM*Y%QuD{Zrb=SzT}XP>=8Qs|n7=Tj6}YZFP3|JkMfr24l%k+@dJuSifX#a^~V~ z=HiZlvAFH|z&GYnX41%*Z@IP@k4W<3Qxr*0-0gO$)9Fx;j}9M_v6xLxUd%;lf5Wgj qWfrqCm6D#*ud|kWSu#rh$juMFh1!9V&)QJ)o1WWh} zSdm!x8@hGOzsct&5fjGuuhwm|2_VwTRp0BnJt^sa;C5SXR9jY`sZrAqSx>V8} zN3*1VyKA(YF0E&tbvl2$wMp)1)C;v3b8&R1^xF!L+@UL~x*9g33<4kk0!#q@0k;7N zfWRaI@DG#7zzzZ+u)}}D5aHj%ARqt&AP^w{{}91}3J8EehyeUU2nLEE00I#L@DC9j zsDJ7r_t{QB*CdGpC;Z!NpF^7Qe#iZbhHb!Soa*w2b)*%^exhlbkDzL?`^ybX@m8>rml ze4fSO2*whjxJPL!vwqFm;>g8Y%*7uAT8CXZQJI_vAJ&SISzI&vddR z7kJEJgm{ZaP9FXrM;LlR)2DvkaWg!wPaUQL5lWBGVQQIDo1HqhL+ZyC^~0%Ua~^D#b*{=>pLMiWSr14dvZYY>CK_*z0kK-mj)d$8l^U9Q$w- zTPE5J#s-6;tz}a@N0Vo<&2wl{huc%0QIe`uq!Q~K_iWQ1?eZ*r6`7tjQDlM6f2V3J zHej2U=;Lx~vCh^dsz#%%Wlxk*42~v!N<*>dbBLC+iypAB3Q0~cl&@1FV#MO~_1n)O z=+KCX!}LZnKKs~*o)-eaJD*^uWI9`N?FoLwPV;-B`>nn1J=JEJ?4 zZ8jRUuB#5Q-A=Qyr{Y#i)NLx(T5h}Ky>iLW97nTde6wvf>n^RQp0rwjdvD`B(5&Vv z5$59Pj`7}@u$e!3o1dty>IN*Q) z2m}ZqKLlXF1OX6G1csk#%K9j=qH3k-1kDXzhYbM%5SVuYcz>ApCji$E0<#Gm|Nbz0 zJR%?f0`o%v?+^2HuW_Xy@UH|8_?z1z{5^392!H?xga{x%gmAzC0T2ifKz<0ofC&O1 z5F&v55W)cm1VA7_0Qn&R111Q7K!^bHLkI^P5C8#QH97p$B0gF_DfM9{pL9-ghV)6D zScxW7wb&K9Lsu2SEv~mKHG5-ft!0$Q$#y^&RaExZZf#_(M?0Oh^xDefhwG}!tfN(( zMKxl$%UcDfA1oglh@HKe;cC1!uGiaDdyn_?EDl#NmI}okN>YLK3)VJQF5YD>{uCI8 z`<{(rQz7MMEyI4p)?zv##f#5Sq&#uI-=|)$M+4q^yh+7kcDZ;lH>JZJWgd{l#sQJ4)w>E_1K6yIc`E!+jC@12ufYMgRZ+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..820f3dff3a56cd20e6bceca6bdce464042bb2968 GIT binary patch literal 23399 zcmeI4&u55}WsPyPix8RG?K9<=O|l3Ln$fbVSHdo%CX?)$v$WZT`2l{PlN(CHGN z<35KW;v*b7z59Rck<$yBK61X3uJEKfHB2>xD0wtTUQ1bb*{E_lq;6zRmd^5-Q-af1 zF<8VViqj@F$)z?qT#9^LrHGVKx=i$xBE{sMm`e7RlY_Q-W%qhTQ%fiK40)boi)W}) zi_9J0W^l2%vP-X2ZmBV2!B_~R$ z74)y={JA2>(?}_^OW9-LA|M&PP`XL6P%aXsZ{L6QF$H=Ijm2a2X!hd@C^JI9zwh?cBSpsJt`WeZKiaa()C)c(stAk(`nUfyQ0fW=>s5!=6E7Q$f8E+Bcg&YFn<3_6>vrys=^wdI*Oa;HuY?W) zAOHeP0R92D0SJJ=I0EnwM1VCV)|EeLvzllLW00clFKmh(BfCB~yfPjwx{DTh$ zR1g4x00H=i01g-+00KS&@DDy1P(c6$0tDb60ytoR00{U9z(4q4Km`F1Pz3sKHRby# z(ZcFW(;=E${2ev~1VCWc3E=r**6#qU9|Xn|IQjWue1Ein00_(u0X#p<&R%1sAaGU! z2mGJgB1>l-i2fh|0#i=_{$c937z+e}i3z|zOpFc#L15|$z&}j=7Gr@RFfjr6hl$Z) zAP7u70r-ch-(oBf1kOlcfAH2KI{1E4;=>HzbPj!n)JdFJX^p99u`6_+uB)?caI{>B zxEoR5T86ow90znowMu^D&V#h^e7m)lTw8hZY+Xf}wY9RnpnCMPtWmJL{_vr`wu@I& z9F4ca@p{`TcR8PBu{naVL?~`ioC;jN< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6aa3c2fed339ed58c8b69efce756f497bf753f6a GIT binary patch literal 23465 zcmeI4L2nXK5XT3oS}lph^k7UAiC!AL*h->_7Z4WR%Ccm42_zm2mbH@5g#gv)1wVoD z?8VQ}TaO$(7>}Mk`3ZV5#tY6oKz2zQLD&3kW__kM3X*_Q3tX|wd1j+gin zk2wqxAK}pP!~f%moSxV8iO)M~hDWt2H`NiM!R3n> zEMgPIsYETRQHNaCA|KW$B05SJh#vQ-kbEa5%i-U?4Z28_ONPgN;+Guyog=y zQ#lgpbDih+aV4=_<=SN`hr_JpOq5a#Ml(CDR_ysAqUGwM`z)*ilF{?U8x#xWB2oJK z?dJe=V8qy9vO^hNePlx~3IYGl$Jnq9XG_)|<6G=BKPS4^-0M87d6YFR!#0anS|dxd zGr1fUy;{p{OHKDWZmaHAUe&xEGP7CIcPl%t^QP(2RzZ7YxK%P+HP`D@I<@gaZ%1_*$Fj{xF>4+g3r00IF5hz|iAFhBqVd;}05d@xW20T2ifKzs<` zfB^y^;3I(e;DdoG2!MbhF!)we-j9-6SiNaFMst(j!-jwW2rN1QJU=Y@9f0+Nz;psf zKR-+#j~);JfyE(!=ZD4FYpfImPDzfb58*AVeYpW3j~3g2_Qbqj1G< zcbSVf0mk9Bm&3k^E@h_8ob{e-%gLA|FFr$&^u)bhkGkD1_4(-XAsLI=kg1gPync(d+{=sj^V{d{}+iR`YiX{%zG)u6QtS#Fq zrI+v*R2(>QXs;Cqq}+P+fdfA-z?nmZI3R>j4hU5h((-1!u6IK%hzl3{JJ#;Jc{8*2 zeBSKSv%Gn!=IqTc@XsYRw~Rw1V8Afu2g602`z`a`4Ktdk`bUOaQgUx?3R ziv&o31W14cNMJ??NIViy_-f^_13) zE_Z+8}t0&n$E$$X?o0(XpIUkws)ZAANmK)tV8*<|m_`4z{?rNveyvBqVw zMGIXy-V@8DP)7w-sq><_j6Lj%wS3(Zx1KVTF^BmdP&ZB4x&}_*)5Y|{mPjX2mm9*+ zXCSNhLTK2Bu7)?Bs?{D)wYtTQUN~PU*PcbvD4I+7`ujiBh8tT>-0vA){l0?2j1Y)g zJ}HfA$=y&(Ps$d1nERyQxA%H)HhrwwPN{0woSX&6s^*JD)cj`0>nf?>_q@)I*SOO3 zw_&fXp|IQ7_S_HK9yTi0ddb^@-D!G$uhDC^d{k`5b+QE)xz^53qvz=OL+PvnWXt}QCsPU~%skP

9O1``?;&Nd}hc`AtK(xCOU(&xy`Fz_M1T=xc zubS%lC?zHIlcpmy&&hMxEF?ezvrd5V!>sQBuAcslHCguW3U}6IF4-=!~L=u>K0`w14Z(=Tx1STdx z|1dE+P9%Y;CqVx&^(N*5N#K|S4#Hn8mcr*JReqS!FP$Twp>!%wtm;kbY_X?s9)rBf z8y(vjyba0M+tcT!oM*YXlEGMIDDEPKveX|)?MUY0UE$&%0pp79{jl#ymu}|l zqH|rOg-lWAMH_p!gfk3e4g@=_U#r76ja<Aj{r}IRvoL7z&k5C1AhVgk~Dw- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4835a365d2d6bf225f52c117889cb6a43d82896 GIT binary patch literal 26110 zcmeI5&u<$=6vyB0nvjx+TF^rTRk1)q5nS40K@cUfu{U8;+iR`YiX{%zG)u6QtS#Fq zp_lL%R2(>QXgO9KkaFwM2M+wW0A~&r;(!oBIUrOaB=g>Q9nXeF5K|7=-&yU>oA+kM zJD-_-n#7xzO3q#XjQ&2Q5({)dCK+THGlS|ZCVt6$bJI$x@?zA9#kmp5L<_4oMZqoa zpKj0PEt59bctAZXk%-s&TowEMY00@8p2!Ozp5a9iYK2npDUe@J)lysfQsVHTlM5p_z$_%AnUe2u+YPE89)qX-d|Y^sChkIm6Y2X`g&G~QkP|J#>6 zJD28(Eb`j8$}6Ku9X{XYb&{eQm8rsco;6o#hqig`gvxSsE%7uYXy!YrSr$J?2A!gh z=F+npES;trKc0rV5@prOWZVxV@$9Q4xy+&D^1v&lcF8-X>eG}o3f4S*`R#9m<*(Uw z@zh~q*!!UZ!juruXFkb=O3`1Fp|gK6{>^jZgsq+KYmJarT(4Mht6t6~&#vSP1*(RP zcF+;Y3A;giE2v*_}$Z6?$d%Qp4F9*K>!3mfC<1qU>Se_2pmTM{^2+>(1QR7><@mwBR`2DfdB}A zK#TzVLktHBAOHdy0r&?E21F15ffxb!hZqhNKmY_Z0`LzS42U2A0x<&c4>24lfB*<+ z1mGVu7!W}K1QY@F);dcA|6r;&O-E>+;rFnSKmY`$odEU^)4l>Qeh@gG!2S0R$Jd7i z1VCVV2w?v(J#&qbg1|`$+~L36mNa+LhNuq$ATaR+;2$P_iZMVC7@Gk6!`SH15CkTk z0Q|$mPca4v0%H?^e;6Ab8iK&Y6M%o1_$kH!LEwZ0_M>kt=A-u~B~s_;o6eEX5S_${ z6>CyOi#+h58b>Ua7{Hc#*V5#Aa@?Uu)V;{{spntHxo>QC7Bh=y-n@KKMVa;O zny>#$iWRry_w?|gs`Y<$$#OK_I>+nns%wL<)0}*cU@Q@eJCvdl=kIdvapdA{<`NzO zYW2}{PiNlF!S!d9nJ;6Y) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..23a2f456c17a263ecf8d4c1a15761400e03ad3da GIT binary patch literal 25968 zcmeI5&u<$=6vw~2YeGsQYC#VXRKfxYMQ|y_g7B-du{Y7Cw%1y36iXbyG)u6QtS#Fq zrI+v*R6^p!p}kfdkaFwM2M+wW0A~&eaX<*691yAy((-1!iPxqP#D$Cf9cy>qyqUN5 zd}j9PS>C+V3hw4-_~(*IE#VL*3>e1TV7Q2>UvuBwv@*KA9ACte+K6RfY3-&`h~>S@ z?S-OcVoN#?(YI2m(NUl4W)!7=e$9%amB*h(6wQC}f}C%ES`^K-TaM*lzHlL&AGKqP z1W14cNPq-LU`7Z?Jd&KLDJ9uX%28aBIwfb~l8Xz@MtaB`vM*gKtd;8ZYJSarOzVah zA4;mD`6<2Wsf8PD=@Ry%{{C;Q;9T!1H>JUm_ z^V(P1-gr2+&)|~ziIFi^%p?1Zqb7bx|}|u!mh)tx)%jub#x-Da?I`x@F1MHE;$WFJ$MpL^_MQ+z^I719`m{ z;>Ek~>S%+gTI~TaN~pG_L*b-(bbO?6lR1#a^=&~ zsFnS7HTAS?vHQ7CoUpyud%YQA)%D6Xcf%{#@a$T#RKiBs>;zpUIbkp8>;#Rg&2Ss; z>MESw#&+Pp-wv=|wb#nQ7TiuV2z!lQvlXK1dcK!;d=y$cJB?nTO}wDn+S$|nZd+B) zq1tE%om%sHfRgR|wx`CoI_1_@fQ#9;+wFh5w2LyZTrX}K!o_#|(jQ*Z@&HS^c|NJ6 zOadf80z!cPf!lxtNZ=#_^baSIp`HXt-~j6ThyEr;7$iUfBrrmN{$T_M8zevi2?F#F z2^bVffCNSe&_9geV1oomAVGlsApwIT36Q`D0s4m#9BhyP2_y*6KO|sKBmol81O~rq zs^_DWWa=kPhiIOX=df8wfCOfp0ON;Q-vOLI37kye-toi9FS>W*zOgRdEVw1_ zhDh_dv`Sw59~PCKxZm$%e}5m5y!Pd#G8Riyk{8RN>rus#y6A0`)koQMD$X-ftG%o; cYyTh@y(`BnqNqIpJR(|ktVjayyyy)41-CFUIRF3v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ca23d182036c7777f47c7e388148902ad5aa1a76 GIT binary patch literal 23525 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4053eec39068f5b281884b6fb79d6a7513f1f1af GIT binary patch literal 25940 zcmeI5!EYNy6vn^ZH6bMtwV;OxDzHF85nS40K@d>c*qdlm+iR^iiX{%zG)u6QtS#Fq zg-iGgDh`}DwAYFQQf@u^z@fki&KwZpfDl4CAXK4B%bW40UYkY`7cTZU*6zG{GjHPg z&G_j>-n`Tb?&g>H=aNY+;SeSa7{=USxQMCWa^K#tGP=DSPhv@J#4@n7c0(z|^1|s79PT1b- zz10k{>U!mxyWtgVcy_H=Dq$mRc7m>woUj*kc7n#$X1EP^brsHTV>|FaYzJ7c+H2)t z3vQSbaJ_@a!oklOvCSK5O?d<7(x2>x4s5aU`r`G%^K*{!f+f(CP zopNg{z=iC)?e@Rj+C>>yt`|2A;o>`f=}#|dd4MI|JeyQfCIJ#40U<#Dz+*rHByb!7 z`iJAlP)`CRZ~*oDhyG2BFi3y|NMM8j{lf?jHb{U35(MZU5-=!|011o`pnn*_!3GJC zK!O1MLjndx5+H#Q0`w0fIM^To5=aoBe@MWfNCG6F2@Jl~RL@5#$<$Ap4$(X>&tbEW z013=G0mcurege3D5;&f~gX4$e$Fqk7NMLpdFn*Ywz2-_u;G_g@%U^D*vT#yEjwb;U zn0f;A4^zLzTp$TdOo0AjVstblfvG1z|1kAi%mtFb#02OcCPqg?5}0}d^bb?N#ati> zoRGjl{H?`e{QRWK4>SCxbLcaaPUVSJy=k2-_6%OeAb0Y*oy+K z??v68ed*PL`}THsIk$Y~ol6&VmRaAf`^kUwuE;6V{{eje;WG>znF5ws$Uv%%sePdm^S#V3tGBZ0RGb&39%T8xgCAwmyK=oQirOQ<6Vj)y M6-m;a7oCB>0Pb5a0{{R3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..677bb0e4d43a8a58cfb89506501a32be8231fa67 GIT binary patch literal 26228 zcmeI5O>7%Q6vyB0nlvR5wb~vcsDwo&6v3rUP3uyqY-}fT>MyO=iY25LlPtkfvbJoe zlw5+uy+VQ$hxS@=K+3I0A2<}a6wVwX!~r3MazLm;NXvWUbv-tXz-c5T`#;w1%)B=< zdGnj`(>UI|l#+#&ujt=XBs53+WRgLKG1YC(#n5l@@9tO;Ri6(Au{bw^GSS@99T9NP zC#QQeDa)iaR_;^B3WWw+Jy2EO5C8I}<@>Q0UMAn4{`w-{?|qqK$*sE?%ei*$oHsq# z4>b?~0T2KI5CDM*A;9MmeWoTSy{?!2An7tGM}ia&5}oi>lPQW`zM5RhRw~8BQu=u% z>kr;9i6WcHr(>!`g#LLZdWX+T<}spEB*&sD^5`>qgKpAo`s>l}`@jDD(_X(nsdEu= zOLc@&W8V5!^*5di`bX%h`GpZNV`i^^E}bYfx$W?o>MqNoe09%ML{jG&(+V}IMs0FA zm-NY*?vI?{(Ha1>cWlY-R83`q;|e zPerHfy`t;(jE_!s*QLz6M$$vGC)o}pUE5?g9?rJ^zkktAC!_osAjdm$mG@(vT7137 zyE8%+DpHB_6iZfVi#B=3dP+0AgZcAEh^Bs^ie>TJ*P!F{`Al?rjY}t}!cM_ZccO%P zKPZ?-aO*iA$z=^B7dJCJbSjfCzeHgpYt7O(-~Z8l(+xKY>c=0;d!s)VpfDi>^py{D zrId4)W$IzxVo&ov$#{*e_FFZN77KQ+R4CiYG}-A=Dx0OUS8KX0DP_F2+uU%gH*4NH z6&4pMvsqntoevu>Ef>>EId_c;&6?}AtL<9dqhi5!>_o<)WPM|!+ICeJ+ilf1wv@im zknB7as|~kVs%^WJO*>B7miF~#uD<5dLiF86Pgc+nzQ^IHWCPcz@!tv`C-y`0Ok(@2NQVm`QhO9(0~94Ob!8@A0}t5 zF;fs2mB4-e%WX+Bqjp4l5CDO(CjkF2_DhTjg215(z&{)s9Xf)**b{(%82cs01VP}? z1mGVIjSd|_VC)INKaBknV}c+sB7xoDTZ`G?`ALZn)BmQk?=wUuabiUqR?%WlbCBe2 zZ0Th>regS7>RC&Z+sW%bJ*#Yy`-`t$N*3N;Z_UT&Pv5w58NOBaFpD-s$M! zLr0v+Xw4HGjkn72dfVz=D)E7{~v2XVR2J)PfEXRKfxYMX@c6moL@4yY&_RT{5X99KnPE!J_Pd)_M%&BrMJy?eSO=EYZz_lMe7M|R zC|D-8rSb?pE0r2YTiD~eX1c>z&0|Me?!zWrrEJlAhImVf2^`EY)?A1x9f z0TLhq5+H#YAt3va&Za=MkgNqL| zRZ(=x_Ns1?!9Vvz@5+A3JOw-jwJn-Mh|h2d*Kh;(@BMM~+b=)=IOq?(FM_nxTPS_a z>)-1B#uIV>3@)2r7#VZbJnmnzbJf?S?dYEBmiSiW=9u1s#Lew=(P(@dGk4>jiR-PZ@&Mtzu-n2#rEk3{^P4Za8Q{M0?C0 z_Ub!<|4}QzM#Ww)1>11jjUepSyNzauitG7a&he3N?(WvRf$ri3o#yVou6J9ixPVH% z6|}33j{_8K-?u&0zS%A{w*y>Ud9T&_uT#4y4NJAcmLXhx$1ncnC3_xXNt@@APU<8; z0wf>=7$3L{NPq-RBEa}?5*hkQfCLWZ_d`nLCou{MkN^n`5ny~6!a;)sNFYIg@gV_& zDhZIl5CO)AAsjSFfCLf*7#|WasFDB)3=v>_7{Wn=1V|u3fbk&#gDMG-fF{uYR#QD6 zr8HANX*xjjygY|ZApsJYbpqT!%=!x8_(|Yo0uSFmoZOx@BtQbQLxB5-*_msOlmx~l za7X@fTgk$>9oe1)NMPy-Fg{HE6mx(iFfjqfhl$a#BMD4B0mg@^pJEP>1STfH_%Ja# zb|itRC&2hH^;66NlE9b*4&!ev7USn9ReqSkH=To+p?oS&tg21xY_VtYGWvOyH*iYl z5VrKQmZr2*uRC~Le-?Fq_N7Ky9?fCNqv+pr|i5aWj_P>UHl{$ Zy(h;T;;2gc>?fs89jlVSJ10H^e*^AwGeH0V literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..444aa0b08d744201c97c22bcd9fc8119effd6128 GIT binary patch literal 25998 zcmeI5&u<$=6vyB0x*;VIwP+6!t-t~aMQ|y_f*{(+#@MT6VHZ5VAGTReb(;0c{8)x z`MlYuNxXTf72J(4=-(xiTA%|m$sog+8FXhg^;_=S`&LHP7vqaqTpF=Xw6J<#3_SA5 z@^G$bnY77`2h_JxsbsG&RW*vzzg)4RXzBc`6h*UNy~XE;pBLG3>w#nWS1(=+XOrVl z0|5{K0T2KI5SS7IydTkLYO?5cU5{eXZK7vl$;CovBGqMvvhOSxR!jAIHNR@Vplrj7 z4>f5hITd?J^~lga_e3A^eo6YDCD|6uP)PUaGTop%^w;Cx4}SgmryqvnN$rb>d#Wvz zy5_BK)Oh2$czlMI&CiUCxnv%WFWdQAo%*i$X)@^PL0mZ3VcuW?yq%Qf1i9rk6YT8{gc-I$`8@2PHCys!;AO`pzXXE#|p zLv?;!40R^TtCgbM`V>yQ8k9&WYd}hQ0F_ZY6`jiZ%ak@s);xXv-5-PHsI}|r>7&Zw z)sGBNri6g*`7~E*Wq(bk&hg3kKhKF1ws(8)HA7l)y>iW6_X;+7cCA<{(R$eI1YOab zuorZ;gT{?!xJB;D3OPHCt-!zC4rr}vua<*Nay!i+>@|ALR!CLX^S!*|Q=zrJ-RK2s zh!=EQ+q3lt;FYYy|`g87vJ$q ze|q}JeHGVCL0YyN)_0HNLKA7rF(;=Ey_&sb02!Oz}6Ttpq+E)PP4+19>c=G6k-1R^qCW_Lz{C?ke3*C_V}c+sHUY$kvC&~52uwTy#D|G@ zF(wECV-rAp7#kf1g22QRKzx{Z7h{4TFd~8d_*;wl`29)A)H(d7a~LzkCV66|HLbG6 zo})Kukac;Do>4i3E%mOY$^GQ>h@MsVBIlQ0f4ktmztvsLEuQ<}>LrzB*0<}v{x2d{ z+=}1V(}#{YlhL~8IU8?-^Y!-Bxyk2g7N0X1ONQbuWvIgCO)fpoT)e|v!ed~3wtXD8 z9cxn6f?M+Lur`}ZOY-8UD3YGI-|y4j-X2B#+2fC7EaoaFFXls)NyDMJ=&hIKRyLf9 j^BR}3mL>1@k9^U`e7wts+9{&v*(%2pr}xgY&7i*l=BYBK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d720d4145cfb57d3bbb4d224cff2470a244bb6c0 GIT binary patch literal 25986 zcmeI5&u<$=6vyB0nvjr)T5S&zRKh9}ir~@)s}1T8+1Q(C)3w)HZxl-$z%)y+l&me= zDW#Y6FQ|Ixi9>s>dZ?6Jk3RIkj}x3ZAjAP7gmOTr0;J`=@uuE2jUc96?C)4R^XARW zX6G}rPtV%*OSRyvKc;_9nbbV(lTI3G+El+gi>Y67U*9z{s=g4N#NbkkWTN?%yQ1Kc z&rbJei-t}c+_+CYBb7?_`dn4RF#XddBMg^beS^Yq`ipa%-+x$S&Gmbh;axd%CYVl+ zLk$E#00ck)1VCUy2(UjApQ+9%UXRORl;S#(Gf~P#iH?P;%M@kbxm;MWYqd&##e7BS z1}7g#(qLOqA3dK4qc>cbc_Cc^6UQ3KmPFDU_2?mh`6V6 zq0~9Af2GE2FGu4ubXk9(W%MQeV0_8US8eV)>{H!iU6zl&Pemkko-wUcn;O(5pG!$M zkN5xF9ABNJeY$;$UKHu9WEW?sk`qDFAJV2SZc#kE+PsWX)CDp>1BjK(!37Cwt>5n);S%hQXUzqZ#^aHaoq+ z(lM&>EuyKMD6e*hTGr=~9o&ji${vtXZo1)97p?NzX-aFhF-Kp1^ILxl4tI*`Gf(vg zXMd`IFd+ouE1%{{wdAeJ)YH7h{>y!01+AU#yN!UB9k*0<*4%^=613d_Uz#hr>5A{t9FhBqVKp;T? z_91}-6%YV{7y;Oa7z~IY00IdDun!3wsDJ{u#rFj1SXvT{D(>30hm7s98Tcb{=?z@(E zFgXO^KTOVAW2PW*R05Cqm)nwNk2(!5Co&fB_*e@|A2m&J$fPEMl9R`BH*b{(# z82cs01VLbA0>uok-%Q`)M74re^TPZ44!ljY=-D0POP-1 zRkYZX^fvV)Ew6I;VTmDZsCO-O?kAr|^pd(3$zOZxe8G8dv$K#}IQjmSMHOY%GizS_ z-$E=qWv>?xAF4+GUs;}`@zyzBZ&&3F&L=r}9Kl#36n7{?WiCJA(&fm-+sq{x0^_mn zffdykh>o*XjwOzoooAg!e*un=GhF}x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d10a3e3cba20dc69bda28c79b9f54489b6df9c3 GIT binary patch literal 25958 zcmeI5&u<$=6vw~oH6bMtwMq{W6k&mcBH$2;1woX`#@d~-zq^RZ-?HD_G}F4i9G%3J(uibWY5k^B$jJMr z+Y1FVfo*9#LeESlhkJdYt6`Y><#jU*SD$zmVL1Qg%aY&ztRR}}w`|k9w6YS+507Jw z1W14cNPq-LU`7atKN6p5LQ=dQm%}KK8=fsPmOe9HE}$?WaX+W(s$ISx+S`d9DSb(P<5Fxw$MfcUHDR}^x-*Z zFKkidCog=0N>*uA`VJqQ>0inVa^)(8q53v8pUJ+B<14O}&j|05tie@TfhIa~ye;dI zMhz8Ir7Vc%D)zA_YZT~~(KQo~JBhjPQ8P{1um(=yf1m1+ibK`R6li3e|+|P3JNnqAinY`sZ>ke zhMIaxw%Gr(VN#T&fke4B_J0Uhz*iKJpMtx_U0Iq)Y-N zKmtO5_JP}g1W4c{0<;e&k)fUhNZ=6a_lN!^Mi?YO0wgd*fc9Ys2P-5%0x<%#4>1@N zNq_`~2+%$Z;b4UXNFYXl_8|s?A_FMiKM9;n;Qs!@$^F?v0wgdy1n57^&RTP(Brq<4 zJMx#?DlLpVko`%31g4$3-glEBmxpnaJ7CFTT4U}6Ha4-=!~ zKoXdG0<;fPzr>s%35-eLFnVgS7`;EK;=>G{bPjBW(y2JHsx_sf#h%4W=tooDz{5I* zu&Lj*Oh`ZVxr0aawW$25=U>S?Z|-!Kv&&~MUplX&%z9SMi~s9~HK*+L;^9NboXL3I za}tfWF7bN1I&Vlm%gK`n#wtQ_2Wgb0d{;_WA{XxomtX{pC%Si{wvi@X%{xW+hDh_- zlnP$_0Fw$&-0SslaBzT7J_qtq5sRfN!Heb4<*?#dU2r!`>Qc7svi+QtYA>sJ+CR!k Y@5=F-D5?(uXGE)xRSDaj6PA(r>Xn|RkWY7@%E{yuAG=Dm3{ z-ucXUG?v$|lC_qG5!#(Ijc+kA=KP-&8usYP|_ zkjuHqCnrgr!lvnOqE{|bIej(YxZ?OL0(V>0WHnJ(6 z-y+ZXWuEZ{wfTCV=RZkRDpQ5?99u5aCT;MV@Knw48sQUuoF;yts;=_~M5Ckh*;HzB zg-geHPUKsHq&(?rPPp($Bv>G7p-Atd%LjS5^PE{)dX^GeL7%2?zW<}YtcRTf{pg;3 zxA#2*3S&YbJo5=wDn(~ehMwRh_Fq0Hrq|r;yjk}sZ`s9)wPa@vvW-fvP@pBR-g4Vg zGQE!5T6b$#>fS0@`8=5$wN=;ou<6ob*;pvLD`d6muGgt`>J5*|mhIRX)1ho*eZAIk zRTJB7H`X^*z15WLER}0bw^gZsMyuFZap_#@QnUH6Q#;L$#cFO@V=j*A z6#lfsEq7=}sVBomltBOlK!6Egf50*T0T9@a0QQId$iNN)Ah1L7{X=~d0}K!V0T74~ z!2S@y0R;p=AVdKBLkI?nAOHdp0@xoSIG}(42!se=e+a=q5d=UWLIC?i1P2rl0D%wz z><=LrD1rb8C<6UoHRbszDaF*2rUNw3@pIS^5CDO3CxG+AxUT?=9|ZO%aR2ke{{E-| z0T37;0ysa6&s<}qAaGCuxA>piB2x!#2!9X&fsrSG{bA&%7y|@>LleOMaAIC7Gm0#W}Io)vhP-XqUk_<=Vt#Q2Gj!NsCj`w<$>_&OhMX=E%hx%*7i5 z)*SIv9PDt?LM<^1WxZCa0_VzaU{MzQ1L@Z{NgBSCq@~C0goU@mT nax2Sb$$WuxS<4c?`zP-79$&Arp~B&_kF!;-ISI4PHjVxPG}Jil literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..119b2739285557b8237565bb95a63f03ba53158d GIT binary patch literal 26024 zcmeI5&u<$=6vyB0nvjx+TF^rTm9RiU5nS40K@cUfu{Y7Cw%1y(6-ykzZI)muSzESK zN-y~jDh`}DwAYFQQf@u^z=0nZ;LM>y91ub%2ZSnww7fT7$78n<*z{z7$J(9u-pp)v zJ~MuLjyErroV)!c{d-9zR_K^aGRQC%`rTPf{GR##wv|%)YJ3rkYa^D4RyJ>ofk!^N z++E6BChc(JG4-rOVzk#+N{yoAuWwsXwD$bV6h(_)zs~2oU*y?xYoU#os zK9r=P;FRn&)gwg@$3*XQyd?dfB?*fbD5Ov6GF_)z^!I~5j(_{*=ljF)q~apto+^Y= z*Sztq8gD!uk5AFM`MHrY*UZ8AqMfa@x$k&Pb%$+fKKd~gk?1^Q+NL%&s7nFYQa(A~ z|8dKrT<%dhGr)R$(sof$uj_61jlsC8#CjRM?K^YOc2h2$)!U*-?pKIRcfS~4_Uv4m zpWYo(E;uADu=3NeAPK-Ptd}TRI@DJ@&=uu&z91Q zJ1m{08o!YYb>_uWdq}p%<0yljCAI7UspYLXe(k(ds=i1`qhKx5H$VK@->BoAV*SjC zeQ@;?1B5vtps#$Al}gdylBp+oi~XPXi4(RCy6-eX+Hk#M#jScdn>@RcFBGU6Hrhc) zBq!_!?Y*FWy%Fw`yRkveetkFaKW+uIRkk;a!4A3YMi6%E-9|H{vg`R?*6}IV+}o>n z12x18I?cTU)$X>Wx=7`ED`;05Hv=lzzHfWdzu7J}cLKVUzS?U2*QFJ|UaaM}4d&uI ze&H`qA9+M8N|&AxqXh&& zV15W7ewd%N#!NwAQUdq*FSn&EO*#<$K>!41o&fyA%r7w}2m(_RfPa`89R`BH%oBir znE55f1VLbG0`L!0qr*TDn0W&54>P~Sm>>vDNZ=^`)?zt+eo``Z4!`Lf`V6s2o>*y3 zs%)|6=r!u+W8R`CRSscGJ!@%lKY87wr_{a3`Gr^C$hq(Cc2+a1=ia+=NoAS!?V7Lu z^N0<%V##`rnyvRO0$0u076Nyw6<1F)%*c zK8)MOnv|Mz3*If37BfjnUi=t~q$lq6dUSYrND;pd`6U^PS>@!#e5iWVFfix6YEf=w k+bKD(a4maTGIIaK7k$9TTWqL2LG%n;r62xiCa zPy+!F009sH0T7rH0=ys5M{07?>$)69N!N*-j8ZyEbT(97W+?T>N;Y4p)ykQ?^_? zrLKAXJ7sS?9oZ*o#r(oZn#<e7&md5!1Te zfibkJGAYXIcK6Jo-LAadjBa<&9NO*DlSJ?6c3~)dWCl*h2li#x%BFbwi@a*C@|tN< zhtCgr1tqCQWvXzQW6f3Cp>1AOfoeIr%J?%voMwKcS}ex5m_cXg^SRXQ21{qD#y75^ za-LYVW}`*+*afdlm86t4Af>nhrPQ*VU8=rF38N63r*D7wV{l_=?Yem8urj>*p#s8` z5YT5n!IetUTa%$Dc!@pEePRc#o$lL>fL0y1SaGUu)*{!c zQSDAkiX|%7TYkIJ*!8Jkd7kA;|7N?`-0VsD6-%hRk94Xdv>jrc2Y_IUAt6T2V zf~ua^m5@OI1VDfZz&~IafB*;_M*#leI5N5<>z35CDNO0`L!GI8Xrr z5YPy~KWH!@f&d7N5rBUf!+{D2fPh8-{y~EQ5d=VBi~#(@7!FiG00cAw@DCach#&w0 ziooDoO?f^_BvU4LlBsF0`LzLKgAdz2%M0>e)O%yeDwUJMCu%U(>d}PqLVnW(wb1wV$abf z8pMBI=IQ^eV$~^m zJw1G=vi`ra3`gUwbG+WJ${Uu{Y7Cw%1y(6-zx7(=5R#SzESK z3YYK~R2(>QXs;Cqq)4dh(FYFvIKi1ig*YIDsvIg*A*AKK@ur@*jUaBh*xzUE&YL&$ zRy&`WeHz)DmulW!{f7QsQi%oHC6f#?jF~}qRugx!KVGv^s-BK7VsUB2I?=-NH8HT~ z!{x?Y!7^!$8+WO1B@+94eXFWbl>Gf|D~cAMd6A-M_Pf{keB-METdrJpEdTtOGvVxh zKh!_~1V8`;KmY`$gaGeH^pTn@dR^C}Sah4{saUeH(D6uhnW4-Z=km+NdcBfcwx3qE z;l+oVG!&h(y{LMm=)pbFyS!hL{wGPcMKcuA=kyj`rmOV#y}P@A{PyeZp+Bj85phqo zg;LkN@`Lg>o{ardbk6+JNSTY~o`1>CRX^mugFV%4!;JS-HMV8=80@KtuB+>%Xq7tD zq#gxaihO#4l*c?BrKh<5HD#Y2l1-~I-4ugC^V0g+X*!bRera*bn^ehiMafNROAOkM z5ZQOMwkVSO6cXdzC;Tg(ozF1x63^Zxp1T%x`FNjaGDUT&P?gI9TQ1QSZSw4fs%3Px zc^VTm^E1^gi%h}#$ri6e#^GU8$Oa6)sonw&kD9?!#wzqokG(%c) zy;9Y!d3l>WyILp~sTMXnL02><>;;{TpmDhwu9Lg8M9yYoJ@7wn2eeYLmrKDKxt(SZ z_8PrrE2N6+`CiWPDc{=IX!HW*;sxE-#+GV#+fuwjl}0=0RGXg!RJ48H_N0HSQ);aR zbT;!|yZv9MmV-5w>V;K{L9llcW6OXPwPhLAOHd&zyuHXr_A8bV%j}eh(W00w6H$1h9XY z_7#BfgTTQA9=?A#xIbDz00gFo0QL{lGuId?2ppEcE&j`Gk-5V1rOEx|bBmr(_af)#UVc6A zURdv@v*}Y8&!1IUW_`Qv>;E)j$u0YRJ$uvLylPWYW;n?e5o DM;bKs literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a488d6886101cd521574bffabd33d8cb2963c0a8 GIT binary patch literal 26030 zcmeI5&u<$=6vw}78?`<;^ef?Na%x-?_IrN6rwP3rziFiBKuBWMH5>o=7^EFWBM zFXo~VY{}pe`q5ZyRO?IK_5H*zZ$y26_37u~`wL&aB=_5&=R~r9%ZfTzE?n>yM(x-m z0TLhq5+DH*m=gjLk01z5)QTfghATIRjiB~}GA2ecDCwZ!tglt#NWHR_T`$z@mCU;N zl$IS&J{H>)tdhB^HIn#eO!SV#OUm*|sIVvw51-(5T*D3gdGGh5Uw{7T?uk8gTm;e7 zA(WnT{~K*@JRaC5u@?EvNJds8L;IqcsqTyJcuaLmWGT4@F%_WtKoXnipotz_skJ1o zrvCW+;2&MWBS5~7N_rU79UYHdR4N%grY(WH#?Y>&)Yrme-$sUG*YOwdsv7$!2o!x^ zy=%o~x_3?cifv|75ZqV9bxtH#v4>sRYfq1i@4ZCsF~omB zJsOpRZ{Q3*T}&-(N$V`?@^LcsT{c~xq+rt@x(W_~%s%*@plbC29iKR7m1@r-VHBcE z`1<=l2FG<=Dd?X$mJeq?7O*iV1j0L?kWRJeY^bFtc3V4pdc53L)kRbqZMRcxUUyM29mlkleydY#ZMnFVdbi#F zuUoq$ip6?v(-1C>+bomkZlqm0TK`b^bb4+BtQbE5ukrKjSS@^KmvzQ z-#_#>F~T4L5+H#Q0`w0fIM^Wp5(p8Xe+a=~lLSa$gaG}+2o82gfCNGW=pRBb*dzfG z7$HFaFoJ^}5+H#P0s4m!3^qxC1T=xcubS%lDAkJSCrwY#JSWd#vycD@%sTslFTg(NL zz|;ijAErh}LlT&I0`w0vzr|c22~16Z{$Xl#G$etUCqVx&^IOaXlE8!n4ufAUmV)Oe zReqS0Uph~GhLWjV!D=+2v&NpoWel=1Z{Sg#LpZ9RwTy_KdfmZe`n{<8GcUfJEx)zh zT}iK;d;7{Ion_WB>rVK;kjR%yPCuMJbi$QPrk;^(yba0M+tc@^+~>JClEGMIDDEPO zlGGnaZA<3jUE$)5fpJ9kelRvRq`TR2!M-7_g>*tCFa95kN>AMH_i=D=0AF4Q@=_U# zr7Ovc<`k<(?X}h$#S#ZF%@QmnYs+>@ z;gbKL;=qYRd#yMi<<_GQ9Qbj9GY5n?AcRm32vrDac{5%oyQUG?^kjd>+MSsr34Y!^|&lm|?i~5ABDrzfHodEtE(ME8JN8I` z1W14cNPq<9gn+~&h(Z&!;*^xp%B@i&s>7&^iII&;J}NjLYLx}#UcFM>C^s6l!iM#P zmYqyK7Tc8VsGSx&BDOa-Vuki|B-XrT{ZYE?eI zIQ((Tsw%*&<+WIDVW{R-?|!^`6krqJbLfQkO(>4F}kl z9SQWv_zp>YoyNlVXqcuPPXp)h=~8ZSM_T96kV9|iyV^Xp-w&#cVtQ4pGoWhqF&dx0 zWLLMILB=SX%lP`cKZb{DTq)|GJCToOKM}AoCj{aXXJ?epU;UMbPoD& zwgRj>Zl&&QxkU@ERWFsx*a})*zo%Mu(D%E0e)D=O*oCvc4tu}3>w6z|d~DXNjf%em zr`z&_ezV_d2dFu&=N4=a#rED_v+rvY*YCCW4)nOwQPqp6H9LN{-ulQ#+44NgRr>92 zrM=_ha_-$u=f7_4vM5#>rENpFc(zym(~VmmVMTW@#)WK?011$Q5TJkHF(3gFIEw)N z!&zh~Cjk;Tg8KbK|0YHlBtQZrkRU++kifwX36MaH0R2M@2Ad>60to{24+$LXkN^q9 z2+%*oV6aI7B#Lp&X+ALeJTxl$6Cl)zp2%WYMbCN-o!36Q|d z6QF;X`7P!GNnmOM^bb>`qag{*JOTQLncreAkOZbCK>si`IvSF|%oCu0nE5T{0!d&( z0!Pue7R%A|lPW*V={KFHK10b=o>(=S(b-}z;3W)mCvW0mokQ5v&swHLPrdHq5&d4& z{plB8DLQZM_Ez(&7v8>lS!bE`tcDl=M-S^x)f>dqhfcVX$hSD3jik>opP79|9f|sk+uw Jx@?gd_#5F0F4X`4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..46dd0512a719532caa8198f26ee586d723fe60f1 GIT binary patch literal 26054 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 W(D6D6@@If2NXo||MZHdt%)p-zPF9)# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eab80ba2a9a23140d601d1380004a7bcee27e2cf GIT binary patch literal 26054 zcmeI5&u<$=6vw~2YeL#Y)Pf!&sK5dVMQ|y`f*?v|W3Pi#|I&J`SmFStUV^1$ZP`vK z6;k>aR6TIw&|WJJNV)as0|$OwfHMb#I3R>j4hU5U$-EhF;#4&1BS6M>@L;FugPz2Sux#S4JNUqG=e&?vVKb`WaRzj z&QiuQ(GcSS`c@<|-s^MS91NmAzh(^vYfn6j!C>)=m*jru(~M}Y-p*O>rSs?g#qn`$ zkpKyh011!)3CsxriATaKHATYhaD5O+Sf}c-K$3yr%s_WpK;o5)>GgcAR!*(kk89m% z@=;A0^0}hDrhCNj_n7EyiI-IW$DqQZ1^D;~7jOkP@aMhX4u1LR$L~kuq2nS*PaQ(( zIj?@D#~Y6X<72pJeqzMTHS=(M!A@0fO5b5j^|fgQG1WEECFB~$RKRULUksb*potzl zDU~Fj9C192tZ1rAVPaWXZ=ORWD&=V)jtfbYlS-?kd}LdN8Amb_vQ>|4Sw!MUe;BeI zD4Ryuz{9{B*Nt-Wx}Fgd=c&~w<8-Ux__AZC6LJCyvd5QYhg;~%^{(u93^m!aij*1A zT*fYTqK{zVJJc*o9tZ=c@bOY&u_3iHsL5ky=sS?oCoI^!2QCgDXH}{* zpi1@NPEMT36{}ApYUHhDeEIF~!(%+zDQKTM(jU%#q+n%E2!wY&Dve6P-B3#xzh*qh zbCUDhyS>+&KC&gJP$^ZNv<=6uWb%1b{btAOs#?zPd7W*qex>Pep_I)cw^QHp+z;9w zHp=#T!E2z@X?lLI-fOmeluM55q;f9Ot?li4&(lL3uiM(*)%H?b6)&J%Z+o3e^O}de z?Yg$3`nNiTR>MO)@lLz_Z?{%C`9dwTX$TiL=jQ)#!Xx*wqMPT!O4dn$1V}&#&_D1P zkN^oBM}Yp}I5O0e01505f5@c16Qhs-36Q`T0s4nA9BhyP34{pHKZIbgN&+M>Mu7fd z3+ zk@v7EBtQc5PJr>lyq^HBp9GF4aR2z>`2Mtz013xQiHyQob*xBbkeLgo{4`#ueRrfo;O1o9R;CxgoX1WK<dK5scTKrf~Q1h;4gFDIQ;+s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aa0ef205e323dda2417682fb9a9092e0bc479eec GIT binary patch literal 25956 zcmeI5!EYN?5Qk@XO-M-8S`eWKkg%v!MIfQJSP(?1?An`XQ`>8;H;N?=VB#fMO4gR` zl)@$dLB)Yn5AC($fRtO07AIOxaOO}|JyaD!IUrP_O3Tdiy52R7s6$Wo?^(NVX5M>i zzwf;rP2$a@lCw8|r2j6d#1b8nP8w<2Ousvei9a>sKlmUk)!~aIJ+h(bC#=G4RNz zmz(o>L#H}79#YRpB;vh(Qfd$+AAW2E!OH8WDF|k7zt88JKjhhR<%VUrm(HB=XXE3b zfdB}A00@8p2uuk9jz^+1)wx9Vs2qePY7;pXmP}aaWT3jtQ2N6QxwS&ITF$PSuPNK` z;zLOq3RcNnQ9V-h@0jQ_j+dnWDR>|igCQ1pqbyOY8bp-HJYPq^Xb_-ODCzyH-V>5U2ehQrH=;(! zAOHd&zy#nQunj-}1dbvA|8Nu;*g*gU4oLk@NPZGS0Ra#Iffxb!hZqhhAOHdp0`LzJ z7!W}K1Y!i>A7VJ5fB*#^frIc{i-qv{Ny*eX{HAm0GsGr&Vx={yvc)dad(_XRyiPBw z9Kwcr)>7ww^14MYsm~(kH{banXMeKYS+l!_T9djn* zbzO(qDEU+V6TKE zr%5jU4?+$(^^jac4h7$G6gj2ssXg~lN)M%!;zKE+1U55It7un=N#y1v{hru;GxOeC z>3#3*Ft*m?O3q$?Krb$-!~*S+P8w<2OusveiN7;H-8NE6Ukoo|aIJ+h(Zb4YG4ROe zm%DR$L#H}7?orQ3B;vguDm4g_e|}~J!P1HMCK)5)FPuK@&&J0= z0|5{K0T2KI5SS7I9FIh2s&k3zQ8@@p)FyH&ESa#-(Li;Xq4X!`b1Q{vwVYis-%_^W z#fOqK6s(fDq-V&jzN8Pv7tL(t1MXX=Tr8%#!?rXZ{g{eKbe=J-Q=1ypC68+< zH{a~P-MlF0dQ{FxY&Ni-kw`9+(Zw1B@?J8V(fIA<-V5W)j+sky%vU)ZybR zUX2u0sZ15F^K7|H+qA_iUQ%~|1`#<-I<+rxGpErD3ww+?dUUPCLIc6naDA1bUXnP%zEWhiuH@(`GhQC4f z@-kUlwGGd`*79hzY_1f&I@#@p=XYz}M$@OV?YK_Xaw*r`+^lsyHN^2c&CPAqZnva5 zL*-h_YgZcAJt~;4YdX@u*)BHg9-U2p*=oJ)(uz+mR`cr`b8#)V@ShVMxl0R5JsCAZ z1_2NN0VV+dfNcN*Ag~_+_=o+-zzzZ+uuJN9Lh_Rs3J8Dz2*e1$Kg4iA0Ra$*5P*M( zz<>w>AP^$}{}96g1q47KLID0D0s|rlfIy4@{6h=}6c7M`2m$zq2n>iI00N3Y|65IY zK1w8AJ!v{b^DaM!jRFE7Fzo~oKTP`$!2CgAe*({sANKE$77ze|=^=plVS3gYGX;Th z3Ebzu+?Fyo?m+Yh0T7sY0`LzLzr>gz2ppII{KJ9KVIT-hJOTKJiC)+A*J0a8lTveb!MVZG zY$hqmi(g@p^u)bhk9Kx;DB#x)za(QZtDL-;4^_tv19RS4E6PV%w@TLgT+3dTY}&u^ ZMIZ6;DH|%U5glWz97~+EJIgkWo&chPGH3t* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2c33fc8290a49d2d2ee12fd8764573b583461c61 GIT binary patch literal 23501 zcmeI4&uNjDGg>_Vn2idT z1L_8Xf{o=9mnfGnVz7u!6rmzDsY-3Kxfa=7?avev<30s)F;PCO#?(Y1Izx0*yl>gN zyXqTw{lsT~)HgoxF36OsZ?Bg#wM3N9CBw6A@?3Rlae2-&N2o+O%5$A$&nE5AHlK~7 zT1L-~$QPuu->DP`^jN1Q`nVijtgv>TN`61rvL}kGwX$+5qL#x)l3I=~b&rKBk0kYM z;VOlF=|G6Se*4)6>gh2wmX6fJqaQh-%n1Sal@GI#&sZC>^f2FI|MGL9JB^+8gQ`O* z!_4H3f|=0B)bh!6nhH*}X}3hvownVq+2xm2XN!zfiuCRBmTkRm*tC(;9%k$c8O^Hg zw9D;k-JzUeT4r3gC{eG~%57T>G3{2pwximOhE%sGS8mwNeD#e@Y0a`UQ~KANnR>;h z_2{!k<8Lpm#GgzlS(FH4=~nu;=^nX97nOP0?Su{jAOHeP0Pz900SJJ=Bm#&JlgPji z0wAzQ>hB-&n-~NHKmY_h1P~uQIADMP2)GC!KDb~&1pyH75I}tJ;D7-FAmAc^_~3#8 z6$C)QLjdu?g98Q#fPjkt;)4qYR1g3GMWFvyQ@)Q9&9A;R9iq9$-(f>Q00icp0G=P_ z{SLtTL0~e0@y`#F`=bQ}Kwy3d;Q3*Gt~FK)0w*P~&;PkCvV78k=nn!QF!KZuA7=g% zV}T$rH37tjsnKB|2+TYI#D|%`#8@B*OicjsVQO?32m&)t0P$hwFEJJf0w*M}H+X9i z8hk$~nL3AWI)^bse3B8o+h##`omy=}Fx@_wGh;ta-;p}0j6 z%5wdRYm+ks`fe#-AS{Mky9z^S^YZKaxY8f=pTI0UtI37BhQJTFR)k2-2i7?W}lD#07M?a A*Z=?k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45f81caf2f1e0462c2cbabfa3627484a74dfb3ea GIT binary patch literal 25974 zcmeI5&ube;6vyB0s;(28Mx-H_q!7_UG0i1OMeC9uNRifN9Z4%zD=g@tRpYHIuvbEo z^XuaL1tEu?dPuIJhk$Q6712|FoZ52_q4ZEnDL#}EN?`NeXdUgUF|E{`r0*=d^XARW z?#^dsAJ5+Pb0uf5eNF$KQi%oHBb_wTw3&W$788GDez<3(RCzHxiNU!R%0vq*_e8-x zUz{Gy}(p&q$Lxyno8`lcE}xsls`lHCJhyws_8bRWm$??13j}<|nEd1}|WZ4$&8L>DhIb zj!=ys3r!t~vT8-Bse1{B{l`yoSp$;GgEoBXyj7~cNlC3>%+q&2{?%W0!;Ql7q38Pj zvp-irm=XffnNM<|Qgl~k=t*8;|K~Ze{ML5&a>J)(+bLG;s*^LxF)R5(fvSF^?R7-5 z{I1vD^y=3d{s!60%VcfUH$3-d%cIq@xl;7j$!<41zgzD%nm(0n$91xnOS$IeX1(jF zE{@k}Zf>i3yCvCKD%V?HyVAJjQNeUw(~|t=4~?TCvi_T7FGqF0SPk z{&u20cWFTt--s$9g8&GC026?Hz%l><5IBec?88B1pa%gE*d_H_BKb)S2?Rg@1Y!hW zA7VIA009t)5P*G%z<>w>AP^$}`w+u{0tkRWgaGVA1O`M90D%|**oPPn6hHt3A_QO` zA}}C=00<}o{kNL(eUwPL`qFfO<}LmX8wmtJVA=`5f0*_afboOC!319HKOEd1H6Q>2 z(?bCM!}QEGMhXI>5_rUaxh-jK)Q)Hm0w6H)1YjQ~eu^P?8DgT&=CYCo&fB_ z#7{8>2m)ghfPEMn9Xf)*#1nvhnD{Bi06}0x0=wZ`i}~>TNr}`sc+)ws8KRRou~M5< z(PEF$yVQ@ayh^XC7{Z47)>7wovOS{1>R#k{>h$|L`@@aSVrKE!M_10ND6_6vbEE$X zV%aXay=eH*5l1pw^DIZ>t#iEIt~xgOIL*oB2*whjxI-x_aeke1ha(qnF&BRbjLW*u z!n&agdsExYk!H>-+Iv-qL-4<@s00_h-Mp#MirDWZ80X$JCvTr6q4R+zvSq z009sH0T2LzDIvi7k>E%pEWvzG_xlpGiJs_7sxNfTS5;;xxq2m&%U3F;bk2BA*+!j@ zG%3iNMPo(PNYLN+L|1vgB=w&m*%r-^N1xD}bdzq;pAUcU{`&Jz_ebqX?Td(Osx6f2 z^Wa<6UVEzFK0#L^pJ|E6N@UQ!V5G~RbKT>6syl2;^4Z%{5lNm$Ok32X8nwygR3x=9 z;uxa^wj78dLNO`*_*ps==QPImzM5O2QtBhV|AF-aJwdCcvT6rnjY{1G;_cbSRjI3W zL;WvPhRbhg+`lIMcZY43M{T%#&{IZP?`!f?eP2c@EXNxu+Lvr2lZ+A-c>b^P?ANKq z=XG8S393+u%A97|a*g(BkJpE%N{+7=p1LT_{6H05=Z8h3v-Ig)a(0`wb5!9+P*Yc; zv|4>UU5`b0#VCoNM~`v<)x9w-m0XNQ?V??VHWDIpM? z`8a3F1!rA`&VEe$FVBhTHTK(Y*F0LY>_XYvv@-_TMmd|$)23Hzx-HR6ukAK>-RjMn zw?o$28ku|59oPAw;nI4^$Q9gevYIv5YggN~x<@6;cI>q2P^P}STW!0liS4%PyZfr# zYDlt7rE0@%mTMonls6p5u%&*zS*UNjbTRo}qw#O2Rz3gMap zF9eOyK>!3mfC<1qU>Se_2%JO!{^2Atu!8^y9QFPHCBKP5KmY_lAVdKEA%p`N5CDMy z0r-ai45%Og0wDtM4IhD06 dvAloeP9O651{=y}h@NJvoGTnMJk2(Z{sP`GK#c$Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f28856e85b1275caeaa77d9ebff4028042a77a33 GIT binary patch literal 25960 zcmeI5&ubf35XWb=s^i2a5lIdvDO9u&OmazFk-FpuQl+(7N79Pb8Vh=8)p+9w?3Iw@ z{JQu*2sz}`Lvjs06nx83^wb}x_S{1VJ(N;{52b_<*vvewtX(yxm70_EePZ{`%zJO8 z_jzxJv9%tTv)0;o^zS7ZpQAmBkw%&})$Pt={6+fbM|x7}^T9=QuC+iWnp=J(1|IqL z@?<8b$7r1!_o$=Ch@ld2H&=we#n_>F79U zAOHd&00JNY0uw@j|SoVVwwuzh!N;)WX%vW8eDD~;p>~g+RDP@+8Q_9xA zcwdr+yje6BRgWb7GbZ|y<0a{Tf+Q@OB99)@Wx7T8>GjLM_Wu0+w`cwFq~apto+^Y= z*SzzS8m}D<#wY1&>>Di^Ta5L_7mQ4Kf%^`|RFBz~;-ec=5sA(-rZs9(joRdLE#<*n z_w8myx%^BkrA64=w_lK0F5R>HzPz8zXtejk_>yg8QyljeconYm8q}%9#|>VQBvq(H zWv+8w%ec8JX*5sLfKlivj*8lIhW7Vs#j~eEs;#G?KU^v>aCi$LDtd|nOoHj z*SXtpX{BT=7uXrGLFysIR-Ukov08_^(SVez{P|t!d1~F`fJ?J3Mli=9Kz=*a#T}KmY`o z0Q>{C0SJJ=K?L9*4k80P2!OyYsox99Phuz_00JNoAprjn!2tyXKp;c_{viYdA_#y$ zgaG_Q1P2rl0D%wz_=gY-h#&w05d!cJ5gbrJ00cq=;2%OTAc6o0C<5JYHRbszkz(pe z(>|Jy_&ID85CDNmCxG~2(suyn4*~}hczgVCaDTLb00>MD0mKiJv(}g?2nf*?w;u{Y7Cw%1y(6-zx7(=5SKvNpC; z3YYXRs5o%q&~U6cAm!Gh4;=V$f-{E-ai|bNIUrOaq~*Qwx*mr{#i1wr`>fr0^WMzH z^O@PFalCn{Cs{(7wC`@WRPLZ^qaGq{5|u-Ez?r<#rPs7mqx4;Ev(%V1NVHm z+@8yu3EE`mA@$5;ald!l!Fza;ISCfOFvP)MKAMY={e>F@i09R7Cim%GFMr1nL`E!7rE zUGw_)s=x7M+~1;=#FvJZSWXQ37wv52Cbu2zsRk52WIAcK_xDsp`|5fYZBUyU)TMw+ zkx$R}H*51!xp>1UXG9UXYYco1Qz^uV`d@a8XP z(D3I)k&H!X9UaTtY=^ApuflWzxu&d}#`>Df)z&Qgt+m!Zx)v1(3ar92Md6{bW=Sp!naZL5sh zDesi3&r`}MnDg}AkALVQaVh zP9vmM*DF@ss+Y6Lvn%;RfvRDn9dtx4wwMx z0mO%ipJEIU1Wrr<@!`bi&=CYCo&e&*#7{8>2m&W2fcS7?bm#~I6Hfr~VdAG40|bFF z2^_@VTFl4KPfDiF;WwSbm?1XF6D!`7$`*T$UZsAn=5;!)atNF1S<3{slh+-3LcJF` zKlk$MIrs8bXEC#Q?#iVlm1WkqYrg)EC05;%-_z5FjyaO?nrAs1Z=Lh?cGbDT=V=z7 zGZ;&T;tp9<;_@RdJReGp19ZR(ca!3 zMf}?1mt-vFDkm@ILzSb3fjRG0i}F@BoRaerm$H^6Tldd=(ffS7$%e`)qNms@$Fii- IW}89(02@C!u>b%7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e3fc9dd1541850e3c3b9f92c8e223e5adc01113f GIT binary patch literal 26680 zcmeHQTWnNS6kYeu^f?475TZQV2^i88sI)vR1jv-WC`=z2256#a2ve{G3XvlHvOvU= zD5M%=VxozWAHzrWgKyKQA2s2lpMLn!_(+T~q|qiSR>rl@ee?{Y7!!wN?A|x`-m}j> zXP>>-*=Ntand#}lmYyS@AEs)kHN(moOhls5TDHunRBY2IhX9o%!+mZ>Q(` z!#o#(Ev2nTeV41*zrWS?tF8IqG?B2LsWwU9-*|52>G&w!kxDe; zS#;q%25<&LIKi5+)hMarJ%-p2dTP*{xJ1sQM7J^CFfpVMaqbA{rC22GQ>NZ66TTkj zcnE!pva(Xj1O79gUmK3JB5$PDP_{Ntb|RG3=gF>xvYmOdTcNByPj)AirSfEVL)lSO zq>q!f_+EBIv6&WOLS#Ky?i?R&%*|OnK zR*%vQI&9hdp=>8288q9n$xzk~H-m&Nn-Q5=#3__x&Hig{Z(`JV0={DiVq@fT5aTP* zN!E?i_1k{SW9Hs~OUndGIxKJY${64AT+Y*b;|k*7 zG%heN^kW^vU_Cto;)|Jlct@pN+8GUU7mCr_+1cA-bi|7W@t)D|^T1xqqZsP2C^jsu zeJ|-zB*FHK6`<=&D^?7KCI!Z@1UMwJ*G09Vx5IojMuJjTPQ1P0 z&v}gHHM?Y9%6#E21UgsY9?)Rx91dl5cyPR71mo37c5b%EyB+r1k=JhqT&Kpr4zo^Y z_%0k+&>?wyvu(i)uH?10=b;0K@zc*6B?~z+4;?&&uJDvRywRa<6Lp8_-I|6EEX1ZI zn;1(@g|iT>glXqhf80~J&ulWod=gl4%C~#U4QfWi8jfZ(j^Y;%E^`-XoN_q_zKquH z&Xon0jB-t))0TNrTupzhD3(tllfC3~E{NjySk5I`ZS(Q8>~C~$WE)HOQVE}%tb3Pc z2D)DpMIlk(2QpU(4Q5I-D6C`58QfIC^+L zVe0$IwX*)WzZM6Lyp!wln8`sLH;U+27(?VKv7y4)lwrw1pDS0Bp^@Y|9^%o@kscjg z9LSsz%mtr-?z0f^0h)oV(D!s_I(g4~Q1OQv!Q-a1y3j+benmhLC^`f*KNQ`RslkfC zBSS#*!y|)Q2T}xz4uKJQ2`71Po<(QA8mtH?0tG}s^Fsl_Rn>|>kt3k_p~xpt%~u2p zh=AsY0)nfm6@em0K=VV9PoSEw2ow+j%?||xS5+$lMUH^xha#UqHD3`Z00Q2(nt+!d zT={-8_)#ZETPJ@kpri;W0t*}geSTQr0ifJn3q ibar>vNx@Jy`!$u$@#KEfcqIxfWxbqDRD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a358e515395d007a171d8a2e88ff44ee803fc9d2 GIT binary patch literal 2297 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c5939e67673aad53550d290b08eb51548b56924c GIT binary patch literal 26014 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..51bbea7b467166f9489b04f1b0fabca060d36890 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85nMvCAPA^z>`k<(?X}ix#S#Zlw&`73SqbKW8UlPM zR~y$P&!Yt&@57V4hkkro)<}Q^NPq-LfCQ$6fcPW0J6=pe(zq6v!cmHAf%GV4M+s*` z-DM7$*DvPRi}iXXw{AV9b>qp$l4>a0WouRUNaKE==(6}r32~2}gmR1K#8decZ{jL$ z;IDgk4}SaQ=O4%8q5UFAPwhhKIj?^!xf?e#$js&{p_zubTCo%T}>ZU2j)4&;gwvd_M66q}J za_9}613A6lhr9Q%r5swFRH@E@D%HnGDO3yb7BXb-TvFn0Beq0sya0{Z^5;yg<=u4pxO0%O0t8# z-);GgtIc2=&e|I6oyNBBebn)>QL)xb{uZ2W(+~QMezP5*;<%ohvpwY7tyZJ&>mjb+ zYqxfFyVFs{%cwLuez)5E*hkUwJj+%6+uc%o%g2Sxd!5d|-P%RzSgIE`4dLS1Uhz*i zI&vRNx_UmUq)Y-NKmtO5_JPNM1W4c{0<;e&k)fUhNMIl8`-lD}Mi?YO0wfS4K>HBG z!3qhGK!gD8Lj(pz5+H#X0osQc4pvBj1R?}zA0jX)k^l+B2+%&naIiuGBoHA$`w)Ra zkpxIU6ByprRL@5#DWRV<9iw?(p2KD$0TP&X0`wnd{RD9RByci;2m22v_h$INDTg(NLz$pprkM3G5j-H=X@nObyI>$Cc=~SFp)tb`L zV$b0f3}Yv6;87hz*woKjCZwNw-N9q}y{P=zmtM;|?`-#$v&-k+y>vlGnf0u?CqMLX zl-8WGH;9H0J!<(sgq%dUP!5NSS}Qo)P=!=%C!4+aD5?d>6y*Pgso#A2yR@M5`i8CM*t3vR8X-pZz3 kwqKM|on;k0`zM+7o?LH;qADS>pAfCORwYPxPILzT2Kps6dH?_b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aff171d7a0a61e73e27f30a34263ff01b5ec8370 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85nM{KAc#`g*qdlmJ8P}iiX{$Unk85`Ys+@p z!X^Jf#eoxt_F8d3%B@EqIPl{HXATH)KnS565ULQ;@@8zuyQUGug^T^2)y}+m^COZcfY=4e_PU++dj*x%>!&n$}7cu#3_M2N~TGv;HlbBK(Lm61vxTO>_^8V@0 zQo&4MM;edNH6jYo&$)3}`Y)JP}R5~J}YD_40+`cC>(w?&tc zd*D+6sxB+WHacjChvZAC(nse9KTe~(1hbsgYRSS-&8=SjbaQij*|qW+@o`H+{HpM7 ziEr8xFSRH8P(v9NDGQ>xihbt~MjquGxY6y}6L zbmvo2sg%4;we*x6u?Kmd*g<>0_eLYYy5p8APSwp@aIH$ASVT2wbo{Q8?4aj&ntuIS zBiMzrz7Bh@zUzA*w0&%rt&Nhu1EMYXay)cuIJ`#5BXNJS?~FJi0gM-&3)bO zv{msU%JsJ2sWd+HQM5eIa#jCUr_|c^&~(7hr>Uo$!}szBtQZr5F3Fes7$3B(A{KE!aaLINZZAwc^OfkBZ3NI(-9 zeMn$n9}@aW(=nQ7I=sx_sf#h$}U7{pHA z#KSs! z6lpP=Qo)P=!=%C!_xpVu92_8&>p(6Qu~@1SyjX5s#uX!V!L63mOWC%!?B}IaXIVwh b{y`?aC--Zjs5}HbCR%l`Ns#WG=nVV?KVvkQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..22a0293d4619ec79adc06e2f8f728f84a0f59e60 GIT binary patch literal 23397 zcmeI4&uwY5zorUzqcBzkEyF^;*gJs1{=t6*M^umAO zAK;~bLvKBD@L-H5&z}4XdNRffzBh%kkdj*3c!2M0X6DVCUpt@KzAV}4vr}gAGo2h_ z3*6=)M0|xqCpZ6(EwXw})5qpJ?hcRBscxzwM2W!|hL*JNaiPNTfVz#VtA-g6}ommPoqacAGV~{MPfUWM(p?ZEZLjWA-n1MmsXLUa*J|aqtGZZjt6tkt^=3n|2b3>2+@|e)a4D-f zj%G>wdef*^T-u1gYBc_JYgf3TQA!s>%*D~2>>n%GbC0g6;#yD%83aH81egHq10Dkq z0D(yaU>_!tfgS`vV2|YYhx#Q37$5)wATUAz_F)7E3LpRi0RpfO0T>WL00c$|z&?!N zKmi0mAV2{2Apip+2!Ox{0oaET94LSQ2m}bgJ_KMu1OX6G1P;Gy%K0df!s?{y5Y25q zhm8ONATaL)@cuCGPXN{r0+R_G|Nbz!J!(Jz1m=eT-XG>?udz}PI4yyF{?Ba*%ct## z_8 K_PETt5d8(rD!Z@% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0304691eabecd0b82b0866715cf619bffa02dddc GIT binary patch literal 27888 zcmeI5&u<$=6vw~o4Iw2FwV;OxB4L4qBDj=dLHJeK*qdlm+iR^iiX{$Unk86D)|TzG zg-iZ}ss~OS+H1uDDYqVd;J}X)oH-!G0U?BPs8EHFmN%14z3aw;xb$LwXSFkL-ppHP zKeKsxUT>aj1!was{CmmFEaDI;7)TkjN7D@$Gk@g1ziDQ4e>pmcDXkI7z~b6Xr4Y-L z%kBB1nZlL~9-?o~%p_-ht-E2E{_Pzz3|F3g5n(v@&1+KM{<0{VYqxCEyLA41FqgDr zj|5171W14cNMK3`NIVih)0C8WKQ4z+iR+Y{iApXiI2-CIv&g=2v9MOE*Q@z8>lx9N z(85*t=!l$rnUY}ud-(gWzx*_?hmMON zGj#~1k9qAoZErjo*=KMu^@Wj1t)!0a^)yx_KmsH{0wh2JkBY$ElMY-05_J7{@gF%F~EXZ&FP7{y0K*FP~gF_FjTLrB8%}} zm-bcHDrDvUQ(0EyvaCr9UD-d9mC2xvDr(XeMROT@*p<}^^vLk)$-S=`%>IbFX-ep5 z;0!*W&(3X$bQX09NDW;Bd3`MmR`%hfKah33UP;zTC^)0&m(P=%msY=!=Ea6 z9a+xYA2~kyeFcRnArN2rv~+4^Z(W^wS}w7N`8~0N_FnJZW`I@4E!Uh4w_w4wYQ<6s z8$q+xZJ-@T#H?A~;Z8)o|uy-5VzV}Jn$9mOTEBjk;I!!<5HG0iffU4tqZr=7# zXzlDYdcHPs{cdY#Pmeoo)x3ggqwRNU%};%lEYGuCHNVvslDOUx4_fw2kDKa7oz zh9ofY1n3_oeu;U4BrrAs`iHU6(U1fto&f#B#4j;VkOWRi;2`?cVj+5dQssvke9}4a z8A_+}#H!J>&K7$PuR?vV0kDq8bq--uKWmwid1||ZC-kVQpL_ZBg7e;XcR9Cw?)^&_ zbe37qs(bM-VOn)6UO%2bR9io)Dlgf18J+E*lNc3yM_{sGqiGeH0V literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d67282ba0746120666e844625cab4a9d7b699633 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85nS40K@g>~u{Y7C{-yO=vBZIzW(gL~+OnOt zaLIpAap1(Ey;dBMa_i9t4*WR5nFB%`5JD&igbJ#(ycuueUDF5>%EkW9YG>ZO`4P`& z#!oNt=DC)yYkC~Cg4EA%m_e}e^a}`r`7d9Q{LW_u(cHLgo9^Xv=lpq~4j=E! z-NALq^Jv4vyKp7%V-TN~H4-2J5+DH*Ac1KiApQvc7%wIvXxiXCjrj`+G|a)-pfPGasmG)z;Dr-4)WWFa%ZDbi^);`l&6JR09xi0w>309^)+!rZY7{mM;o{nE@lPi@at}+o zdN!)0Oadf80z!cHfyaOZNZ=>}v=2v-p`HXtU=R9lKIu+3O#K#ffh2HZ0<;e&M#q69F!cmzAEthbxj+&) zF#+0#6Qkom5}0}dv=39i#ati>9FxFa_^HKW`23`b)H(j7b8It|PQ{5;ttlNX_6%Oe zD0cEX9@Q~~P5rE8Li(xeE*{gbMdi=F^lHBH)>eNxyL{&D%NKN%S=VZ~@tG_6YEVXw|(ULArCIGw>Jde>H^w literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..15fa48067e82417c0475914aa3519968890efcad GIT binary patch literal 25940 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce16926dded2717595a6242bd2683509af67706d GIT binary patch literal 26110 zcmeI5&u<$=6vyB0nlvR5wMq{WRAmJcir~@~3xX(-jlBt*+FonDRxEL#rdfieWNq0_ z3B82BpyI%ZLwl__Am!Gh4;=V$0nQvE!~r3MazLm;NXvWUb-Xr>XiPa^f5+PS@!rhr z%x7kwCh_K_oORYer+*G+Vu21xCyg|1rdOS_iC@#--Zo5CUW__1IMt#&(ZcF&5pc@~ zhr4q*L#GW^9#YpxB!+8!p^9Oc{P}ew440mMp2BeU%a=L6`&o`9yLW8EyL$0rFgx51 zB@h4s5C8!X0D&nXz~_>lY<2g{~5(1oFlO8Or?>B%S_ zW>kD%1)+D}ZtT8?a$lvpB&W=DTz8=LbroU^*|_c-y|24Tr-kI*m8p z|9|_EYh_c6tiXHYI`51IwfTCN_lZeWDp8r!980d#4sG+^36y4ZFY&fX(9HK#H4J`` zG&)V6%%x^GICqAs{CH|ACd#NipwK^psBZ=&l{J)99{AC&b9S-vEG4zPF;8E8_j_;q zk5-Dxr;p_Q-j4*xObLPb$|t!{E_iD)b+(7vzj;pVpt;j|qaM(T;}*(J#m!peTIF0m zPnDqF^4pTLgO1!3mfC<1pU>kq{2pmTM_Te}(kb?jS?Du{jB|nKF zfB*=9zz_l0hans&fB*=@2*5tXU?2+uATUG#_F)JI3LpRiF#@m;F&M~#00;~bfPEOk zfdUACK#TzFLktG8AOHf2fO^}Vb3OZ@t2a#tXrANuun|B21g4z;&JWYR12BIOIG(_R z&kx7fhXw>dV0sAP{4hOhjhTYLNeSHJzucBEchZKa4+0=C@dRKWCVq)AK@b?50PMrq z=+F=ZCY}K7!^AH!CI|v!6M%gf8yy;gz{C@PeVF(q#sop&gar1ZZ!PAd_b0_u=ir;p zfz1$^_=y#5QhAF#M=w#&%kvuh1{NQ{hI-dh=X&zFM~|zqNc_x;uVkIKHrtEo#dB|8 zy{x>F`H{Kfi>+Ptx&haz{k3ATRhvGJwROIx1PF?m~ zyvlc7|md{RO<#KmY&$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..64407d06fc494fae34bee2d555b4aea70c0796d7 GIT binary patch literal 26080 zcmeI5&u<$=6vyB0nvjx+TF^rTm9RiU5nS40L4Xq3*qgAa?X}ix#S#ZF%@QmnYs+>@ z=_UOODh`}DwAYFQQf@u^z=0nZ;LM>y91ub%2ZX8$X?btFsb@nYh+7WW-)HU2n>RDN zJD-_-n#7xzTESiag8o@DsRi06lMFJ9nPGEQQ@`cDyKZGvc`-hT#kmpdL<_6e#lSu9 zFE{6kmPs32xlaQtl}fhyQWc{p{mUCx6fHgdJVnv$S1)sV^YbEGF5hr0|H7Fw;cT)W zN+19NAOHd&00L7&fcGQ%NKF>Ktm{!Mx=r*savT_lV!awisZU-h6lK=I`{Cp0~%d-U+ziOot1rLPOsDN zi8qf0z3yA{px+gGl;|y~yD9@=8Q!nV#CZ4n|L_V22m3j6r^4zzn$H)7;5;D}F zD%ChIvgIP}&^E7*P}Lk>ExgxH(aaCjuq=L53_3xd&ShseSUX7#egF-X66Mv}Q|KQ; z!mCe-^=Bz?Y+1!2G0Z?!|Jx}NXl9iIyAt*vH1P+h#B*WTJu^=?P9vs7(% zf^MyKC7_b+`?e?T+ud?|BcOBH_d1<_JGJ7M%Z=i?!CZXDFWvR@o_n;Qil=oWbPxam z5MToE4_F2u00Ku5fPXlO4D28P0(+!>FC;&SK|lZmKp;T?{vm+_1rPuMjR5?E1_LSx zfIxx({6hi<3LpRi8UgqR4F*&Y0D%Mn_=f}z6hHt3Gy?Ds8Vsl)00N4@@LNrJKT0%H zy=gi^^Blj24FLfVn05l#KTP`y!1zJnXae`&KOEg2H6Q>2(?bCJhv}JXj1&ZpOW+p& z<+jM&aXX?t2!O!E6M%o1_$kH!L11hG@DF37Lq`ypcmnVb6Fo28t9x6)Y;D2spIGf*?X=V{d{}+iR`YiUkKS%@QmnYs+>@ z=q3CG6$iwDLwl__AVuQPqYoVTaeyeuop2-)?DdUm`hM*8>tWwtc=|c={i$zX=KRK&8J28c&xW0gXU}?5 zK|8cS00ck)1V8`;CWHX*NA!_~IO%P@?)Q=|lX|?DlD$MHebpsOiB~VAmvfa$F|}+y zrDVg44{M^xWeetl>Jg{Id!l!Fza;&iAlVj0$)itckuK9!x_jr3{oj85<<`)i)V_$g zr`kfPYi@t9?2Siz_Hnup`of5Z7D9ddyqPKqiGzEp>ujIkv%9Avk~WW+R;fufYLm;Q zq~zR?;|#sXl06YTOf#bWz;}U*Z*9rxu^@ z^Lma`g^E<-GQ*Nfv_+e|#yzDuxaRp2ON637Q6(Ja2i~9)^!apRYK?0rslsl=P&rXb zJwXcn1IYNPQb|ggLn*~EDWR5SvW4=q6f<(+8TxMT&+cQY>GkS~L8E{5fdG{WA)wEE zj2orAvm!%hzis@R=OpVjw%TvhJX*5se90=?X_IWTl*#3&?A4lXOKMrK?KU^u>gAfZ zPS(;AWjCwquJcjDrIn(&oOjpAYSvt@U2WIu9u+Oyu~S)x()EpvYTH#Nw%e+2Y$?6f zkm4K_s|~kVs$Fp@XF85)OaFQ^Ute?SeB!-EhvuZFGC+p00E5v{DTGqRS*Dy00H=i01h-j00cAw@DCacR6zg)6an?N zJJ-7YA*9|k9ilnI?_nc>00>Mv0qh?peFb3rAaFE+`|lr)?hg$JfWYJs!2V%!<{Bdf zf#VXm$$z;mY5KT^=nn!QF!luCAI5%)F+dO)nE?F5$mn1Q0%K1A{$cE=7y|@>kqN** zjEoM3ATag>;2*|*iZMVCI3|JJ-nSMrz4s?2Qs?lS&Y{l`nZ$_|ZA?XrJw-23HyZT{ z#|D-dz+v^SWr+L9ag!cV_agZ-FT9er-d=CbC+APSbMd^2GV7QXNB`FuOIE?@=;1?^ z^?#M6I2v!2qF;#A=BLoRKOT)fF#yaQkymVMCE9Z;!e+RE8i zxi*!IN$}zam?S)Lr_-UGogMP|*x^GW7ITw>7xSsgpr9|#*yX(3%4)WdeV$8M%My?L aXTIniK3`)&=^>)WSt{oON6SvJ%%FRYdQGAL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b7211b6b663198f336a0c06b7a0e1fcf6e7ecf69 GIT binary patch literal 26052 zcmeI5&u<%55XWb|CZr^y7W5E71uKwH1ec~*C5Tel*qgAa?X}ix#S#ZF%{Ev{)|Ty* z(o6UYDh`}DwAYFQQf@u^z=0nZ;LM>y91udt2ZSnww9GuO<6YAT;{CxqCgZKXQpF%h{raXJ1gp=!L_skB^*O%Y`XbMot2Zs(y?Ew~KOgUh z5(t0*2!H?xfWV9pV1Fb!(*&nzIVuNXit0o*!juUUoeWfyIZD5NA-7(r)ymm*^BJWZ z4L*{jqF|NGRn@|vfBQu5vA-nkpC)mO=E$c{={#Mb>-6{CKl;D@^7EZhe^P!CaZBYw zsWGp9tNLqChW!n?koa6P603<}|Du_#JjZPZK2@KcG}Vz-I7{<6@TrJyDz!nI)TRb? z$>UtoC#MG=w- zVcj))oak+-`W-iUhioC*?czv7H;qx-Dwm~&2Q-$+9YcA0q z?eY%rRn7R0V1GVIb3al|*ZFbK=mdSXke=US=_J+oA=A{AD695>LjM3R!-rXNSwqR? zu^k^eZt+u9LM~$~AX(>RnHDalB4*XHV7JEy-4>TyJ^pO5>VG1=Dp+ zN7^^r#paerXVdSuTK{)x#pV}l`Av24lfB*U_b-`5KsgL-)hSHQ6eSOo2DZ)FYsQB%pU}fCU9^6;pq0L0Ra%09RlzlW@oK2QxG^Vf!q9-+maTJ+Y#+S z00gF<0PMrmFEJ(v0uvK}eV7;>I)cE|6M%h~`X$B$L11D6un!ZXLq`ypdIGQyQ@_NR zAP5|jz(M$}#bWsWq(tf*ebYIz8KRRou~M5-(PB^0t2Bt!yuq=7B?hpr-nC3{J2`IC z6Y5^%`ur=e;8!cz02nttf)Lj^b~95 MT;-tPS=MRv53mV5ssI20 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9ddc266373a89d4b11b4dec66bd3191318c5728a GIT binary patch literal 25986 zcmeI5&u<%55XWbCO-M;Zt+s~g`;wWj+B|j;Q{B__D5k2hF3ZOtrXsqnu9u-r>QIw< zJJqVW`sa|<B;V3fDg8&E|kox_Q{3M0~0w4ea2?FpB z2^?sE00_hgz(2%bKm-90NDzR3NZ>#N1VA800RABc10o23K!O1LLjngHAOHd}0`Lzp z7!W}K1Qdb6x0>>Plt{XI({zO96@CvJ1q47~)(IefnDrfi`GdgG1Rfnf96cUAAOHfh zLjdu^?5s6r3IfL^aF_pbTgt+54KW@BKw#<#z&}j=5@UiOFfjr6hl$a_5Co>40Q|$$ zFEJ(v0uvK}f0!5@3_)P(3BW&0{Ssq>AaG0q2hq0{i_!a&lBsj_P3OpGh)(jvN^e?a zi#&MfFD(nC2l;>=`4bIowQ|Bh1XSsNs!B{dBcPT?OH1>L^g;RRxS*OthF!(ZI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f899b1a6800f76f01c3a39f9b908ad61d75e604c GIT binary patch literal 25968 zcmeI5&u<%55XWb|CZr^yR@+0=R$zgIBDl20DnYc7jlGFBwY}DQqgdk5nr0g;C2Pxe z`sj4hU5UX*=`0so$oFz^0el-?4Vzym>#g z?=!ojmv}R&=AE^#=-(xsoTEKTkU@qq)$h(~@{jEI_sz7b&qtG(TpE#1G`D zdnQdas!)~70$Z-rHf^!D0@ZTRd%X8f($tSsGflpA4LU}j&t#_8SvyWOejp5WCd#RM zLA?4rguSUmO7Ve|@`x#;b}HEA)i)?*6wO)s_J_avH=Ne4tH+*J_Gf=;fHENjbkC=_ zQZ0Eavh)<+V*lqkv4hrj_rpd&OO9KrI;(EpBG;-GibYxt8g0KLnjLig_NHII*$6hs zSz01{tG?lTcUnHJRIKHazfMlO;RoG%x6ur!;<%ohvpvc;H#h5DUk!2nPIGfxwL2{- z&Qqn{^4rzMU7w1U=UJ}wZ?;R#b)PO|K5n)Ct7|1bmuiJIgSmLNSNz-6NAA*`s-D%2 z&_MtMK!6D#KHxS00T4Kf0OG?@WMBsY5ZER4`yu&B3<3fm00J=rhz~IwsDJ98KWx{ln4y(EFgXOUf0!I!W2GQ4 zEP+S-m)jyU!wy7$5CDO(CxG}c_AbT(L11J8hz}#9!$1%idjg0LWA9=t5CldhfcP*n zIt&DXu_u7|F!nCS0zqI%0=v<-7PHa&lai@(@J;6+W{6Gl#7b*QWs5yU?@~Xn@(R7I zasZp^UCRXblg}f1MO}-WUw`Mlyz|jUXFfZB>gtsXD$A^A)ja*5KrA_BucxOE9daeZ zbahbrTSeRIKGEy<;<*=74}F6Ca9%-TQm Zq>uUdfDP4`h)%Fojs;GVonxCpPXKu=Fe(55 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61aa09e08d47e8c245a7f287b2f668d9c8480d62 GIT binary patch literal 26000 zcmeI5&u<%55XWbCO-P%FT5W}*76}U^6wymttTu?YvavT|Q`>8;*NO#)Vw!ERl&me= zDW#Y27gQWLacHj<2c+D3v^eqO0-QNihyy|h<$zFykhU|=>w4ETg1GI){yuB>&CGjm zyYKVfjwbPDTFKdKU(tV;RAPblNGFXnZKmIy#l#<(A8s2dr7wmTF}T)3nP_3M80ggwaGu637^{5<#C2A8n6_!j`=t!Wt%uxExOSzRotya#im?xBN zc=4em4F#)YE~y?V`gct9KF3Sa{}@SFG($dpMi=Nh-J*XU{JHo0ufNqhg>Za|F6m!rDSb&Fj4ztm%DdcmG^V=4wlp98n2Jbro-wUan;O(5k83HP zzS#fg=0&;CqjE-Kvw`)DL~@zF^}aT+R*3_*T;KY=KCs@T=ZW4C>n9ws$$iPp$Gb0# zFFR%~&6tb4T330kn$+Rr8n0Z6YE-5Q*Lk*FrES{c)$~=%=<0H8o}ihZsAd?vwKY0K zpU{LzRbQc`Rxsx2+aLexZ^+S3 zp?>I*eQ@WgE%qeOiRHJpyKgsqTDF~H#jZLzlN_^>FBGWi zH`-oDB+Ku5?M<(Kz2R?=y}V4;R(->BKW=%nS~gdT-a6UshUa(d-A2==vhBD|)^aJ= z+}x~pJvGGfI?c^()o!<>Iz#1p%WGE}H$5ttu4_8dzu7J}*F8F${-D+Rze_9ryI9Mw zY0Sm7+``{ZbmT5ADD|bN5i$sX00=Mv_y=qQ5CDPw2*5w=M+SBf0D)anzdw?n#85y0 z1VA800RAC{0}2R$K!gDNLj(py5CDM~0r-a)4k#c10uci64-ps;K>!3|1mGWHIG}(4 z2t)|LKSW?a1OX6G1p41<%KK3w>FQ0>A(~hDJ!}*Z0D);IfcRnBcL3%O0{atqeEhI~ zf3$!A2uu$F#1GT6)|e>>9F)L4{>yDCa|a!W{vZGX6HfsCVd9q<69j>=3BW&$jSd4r zVB!hDKTP}*V}c+sHUapDvC&~52uwTy_=kyKVoVSO4oF}({MKSVe1B3hbq>Gj9Qq8g zNuF3~O{#3M$LS5~=UraqtiqB**ii3U>fBF0_vo;?7dgLt>U_?AZ=!K- z)-`Kx^goGMwo7g=nm$yGepaU}XXCANzTU1nH~2iw#pMjflA*XmDJpUO5!Vi9F5Y4; z{ss-rTmdx8f^F<%<@fI5@&k-GEs~k(5;5*AUjUEELVLDj= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7bdaaa6edb83d73ad00288f7709f04c6c7e5b52e GIT binary patch literal 26000 zcmeI5&u&CGjm z+4p&GN0WFnl(W{#PxRuFjL*>y#YiJfo9cCEG5$~bx0`xW>GQ!wbgs2PCYoEiDFz<- z{BnCHr^jfO8+WLy$K%mnKP%Pu6Zb#WeShJ-kI46@e>utL+dt;ma`l#}JLirb^`@ia zpn(7gfB*=900>M70ggw)GmUWx>tWdsO4ufHGAQYw(0*TanWEI`v)QG5wOYz78Sg6F z;Nk;G8uDh*SWrEZ^lVJ@CC5wB{{Tr?G({fWpfhxZuG4>y{@r=-_h0u0<4MIu#649A zrLKAPS2bRHI~bp&v#}qvWNabUA73ys<@4NkIHtPAwiF+|n2Jbro-wUZi|W)Nmuo5C z9qRpav!a~2rj^nn46J1VARxMh3Rds@G5h2A9kT&&kPVw3xlnU8i~7+F`# zc(pF_S~aN6$0c65Bvq+IWv+8-1mf_Xq*gQ^Ce^6D|d24I5kG`KtO|P=F zpQ`-8XzEOqQF}#J{#BIzgDJJ_0jcE?9KLqWELJ|CgqGK5>F)0jdmD1NQ=spAYVTkD zsR6=-5D2e)f|YW?S(d3Mc#Hj?=fw1yo1M?<9xYmSp=?#`tU&gJt|@#-zNEt2VV z+}65VyHfYo$XZ+^bECHAI^Q&1S}qw&1$ULKR^9bFwNAa^QOU9$J7YSOZLF`?I<6XG zyY0sMrfRpEQXQjGt?9PP^>1Cu8;)bx(!bFvG*(?Yp8BfUeA%TH|6Qo&Ry5|~m`?t& z9Ui$&b4ooNHbMpg5C8!t0RMn(00JPe8v*!--N?WW0wAzW>i0+TlNbsJfB*V2m&AwAprjn!2tyXKp;c_{viYdA_#y$gaG_Q1P2rl0D%wz_=gY- zh#&w0ia_sMO?f{`q?mfsbb#hVeh(W31VCWY2_Sx$^c{ftgTU?to*zH#-XAR>00NUk z0P(}*tTko|0(&KJhyQY0%FJE|qCW_Lz}ORje;E5E#sooNWCHLHBcsDW5Ey#`@DF3Z z#F!umj7$LjVPtd|2m)hI0RCa@mlzWSfjtt~4!*UR4c?!WOr3*oItM;OY?3EdS`#W; z>_Iv~y}ZlIoK;wI2Ko%rEb0o_F=tx5)EmzP`fwlP^lFdGNTb+3S}sxie8a zlt2IkKmY_l00gFl0Ix^FJxy{7m&39bq_9rpY>@Inq6JSiNmK6H`^D8ty>1p(^$SWj z*!e(`ib{Dyzp7efX>?6=o7YRy{w&F|C`~SXM%U>cJ*2Z``J-@Esp*CsC_Dq{lcjIOL6R98MRNxu|Gd*e`dUWRWHgG8!XL=tKaQRE|K&eTOE2d5B1n~|ggZPTw5ZF^yF5K) zsZJ){&?@t6cDC_KzQUc zT&Pv;HR*bWPq7zypOoG9e(!^(ODl#|tr?qEQ722Ul`0k5bekQgD^l6*Ih|dnaj)s_ zkg>8t<-Nv^V}IOsXwB4DtIjqVou=dV8og%ACDX8Mt5CM7*xKD~^c-bkIo;OozN$Cc zvU!KhM%(Gsnx8mS(QR9|qW}}d>=Lv2!Oz}6Ttdm+Rp&=9|Xn|IC=drzCCI{00gFo0M-xFGuG%S z2*f4ulz+J`X)dlI+JgWHOgsVjhlw9zbPxniO#uGk)aYOc0uxUF{$b*W7##$GQxkxH zI5j#Lg22QRfPa|yAw~y5ASQvs;9HCN;QmR8)H(R3bKo;XCvjq>Hlw1&o}68+Jb&J~U=e z;x#XDG~Ncs>+Pw$$@w%Vn*_fb$cRHceVqhdW| tX(peM;Kl!8lJLa+exD8w4#?xj0Y4;SF&8;_F+bHdsyJ4ctj($%%0Ff)8?pcZ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4c1e4e0f96f6f0b41687311981010668cd5223d GIT binary patch literal 25912 zcmeI5&u<$=6vw~oH6bMtwV;Oxkgz~P5nM{KAgHB^u{Y7Cw%1y36iXb6X_jCqSzESK z3YYu`J#pgDUMmhrx%KD+2Y#I3%%MUY5JD&igeruzyqT=)-Lw|ijf7->$J(7YZ{Dmu zpP7AnmNzf8g0uA%{=H<=OE^Lb1`K2F*j&W)@2lV6GPAn999_he+K6OeY5kT`$jC>R zI}1fKg>C6PLf=fMlU85rW*BCEea8&LmFHhU7|wt5rkwA5SrpBU+qUUlIe$KwPmW`Y z1W14cNPq-LU`7Z?JQAO2N=m#Pm&2&UbxO`gWi=`|8)}m|O@B4oo{r1bx_lD!4<06Qq4x#il zZ+xf68&5~$v$&l4!pNppQiJhjD_>LZpkk_fM&BGzxGn9R9LF&gpxQEHY@vfDdhn%& zL{_IcJ|5Fe^8SxI{{NVN)wK#aiDSz`^_nnkp)1FSLO6>$s;Eg_6wNj4V^63Dx+l2` zYGsTx=6*!oG-V4KID^j@a`W3Fokd-i)X--juh&%BpGFznK&n=INY(0AIeG1(UDX(=k zB!P(u&_7I!jsrIu+4O#Kpbf+R380s4oD(QzONOg#bmhpAs;PLKq~Bybr0YOxqS zKdJJ=41ehy`V6I0d16&>MrVsXhc|GXA9({$>Kw$Te%3N2cItH(PwCI1&M&_9R>661 zr@OqmeD3O%3p&fJXVty8 z#nP1I#d7F6sTin>?q*qilr6hrzbdub%PK?mPjb=wa$FQe?Frx+(W+xb5^?86XW$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..55eeaa2349e15d879983541048fc6827beb82527 GIT binary patch literal 25976 zcmeI5&u<$=6vw~2YtocN)Pf!&K*9nEMQ|ytMT3A6V{f8OZLhW7D3&--x7ntalC@>^B!N^~d7(x2=pW&qsq;k{gi>%q`zm3hDXy zba$p;nOK*~J@l+pD%t95UF`SMzrAnu`wK6U&TOifAga$N35DXvp;CQ6G@!l}M)GKK6r7xK%+TCI{>wqMk` z!QcZ)RTQ1Fy`WoU@Nb{!lK4xi{R>cT(G)^_hWBtCxA4z{KlgtB^_QOp{h|FLNK5TP z=`nA7r~4bvMg23lV18j_%mwqHf62~O)h8&Q>Y8!ioQiy-52T!xbKj=|?uaZi#v0ma zpo>6qh-Y=2{r7O+v?8DGri?!7=RfWKPlI2@v-4SDR+3e@B5Tq_N6xFVLK)OhK~?gC zXs%!zTe51Qu1T(aPU7>K?DV=wr%;m{!_ZeCr`JTaKaO;81FBr@ zL6xgp?eN$Ir@ZY$@NNAch$?=@a$@#Sj1}BXa^l7IbkyLdsTxw)EB5hyEo-7$iUfB#KiHbnHk1V^4tgVeFTf6C{Bn6QF%KGCFo7fw3n*`!M!P z%n6dfhy-?{rxvr(`;#g@%-~7qz-B0&iW949(>hx0X`I7;eB~9K&@qfH{jQ}c?bLA( z&+6}@uCKg#KJR|C(V1VIKmGB=vpUMGZ`b_zKZ96u%YH8&K6J#HjMh9S(Rk|;ueYnO z4Y^Ko@+E??ics7^24%@Vk=&EW#aqH9JOsuU-LxK O(W-Mn0(s{|XW$`-E;DQZ literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_instruments.cpp b/src/importexport/encore/tests/tst_instruments.cpp index e93a1a9564d81..38a30bef97879 100644 --- a/src/importexport/encore/tests/tst_instruments.cpp +++ b/src/importexport/encore/tests/tst_instruments.cpp @@ -593,6 +593,63 @@ TEST_F(Tst_Instruments, no_tk_blocks_large_tk_layout_reads_all_instrument_names) 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) @@ -610,6 +667,31 @@ TEST_F(Tst_Instruments, gm_perc_range_midi_program_routes_to_drumset) 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) @@ -636,6 +718,15 @@ TEST_F(Tst_Instruments, abbreviated_name_with_trailing_dot_matches_bandurria) << "Name 'Bandurr. I' must resolve to bandurria after punctuation stripping"; 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) { @@ -646,6 +737,63 @@ TEST_F(Tst_Instruments, orchestra_sanity_check) 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) @@ -693,6 +841,131 @@ TEST_F(Tst_Instruments, instrument_weak_substring_name_defers_to_midi) 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; +} + // Oboe with keyTransposeSemitones=5: instrument transposition must be chromatic=5, diatonic=3. TEST_F(Tst_Instruments, key_transposition_non_octave_oboe) { @@ -708,6 +981,43 @@ TEST_F(Tst_Instruments, key_transposition_non_octave_oboe) 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 diff --git a/src/importexport/encore/tests/tst_notes.cpp b/src/importexport/encore/tests/tst_notes.cpp new file mode 100644 index 0000000000000..6384706b6d186 --- /dev/null +++ b/src/importexport/encore/tests/tst_notes.cpp @@ -0,0 +1,1922 @@ +/* + * 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; +} + +// 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; +} + +// 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; +} + +// 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; +} + +// 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; +} + +// 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; +} + +// 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; +} + +// 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_parser_ticks.cpp b/src/importexport/encore/tests/tst_parser_ticks.cpp index a29dfdaacd04a..a6e44ddb631fa 100644 --- a/src/importexport/encore/tests/tst_parser_ticks.cpp +++ b/src/importexport/encore/tests/tst_parser_ticks.cpp @@ -28,6 +28,7 @@ #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 @@ -207,6 +208,27 @@ TEST(Tst_EncoreRhythm, dotCalculation_noFalsePositiveForFractionalDottedValues) 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)); diff --git a/src/importexport/encore/tests/tst_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp new file mode 100644 index 0000000000000..4183c65412f3c --- /dev/null +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -0,0 +1,898 @@ +/* + * 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, 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; +} + +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 +// =========================================================================== + +// 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; +} + +// =========================================================================== +// 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; +} + +// =========================================================================== +// 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_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; +} + +// 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; +} + +// 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); +} + +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); +} + +// 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()); +} From 32d2ae542934d40d33c486758d72dd5c53007179 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 31 May 2026 13:00:00 +0200 Subject: [PATCH 11/33] enc-feat: import per-instrument staff size --- src/importexport/encore/CMakeLists.txt | 2 + .../encore/internal/importer/debug-dump.cpp | 13 +++ .../internal/importer/emitters-internal.h | 2 - .../internal/importer/emitters-note.cpp | 2 - .../encore/internal/importer/emitters.cpp | 4 - .../encore/internal/importer/import.cpp | 24 ++++- .../encore/internal/importer/mappers.h | 2 + .../encore/internal/importer/page-layout.cpp | 93 ++++++++++++++++++ .../encore/internal/importer/page-layout.h | 42 ++++++++ src/importexport/encore/tests/CMakeLists.txt | 1 + .../data/importer_enc4x_line_size2_70pct.enc | Bin 0 -> 25940 bytes .../data/importer_enc4x_line_size3_75pct.enc | Bin 0 -> 25940 bytes .../tests/data/importer_score_size2.enc | Bin 0 -> 25940 bytes .../tests/data/importer_score_size3.enc | Bin 0 -> 25940 bytes .../encore/tests/tst_staff_size.cpp | 89 +++++++++++++++++ 15 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 src/importexport/encore/internal/importer/page-layout.cpp create mode 100644 src/importexport/encore/internal/importer/page-layout.h create mode 100644 src/importexport/encore/tests/data/importer_enc4x_line_size2_70pct.enc create mode 100644 src/importexport/encore/tests/data/importer_enc4x_line_size3_75pct.enc create mode 100644 src/importexport/encore/tests/data/importer_score_size2.enc create mode 100644 src/importexport/encore/tests/data/importer_score_size3.enc create mode 100644 src/importexport/encore/tests/tst_staff_size.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 9249660e79117..00ee5d0f3c0c6 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -54,6 +54,8 @@ target_sources(iex_encore PRIVATE 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 diff --git a/src/importexport/encore/internal/importer/debug-dump.cpp b/src/importexport/encore/internal/importer/debug-dump.cpp index 86d65f7699be9..60b4498f9f701 100644 --- a/src/importexport/encore/internal/importer/debug-dump.cpp +++ b/src/importexport/encore/internal/importer/debug-dump.cpp @@ -32,6 +32,7 @@ #include #include "../parser/elem.h" +#include "page-layout.h" #include "log.h" @@ -83,7 +84,19 @@ void logEncRootInfo(const EncRoot& enc) 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 ----"; { diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index 0926b12ddce00..3c70e783731b5 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -122,7 +122,6 @@ struct NoteElemCtx { void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); void handleRest(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); - // Resolved MuseScore destination for an element: staff/voice and the derived track + lookup keys. struct RoutedTrack { int staffIdx { 0 }; @@ -159,7 +158,6 @@ mu::engraving::Fraction collectVoice(mu::engraving::Measure* measure, mu::engrav // 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); - // Render tempo text (beatTicks in display ticks: 240=quarter, 360=dotted-quarter, ...). (emitters-tempo.cpp) mu::engraving::String tempoXmlText(int displayBpm, int beatTicks); diff --git a/src/importexport/encore/internal/importer/emitters-note.cpp b/src/importexport/encore/internal/importer/emitters-note.cpp index 5f0ff97fb954d..376c607e458cd 100644 --- a/src/importexport/encore/internal/importer/emitters-note.cpp +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -593,7 +593,6 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) const EncNote* en = static_cast(e); - if (!isValidFaceValue(en->faceValue)) { return; } @@ -644,7 +643,6 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) } } - 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. diff --git a/src/importexport/encore/internal/importer/emitters.cpp b/src/importexport/encore/internal/importer/emitters.cpp index 156f8505c12c8..d2ef6b015b2f7 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -1021,9 +1021,6 @@ void emitMeasures(BuildCtx& ctx) resetPerMeasureState(ctx); - - - MeasureElemRefVec sortedElems; prepareMeasureContext(ctx, mc, encMeas, sortedElems); @@ -1036,7 +1033,6 @@ void emitMeasures(BuildCtx& ctx) ++measIdx; } - // 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) { diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index 26c9b67a84659..b413e095ca27e 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -25,6 +25,7 @@ #include "ctx.h" #include "builders.h" +#include "page-layout.h" #include "debug-dump.h" #include "import.h" @@ -147,6 +148,22 @@ static void respellTransposingStaves(MasterScore* score) } } +// 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)); + } + } +} + static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOptions& opts) { ScoreLoad sl; // import edits run outside any undo transaction; see mergeNonOverlappingVoices @@ -154,7 +171,6 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp score->style().set(Sid::chordsXmlFile, true); score->chordList()->read(u"chords.xml"); - // 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); @@ -175,7 +191,9 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp buildInitialSignatures(ctx); emitMeasures(ctx); - + if (ctx.opts.importStaffSize) { + applyStaffScale(score, enc); + } EditEnharmonicSpelling::spell(score); respellTransposingStaves(score); @@ -187,8 +205,6 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp score->setUpTempoMap(); score->doLayout(); - - } muse::String encoreLoadErrorMessage(const QString& path) diff --git a/src/importexport/encore/internal/importer/mappers.h b/src/importexport/encore/internal/importer/mappers.h index 028424528f2bc..240b900454b7a 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -50,6 +50,7 @@ mu::engraving::ClefType encClef2MuseScore(EncClefType ct); // 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); + // 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). @@ -103,6 +104,7 @@ const mu::engraving::InstrumentTemplate* findTemplateByMidiFamily(int encMidiPro // musicXmlId, then by track name with any trailing "(...)" variant suffix removed. const mu::engraving::InstrumentTemplate* findInstrumentVariant( const mu::engraving::InstrumentTemplate* base, bool wantTab); + } // 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..1b91c3e2469fc --- /dev/null +++ b/src/importexport/encore/internal/importer/page-layout.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 . + */ + +// 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 { + +// 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; + } +} + +// 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); +} + +// 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; +}; +} // 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..26c1268118f82 --- /dev/null +++ b/src/importexport/encore/internal/importer/page-layout.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 . + */ + +// 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; + + +// 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); + + + +} // namespace mu::iex::enc diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index d505e308aee1e..c38a159a3eb62 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -28,6 +28,7 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/tst_parser_ticks.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_parser_chord.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 ) 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 0000000000000000000000000000000000000000..af4f59a218f3b26dc56c1ffab35df0f97b18f3b1 GIT binary patch literal 25940 zcmeI5!EYNy6vn^xhBPG+wV;OxDzHF85nM{KAc#`g*qg|y9b4;-Vu=Ga%@QmnYs+>@ z;gbKL;=qYRd#yMi<<_GQ915J^%mE<|2qBaMLKQ;VzBhK_-KY`R^kjc;wL9;& zGk%)Ho0n>?y!kc$UC!8xI79*#ENiykT-2FAGT+~}Q^sBz++9lgyGybZ^-rTS9$T=yi>5fs~0Z@`l1vP zAOR8}0TLjA=_4Tfk?5ckB2hc4hXaZFR8I{gGZ35$O_N!q-@1}pE!Jw4?5gvu@eOZ2 z)U={lSa+69ixiIbM7jm)zqxC&UsCNEsBablKF4L;z%Bgq;LpR~fBogBVSku?5u~Nr zLYaHs{Lb{Zo*MK|;Y#95E0tJI9Q7|b+3NGs_T--Gj`-4Y_V-kP+A?BnqKyW+@TFAw z>_Y$L7Sy;5yOL2wB@Ff6I@&HfeF$)#nFTN2{eg?AGjIX@KoDb!FwRm!}0u450o zG9!VhIXOeJmS!;fBWkuS%hSSHd@-M%+Y;>@YO?4pa|N$i9O`i(}gjq=J03cK}f-}|KHW3A$> zmi#T0+YLYH*1L^nfJ)i*+-$)^uDP>Q@A{^T>vx(vd#1kJ(&9B#>Mg%rZG7sZ=y;yv zYWrrp)ZFrMDg8mK^#C z(+Ds=oJNLz5+H#CnBM{EPhu1jAORAH5nz0X;h;eRBoHCM_z;0Xl>|s2Mu71lhJywP zkU)e0<3j`nRT3b97y-tI7!DdFKmrj0j1LhQR7rpY41xZ)n)-Z{Y6P}P{GgmIUw?v!EBsF>QV-!tK-0StQ zzrT-Aj(s^aW3iZ$yjV_C#vMoQyt`4-x3XDSFT5gUKg;)h{46(pAm`iSs6GKaEp>V> LOOo!a_$)jG>t8Nw literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dd1d38040d9592b016e4e152087579aef8b5b923 GIT binary patch literal 25940 zcmeI5&u`Oa7{?#mr4(9?XyZ@;+9D=R)plVlf+847NZiULi6h5VBXM9E^-84HiIS87 zm;MK6hn+aU740yoH;y^%u#FR(IZV?I(=<&zOcRRC2s&Zan z%PrcbEuIlyl?=`hucaxPen~aM;N_{&F?u+gnOSG)1l4%aYwAjrQ}eB+?loM(^&75Z zSp%ZW*J!Zof>mBUODU~r%+dER{s~_QRyY+6P=~KmaTqkF_ly7cs*1Mi+;&`3r=C&%gTatZ9m3qr-R~z>| zDw?iqI#R#cE;ZLZI-mKp)%vefD?YeXE39eE#kJhxpH95xF3l_TOxy?=1V8`;m;n3( zmH`NWz+nX79}Xh}I|zWlE~(!E$xmWPAOHd&kRSm6kiY>21VA800RABc10o23K!O1L zLjngB5CDM~0r-a)42U2A0to`}4+$JlKmY_{1mGWHFd%{e2q*&KQ%!k4N+eyqY1&8g zF29G31Ogy1=>)KUnDiBZ@q@tO1paydaCm)`fB*Q4=(2I8ylU4?83>L*Uzgcv#wcl1UX97p4=bG+WJ zx;FSa$;ssi#uA~pLuo2={yFCkM=suCF8%-*muwV5;Elc$5AGy<4d|qZl^$5|M OY?X77gLLQErqSP`?k;Tr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4e5312510125940e6fd1d7780b85a658a5bf517e GIT binary patch literal 25940 zcmeI5&u<%55XZmkH6bMtwV;OxDzHF85nM{KAc#`g*qg|y9b4;-Vu=HoW(k&(wPib{ zaLIpAap1(Ey;dBMa_i9t4h2qd=710fgb>OBp$Z{wXP%vUH);enJ=x!}cHhjrAMyLV zm(feSnbh)?t*`O#l1?t+2nkrQthqsRQImgUzrSgxjlDb^Bq60W)Pbe7o3)I$0c0D4g7QO&!gXe{pF`of0%O-q@_7R znK7?_XZl-D4*REZIq{{HPOK!3`Oe0XDUE#nK8D|K@&arQmTA* ze(>cM)wl$^npH(540UXs?PO!Kf7NyJ89C#Yh4@wB-9lHc4~2djbyQK4vLK$T*vFo% zNMLGCu8{1dB<6lZ-L_?WS~!C*7BchOqMb!uHoax;K+deUS-MAX(_O2grS1VO_1j2_ zm=uZ|n=c?`mFz`)`@>&@O%-b~GR{0uk4JytpfV!_qAQ;gqgM9Tb?PbEV*ls+q!_gK zd+#&@tXAA|t+MIn9k@=dP%2?FXm=EcXs{8wPvt`%IYeLdyO66`=sq-z3Qx$ z{cTh_O+V;0dd*gVYQ^>3T+u_mwY%Hs`KF8OcU!yrroPhF;#E`|ZNF1%e(Iy-c%I{G z`&Osi+V*iV^Fh1)Uzb*6aJgRCvV@CQ^h$rb(VmA`GUoZHlR62I00{^I#s_W#5+H%o z2rxdJMuvV8Ab~@e-vQ}QViXb}0TPH2V0?(-pg{s85Fx<$5P?CJ1V|u8fbk)Qg9ZtZ zK!gC}Lj(p@5+H#X0mg?I4jLpt0ucg?4-pttNq__lfx)Mm`h1jX3G<}s2+fP~95#gn zNMP0p@cc0AJAm^ifzt^*{QPiwd)ANu3Cs=wo*!mstvOQ?7?;2u`O9sUg>gHwJqeJ& z)DvKQnEEB=1W90G0*nt6qhm)Bn0f+?4^zLyoFEBIOn~uWVsz|C0#i?b@nPzhm=h#{ zF$o+FKebpKK0m3MI!B*$j$(%TG*7J7rcAcjb9fDd+{x>B+~g3p&9jyXX{WC{c*49F zy+8lT8~Mt+JKg2%^11h}Tr^o`J*VzP|Ix#0Wy9-7(}#{ZlkuA8BpYu-^7Z!2y(#xu z7Edx5Ylh-3(%6vlV<}z9T)Zb-f)ijo@qIL`J5goKe5K^x5N$r2(&WXDP&7Spzu(8f z!2v>f9mq>F7K%JFvV7mg&obyex!w{-?J?jfsncsk Kl62?9XW>57LN0#* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..647f355fc52116f59ed3e23bcf5b7c96edd0e790 GIT binary patch literal 25940 zcmeI5!EX~s6o=n>O$a0^7I26FfdvwZ=!H_NO;td4?M<}7_FC(WVyTDLCEL_evbJmo zXfOE>5Qm;Pz!h;o%8jEB9H2e5=N=H^fKXNCP$7hnmiNXRyz3Gb8&39btlfF<&6~mV z&G=~&Zyx2Wwfunoza--`v`sP6NYf^|?kvXtN&j(6Pbz&j7)0k<3uL01gdvLJXT8a2KWHET z0w4eaAOHemLV(XB;hDy`g!QoO2PJG1IT@66Q0RcKTqY>>)y3>WzEUY=7K|gx)*HMh zNkiT&8gt4cN&h_)eb475@jpazESeyXex>ttg|5@OB4STQ( z`(5?dJ_-6K>0<1zmW<8CcKR2LO!+wb_MfS4vn|C}_e@13I?tGvsYx|zlgqV~8^^l; z+_Wg?NiU^EkmCC?w!Hh{#zy~=ZDdn?#w{@M%gnn@Exvwa`bnx#iOO8(*m9XRXq{KY zQ!V`~#OLccP5ea_UFYqo(LTC4m6}{(=>S!D(`)KZlu_%gmhL0mc0OEE%N~$gK1TgR z=gi{LNlIvWeVTsz^F?=4^*aUnzPI+B(cc;%j0u78$|qPU7o0_zdV;suhkQ;_)-t9w+kY{$-+4rS|WYt^=^y4Y^3zP6#-t%g*eQK{N+o8{UsF69l!F>LX# zHw*O@m(HbrXf)n;X(a|1D!FBixj3eie`SYzZqbZVkA;npK>!3mfC(TzU>kq{2<%1x z@nJVIu!8^yY?1mMko+Wu0s<+j}j@So;2;DImORmqksSi zj5`6GAI5zLVE!PmJArqfA9nXg3kZO~_z=MPVSLsaGX;S`3Ebzu+?FymXh-;i00@jc z0mO%qUt&xU1coMn_%Jj&bOeEsCxG}c@=J^fg22!O5Fds{hmIgH@&phcMt+GgK@b>_ zz*g|B#dPreq-5&sebd>C8Df(>vC^7Q*b=PQ z#HU|ot#4Obv+3Ex-(5PVvdlV0#R>mM5A#;h>4ejV4mgv++%uewx61i?+v;B9`#2Ye zGZ;&T;ua;T$n{mOZO&Z0&Ro1cFb>;(3flUbl$y2j_H~vf(+Npl{1J7Qw7I!S zKEF2kB^irZ<>bYDsXA)dG3V^1g1nVwvuK{-TK2MJ&wj##zU1p`Hk9`e9b~IqbDX3* I!#0iH0Pxc;f&c&j literal 0 HcmV?d00001 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..8125a7e4da87d --- /dev/null +++ b/src/importexport/encore/tests/tst_staff_size.cpp @@ -0,0 +1,89 @@ +/* + * 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; +} From 9425b7341516183e9e2f04ab6f3da5890ab7e9ae Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 31 May 2026 16:00:00 +0200 Subject: [PATCH 12/33] enc-feat: import tempo marks --- src/importexport/encore/CMakeLists.txt | 7 + .../internal/importer/emitters-internal.h | 5 + .../internal/importer/emitters-tempo.cpp | 172 ++++++++++++++++++ .../encore/internal/importer/emitters.cpp | 2 + .../internal/importer/mappers-tempo.cpp | 75 ++++++++ .../encore/internal/importer/mappers.h | 4 + src/importexport/encore/tests/CMakeLists.txt | 1 + ...measure_count_truncates_ghost_measures.enc | Bin 0 -> 25968 bytes src/importexport/encore/tests/tst_tempo.cpp | 88 +++++++++ 9 files changed, 354 insertions(+) create mode 100644 src/importexport/encore/internal/importer/emitters-tempo.cpp create mode 100644 src/importexport/encore/internal/importer/mappers-tempo.cpp create mode 100644 src/importexport/encore/tests/data/importer_header_measure_count_truncates_ghost_measures.enc create mode 100644 src/importexport/encore/tests/tst_tempo.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 00ee5d0f3c0c6..9e4b9841dc50c 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -45,6 +45,7 @@ target_sources(iex_encore PRIVATE internal/parser/elem.h internal/importer/mappers-clefs.cpp internal/importer/mappers-instruments.cpp + internal/importer/mappers-tempo.cpp internal/importer/mappers.h internal/parser/ticks.cpp internal/parser/ticks.h @@ -69,6 +70,7 @@ target_sources(iex_encore PRIVATE internal/importer/durations.cpp internal/importer/emitters-note.cpp internal/importer/emitters-rest.cpp + internal/importer/emitters-tempo.cpp internal/importer/emitters-fill.cpp internal/importer/emitters-overfill.cpp internal/importer/emitters.cpp @@ -76,6 +78,11 @@ target_sources(iex_encore PRIVATE internal/importer/import-options.h ) +# While the module is partially populated, unity chunk boundaries shift and a source can lose +# the neighbor includes it sees in the full module; one unity unit keeps CI-equivalent +# semantics without those boundaries. Removed when every module source is in place. +set_target_properties(iex_encore PROPERTIES UNITY_BUILD_BATCH_SIZE 0) + target_link_libraries(iex_encore PRIVATE engraving) if (MUE_BUILD_IMPORTEXPORT_TESTS) diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index 3c70e783731b5..9856b5b7ad8df 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -158,9 +158,14 @@ mu::engraving::Fraction collectVoice(mu::engraving::Measure* measure, mu::engrav // 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-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.cpp b/src/importexport/encore/internal/importer/emitters.cpp index d2ef6b015b2f7..a18ea28ac17c8 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -1033,6 +1033,8 @@ void emitMeasures(BuildCtx& ctx) ++measIdx; } + applyMeasureBpmMarks(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) { 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.h b/src/importexport/encore/internal/importer/mappers.h index 240b900454b7a..a02f784ab0012 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -105,6 +105,10 @@ const mu::engraving::InstrumentTemplate* findTemplateByMidiFamily(int encMidiPro 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); + } // namespace mu::iex::enc #endif // MU_IMPORTEXPORT_ENC_IMPORT_MAPPING_H diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index c38a159a3eb62..195979cbbdde2 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -31,6 +31,7 @@ set(MODULE_TEST_SRC ${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_tempo.cpp ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..452d2cbe90cb7923844c21f5e661691c8ddc0ed5 GIT binary patch literal 25968 zcmeI5zi-=C5XXNv5{2x$f*2^ygV3Zq%_Lug$z38YH2K}Qk=iiQPbNR*g$1=y4z z$@#VTKL|2(>X58KhXUR*YRJ$ZQ#*GkiVj6l6dsBqC<-ulPjo~oFHNhggoP487_G4V&{`};;p>GNSH2IpER6U{B%7X!C^ zae6qDH*{L#!d>bbiA22C*Gdh7e~V;WG(|prPUq_wtQ7GID5SJPSDhkR5c7DltW=8bvJ5@R zOYCKy6U%RIbv~*4v}ilUioN3GOmfUhzEGeQzuxlNB3XXNYi)S78+Cu3?8Qa0Hf!si zd%Nk;a@kxedTV62>Ym@Jb?ObD%C_S=S<9tdV`HP%@l+GXYd1EwRJq-h>. + */ + +// 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; +} From 1e3abf812a5a61d085db127bead802e860b46f8b Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 6 Jun 2026 10:00:00 +0200 Subject: [PATCH 13/33] enc-test: add tuplet import tests --- src/importexport/encore/tests/CMakeLists.txt | 1 + .../importer_inner_tuplet_note_level_cap.enc | Bin 0 -> 26186 bytes ...porter_isolated_explicit_tuplet_capped.enc | Bin 0 -> 26226 bytes .../tests/data/notes_canonical_triplet.enc | Bin 0 -> 26024 bytes .../tests/data/notes_capped_tuplet_note.enc | Bin 0 -> 26080 bytes .../data/notes_cross_staff_false_nesting.enc | Bin 0 -> 23977 bytes .../tests/data/notes_dotted_note_capping.enc | Bin 0 -> 26054 bytes .../notes_explicit_tup_rdur_truncated.enc | Bin 0 -> 26176 bytes .../data/notes_mixed_duration_triplet.enc | Bin 0 -> 26148 bytes ...es_mixed_duration_tuplet_boundary_fill.enc | Bin 0 -> 26024 bytes .../tests/data/notes_mixed_value_tuplet.enc | Bin 0 -> 26054 bytes .../tests/data/notes_overflow_extend.enc | Bin 0 -> 25968 bytes ...notes_overfull_messy_precontent_tuplet.enc | Bin 0 -> 26138 bytes .../data/notes_overfull_tuplet_with_slur.enc | Bin 0 -> 26171 bytes .../data/notes_partial_explicit_group.enc | Bin 0 -> 26052 bytes .../notes_partial_triplet_measure_end.enc | Bin 0 -> 26112 bytes ...otes_partial_triplet_unreduced_cumtick.enc | Bin 0 -> 25968 bytes ...tes_segment_no_override_clean_multiple.enc | Bin 0 -> 26140 bytes .../data/notes_segment_override_12plus2.enc | Bin 0 -> 26424 bytes .../data/notes_segment_override_15notes.enc | Bin 0 -> 26452 bytes .../encore/tests/data/notes_swing_offgrid.enc | Bin 0 -> 25996 bytes .../data/notes_triplet_orphan_missing_tup.enc | Bin 0 -> 26082 bytes ...es_triplet_orphan_prior_complete_group.enc | Bin 0 -> 26196 bytes .../tests/data/notes_tuplet_9_4_nontuplet.enc | Bin 0 -> 26164 bytes .../tests/data/notes_tuplet_dosillo_2_1.enc | Bin 0 -> 25968 bytes .../notes_tuplet_last_note_short_rdur.enc | Bin 0 -> 26192 bytes .../notes_tuplet_no_gapsnap_spurious_rest.enc | Bin 0 -> 26024 bytes .../encore/tests/data/notes_tuplet_sort.enc | Bin 0 -> 26082 bytes .../notes_v0c2_full_measure_no_false_dot.enc | Bin 0 -> 26082 bytes .../notes_v0c2_implied_group_boundary.enc | Bin 0 -> 26148 bytes .../tests/data/notes_whole_rest_2_4.enc | Bin 0 -> 25930 bytes .../data/ornaments_tuplet_mixed_baseLen.enc | Bin 0 -> 26080 bytes .../tests/data/tuplet_4to3_quadruplet.enc | Bin 0 -> 26054 bytes .../encore/tests/tst_notes_tuplets.cpp | 1341 +++++++++++++++++ 34 files changed, 1342 insertions(+) create mode 100644 src/importexport/encore/tests/data/importer_inner_tuplet_note_level_cap.enc create mode 100644 src/importexport/encore/tests/data/importer_isolated_explicit_tuplet_capped.enc create mode 100644 src/importexport/encore/tests/data/notes_canonical_triplet.enc create mode 100644 src/importexport/encore/tests/data/notes_capped_tuplet_note.enc create mode 100644 src/importexport/encore/tests/data/notes_cross_staff_false_nesting.enc create mode 100644 src/importexport/encore/tests/data/notes_dotted_note_capping.enc create mode 100644 src/importexport/encore/tests/data/notes_explicit_tup_rdur_truncated.enc create mode 100644 src/importexport/encore/tests/data/notes_mixed_duration_triplet.enc create mode 100644 src/importexport/encore/tests/data/notes_mixed_duration_tuplet_boundary_fill.enc create mode 100644 src/importexport/encore/tests/data/notes_mixed_value_tuplet.enc create mode 100644 src/importexport/encore/tests/data/notes_overflow_extend.enc create mode 100644 src/importexport/encore/tests/data/notes_overfull_messy_precontent_tuplet.enc create mode 100644 src/importexport/encore/tests/data/notes_overfull_tuplet_with_slur.enc create mode 100644 src/importexport/encore/tests/data/notes_partial_explicit_group.enc create mode 100644 src/importexport/encore/tests/data/notes_partial_triplet_measure_end.enc create mode 100644 src/importexport/encore/tests/data/notes_partial_triplet_unreduced_cumtick.enc create mode 100644 src/importexport/encore/tests/data/notes_segment_no_override_clean_multiple.enc create mode 100644 src/importexport/encore/tests/data/notes_segment_override_12plus2.enc create mode 100644 src/importexport/encore/tests/data/notes_segment_override_15notes.enc create mode 100644 src/importexport/encore/tests/data/notes_swing_offgrid.enc create mode 100644 src/importexport/encore/tests/data/notes_triplet_orphan_missing_tup.enc create mode 100644 src/importexport/encore/tests/data/notes_triplet_orphan_prior_complete_group.enc create mode 100644 src/importexport/encore/tests/data/notes_tuplet_9_4_nontuplet.enc create mode 100644 src/importexport/encore/tests/data/notes_tuplet_dosillo_2_1.enc create mode 100644 src/importexport/encore/tests/data/notes_tuplet_last_note_short_rdur.enc create mode 100644 src/importexport/encore/tests/data/notes_tuplet_no_gapsnap_spurious_rest.enc create mode 100644 src/importexport/encore/tests/data/notes_tuplet_sort.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_full_measure_no_false_dot.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_implied_group_boundary.enc create mode 100644 src/importexport/encore/tests/data/notes_whole_rest_2_4.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tuplet_mixed_baseLen.enc create mode 100644 src/importexport/encore/tests/data/tuplet_4to3_quadruplet.enc create mode 100644 src/importexport/encore/tests/tst_notes_tuplets.cpp diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 195979cbbdde2..42da533c90838 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -32,6 +32,7 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/tst_structure.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_notes.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_tempo.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes_tuplets.cpp ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..fb37c1a0a91933d93f1c28bcc52e5d030903bb87 GIT binary patch literal 26186 zcmeI5O>7%Q6vzMT-KHtFs8w=^XayEXD1u8c76efu8{3IC^_SLb#S#ZxajZCS;?Q2J9FTJBrIHUE_&D%!=1?IH2qBaMLKPq_?~R>!5*mR`PxgD(?!0+# z-mK@hG;fDjBA##FC4sn)N_Z*Q3~RiEp3Vp3`JbzpY+78@w? z;c_ROF+*4*=K;E=WgTzzxvB<1^yk;jAXvEg9D-o_iA{t585XbL_)#${Z`P5kxXw}W4P`tklC9%^3%YN@tR zs?Qr=sd(e@etZmzp*PVT&Ry?sX4V9O9N>}{ z4`tL&8M{z<22mqtM)2i#zxO^$nqODX9aRo{KQf3iAq4b0AEin;@2>FBY3(xp&*#MU zn_HbX>OPi=PQF~MIO!~$Y&nz5q2kwDUYj-B?|7{ZuXereucNrM1bef#?z!(bJ*i&NK4w+*l5P}T2nYcoKm=rckZB+U zgurP8WPLb|jM#;M5ZLEmU6Jr_kwk=m5D)@70a+h(8lnpUA)phG^+Bg0x)2ZoIssW9 zbQ+=y0U@9hko7^QA-WI{0y+U%A9Na`3jraZ6Oi>mry;r!5CTk~_pK&BAH`ZoJ!v{1 z^DI4wEg}Sjz@!t9{llcs02#j!IGw=5_YbGH7mpAS0+T~P_79VDuVth{U|0fo=`Xig z!ovnidm$hM#-4zz4`Y9eWq?9pWCF52jEqhKg}~Soko95gZ?Oze2#icX)`yYNNuUrI zdjhgPjQuT^0SbX33GDa3wTSeepXB^7gKs(qYX;jmPb_<*DqHLfUP3Qp^a{?Z9Kxo0 z)-pux_;nYLsrSO?r(bv_U3_!BJ(rxDdFx7CWtnxeRagJ_7E8r~+tt&D4mpzHnx`lm zZ;kTxcGS5}=SdcqG8l7);x=L^Q28E}4rMOhBrg65FfQ5N?fXu+R5e}9IX6k0PDVL- z@go#YPu%Txv9q&-fL=TF;*7;qrR2qQsPees(4289d44OacENt0N}kJ{GyDhY^Z^~O Uk)eDBxIk7u7AQ}5ifjh{1{~%`;s5{u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a3575d617448ae0542cf7ff6378bdf31e80807d7 GIT binary patch literal 26226 zcmeI5&u<$=6vy9s-H?)qTF^rTRk1)q5nP&JK@e%M>mOlL|I&J`SmHoUv&5E?wPiac z^pd}TiUTJO?X}{7lv|HJaNx%UICF>)2ZRvv0ig;ZE$@wgjYA`_>B;_%wL3HK%^Q0@ zGy6QYH!p>FZu<-R_Y@8;QHKI#kYUVqn{zSvTlBlTW?0pid!3k^8@)2o(&k+eu;ruE z{e^@XpdD6rsBH#=qpiMFRo@T&a@F+xwdYfRkR=%YHBEGAW09DcVbP!BW@0Xxf3liC*%w^Umw z)#u?i%HDXYXCJ1Gz-LA{uomdsXOgkPI+HlPr@F`X5k9(mDk7=#h-sS|RHYWVoJ)#c z9&%iy%Pct*fkBI+{rD0EL!7J1`VGoQMJnlVpc5Oqs&u^>>5TNcLUC?TG5WoB29_)G zOdEQy6{G)*anZe{_j+RJueC@O?zI%?_j)q0EXwXPs`pw7^n0xc$C$4BR_SC2w?%hN zf5x{>wqqgDM|5tLQo7Ffg}lt|WaMF=x0CS*e*(zxdc4W2u|`ck-siO$rV{0;z_K*L6Ou zyR?;0Zf4va$~CI4*DANFHIMQ++p%MoL-E?)Ub*Ee6WeXp_70RjSC?#s^5weQC{%B| zlukNM(w6qMMy9so(rV<5IBnf{KHvfAO`^uIPQK2CZEKRKmY_lV1xkt!w3#kKmY_Z0`LzS3=}~C1V#wJ zKaAi&1q47qBLM%P!9WoNKwyLb{KE(iR6qa(Gy?Ds8VnRc00a~Pb!R`9y8a=c?lc{u zd6DnKMgjp4m~{f!Kg{|H!1zJnYyywoKb+kj8V~@1*&%@a!|cp8MhXJs5_rIWxh-j7 zTtl=60T7sa0`L!0KgAdz2uw@>{$XNtFa&|ACjkF2^;3)ig22QC;2$PN2SX5;dIInd zQ$NKRAP9^};JEjz#bWRNNr}`s{H1f~GejnFVnrKL(PA&stJIAwy+u!`7{aEy*D}EE zRIf$O&%g3|JonCSb2++v@!jjID$1;rEIInW!ARw@PFoKjI_5~mYaZihyk(Bp z+fwH$pJzEa9Kl#36gMeMS z@WkzQn~si-$mhoqKO|x?S2=hwA1WUe^u-Cgn30#VZDp;OIhVC8vBiJnP9O5|CJPGZ Qh@NJt9BUG0n`H+54V8gN^8f$< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b98e6a867c9035cf8e649f863efc42560dbf9d15 GIT binary patch literal 26024 zcmeI5&u<$=6vw~2YeGsQYC#VXRKfy;BDl21f*{h!#&#m7{-yO=vBaU6W(k&(wPib{ z^b-DpiUTJOEys!jQf@u^z=0nZ;LM>y91ub%2ZX8$X?ZhV$7|CFVtTT_W9`nHH$SrT zd9zPr?fRvXE^U2@e^0T<5{_WPfMLw_n~NCvJ^B4DE2hiK!6268Mj!)A8@H4~dOkee zS;$x>wx#k2Ju4C!ZS|Ed`hN7+OP24iJo^%SfBx&&szHTyy#2ahrjeM(-78p^0ho)OJe>|;-??CF|utIM7}g1H}2vn*Ni z2F~Eqg~a@}NM})#8_CdTAf?xkGUKC2gBw=mY7MAd-J0WLXL7~r^N1RGYZ2f4@MnLa zjyDR*XO8uQ(T^1rW`sc4@=+;N3eKjQdQ_I!|G7_cUTeSmR>Q-3$u3k%RXd%9ovmc@ zc~red+wCYR=XKrou3Nv>@ODsIUq^1QzT-L{wp?tMvl|6>8>M!`^}6+Lqv@esvK>2> zbC7QC?$*1m?qa*0=I*|(FSS&55#@TzZC4sMT;#KkleJa*X1mbbb`ej!+iLw+(~4h5 zp_bV)go~4N@(=8A&qFNf;`y+WG6|3X2?zoD2QC8=Ac2zz&_A3+hI$epfkUY8ANrdZ zVUPd`kiZB5`iBu5ERX;Rgb2_-gkVr40TLJ?K>sj;g9Q>Gfe-=uhY$>kBtQZq1n3_| zaIioEBoHD%{}6&fkpxIU6X-wHRPRSA$<%L}4$-_I?_o2M013=G0q!4WeFbp-Byci; zhwmRwZqFJLAc5H-!2QGQ*qSpXfzuMWE5F=UY2maT*`5SQVCo6bKTN%eIYAPbm;n95 z#OTNqy zIrJGyr{ct_+Nh2edk(LnACGwxkLwu1mVVdLly>TK7fW{NV-=yegBXgEe;~On zk&E|)i#GE2$Kp=-0SslaBu)$J_qtq5sRfL!Heb4 w`KV%`&e+w0x|FS4G550MYAvgX+&{^n_vLs~6qUz-r$noc6$$*E5}kp+0n`CF5dZ)H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7f589c9fcf25f0e81686ee6954a8c229d76a32c4 GIT binary patch literal 26080 zcmeI5O>7%Q6vy9sO-M;ZE$AVFN~}Oa5nS3}K@cUfu{Y7Cw%1y(6-ykzG)u6QtS#Fq zrI*||R!DH-&|a$^kaFwM2M&B(fHQ{*aX<*691yAy((>MT9govSVAGTRA8U8s$IQm_ zo7u@rym_hQ+^sL^-;0%8q9aO>L54BcZ_Z-!_ssVa#CW6wQDADxYtEk!Q=bTaM{pzHlL&AMFPX z1V8`;KmY_lU`7aVJfe>@!9~|~If{#J6WNMOCN6X~QcdP4{rXyNqfo1rvm5p^$~L_C zP!dPMDcP&4g+&j>MDKFEB<-Ij35(_^q|fLQU8NiJ_x(SPe*5L;d&B;u;v(XfDuhzk zy!Nf?Z#)_Iw`eVK$FLHsi9!FOovqyAwkKn%TWm}7(T}N!MCTFH7PYBCT?)9C^2z!B zms=F&Qjf|R5u_X)`z{LVHQjg581$=3te4S!-z5gV>twD_HltnBG6%koDY-&9-Pbzy zefmJZD3Wms$;rlv{$G^Gz&QgtUKtr90vTE(AW%LM6y!up;TGoKna!V_tcFH@Y z>hqK`3g#kx^TVJ01+MvZ@yxL@xcadH!i*5mXFkPBrRZh82ggfM}uamP^-wFJWS^;gA?TuovO>Vmp zgxz|#(G02VdcK!+e9ARt%sc|J;{gnH6+h~@=;4jTmoKw#Dh zAbyzj6@c-B!07}Y9zUGk9v%K9KR+p%I)|Th4)+YPNuF5orc}1rbMy-J^EhwP<0^-+sh+h=a65V3r6<&Tk@Ise zzm{{~-svo7me0L&c|~QJ_3fIk|I>+ex8(Qq^r2&pWW45C&c<8ke7#+DZt!`Qi_aO1 zB|~wCEGlvRA=e&fF5Y7<;R!H4+dhbWCtOO+xdrbAOY@nOBrkr1MbZ=ZdObQgIG~7M z2mF$Z#jJAjVm?$oY8aUFUbQH1Wy>i!FL5nvSu%P5#23BK#~W;@JVx{sTjf~ggyC7X G8T1dPXFq-b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5cbedee8d756c6f4c781f091212a1497b0243164 GIT binary patch literal 23977 zcmeI4zi-<{6vw|LmBwiVR46h8h0zL4hM-+))j$d~EvQO#K}Qk=ih>1n2$Yy{1lW`y z$t~)|o;nr=vUO?x1#cNObSN?fnKK0W3p@oy7v;U9B%eYXi5(+@^p4>Dc<y4{{Rwu_L;p`hD6Wu3Cy!3Dq>Xk!BQ=d_!nl*V8tEKv@u+*FzExU9th<`fJ(}pQCv=BXGcJKQZQmHr$ZXD7tn$*yqZ#k>K5?VYcK@eV zgcZ*(*Sxx4aNs+&VyT3B)ar&kuDMY^>~4n5Ut7@zypY_0r3wf zkrBBN5CRABpO^SIF-bx|2nc~G0^%R0a7aT42my_N_y-M!RE2;Lm?9wlVG4&dgn$sx z2#9~sU`SO62!SaA;vc4PNJ9t+0gZt82MvZ)g@6!H1k}_1REzzCsh%`FLUWCt!%o7m*F!Nh13lsupCLsRd%;>~W2+TYI@eeb< z#j-#la7qFPqi-$Jqvt0%Qs>b(oku={WgI7#wHXyH_6okmXKJ=pTvRceE%mIWN&WbG zjE|L;^Dn->S@3?@=-seyT)A^Qr=rXTP9s=QJ>qP|s{{i*e5j^7|7xD1@ir-5Z(rpt z%I7%+6v3Dy6!(xth05Qk^eJ-j7IER&&3Xz*_F$yb<>Pw6EBSv?W5LdF@Zxh!9G-YE z7+`mI7el&s>EejR)TH3WbgOb&Fcug6dYNC!np<(dqLR-tM-+cagZ@YN_avxMsP0cm O%J+8^y*p1b1Fr!smH-z3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3cc6fa40e62bb6464fd350625750a219d2beb762 GIT binary patch literal 26054 zcmeI5&u<$=6vyB0x*;VIwMq{GsfMf4e6r5kjU9zOWv@V8%nzCY+s>Rd$JQXQex zn0w!;{>D>Df18%gFAUpUGLQO~oLuD=x9y*)?i*%urmC_o!&m=IMRZ4vXVV6?sX<)| zxs;T>G~hT-3#=)N!lZez-nvL<(p;*_Rh!CL(Mno8ww;&u%et+6Y+I0%U{<&7iA_!L z5*N?pY-2EH9LsEk*Lbr*|FZAoGrR*zyvEmgg`3pj>qA~|n`*qW6)p>`xlX&Z!+Ri7 zmXmvePxL98`H^at#W#dOXX*2~%g?By|8t+XQERvR zP9vff&o5QHs-JhrcPfQqk*ZOn9d<-=qi)#V4(m4>(H40tE9CCfx5D6)R!D1QXSEb= zlGkp8QMcZ0G$Sf|e&FZafbz}l?Rqy4ouO-E+RIazecBS!YNJS?I9ADZu z+ok4aNLMl+v|9glX~nOXYK0Ahxdd)d{L9yS9;nX)qKmo`G6;YG2rvQo2W$fn0D;p8 zz(1Tu26_+xfrI|iY*%J!v{X^CCZojRXQ9 zFzp0zewg+hfcb;K=>#5qemK27EFb^^(?bB~hv`{s%oGGhC2)`ba$C~es2$NB1VCWo z3BW&0{1Rh=ATTxo_=mC4p(6-PJOTKJiCn@vmb;>VaIJaMnrqy7DT ziutwAFNs*pRSsUvmnw%9N9uxKEy-KiaM#_Jxs<&u@w$KFK_Bq-7Aq=eh|aTCt|g9| JonxIre*=9+HAw&f literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7539aedfba6560419053aba8a72b9ef9d5a02290 GIT binary patch literal 26176 zcmeI5&u<$=6vw~2>zK5Os8xE1AQTHE6v3r276eg(jlBt*+F5tKQ7mytO|ztyoVBr? z5V(ZDpyJRIhxS@=K;+h=RdL|Q1vqm+hy$t+$^oGYA({8aPCN;XN*hp^Jd;S z^O@O~k-d2-X58g3@bDCmOkfu#3>ZeV+nvP7Z>jHYT5(mM>J4I1Y4l`ZV*Vy8$nwGI z_E^?3u}Y1*Xj_rU!CqggYNr#s_qx^T%sllxI-Sw4UZ(u^XIav0-Eu7d(uE7*=s`QF z2>~G>1cZPP5CTI&fX*ZOOifC9U6(t(r0ZCY_fo2taHgZWM3H=THZz|ql?v&3`$?ti z58ju!A?GaGGpa`%|2z|2q4N^=e*%0giXy}fT*M}>epH_JsD1H%yxK2n~hzx&@sunIo^%VUBN*?%^E8zTnxJB<-X;?XIh|w`yq8^)BtS zI7%p>NM)8ZSFwS0+I^vFIlLQbVIzqCfRbg=t!UsBJ{e1nu99>HCAz^4l>=$DCcJ`= zBGGuqPyf}YOhEj@iP4Fn5Eys@;vWWniDiO9;KT&P zKb#ny7z%-bCm{Y|;Fnk?CM~al75d_VzY9^xCEuM=Yi)1uv#cl?N4j>a4ev=O1O+S#(~YlJ_!4 c3jc`)y-(L`q$pB+?6aihYlZ@nr%7kvZw!Az?*IS* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..923d9c3bcfa9975fb3591045c08ea02cbbb9bbdd GIT binary patch literal 26148 zcmeI5!EYNy6o-FnJ89Y&YLy-$sK5e*B64Yr1wqit#@E0%gFrdfieWNq0_ zDO}ROpvr+0hn8c-0V%g0ec%w_1ZNHv;(!pUa;Q*+ke2txPCO2cz@{hrJ!^N~yqUM_ z`DXkwNj5K~jJ@^^{$1jcIUFDa1BNl(?M`asVd{rF;kc^L_bw8q(&*{H+{zs`kmr-- z-fT7;!a6k`pc9Tn27B$RY7oSJyA%$Br59d95X^l0CY|qnoh8f7yH?n}a`B=+Gw3HZ zAs_^VfDjM@LSRA&(0)W8X^2Fx>w3@=-Nt&nC#jy`Y@oVKBl-5_%u23ODW+G<=asF0 z@xI0lIjdkUsUC6sb5HaE?U%U!bKq^!G<)=2fzRN%Y(i@)V>JRQ*EKt zHE({e{EcUO{&8FmePzT$OQA#myqPXNq`uuf)dM5c+f$XvmZYP*rvmP(>&3B#CTeKI zqmm`{VxMCa3uM`6gMlcw?p{D7Mr9Od63C}eOx0iX&BAN~ zw%!&5%!+4YG*;qYbj(bW<~&a;=sK;RI$CsmM5`%|3ay9|m07Y}$2PWTh54%G_==;& z6~XjRsD#7xaWillU(670{YCys8Y(it2}gyddC0xoLGKiyZv6x$Fl9@ zOLp1Gm~hNeHkU)$uQk0EYnI>knwwtrM$O-Vy}S%-tGeO2pEf+K7R{Bsw+_2m^Za(T zU90;j+K%g_Ef<;k=4Q3+DHq3U)i<|QyWQYo5yfi5YnE!CdB~ZrYdYM&-ptq6JtUGJ zHX8qRYWYh@zLH(zufK;pwmWW+86 zguq_+M_Bxwn1~P%0zzPbfW(IZ98wViLO>%R@j-(jst^zY0|X>K4B(K85D)?y0f`S9 z3{i!E5EvjJ@nHamRD^&K&Pgc+npfyKY!M+K1SXw; z>>nn51<3e?z{v!jyni^kzqAMeAuu@vWdATZb1fqk0>cuxPk*`1GCQoJ^cMm`VC)G< zd>H#FmH`TZkqJnA7#W>73W2dFAn{@Br&tCk1V$zx@nK|i;wS{ho`A%Mv7cfYpb!|6 zz+UgS7SZ1Glboru|C`Q!%wQYmiRIRq$`*SbZ=jnCdKITs4&ktR)-pu>_;nv=)O+Fc zOABvh?5i8C`PBURYgZB~%dBfwT>T$cEZYUQqo)rYawNlbPg6GDD&^~Kt8N8TT(W)KYdhAYsu?@y+$L=%731W^Pf<8Mai`P4&dv@3 zdhO7QGZs^ok{8pV%0a`SIqQ`3{8rYig7rF;yp}n)_s?|E$8@|yh7x7#K1WtQmMH0X InrsIC2HvqlzyJUM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..689ba3e57d3c1910f3264a484bd7b500411b5408 GIT binary patch literal 26024 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 LNy37s#b)7ev2!@G literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0584dd296c232cbdde3e446ee4bbb5ddaf67d25a GIT binary patch literal 25968 zcmeI5&u<%55XWbCO-M;Ztnu(u{Y7Cw%1y(6-zx((`QXs;Cqq}+P+fdfBIaOO}U4hSKX140!-T4tU%@vhqlY$^r6uDtLnh2i`+m-u||%K~fm?%IZT^}>ZqIuAl8p+T4wcCqW!}1yUn|z@mE4;3ywVLX zK9r=RXqT-OWnt2zG0}C7m&E=#lCWrw0{VRk9*T**orgQB1O?HtY*Sd0&~?Wbcdd71zpVc&$pj8aH??TGZv^ zCa;W1b*fO6>jGs!q+#TjW~RLa|7-pxN=eBH2OD?`->xTg_mL zoYhsbcN$y1_i@{&^@_Dt@;AxpH2t90=rvmbRUFrIbGAqM*7kOz=c^&E-)(L0s(z;} z)$3GgwEa%C`H4?O%kwN(>|32uYtyHTnGf3S|GKo|pG)<^hQ?ex+bjO%#z!8|l4_od zD=@88;{2Vq42!Oz>6F~eh>pKAR2Z56bJU)In*&aP000Of^0P(}@tTko| z0^<_6&wsfsWntWbum=GUn0f;64^zLym>>vDOaT62Vssb?0#i=_{$c8u7!w46i3z|z zOpFc#L15|$z&}j=5@UiOFeZV6=v#}$==n*>)H(d7bLcZfCwXF}H?6Y8o~1WvkXLz~ zo>n=84fU+0&UW&;PtT~&BIlRRznOQgZFQHk%V*!edQoMW^{l!V|0fWuPTA|n(}#{Z zlkv=RoQ=1^`FeZm+~o5t7mqU-ONQbunN;TbBd%S}T)e|vf)Owt>pqP7M!Hlp?-bqJ zEX`-rlDzl{7D-Rs@Aqkcf1g5r?ej}A7ITx67xSU&q~b_jaBC&`C>wU!evNC{%aU39 aC%))IKHgzP^(mq=td(Ph6L{xXr_n#ifG`~Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..305c207b4ed341898065ce257565b01b488e6e94 GIT binary patch literal 26138 zcmeI5O>7%Q6vzL&>$)kis0BreN(B~`Pz09{EC`}RHntNsU4Ll3QLJzP(=4f_WNq0_ zDO}P!QpEvr;E-M`4oJE6=mQ5nPH^UcP!9+pltYCogjn7i+jyKbf|zo#-?MgS=Dm5V z%x`9&$MNQ+oGGq-fq#}nWCjN?VZbn=-R@M4{F?f9&q}EFZ0{l#l}4`)%q;J*f;=BA zx2Lm~i48IypkqZM!@a&#%`lAr{F)Vp3r{|aFr518B|6{!JWHDEw`|M1JU16i4f{z; z2nYcoAOwVf5SS1Gv>(w&YLe(}y&m>N*Ks}3lT=S|HdI}rNWQX|S#~OwQhGUeUg-uG zAJo|3*oEAJ>XE?FJ<;2=U*i6cgSSOd1o#-2a1A%{$NhT;zx;Ifhk-xTz6jJ)ZK2dP zuYaTbjYoU_2`rkQ83}X2?EB|)>GECbdw5TEi*!jkx_c^s+caXVp@|yW@Tp`;JvHDM z1CMo`6((YA{pbQt#i@+p5ggIcD;L*q z)wgz(z1ZO51(d1{zgez*;KRvzUe4wI^=7`l;bT7ePNVT}=a%0%@|EnGL0mlBbAESq z&wb3O<^^3Tbs-=Gga8qc_#n$b2nd1W2uOT5j*RGqfDqX4{^E*1iHQgSAs_^X2uOSw z!XXVIAOti55+5`eQWXM1V2FUkhanu&5CTF#BOviXgCSKRAOwa8NPHN=Aq^oQ1T+E? zA2b+J6#_y)5m1lgQ>_~xO!cJc0L?{u4qHSB2!Tl_Ap3_&UjZ_HA#gl_2k#$_?=Kc1 zAOt3dfb1V8XRc+WLg1tX?(kpxlT4q~QThu3Au#p?BtDG&6w3gGz{mt7K8%b`9EHHx z6Oi~Y_ERha6apg?koYh%I&l;NV^2Wh!`M%;3{VK1kidTLTZ>rl`AN>yIrye?5HncE zd1BcbSJ`6E;YF1d8Cbz-l|$H4&sv()k6(80OPPzeiA!(@ zj7PewJ=-CZYG#U#dy{HYsW>Mueu%>9i94MRc6WCX(rcGqoUxdil)RV@RSqlq>a4q( o=O1OwF4)ge$!nQ&fd5Dry-&xhq$r;P9wRLu3zUgFO*#XA0Ugpr#{d8T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..08851dfbb590a596835f112f7b73ebbe42a9758f GIT binary patch literal 26171 zcmeI5O>7%Q6vzL&>!c~Ms0BreN(C;JP*g4sY-}euU3;zdMzO*HOtYkxlC@nqe6G`Bf_n7oL0;VL0{Wi*&v7S%x$>Zs#oT%G_KqHRvZT zAs_^VfDjM@LSRe?(0)X}Q-DfJx{mAdt|YsHGok7-iNwo`>1DfIE~b{V=ajBL zc)!L5J6Fgqs2*_~-4ne<`z7xGICxt$i2xtrGOptm{=E0w!7o4k_VuAgVXU*lM3g&ycpeimDx-J=$j_meyvb^k%WRuu+Y>yjJ)_yy z|Moj~#P4IaDFPSC&#>c0f6Tv(o^2hc=V9N}#@sV|w&oGrqSdo~fK&5${hH<%hK!IW zX*h)9Uv#tS1g)7oEv&1wtZHb|`3^0zILauZL}i9FSFwX_T6lr79A1d@F(QJ=?@+cZ zdI}Ai`%k75QyWw}gEBqahPnbNwXAuCJq)3htb|Km16*>nP)hBR$rV=jk-fK%aE(G+Mk5QwP_g47cDIOaCbM z=caNV(zUIvO3PO+uHUR}?I^ob=i+%3D|Nq7s=n{T&U#+f<^HuszP90GKJj+F{%_}& ze{tl?nKgsBcsbAh!___aF{7I2b*0pWfDjM@L_p$$ECV4R1dbyh@!>c!q89=}V88Q+ zEB+)VA_Rnh5EvjJ@nHamG=zW<&Hp7S$tiekJ$ie-@FPgr;_Vf$z{l%|emFw*uZ$-BwxRj+055!<@BcYl$H%U zAC^=@K3BBXv_>5N+!MVc`z59SB-FNO0UkcVIMfM+ z^V+xC-grE)kKmQ+<(qg%McGC7dA_=6zZ7E}5cM;J9GU2wnCv8=j38Z_WKEI0$?5?YiSr51Q zz8Z_7HQiWfU$(7GQufpZnc*ukzfE-Hd{1UOjvC6SNS+nR6&zq+R)D8l##e;w=c8Eo z5jE44+rhvYe7cle+>z2*)Z`{J^c6_!^`)lqVWh#$ta7ymRIYB@@xHUUV)a?XjJ&yw zZ+`f*zlg_`g8G>g`Jnd`0Sj|NAUyLisZpAOR9MjR5__X=ErT0TMWZ z`u(AQi4g_~kN^pc5TJh;!NCd%kU)q4{X+-_izGk-BLwIlMsTn~0wfS3K>rYe!6FHe zzz6~QhY=jCkN^pU2+%)-V6aF6B%lfOpK7Z2qf{xP-!vVfc~0KLW+DL+n0Er)Kg|0I z;P^@4bOI0FKb)@577`$V`60ml!~D!OM@j;d61XFOxvkRDq=wWd0TP&b0`w0vKgAp% z2~16Z{$Xl#G$etUCqVx&^Ha?qgGe5-~APG!J;3#-%u^hZV zsp7*7pL7m=hLWi`v8pwuqs5-XE9l2+-om3ghOnvMwTy_KI_}^x{aw`cnU`M6l-}Cy ztfp4ay?r^Mqs%&1%?bZwiH%ax>4n3GPB@au)YB4;w=VH|yZYLY>pUk%A{eU(#T~>^ zl>7t9ZHZjGFI>DaFpkLH58B3>bTw1T+bTl#Vk)MB7eBGYnQZ-}7s2=IhR)ww1?!P6o$@HYk* BJb(ZI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..570c7b52e8b4f0c9f25f2c3e1f0668e5612314ea GIT binary patch literal 26112 zcmeI5!EYNy6o=pLx*;VIwMq{WRK)@bMQ~|C1VNO@#@}`o1X07vv%jrn|b5R zx3e#kc=J+ByIWt=ze^&vKpis4Aj6pHc4syAd-D6cRzlSmdxKb98aWi;Z6wQA7CZF$rm0`=lJ;(B|U%eX64*Q`7 z0w4eaAOHd&FeL$+l>QLi(Jp(QUdze?R)8^V=^!KOFdz+7}V`R9h%D z=D~N$-*~R)pP)7KOCw>fnkW7_J5_wZeY<gTAjk-iG_dhmxR8t1( z^gWkkpP1A=Zy6`=n5Nh=rIXq_9{1Z^I=4)ALTig6;UJ{P;{gAHXQx+q?c{iM+~l=U zqb468@k&WhnF>_oGQ*aev`>4ydP3E5dNuLFiP6lDRJJU>l?*yhU(Bt{ZnJiQ%6x+w z>P(bUYg5+I(-2-+N~DxEAf?>$%BY<(PQLUq#f_{rPY-_hvwH(*?YertUpX1QZ-6o- z1oSH(=Snf>Z_3m;A{zhaK5@eOe(SwzNb9bbE4n2wZIfpgGubSa!fGREispo^ps^cN zZdbz{a@W_%*{kdX{wMW-HVgJfF4!iwQ4PXYrB$tkRB%1tOF2HJYrDIZR-jzGpjq49 zSM6?HiX|#k>OrGe{WPGg?fbST{cDX}Z9Aall@IIn|9WZVJ3_9U*^;pCJAU>rPxm~c z1y#MQ8=->$2!H?+KzzV5009s!4X2p~QT;XnlhKtLma_@Kdn3IZT7L;&$&2nQ-400J5T#0L!qR1g3G zML<1g&ssM=nCeN>0h(9%Icx|BfWWj9!2V&{R{-V@0%sF=^8VrM{%8RK5SShU*gs6q zwZ=?AU{nIf{FmDzbE7(6 eElZ~FpLozme7wVk;yI#=Y?Wh`6N;zUX3#$$LOpK) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..406c66138998d349272c9fcd2b08aa6330517c69 GIT binary patch literal 25968 zcmeI5O>7%g5XWbCO-M;ZE$AVFN?jERMdeb01p!KBV{f8OZLhW7D3&;YX_jCqSzESK z3YYZ6vEsm~hxS@=K+3I0A9~>91ZNIa#Q`CNa;Q*+kd~R}b-imEflW{L|5>|lX5PHD z-|xL0y~LYIHScWQrGGDGYKe|WCyg|1uJ6ub>i6sqHw{zi%h5#)uC+)eT3Wv;2JZRz za(kg*=(Np^N7OS?sifC8N)5yGukRRPxccJD6o&KPzRu^{Ul-VN^_FdTm(HIL=9B%P zfdB}A00@8p2+Rlp-jBpbs&k3!aXE}i+$ORam26b#WT;%`DD&pU{CcrgtK`R)N5zN!!Um#kd%E%qJVQ{7@)hL8T9ib!-GF>O(s8q_79Ybjrx z?!VolC>O5jm8=L-Ok>dYIq55uy+*^fFqHR|kxll#=wETIe1`YGC7zATJQq#s@bMAP zj7c@BP?hTfTQ1W+?eS~{s%3Qcc<-H}xu2+J7`${fIzcxUGV|LkounGy5Sls@<qfU{2;*ouD^+yH>GeRIf^J!M9 zC2vE9p5`U?f9?}IXzh33Z3MLDxTUJI>EV~V27Nw zHL~~WJHGd6%cqTswO;bK$!RzIpj+=YngLZD*K>2WNBQRNZoTWPF0S8c?(VC0rzO>^ zRH?W8cD3=DPese~ELZ%S?NW2wrhXf8NAOHd}0*DVW z7!W}K1QG-g9}+mAfB*=@2p~ShU_b-`5J(U}d`RGc0sl)KtK`bf2%3a zM~S4XCryWFUgGDlQ9u9$W}N``53{}kFn$m?p1|Yx56AnX1q47~b_ihqFgtUNk%GXu z1n%%(ZcAAhwa)oC%qwr?o%eP+%h}~q?_XL`S!O+}=EeUB#F|s~dhzt3V~%7z z_Z(;At#iKKt~xjPJj=!7491e7xI-qDx&DM}mopddF_&NjjK{W*qqdPIrRJTYd!42E zY+8~RKfxmDiF>^s9UL4`$gcx_NycJUIe9T3s!kdP=7PIfl8>@wm+f<0%UYJq+V}aQ Y5BYeT4b`WJo@1*VtDL|)$2N`r2HTf0`2YX_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ec98f708dd2fc0aea4bfb7443bb7b105fdb5b20 GIT binary patch literal 26140 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a0bfdf81f18e2940c6419a2bd1c2764048acdee3 GIT binary patch literal 26452 zcmeI5O>7%Q6vzL&>xPs>)Pf2{P!$U#)WW4S5d_f$yY|L%YRB$+qgdqtrdfieWNkT4 z30xBOid1o+7Y^;U;((M}k3MkV;{<082ys9Np&Ssh5YqDAc)cE{jUXu}`;E0bGw;n? z&-`Zgc^um><#ci7OZ#q|3OR2vaglI2EWSW0-k;KAp{1Dy3A`en!dq z7w?x?k!(ZRfK*N$}OS@))H z2Se}8G_KO~w2i1YMz1&1Fgr-;n}oX- z^~O{)W%YV?ld%U$J_9e$o&(M3E0^M=f>3*wY zlaBZ3?n|J863UckNOB!p*q}SoSDJ%&D80XmVeAJ~EQ{Qefs^=bVrG1mN~cgEr)Q`$ zkW%-1cdr^zoUOX99dRLDGQWy~e z`pn0vQZBfQJal@MF&^bU$@z_~*5#Uyg`!g^7nhy14aY8L@_8)#wWhbprJUdLn(JQm zM$KPCabW?ujp~}`e%SD^ShBMPZxzL6>E;R;})%RCHV?m2;7sPUvC!bs~#@SyxVB}*Qw=~+(IR@!Y_Gl&dvYj=$3n! zQq?oMP|89;2nYcpApSv?fe;V^hY=9}a2Oep3jrap*Zq?pzY~)r1cZPP7$6}2VE~6z zgn$sx2#9~sU`SC22!R0t;vWWZNJR(;0gZt82MvZ4g@6zkARzu>0EbkBfDq6Kh=0&v zNKps~0YyOlkbz2F|6rF$S8NU!XoWR5P4~MrGjSvt5 zqeDRU52G{JGEyOMR08+um)o2sj%q0Fg@6zkdII7fhJK1=fI{Hd1jIia8=V*mfuScL z{$c2+SOzErj!i)P!?Dqcp%55)0^%Qreu`y)Lg0u5_QFpsCd2Pfa-`1wC!Kwt!7`2$ z%i6e#7JC}g=*G@o#A7Oku%*6hX;M3W+{fdpFMNLP<=4{1x7Ie#CC{CH`^rTXW!AMT zuKtfc7K%%5TMr+4#E~4Wd5WU(Rw-U@OPy6v+(y19`b;-T(jq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..30fb2ed41e265b888e41cd092869c2b188841253 GIT binary patch literal 25996 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..55bee227f554ea4e8ddf55ca6698978e9309ca22 GIT binary patch literal 25968 zcmeI5&u<$=6vw~2HX$VuwV;OxD&c~JBDl20f&itmu{V)Z+iR`YiX{%zG)u6QtS#Fq zg-iZ}iUTJO?X}{7lv|HJaNx%Y&KxSl0U?BPK&V2MmN(;dymlLbT~79QR=e}&%@2D% zGyC+c-Mmz?_SV<<_mqsU;0PuR7{)@sxrp&U(m&jaC3Sf<93&>W5z4^I#x13go{vs< zmU1x@+fsRiUMwCTZS{>V20`MtcVa=X_Wa8Tg2iuNm+PIca-z9$J0EkeoImd`j`m}T z1W14cNPq-LU`_~#KN6j(DJfcx%0ZZ-IwdE=lnxV42fE1uQg2?)ZWL;@a%RJNPV0t) z4<%Jm$d{}&-6DyHeWI)4FRAv=Lb*i?@bNh=;W}>O!Tmpve*g8Cd&BJ}RxH2m_+fK1!SDmZ{$ExHC1yub;+v_MP?{~fSu2;X_@ONOZ zuOq)#-|^f}S{^pb)<)6WhTU#>ez)FjG<}q9$8|D!7un|SZoTX2E{@k}?(XY)yQQ)V zDA!wFyVCg7L&0)g%Teu{?P7D=!^PAGt=4~CTCvY4)^b~haB=f);cq9}^AIb#cs8n} zOadf80z!cHf!lxtNZ=#_v=1kdp`HXt;1KHfhyEo-7$iUfBrrmN_F)7E3nV}S5dyRi z5f~InfCNSe&_0adV1WckAVPrlAp(OU36Q`D0osQV94wFk2}B6cK15(pBmol81o}@k z)%#IOGWDCLLo_eRd)Q1QKmzklfd0e0?*Pu91WqRKX#e5l_N*ZR5||$X^dIJDtvOQ? zn3TX>`O9sUmL~1U_9Q?8Gf#l_Vdj^Z6C{DD3D7=FjgB2jVCD(XKFs_QbAlu=H38a( zsnM|`3CuhJ+J~86Vos0*CM0keKDAg5-=9?RVTMmShc-j$RGe5&)_xm<0^0B zNgYEtrr)(RrJXwN;wk-I)b)i|-^kkU?Q~Ytt7qQ7a#2T_b*-8k{U;FXcFFBU!-q~d zlgXNABpPpB;`Mg*wISDePOd~SRuPIjNTMY9$C5h|xp+^w_+wyP(R~!wjaBJl)-E_V zMOsWJRPf^eF{$vxy<`Bx-Yds#)+ b{#gdSFX!8$s5}8YBU*K?NdWJR=nVV=%3(3- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2eeb547eac6cf8efb2f94adb95244daa9dea6828 GIT binary patch literal 26192 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 UqWTQc^Q;x;GKUP$u}-7E0gZ$@ApigX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ef00277652742437be85efbed034418fd520d4ad GIT binary patch literal 26082 zcmeI5%WoT16vn@?-H?*dT2P?~P_aNl5#1yuf*_!>v7N}NUyR3!r7oyxhQv}bwj8Gf zHsLR*Sg>NzZYzY4Ds|Ci#EQoX)+{Q-0wILFK&V1U!#USZd?z&GRaX9vHFM87_mSM+ z+&Q_4$0xOHW%V=swWJa==pzOPjx#Z6E^6ZE^w-zpDPzwL2Z>AR40T{;`MNr!=l$j8 zR4yLFnpF0&A5SErtv)wq7$$#wEgpu8Pdrr*b|dQYhLEFK2RbLw?&A9`mo1yl##eq{W4WTn@J zBjYwiVAejD3z zye(@Xg$AmqNtqMR73^S3)<wBFB%NY>8ee|%>tKmn^|pHQ&^Q?V&_QKP2-sIX zDMqd2t?1Mxx;g*lJ}Cs9o!;xM0816ORIAk8Y#wgDmMa!f4_aM+TeU*a^ShgV^J*(t zM`dXVg{|hg@4egcu~N-1m;5zUx-CEGHG8dgfNI6{+)Tkkw!OL8?D?jP>u+Wk3QXa2x@~hvUf5PXZ)xYw!ms{Y{KQ0wh2J5dw@45gar~fCMZ8 zj1LwJsw6-H5dw@45gar~fCMZ8j1LwJsw6-H5dw@45gar~fCMZ8j1LwJsw6-HhJbmj zUbI1ch?yr%M`)gv=ddXxKmy}VfcuAWUjdvy2^>%0{`-gH+p~rQNML*jaQ`qq*P1gW zfqzQij(l=kMLw%fApsH~fd~P{hX@WDBtQZd0mcUl22~Oufd~P{hX@WDBtQZd0mcUl z22~Oufd~P{hX@WDBtQc4)a2k@i|OI>lbWe>^iJm}W~fi|#A2oh-E0@-{XVbH1-n@9;WSRBy4bT3!6HAq{w{NEpHNF3@ zQ%17!HYHzg&zxIw9%u0+gRy2P-bM;#Dc_OOmCVIk!X-EY#uMMhu- zbW)QSKS0s+#QXdE*xTDfD6c(vX~tqPC3&$Nri?lc+&Q;i(r;z8P%b<#rLJYo<^7!u YdQXm5#8Eo|JStv27A0kPMtlzb01zlaDgXcg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4cdd365007ea198702bd813ce5195e9a142ea97b GIT binary patch literal 26148 zcmeI5&u<$=6vyAL-H;NCTBSk}pt1r9MQ}-qMT1Hs8+#LNYJ08qTCv2THO-P*O4gR` zl)xqY1yv86IJDP_15%_OdbBw4;{<0872<#ps(e7GLP+Ml*^RvpjUc8c`#aI@ym|BH z#q*i*%Ou&nl(Y86SM=}2h|g1xbka!EW&(2-VxuT#H2`_0>YzxQR1HG6lhn0sY$(Vrb1 zhZYEc00@8p2!Ozp5a9Djbf!9&XgezR!xGhrY=k8p7CO^cCNq?JXC=FquU1Q$HS|B9hQ6#HjxU&*@*}nl&QuRIJv>uYSeN1}I8zbbRsIaxpcd7s zLmt;s(&vX96KV$cMWItddT+l#@g&y?(XCvgQkrN_w5LRSUe0;xMWwwz&@L$3K-(Mf zzeJY!)Yw;C{PzZ9mXs$$d79S_{a=(>X98_{puHTiy)n>c)n^TCZ;MvFZi{$DFQwV< zr+kVORpmUH<$Rs4F(Yl~$09j~?R)o#}PEwWcv$=a!HdG5zekJd}(TEW{SyH)r6 zPOVdK_*AkT*U4BeWgFYuwT`ETI9|K4y{r1|rc_H*sx`e02n>iI00N4DdN7}*AU^2oNz);k*Z4VX6c7M`X(xd5 z!?f=J%pU}fC-CI+!}0d$0Ra%09s)Q&OwU?lrXX-q0uT5vx24RTbRg_O00btU0OG^M zFEJ(v0%H?Ed>9)Y272!vc;aGwg7FW}e|}yfx0(+fnyA->12_oWWQ! z6t~HsBG(^r?QrJe9p>U60pqgnM%Z_xOEt50-nqrnY&t2)i=SeV^u*n6m-hGfIgfRp z_W46H7ITx67xSg+QN^J;=Tr*vQZ}rj^#<3nmn9qcPwezDUzb=>K1FnvwQ?;>s%_S3 F^f&$IM7;n2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cb90caf11d2d3596fffd94c32fefc0945acfcab0 GIT binary patch literal 25930 zcmeI5&u`;I6vw}gL+R3rjg}rNKq3MORd89B2#SDJL*iEMCUN9AY9tO2wx&dCohV6{ zg-iJh_QZ+9?sdfhskc4mz=0npICEGb4hSLC141i=WZq2NjUBdvggqtSvFv&C=FQ0S zneo#^c3*1+XXi`&d&;I)ae@>K7{1~18`;!)>a=~?%GXxJcDSdyYoww*Rb6yBIfr{H;Fj($iyd^)LLa{5 zDt&Tc$Z=WjPNgtZJ*s=2u1B`7x>g}4vrrb!SA}vLJvrYO=2ti1!%kwN(*|)pp z_O6dhx%WDq|2nnHVpwhzcMRd;*|}tEs+^Qc_BPX*xploP3APL;@r* z?*zDinD-UH@sq&W1RlJ9INP2zBtQc5LxB5-`I&2ulmsRva7X@fTcxE*8`7QxNMPm( z&_B%l6mx(iFf{@ChpExAAqmVp0s4oTpJEP>1g0iH|1dQ=HY9p5#xP>?HXhY6giZaeWlHSSaR-m-dr{YC zUVOFSyuH_3+gMw9=gK7=W!AGAUi{xXY&sQh5DyuXD{^PD`1 zV5}k(_mD+J@((30qk@ve*gdg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..946df4b87683a06e52b20b0e9210a08e4b284e6a GIT binary patch literal 26080 zcmeI5KWrOS9LK+R=Z2I-)PfEXRKfxYMXExc+p>s_Y+MB z2mv7=1cZPPm=glDABm1srxMkpauAlNjpbBWvSGowKs8xF=8g6IX0cwcobmeFH4Y2h`v<~6`I-_oIZ zR28gp?FA&YqOpu`zW-yez(?M=aCW2)uRbz}Fee0}GoK`-T5`8|=t){)|K~oj{Pscb z?WT_n+bLD;nv*x-nAJkDh??K*cwLq(zvp%KyvDVrzYBX~1J-_H*KuJ*U4Ef@~yqSM$c1S9IxBjJ5YYR&DAAT8f~vrZGPgRXu7WHaQjxL z)Y|co&b;4l|JSMI?+2xNVOt|EuH_d0bfP_vu%grpQKQI0KnMr{A|UZWmVpou0;ds> z_;4B-u?qnqa0K=HDE>)IiVzS2LLf#!;zJCFC_+F8L*4^yL)jzVDO2}pdH z`6-qG3V{g;9EG1+EQilea;DDlC!OP%!8Xnl%ig5Q7JDAAVvxsq3s0yV!iIX*Qm1zO zx{oK-d*So*ue_eO-`VZ1W>?R@dnK*1%(`aXjsDY#4ZH02qv=B@9LZ$ObCiv@LHT-n z>fEIBJQtTT7;}c=E>b8{{Snm;WiH+)F8&EHF4;Z|eJ5N>&D%xiCP|CgBquL^jD^z^ z_xpVu9v&i~*CD+)V=<|eyqFGE#|=Ys!Ksz_t!!Im>t(8WEpsOCAL*hG>3EY2)ic1; OWaVRxvTf(crr~cVZa-lF literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f30a523d05c7d2bae709f431bd1b9386223edfa0 GIT binary patch literal 26054 zcmeI5&u<$=6vw~2YeGt*)`A|QQh@~$ir`X;fgnn-S#QFow%1y36ssJ-G)rnJSzESK z0+;*;RS%pvwAYFQQX~#N`oMu7CpdFJhyy|h<$zFyke2txn|K@=6^EYe_g1^}=DnGf z=QFcUlX&w|wY{}3@nFfM=CBVF1`K1W-<;Ic@44@8Ss7KIPi|sSX(T!@w|t8Y)bsvw zXQp78SSRN`dR8hm-0CY;jpOvMZ&-1>@Z@ud$rix@BOj=+b=)=IOq?xF9NkxTPStU zoo`it<8;zLgGKW*BV#U@2mMPRe+pASpk`V0Ko~fMPiL~z>!h7QjUF>YU4gus6Q0Y5A++k0aLH?c zOMY;bQM(k}^6E248%1jt-+cdPe;I3jT|ISJIk@|wL6iw0pwE1oD%Dc3!b7LHX8fP$ z#En|p-M1PMmOQ^y^;Uh`f$vlc#UfUtMmy}V=0@GHy&2Z8H=+%AOG|LK>KkG3Q7gns z#aS+e>+sr*FzVL3jb?<3=Lde?4PZAnH|yO{b@9Vab8}nyy%rZQqEc^#?P}xW5Je{l z9G}}a+ok4uh>O|xTdn^(wH)M2wZfV~Tmm;J{^jdE_b{ib=XIm#LO=)z0U{vrL6(6K z5CX>$koa&M8LFzZ6(Jx5Gy)PIG#H`^0U@9Ws8{Dn>&FLEy=gi? z^DMoGEg}Sjz@!t9{llcM02#j!IG(`$_YcRn7mpAS0+T~P_79UY*D_KeFe-t&^q1Q# zGoyBt_Ci1ij6DH~4`V;YGC(14VgeE$PK-`E3W2dFAn{@Br&tCk1Wrsq;=_s2Nk<_t z_5>t8jQteL0ENJa1oo0|EoPJVCplB+;G51t%wQYmiDhqEWs5zFmsAF0Ua+0m=@(wLy>~V`^SSx6?_R#BvdjifEztk9#FAGIdV2cM5l1pw^E_qa zty8|{1H24l`p+(8CqDnF#sr_9A$#3eccCLr6j#COD{sfOLOw# zhbWw$xYz4pcXt;t9lLaJ#$u{c@?ttwIczvE7yQ){f0Q-1>^@H=uVv2H{S)2v9-Xg~ Tq525$I9d5zptRt5vKjaXB(yVz literal 0 HcmV?d00001 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..8a8093a0edd61 --- /dev/null +++ b/src/importexport/encore/tests/tst_notes_tuplets.cpp @@ -0,0 +1,1341 @@ +/* + * 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; +} + +// 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, 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; +} From cfdce9a6062cd6f9740236dab39f286b6d446873 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 6 Jun 2026 13:00:00 +0200 Subject: [PATCH 14/33] enc-feat: import ties --- .../internal/importer/emitters-note.cpp | 53 ++ src/importexport/encore/tests/CMakeLists.txt | 1 + ...dstaff_staffwithin_tie_on_second_staff.enc | Bin 0 -> 23481 bytes .../encore/tests/data/notes_sf_tiestart.enc | Bin 0 -> 25984 bytes .../encore/tests/data/notes_tie.enc | Bin 0 -> 25984 bytes .../data/notes_tie_18byte_real_forward.enc | Bin 0 -> 26014 bytes .../notes_tie_crossmeasure_arcxx_equal.enc | Bin 0 -> 25986 bytes .../encore/tests/data/notes_tie_dir_02.enc | Bin 0 -> 26040 bytes .../encore/tests/data/notes_tie_dir_03.enc | Bin 0 -> 26040 bytes .../tests/data/notes_tie_dir_04_forward.enc | Bin 0 -> 26014 bytes .../encore/tests/data/notes_tie_dir_fc.enc | Bin 0 -> 26040 bytes .../notes_tie_intra_chord_arc_no_spurious.enc | Bin 0 -> 26096 bytes ...otes_tie_partial_chord_source_position.enc | Bin 0 -> 26042 bytes .../data/notes_tie_spurious_far_receiver.enc | Bin 0 -> 26042 bytes .../tests/data/notes_tie_start_flag_byte6.enc | Bin 0 -> 25984 bytes src/importexport/encore/tests/tst_notes.cpp | 44 ++ .../encore/tests/tst_notes_ties.cpp | 485 ++++++++++++++++++ 17 files changed, 583 insertions(+) create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_staffwithin_tie_on_second_staff.enc create mode 100644 src/importexport/encore/tests/data/notes_sf_tiestart.enc create mode 100644 src/importexport/encore/tests/data/notes_tie.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_18byte_real_forward.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_crossmeasure_arcxx_equal.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_dir_02.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_dir_03.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_dir_04_forward.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_dir_fc.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_intra_chord_arc_no_spurious.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_partial_chord_source_position.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_spurious_far_receiver.enc create mode 100644 src/importexport/encore/tests/data/notes_tie_start_flag_byte6.enc create mode 100644 src/importexport/encore/tests/tst_notes_ties.cpp diff --git a/src/importexport/encore/internal/importer/emitters-note.cpp b/src/importexport/encore/internal/importer/emitters-note.cpp index 376c607e458cd..8b952e51463a5 100644 --- a/src/importexport/encore/internal/importer/emitters-note.cpp +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -106,6 +106,57 @@ static bool isCascadeFilteredTieReceiver(const EncNote* en, return false; } +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, @@ -663,5 +714,7 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) } configureNoteHeadForDrumset(note, en); + completePendingTie(ctx, ec, en, note); + registerTieStartIfApplicable(ctx, ec, mc, en, note); } } // namespace mu::iex::enc diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 42da533c90838..567c3cb5ff6a4 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -33,6 +33,7 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/tst_notes.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_tempo.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_notes_tuplets.cpp + ${CMAKE_CURRENT_LIST_DIR}/tst_notes_ties.cpp ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..86587e0545c8e96735990ec3788718efca19782a GIT binary patch literal 23481 zcmeI4L2DC16vy8tRa+Y{NDm?fgI?y=oL^-WonU2J~>H$S?)U5G@0L@; zd6MCI1~xfQ$kyu>O*NC`HS4^pbzW10+MJ&93KLYJB3WE!xn-TUXp>j%Nz3Rih?F@x z`;{uuXpePTp!bW(`5J2%s1k{ADRQE;WD;EWNpyJ@Jb@_1?4sS0m_UJ2%dbLE0%7riLJ-^7CXt$iRLx8{CloPIm6T~qhw}O zGF2;^&r`{BTTWYOn&&&Mx>J4bdK+Zqa-?lmHyrzA)1kGZdS7>HWVBqz^Q*qw@Th2* zwwcy!$~5Zrs_)1lrqgcJx1`-@isB{}t4*h6xvw0`tG2D0qJN{MH);;8CZ9B$|9Z4B z?x{$XNt7zhGWPXPX5>W>&31c74`*xCQqBDVkjq=*kQ z{HAm0Genz+6DzE787+2+Zqa4=X=@x~SH$p*%6Baz+)rG)bY5CTe&xpPjPYory^>m4 zdi-!zMwzwMiajrT^s}5%u)D$Vp#j_Jt7(qLTjhAYzRX?DXIX5HU@RgOw<$pdE?;nI za^&Jo=HiWjvAONpzHP)Ls~ID2zF}=X6&JyaPfJ~olb{(e0BH|5sSIX!HYS` vGTbm|&YC4%ETya!wCh~TfX^IZ`a4hhlha*pu+9)&WSdBLq8xFV+Z6f(Nr=dV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..65aa805509c8acdba942b1b04dc41e191cf1eff3 GIT binary patch literal 25984 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5eb09e5680b2f19c8a1a827c7e425ada47d2c5a GIT binary patch literal 26040 zcmeI5&u<$=6vy9s-H?)qTF^rTm9RiU5nMvCAczv#*qdlm+iR`YiX{%+2yA+?zt7s8H*aRv zp3lray~LZBYTnuUn*LqV$t60b1Q}!)bA#@zCV$WVaMMhy`f_v;lS?DgiIz5Qih)Ny zTy8BC%mi(7<1zKkWHR3C8&wU%)UR)vVYvFt3lxU)-@eM{TVEB}a{bUYy~`^r!F+ri zY9IgtAOHd&00J{Yfa4K;rU@3kuIphWx=r+SB-u#lT&TLtQRelv{6?`}ujDqYrnW#A;$VzGUU9pK#xkG1Vd4GJFhTDk7=#jA@HH)TAEyT#Bru z;=$Ud7Y1*?D2NE(>hAO8d0Os~)J9le>Up_$1B!M0L~T zTfm^R^!Y+&ew(#(ROcJZP-mi?+EcRpk3x8-D3Mb3fRu8pDx-EP*yY-DlroCuB7OJc zpM#C7wd?BH2bJN~9~hv_2myWNQ(UQ*yiJ*UinrMRxlin%z2AGQ8PK}pma0z8&0FMJ z)k3jIwV>JYyQ0}a&+qK|jcd(dhn)3wviBN0zV~6S009tS0`L#m1|R?erxAdEIE@VKAOHeKq<*g? zKZ!v=00clFMgaaHh65E400E5v{DTGqDhPl;i~#&Y3KuL3 zIr14|lRUA~no`+f&(kY3$j`hut=j-jMbCb`rEFNbtmJG#RN>iE354dzW zbMYQ?2~L3V*!F(ZcA`mD^G?yd!PwsU9v6!ozyqFJF v#tlPr!L60#qioq_`$aBgFH45*pZTKq_;`a2)iXp-vQ>^%P6VD~n?ZjAj`cZ6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5294b1f302f26cb3c70fea87b2773e4faaafefd0 GIT binary patch literal 26040 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f9328d0690898846a18d8f979dfcb4ddbe529ae8 GIT binary patch literal 26040 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ff9aaf1bc0bb7107dd41724456793529a775f757 GIT binary patch literal 25984 zcmeI5&u<$=6vw~oH6bMtwV;OxDq(?yBDjQNK@g>~u{Y7Cw%1y(6-ykzG)u6QtS#Fq zg-iMuR2(>QXs;Cqq}+P+fdfBIaOO}U4hSKX140!-THcJ;_1bL&PC41%XYI~=Z)UbT zpP7A{#GBV@-r4>N|1Rm|3XYM00mE1rG#54bd-nUAW?I{;(M3!tjYtPpHg772^nAG7 zUMiRg>`3J?`ergY-s)>@hGFX0x6CkHfA&R$;o>*1$@%t|1@T4|UjRa>y-DCloH!kHji}iXXw`o12eWQzy zG*wZw%htMXk;a32qW5IKq}o3XwJlmefX{FdS8)S>-}~eEw_kp~JL(U;FM_nxTPS_a zYv1Yq#*7miRJq4E9ujvSq~BMh8vw;7h4wErsND zDTngu`N2QGtQ;4w8|b%Md`9-eC7GQoGB+)B<@i8m zDvdfSs7hH7&lMbCUuG}RHIuU`d+{U|enj0gWhomtgU^>Ti#wv7MO_{cL!W`1UK_)i zebjRJAgWTW0adC;Zt}VXyIgx7DWhmEb7BYWgWlWC02_{5sya0{Z^5;yg<=u4pxN=eO0$EW-`VpUSDV2uoQ)0G z`;A@S`?&36t72`I{2e%*rXTbgy=E&w#c@41XM4!E_VyY*Uw3i+ZfoyA*E?-hyn;%j z?RTopPkaCw0S=6q)q}P zKmtO5@qx>L1W4c{0*ntQk)fXiNZ<(i_e1I@F$xKg011o{V0;+EL4yQHAVz@kAqImg z36Q`T0mg?h95hIP1Y!gjA7U`5k^l*e5ny~6!$E@tNFYXl@gW9-DhZH)CNTI`Q@tOh zw1j@sbcE&wc@LXH0wgf+1h{{g_Z7hLlfcOY9=?A#xjk!0fCT1;0QV2`GuIp`2~11i zj{N1elBH=ovONiqz|0e1e3xQjH(Qhp?*E18S;g-b92#uML%QQbt9HuFx=y&>9S zHl>mm|Bs^56ZiXl93CDbl+U4jRK{X4C3&$Nx*T^5-37N+Qn#{gm+hCNRBKsf+Wtu{ YdQXlw#Zi3>cuKtLSeJy}Iq@0z2h653A^-pY literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_notes.cpp b/src/importexport/encore/tests/tst_notes.cpp index 6384706b6d186..3429363e3485e 100644 --- a/src/importexport/encore/tests/tst_notes.cpp +++ b/src/importexport/encore/tests/tst_notes.cpp @@ -991,6 +991,50 @@ TEST_F(Tst_Notes, grandstaff_staffwithin_rest_on_second_staff) 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) 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..2e4b1b7eea0a1 --- /dev/null +++ b/src/importexport/encore/tests/tst_notes_ties.cpp @@ -0,0 +1,485 @@ +/* + * 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") From 7a64a73a1ee25d65cf6b946160961b46f7d680fc Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 6 Jun 2026 16:00:00 +0200 Subject: [PATCH 15/33] enc-feat: import multi-measure rests --- .../encore/internal/importer/import.cpp | 16 ++ src/importexport/encore/tests/CMakeLists.txt | 1 + .../data/importer_mrest_followed_by_rest.enc | Bin 0 -> 26032 bytes .../tests/data/importer_mrest_multistaff.enc | Bin 0 -> 23509 bytes .../data/importer_mrest_preceded_by_rest.enc | Bin 0 -> 25994 bytes .../data/importer_mrest_single_block.enc | Bin 0 -> 26162 bytes .../encore/tests/tst_notes_mrest.cpp | 257 ++++++++++++++++++ 7 files changed, 274 insertions(+) create mode 100644 src/importexport/encore/tests/data/importer_mrest_followed_by_rest.enc create mode 100644 src/importexport/encore/tests/data/importer_mrest_multistaff.enc create mode 100644 src/importexport/encore/tests/data/importer_mrest_preceded_by_rest.enc create mode 100644 src/importexport/encore/tests/data/importer_mrest_single_block.enc create mode 100644 src/importexport/encore/tests/tst_notes_mrest.cpp diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index b413e095ca27e..b49c443b26742 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -171,6 +171,22 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp 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); diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 567c3cb5ff6a4..5d6d3df80e889 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -31,6 +31,7 @@ set(MODULE_TEST_SRC ${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 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 0000000000000000000000000000000000000000..6d58059791cd120f19029848accd017199fdda8d GIT binary patch literal 26032 zcmeI5&ubf35XWb=s$<8d5vdQR39V?MnC6nEqIIbgq)2PCj-(Z( zz0P}@6y(jKv7l-s>BT+K7rbAR`frhJizdjUyR=AG={7xm^7r1KzyJ12uRW=K5phkm zg;IT9|4Fsi-VE9&>0Im^Eg4&g?YA!&nesbaw|`G{pKU2VyL&1k$@7S5jha-WHo2UN z+&S6(xEaYTl3q%SBI5h6yYE^4lwt4t*75HA&oEUPLHBMCt;a(iKW}uiFS3=DG2Hll zzhoQP6n9(T*}TGYS*I4CU-HZ*sX`?xbDCqz722jPo^4N+^v^!;-QzUzm@2x?OJAcY z`g%Gwxz5@#s_+A)sVh-NttGzQLy`RlS5jdhsXU_no#)Kr>U)&X^7;(@_~@VRg6*#q zwqbG>%8U8{RkvTVoBm=0y@ zo14|PtD4wutG>Cd%B_YZA5p2=aGT}YO_%b9;~2KouQv|m zjk!3clmFKax7?*Ul|30YLI(j500AZd|A1ux0w8b@0r-c5$iNN)Ah1j7_e=7V7z6}B z00bfg;2$D5kO2V@2oZpP2*H2~0w53}0RIrdfeZ+MK!^bRLkI>`5CDM)0r-aq4rD+8 z1VRMhA3`vof&d680^M&l<^3qpV(LxP9-8<0J!}XFfWWvD!2V&}R{+Kj0tXX#`TpVH z`X~Vb5Evf<*guTVTw|mlFerhC{FmDz(}OldeGmYFktYEEF!EE30fNBL1mGWrMu&zV zF!BW8A4YzPF+dO)ngIO6(CE+*1V)|!{KLpkF$M?%0}|K`zO|SM-k+36oxN{5dp<*K z5+_zl6DnHlEPYJfxXdebM8(k6)w`B4t|!MsI;!qPuJ4}uG;3YlXw9eRXD?kiqoT|@ zM#TyL6Nx3O=yby2LkAqmVAV4mjkn72dfVz+o*W&IOKVonf0s&j5#oGfDse literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3b3b8ca65e6ef7a6e25677fb5e575f38fe55fa19 GIT binary patch literal 23509 zcmeI4L2nX46vrP>Yimg)O%KL2k!WHXJy}UK>H&nsYz3AqOCa%Ju&i|xx)7imz2GM> zJ$vyp^wuK>55}WsPksVV#(2T^1}M9vq?R@w;Qux=^XAR$?96YbFSKl5A!(F8)5#KD zkUjyvg2CX)-Tz~cjGoiARI3+j`ruX)CWi$=X#invU(ZD{ZIlQr<8v zGof3Qtk-Ilwykb4?N+_Er`nB%6n7|JY1qwz^UkJ>W?7mk{p-zay=v2D>_wyTuUjkr zC0kCF#D7`3mHA_O?>wL@s=DqqLI(j500AZd|A1ov0w6Gn0Q|!wGO&XH2pshPz9GMf zK|lZmK)^=;{=tU>6%YUc4*~cG4-BXv00KS&@DDy5sDJP^!Tnj8EcHUtDfVBQJf^TWKK0IVMbCKEXR z^TXu+XaNBbm>&Z8{4hUzjg^AHX$c(ie{PGcoc2ca2LTY6c>?ecGrz@HAP7uN0RCZW zbhr@&W}X22!_03n76<}U6M%o18Xax~fte=&|1k4gj0J+gDG3}5zO@Js-k+36ouh9$ zM?OPr5+_z#BPv?#8r`L<>SG(e)}BAz zR8eLvt!yo+9{ntBr1 z(7w-mJ9^1(CgrTP_9OkbB;)h6M={b!)26!BS&ctP|8`qXs{BIGiO#7O=tT3&x5dCM zUoLlNa(ax`xp0p7PI5>)ju6Y`J;I)SXKwPkPhQ zcF2JM2!H?xfB*v!JQq#$n=jYU->NiXh+uJV3K>K`H57EO^y-_ixTLAU7XKS~9j6>$NW!nerUh9o$phVOxsN?w*QB@;qW%qbAj;O)jS* zUmxyroaMU{@%=Zv#{<24e2?#T)KPzPtP6QohJ1naQd$&|?)YWSM(g|4+m~!3o8p~u zf#>NO&r+RQd|u}{OHzeOROU3tmTR<4TRe}RDjA$t-j&B`>JO^uIxlLC4$^lssp)mr z4pD_46-`}U25Fr5n5W#^A2!KF{0Q^G;22>CLfd~QkhX@X2KmY_n1mGV+ zFrb0}2q*&Gx0>>OlxQ*arD-3{hx{Ej1Oz}}(g|SyFzG7*;|GEL3A}v&uz!7&fB*;`WwW`pl1B~oYqO=sU{h)v?eN@+qx zi=Cr0)Qz~jLI+d~UR`}_8RL3#+@rVEy~y?b6KAv5<&D-tdSUL$rPC_PtYcK1@V|#x zvWiY896of&kqlQo!_j!F9Iv;nt~I_+vN#;USRxd+C`m<5zu?s7$i-XC#Tx+QuK*u>LMn8&wh8qOly55>co`Xh-6@S{f06~%Lk`h z3k5TQZ7DoL-%KWlYu(Xm5Tt&6(+q;ur(Zx2%zyo=+;4qZ5X-fjw&`9zf8L)TZbyv- zNPq-LfCNZjMhM7xBtFuFq_`fJgDAyqN=`>98zr0xbd@<|UcZ!IFV^eT+`9FYwhcNT zNUES{m#tM@BaP!Tkvi6dZ|+?=FRA)ZLLH0d;Nvq~#8q6!y}N%L{r1bxKMvYMpNoK` zuB*GmwQqHMu=<-V6>1d}rPXl*hA5BUCUARTb)vC+rx#-d+qkWom%xBrc^I%8N$W2-Qr(PyyYR5 zwR$dYq)Y-NKmtO5{(;MY1W4c{0`w0jk)fRgNZ=6qH(cr`F(wiq0TLJ@K>sj=g9-_d zK#TzWLktE*5+H#g0`w0nnicCxMd*Jox-@a($MN013l@`Wr$oeEe0#i?b{$c8;m;)q%i3!j@OpJ~VNnq*;&_7K56mx(i zFfjr8hl$a#Aqh-90s4ojpJEP>1jZzA7=3E77(G9!B6SWv=^XeBWm9otRcT5`i(SGi z2qQyp;FOLuZ0ct%6H-sTZsT$NUex{BmtM3dV|vz%OsV5}k(cacU}@((376m#QlCB2L}fTy@ggP)|+yK=oDhT3DmSt(Q3szklciOs;@Q*=R@ literal 0 HcmV?d00001 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; +} From f677d9c339a6c42a90effe044f43f43ab1fc5285 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 7 Jun 2026 10:00:00 +0200 Subject: [PATCH 16/33] enc-feat: import articulations and note-attached ornaments --- src/importexport/encore/CMakeLists.txt | 2 + .../internal/importer/emitters-internal.h | 3 + .../internal/importer/emitters-note-artic.cpp | 207 ++++++ .../internal/importer/emitters-note.cpp | 1 + .../encore/internal/importer/emitters.cpp | 6 + .../importer/mappers-articulations.cpp | 160 +++++ .../encore/internal/importer/mappers.h | 18 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../data/notes_artic_dedup_trill_on_chord.enc | Bin 0 -> 25996 bytes .../data/notes_string_num_orn_no_dup.enc | Bin 0 -> 26002 bytes .../encore/tests/data/ornaments_arpeggio.enc | Bin 0 -> 26065 bytes .../tests/data/ornaments_articulations.enc | Bin 0 -> 26024 bytes .../data/ornaments_articulations_combo.enc | Bin 0 -> 26322 bytes .../data/ornaments_beamed_triplet_capped.enc | Bin 0 -> 26110 bytes .../ornaments_double_barline_multi_staff.enc | Bin 0 -> 25940 bytes .../ornaments_fermata_below_not_in_tuplet.enc | Bin 0 -> 26054 bytes .../data/ornaments_fermata_not_in_tuplet.enc | Bin 0 -> 26054 bytes .../encore/tests/data/ornaments_fermatas.enc | Bin 0 -> 26024 bytes .../tests/data/ornaments_new_artic_bytes.enc | Bin 0 -> 25996 bytes .../tests/data/ornaments_ornament_turn.enc | Bin 0 -> 25968 bytes .../ornaments_partial_quarter_triplet.enc | Bin 0 -> 25968 bytes .../tests/data/ornaments_staccato_orn.enc | Bin 0 -> 26123 bytes .../encore/tests/data/ornaments_technical.enc | Bin 0 -> 26136 bytes .../encore/tests/data/ornaments_tremolos.enc | Bin 0 -> 26024 bytes .../tests/data/ornaments_trill_mordent.enc | Bin 0 -> 26024 bytes .../tests/data/ornaments_trill_spanner.enc | Bin 0 -> 26123 bytes .../data/ornaments_trill_with_accidentals.enc | Bin 0 -> 26024 bytes src/importexport/encore/tests/tst_notes.cpp | 50 ++ .../encore/tests/tst_ornaments.cpp | 632 ++++++++++++++++++ 29 files changed, 1080 insertions(+) create mode 100644 src/importexport/encore/internal/importer/emitters-note-artic.cpp create mode 100644 src/importexport/encore/internal/importer/mappers-articulations.cpp create mode 100644 src/importexport/encore/tests/data/notes_artic_dedup_trill_on_chord.enc create mode 100644 src/importexport/encore/tests/data/notes_string_num_orn_no_dup.enc create mode 100644 src/importexport/encore/tests/data/ornaments_arpeggio.enc create mode 100644 src/importexport/encore/tests/data/ornaments_articulations.enc create mode 100644 src/importexport/encore/tests/data/ornaments_articulations_combo.enc create mode 100644 src/importexport/encore/tests/data/ornaments_beamed_triplet_capped.enc create mode 100644 src/importexport/encore/tests/data/ornaments_double_barline_multi_staff.enc create mode 100644 src/importexport/encore/tests/data/ornaments_fermata_below_not_in_tuplet.enc create mode 100644 src/importexport/encore/tests/data/ornaments_fermata_not_in_tuplet.enc create mode 100644 src/importexport/encore/tests/data/ornaments_fermatas.enc create mode 100644 src/importexport/encore/tests/data/ornaments_new_artic_bytes.enc create mode 100644 src/importexport/encore/tests/data/ornaments_ornament_turn.enc create mode 100644 src/importexport/encore/tests/data/ornaments_partial_quarter_triplet.enc create mode 100644 src/importexport/encore/tests/data/ornaments_staccato_orn.enc create mode 100644 src/importexport/encore/tests/data/ornaments_technical.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tremolos.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_mordent.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_spanner.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_with_accidentals.enc create mode 100644 src/importexport/encore/tests/tst_ornaments.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 9e4b9841dc50c..9e1690539afcf 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -46,6 +46,7 @@ target_sources(iex_encore PRIVATE internal/importer/mappers-clefs.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 @@ -69,6 +70,7 @@ target_sources(iex_encore PRIVATE internal/importer/durations.h internal/importer/durations.cpp internal/importer/emitters-note.cpp + internal/importer/emitters-note-artic.cpp internal/importer/emitters-rest.cpp internal/importer/emitters-tempo.cpp internal/importer/emitters-fill.cpp diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index 9856b5b7ad8df..248fc87b19986 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -120,6 +120,9 @@ struct NoteElemCtx { }; void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); +// 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); // Resolved MuseScore destination for an element: staff/voice and the derived track + lookup keys. 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.cpp b/src/importexport/encore/internal/importer/emitters-note.cpp index 8b952e51463a5..0f95cebae6920 100644 --- a/src/importexport/encore/internal/importer/emitters-note.cpp +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -715,6 +715,7 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) configureNoteHeadForDrumset(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.cpp b/src/importexport/encore/internal/importer/emitters.cpp index a18ea28ac17c8..297ff9465204d 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -303,6 +303,12 @@ static void scanMeasureMetadata(const MeasureElemRefVec& sortedElems, MeasEmitCt 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(); 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.h b/src/importexport/encore/internal/importer/mappers.h index a02f784ab0012..e4072e8e436e5 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -109,6 +109,24 @@ const mu::engraving::InstrumentTemplate* findInstrumentVariant( // 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/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 5d6d3df80e889..047c1bb2bba54 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -35,6 +35,7 @@ set(MODULE_TEST_SRC ${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 ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..8f2a02dad9f3ceb0c0d172f7559610d601f94b14 GIT binary patch literal 25996 zcmeI5&u<$=6vyB0nvgaTwV;QnRKh9}ir~@~3xa4PyY?p9)b?8I4Y9-lOtS<_$=b4= zQn=(lsCwv$Lwl__Am!Gh4?XbX1ZNHiaX<*691yAy((>MT6VHZ5VAGTR9cy>qyqUMz z`MmMdINrR}3hu@u`uCDaEzkj(WRPLZ42H9q`YreEJu9Qyi}6J)u8mkGT3Ed&3buT5 zc`#SBOxonm1L|9;RC3gpsu@M;U#?nFwDjUD6h*UNy}{=PUldt$>%L?8*DqcSXOnhl zfdB}A00@8p2uuk9-jC=rHM!_*U5?_S>qO4PB^MVu6R9CHlznTtuv)6utNB&?oYD<1 zK9r=RH6la*+!MXe`z0Cw0?D>$hC;eaSLi0)roSHle(>whKm9PYC$%pk9;voa z>YBH{QTE1jv3-V?&CiXDxnv&Nm+gG*9UgmpPj#PlSw04PDk5p~jA?^9)TAB-TuZrg ze(>YwrE#TC)tm@}joGk|=aAF;4vy?Di5r%4=D<#Dqe$*aW%Kn;6J^STl;2!H?xFag8|ECUb#fs+UzKAc1bdJq7CeNw+Wl22kNAOHd&kRX8gkidZk z2!Mb_0P#VC0TBd1AVC1}A%O!85C8#<0OErN10o23K!O0`LjngHAOHdy0mKIl21F15 z0YzZ&t)@I5C6cM0G##ROnV-W(0Ra%0b^_QxO#2GJ{6XMk0#DvQoID;qAOHf>Lje1S z>G3sY3Id}Nc*uXbEoE+0LyQLj5SVxZhz}F*VoVSO#wLLHFg7|Eg22QRKzx{Z7h{4T zFg5|ihq2MY5CkTk0OG^MyBHG$fe{Jp$KP7a$Inknrq1Cvox_+RI>{3&y=j##_AI?l zgPhB2^sLGuY^i50O&%w&hjd!qi=1D2?ahMw?pAj(w|Mrw>z7rQS>LYv`ag(RaVvgb zPaitsOhy}?=WM(U&ez*h=O&-0x%iyHSTYoMDMJ;mKjzxw%*8v*B|HYkXWd6}-?1*$ zEVw1_HcPX)v?MQnibc{B_xpX?+uNgvUwizLjK$pKGn2)zvQG15ydDhCY#L2z$tTX6uqj@x~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a0d1b4ad756ed03a7ea2c1522da760bc4a2683b2 GIT binary patch literal 26002 zcmeI5&u<%55XWb|CZr^yR_P&vO0}qjqW01s7Nnv?Hufgk)b?8IwPFb*FwGJyC2Pxe zO5qazf{FvD9@=Zg0V%g0ec%A)1ZNHvLI@#*e5k6bLRw~?*YU2~2yA+?zt7rzGxO$c z_I=*-=sDg@%2{jkTl)8sj4x4-Vx*C#&Gp?`jQ^GX@wT2+`f_j)oog+SiI&!Hi-CJS zyWCyK=`q^k#vXO`cs%O$ol<>2@y9#5@2|Y}2KoN{_iys~?l(EM+_+=v&eikhz4>TA zXdnOrAOHd&00J{YfcGQek;b@$^|0&*C2SKp8I*KT=(w+3<|uV}HM^d#R!f<6<5guF zUVJD?L*6VJE6O8DPw$C7{-pLr#GYyk zrLKA72i0GDIq09H)!5fsGPV*M^e-5h^84&Nx~ICswiF-zJr$AYJYw3U7S*XkF4t1N zJk|eki=tfVQYkIMWj=aTO3-hj^-J&2_ky5b72m-WJkY*4(ckn{c$89nU+zrCKG~h2 zf5|qoDc&I$c)qUjY&EFO$HzQ(Nvcwb%3SBza*g(Am*>+{Eu-_xyYo2B{Y+I|=f$nj zF}k&on%`pSI92&Aqp34dMy(b#bu8jxDvgrk?vnZ?TMl+g0}BHjP#QGZE} zItBW%L;K+BhXx2ULLfZz30BGlXG4ab;3f8d-Y2Hl-0Qqs_h`+s3uUWfXAQEAaxR~z zidS#BZIMi`?_Efvolf>~PNmT2kt%un{r{fB*6Ttpq)>i<=4+2LMc=rC` zXn(YT00_(u0qh@UXRa|)5Ez%hJ^ssWDGTFvgg*#?z|<2!e3<$v#sEQJVgiT{6Qe^% z5SV%bh!0ag#TXz6OiTdrVPbUX2m(`20P$h!rx*hSfiVdj1m9XL2G37Qrq1Cvox_+R zHpvq!tqGMa_9R`Re&*#3dQs&N*449?G4_+!J$gyqi=0oNdn;?bx7}V&FQ5G2>P3}h z)-kG1_}@gVSw*KCP9HkvNXB!|a5ml==j-jLbDht#TpZ3|EE$U1l%yippK@(;=HgxE z;*EfD*!D@#HqxZjtd+NKvNWGgNb=(6SR_4hx7(%t{eANJwa+ifSj;LXFXltlQNzHT qvnvI;l})o~p58;H;SbmTGK44rDSc{ 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 Q2Am)*AB!AVoOBxg0hA$K-~a#s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a34026772c102af9bc65b19c76320c79109e833a GIT binary patch literal 26110 zcmeI5&u<$=6vyB0x*;VIwb~vcsK5dVMQ~|@MT01jjlBt*+FonDRxEL7O|ztylC@gF|{=Fx<%KIfLe}-gRG({nOMwjRY-J-uA{jvYsFF!vV)F-tsA}*=6 zP^!(F->Le>bG`ZrS~9;d66T`Wub;QmCD}I}-BaCVTau6No{C8F++$j!Ce^4-0jDCL zp6gz2Rx+15R7{B?vVY*aAgGsg-$SEcuPl4Llwi*_eS-nL!KuIs!)+ioMzc_jkalv=T4|RN9PiM5{S{%k5sWNejyojl0KhFPOr0eiYlgj znvgROx|*4+JQd-Yr9@I`T~c|$E2DPGI)(Df6gP6#EPeaKpWW%N`E~W=L8jmOfdR^d z5YR_H&Y4o)UzM)&+GRY+>%<8g+wFI2AuYRJzT}p@j7^?h%I0!Z4r|SzC7KhqgXU&X zy-^D{$X#A0XREpq_#Za{S}ocu`Cy&gW-SQY)po5OQqlE%FYWl0sc&vp+kvX$1+Dt# zw(`3TNiI;a+6bDZ+D00cnbI09H7jw1s*2!Ozze38T=-y%Uk00cllCxG=qrvV)V zKtLye^+Bfr9Rxr?CxG=qrvV)VKtLye^+Bfr9Rxr?CxG=qrvV)VKtLye^+Bfr9Rxr? z2y}ndl>4JZGu54@12V7heb^8X0D(y-fc?Xy&j9ov1db>0`2EB2<>3JV5SSbS*gs6p zSfi&PFf4)l{FmDzGs9Lyc@O}Bu_u7_VeE$(9Rz`q31EE~867Huz}OSO`Y`rGj1Gdp z$ON!HjEoKyL163&V0{?-Aw~y5U`PUcyb9^@|zz{f*XqYX0n%%L^*XtZ!F*{a;cny9K|a zhYuaHC&Lv_b2Q#6$LnpYbB)iFEIvmtmI%cyN>G8*k2v)>a`6^(36FsB*>=6>JK|E= zjGOaru{NEGOYq{SC=#By)9KLe?k+|A*yV>rEaof+FXls~hYfvm)+^`bsjN8#=QU1c eE=!#6pSaOSe7wzu(g~u|Y?WhC!fdn6pnm|?fI^Z0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7983dee2ec2561c708c17035aee74839c6a39d06 GIT binary patch literal 25940 zcmeI5&uK$z$qvDJJ#--nfC*~ z&wC!d#G9d#vzLFMw@WH9L))a2M!Gi9Yp#)&_#^Y|zL8S)Y&eL)r55T$GYj{{!9CwB z52x~mPAgowO@hxuYj32qvGL;rqky^X$3(z%ty+r%w5k@qVyC z00ck)1V8`;#)JT$N1`*;S)z7S4?>ChL{Ej1355;@s>uYU&t1wb6spy7cELQNeEq@u znp705k~ybZr0BnAqObV8B<&B89E&E%r*G*3-K2Z;&#OPTfB)^*U;6z?or{QDsw0#d z^Y%}wzxH9+KSh`HM_Njs(|7t8&1~g3w~d{tcz_4&OY_w`Q}t|~F)dSz>eL~ROObDm z_5QhOF)ol%&WIus1Twa~``FlQzuVa8Uv|t~n$NgJCVq{1H>l0mb*7)9DwV0iWu86P zXoJ>yMSN8=xI%osPSC{9R5c9Vo*M0^@21j|E36%)DsOsC-HEbly=CRzhwOZ~q?A1% zrF@JA$Ie@&rB5iS6^v+qfYq^wbtgY2Lp6cRw?Z(=Ms<)d`d`{(B(`!}gcRebY zu4_8dzR@Z+Ry;bL{<_(G*QJ#hT&(7oHRj@4ZsBhy+H;F$lzA-bgbo5A00K+^@d4Wa z1VCUn0*DX0k%1osKwyj1?||edF$f5N00_hgAU?!!zyJXdh!8-0h`@jf0w540fcOx@ z0Rsd;AVL7~Ap!#`2!KG00OCUo2MiDZfd~P_hX@R)AOHf2K<`^kc|J-sT|H^qM{|Oo z!-jwW2#h-coFB%02VnjnuseY_pC5K_j~WmFf$<@L^TYV8HD(F|!xDJRf4MC(HEc(; z2LTWmc>;(JBfrF$APDT40OG@*(V-&MAP|sTG+)iGP z>417Ka{u_VvpM_9YI`;_d-&?*(<;lXYgXOpfAlbKm)veNedv%g8LoMjv+>qAUvEd< z>wF()aXEvrWGHS^ib`DG;?m*F#p}$)9{}UB??zZRP^HYAU2yKPHknCE^5XX>lAgHR z?b7DvCI$T3yE`|UA literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fd8944d000105f5523f84432bb707ce876c1bc69 GIT binary patch literal 26054 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..da37fff1889ebd0434177fdf3607fb12607c200d GIT binary patch literal 25996 zcmeI5&u<$=6vy9sO-M;ZE$AVlm9RiU5lAR476efu8+#LNYJ08qnpolhrdfieWNq0_ zDO|!|P;ubIp}kfdkaFwM2M+u=!I?vaI3R>j4hU5h((>MT9nYqXz@{hr`>fr0^Jd;= z=QHD{alCn{=AEst>E9)tT%sdNkU@qqH|Wl4@{jBfH_f!FFGm+Kxili3Xldi77})dS za(kg*CTN=*kEm}Zlkr~PsA?FdetXjl!`1UIP#DgC`zoJre^p@1^;@>-U0GQP=Hq^- zfdB}A00@8p2+Rlp-jC=bO|a;7T@NGCZK9_m$woqFL)B%DGOw@YH;VOoCAVQcqimy# zk2Gm0+GT51^+?l$d!qMvza;&iCfOFvQ9z&5Wx7T;=HupWbr@F-Hfd>kFOT@=)7!?u^X?XEHOtI1w3`>8STTj91alzWmburzC1~DKT+K@d088DhQ3(H z%x|-Hmg@XwG1QqTr`8Ei*CP>LDN3Z2H6W$D{ghEV73^~DIZ7EtbCJIL@vp&R)Y^6R z%yDIS^CPw^7_KkpMeXz%ylY6i6KxTUI7bMqFtR<%$pQY~n9 z{H|zr(DOUHe&bp**db?qo$S5Fj_-Zk_GzyVJNK2}EK{rAN0T2KICV=>WWdH&oa2f%`httTw4gw%>Na}Y-@<|K=0w4ea zF#?DWF&wCX00?LV5Fa!cP(c6$VgwK$VmMF%0T9p#AUHX0H z0w6Fu1h9XYow>$HL10`0cla;2MHa?&M1K$ffvG2e_%QWTi~)kc!~_r@CPoKG5SV%b zh!0ag#TXz6OiTdrVPbS}1c9k1fcP-=Q;Y$Ez?cLMqi-!1qvt0jQ|IWL&QZ(|o8*a= z)|AQ?dyZbALC)n(dR*lYHr2D13GOGaJM@IQ7db!s(rbC=ot^G-cKO`9S1ze6vz}G= z^nVbs?v%a0o<4NUk&M?p$JuxroUga1&P_hgvUr@qSTYoMDNSWAKjPBm%*A`mB{%`b zW7~&O+leMs%{xW+25a-#lq4^Hgd*vQ`~5y092`){uLFKb#$v8=@?t(z88-~g1-Dj` nTiLS9_KRG~T9&NaKl4TJ@$m*5s*e#p#a201Ik|U^Z3g`VzkW7{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b38b65acc105fdde1a40743e59f21b1d82b4ed67 GIT binary patch literal 25968 zcmeI5&u<$=6vyB0nvgaTwV;OxDq$4~MQ~}01p!KAV{f8OZLhW7D3&;YX_jCqSzESK z+FtS>R6TIw&|a$^D&^Lr4?XbX1ZNHiaX<*691yAy((>MT6VHZ5VAGTReb(;0c{8*2 zd}j9PCEmQ$3hu@u`gh5s7U+OXGRQDy2Hjar{g(Uoj+IgM#rPr?mqx4;Ev()V1CM;N z+?y*}CT()#0rjm^D%tBxRgI$bFYj4VwDi)e6h*UNy~*c$4~lHLe%G=5D;F+=v&nI& zfdB}A00@8p2uuk9jz{#Fnk;%<*P~c;o9LNXavqnCK&pm!$s-Bw^7Eh4eXHrfYPI{(AiT!LL96^uusGskn%^rwXCe zHLri8#v9MY<1@5u-ZwJll6g43Z0Bq5aNpxG)m^q_`544hL{jG&(*|{@Nj(a<6uEtF z@OJZ3x!k8}P812S4vyL`3hL#YIcSsiD3bS;nT_|p7+>}5LYCv-GOxx}UW*oW`S^ra zCPQ_qQjNncY_isWe-Rxx0o_&r=n9?KTm0+WX;pp-~BPza9X>ro;s==Uj4`bWl9L>E1%{{ zt?aMK)YH7h{?C2lgzeql`^}J6T(4Yn*S&&Go?R=JO0*s}J3&`8C+r2C?Vxe38E%og zvO>;IV=M4)v;$hJ+N|0K>XciX0bR;|+;0EZr4|2Nt`|29=Hfek=}%7|xlaqKdQLY& z2LTWO0VV+dfNcN*AaD`^_=l6ozzzZ+uutmuL-Lat1Oz|;1QG<`9}+lF0Ra%u2*5vR zFrb0}2qXx=KO}IV0s8 z;35Czw#eM51JNG@Kw#ntz&}j<5@UiOFg5}Bhq2LNAP7u60r-cBUt&xU1jZ%+|1dT> z3z zTw0PBKSh!B#QlDs_V)HD;@2L(Bx5mGIe9T3s!SRV%|&m$EFWdVsW`83DSKHmYyZd> Yeay$ZY^Xg$^gLVTSmFfUdA1q!H>U|QL;wH) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bcee7006f1def6bd137b7704758b13c85ef3cb85 GIT binary patch literal 25968 zcmeI5&u<$=6vw~2YeGsQYC#VXRKfy;BDj=bL4Z=(*iN*m?X}h$#S#ZF%@QmnYs+>@ z;gbFZ6$efnT8TCE zXJ((q^5&(QbGE+3zn4^E2}dwtz%b_e?jk0B&wO{&N@;yLx`-vU5y`;P`b}k!p7$@e z7xI>gZD~A0&q^dly}r_F7$$#x!wSRIC!a$Y&VT)~oNs@T7t6IKfV4J7PWQg-UvUe$wCsONG8 zp!ML!Q?*(Hs#dqy_;vGkdGl!`jiR-PZ@&Mtzu?B5BK^#TJ5X0^CA1Uo2uXXte!~lI)=Cw|D*e)kd%bXKfAk zUVX>+K5Y5es1(*q{x+O;!w(okoMyZzHGK7m~d&PThyyqd7w0b^nq)Y-N zKmtO5@qx>L1W4c{0*ntQk)fRgNZ=6a`-lD}Mi?YO0wgd(fbn4j2Ne<^ffxbChZqcs zBtQZq1Q;Jia8Mxu5{MCCe2BrINCG4i<>PXZ?sxc~m)WPi4h013`RTd`gNPiL_fvG3J_%QWT%mI?XsR=MXoEjZFlEBmxV0@VRDdqr4;M4>dA5M*q z9Z6v72{1lP{Skb~#pGBRYdf}Cv^VUvhIkSB3?aM1V%dA(ZdGUV&vF4P$UOav1gd>^E zJuBIG>yodxtIrKN&vNl3gR#m`+(8OusXvg~mCVI^!X+33TCHW3S^Fos Y=v_Hp7en`k<(?X}ix#S#ZF%@QmnYs+>@ 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 S%1;nI!&*6(B|JClH2MdUX-EeE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3e75100800ff63e5595eadfcde3482ebdd54edec GIT binary patch literal 26024 zcmeI5&u<$=6vyB0nvjx+TF^rTm9RiU5nS40(I84>*WN^%+FonDRxEL#rdd);$=b4= zQhLdMP;ubIp}kfdkaFwM2M+wW0A~&r;(!oBIUrOar0siSJDyD&flW{L_gTC1=FQAz z=QHD{=Xmo{$+??f(Z5S7u|$Vtl0k+s*YD11;*ZP^cdV4EFApwaacK;6qNVjaV&IWa zmU|0%%cL!CJfxnLNW^=6t*TL!{Ovs}idJ5JouX*|o45IV@5?+}Zr*h)|H`>@;e31? zY9IgtAOHd&00J{Yfa4K;rY4JC*Y#*1x=r-dKr#cN(~;^jN9lJ~bL)j#t(;xAUsATw z#YdVn6r7U1qI#t0@tEi;$4k=xMUt>+jzap3F3}CTP5(Un^YHgye|az(Pbw}V?x{j3 zbO+t8q}qLOOa2{ z_J7=>R4(FJkQJY2Io5QwSa_Q`-Eyt0Z+^-Os z?0zx6?Af_A$IV4v#cRBVP3rLR5wB>9YE-5QmwC2aqdnT?RSs3l@m1%TJwbCnQO&Y= z%NulxK3_=BZ?SfoYWzkr)LHg{?4hS6yo;1bDSJRld21@8cFH@Y>Z_DA3f3Zh`{Q5z zjjFZl>Zzm3@ajhfC^JGpU-=|gDn)-ork><2_J7_dPT1P(UT%c6=6c17TlI1_d3GgV zC{Q(Qw1bXlPS_3FJ3;+MBitr;ZH=7W`gY)d(h6v!Y_AuCEppq9Anew=jb=z?*Ymxs z<5RA=vs3Q|YKRwfnmc={-EB#6p33!B(5^IY1yrzo-}a<`vt4X%1#}_(VXO6Dmsb3G zv6kO7n2YcDg}*(0RH_g9RxrC1egH)1GWJOfWS!v;2%yR13L(SzyYb>E6FD@ z2nc`x2*e1$Kg4jL0sx;KtK`bf2%3aM~P;tCrw9aUgPJmAs_$(vrYisQB%pU|!Ch+9= z;pF~k0Ra%09Ri3SW@oK2QxF)Jz2cka+fWXuffPa|!CB_6nU}6IB4-=!q zKoFRE0`L!0zr>gz2uw@>{$XNt7zhGWPXPX5>X#T31c5OL91Om-SR6b*DVaJ)-*k?A zhS(%eth6Row%9ZD7WMNnZ_u+Uhp?rdwKTb(yzbL;>R#mh%9|H+?)%%F<;?P#53XEL zS!R8^=Ij4FV$Ci2Jw1Ksm@^r#dzQ2D);V8qSDhPto@MblgRx{N?of(KTz<@@$C-bYDs4{LC rn)6<@D7Uidl$# z!>>#J1H_>x4sb<1RN}_b2M#S_NriCTW0{C(EWym@bC zl3P5hpDddEts_Uzy!7T3l=Cz@HjBL*J% za=AB^w@g}L=N9#>L?Yhnd)17h*61ykC<32S~O>6BN=nbdIjjP5S%AA6viu^8DFoJgI#VaZj~{ zQs=z(SdBN{AB<1Yy!ow>GUv?U_@bSyw7KujJ=I;drTOUZsfeV_S4_*)rUrE>;9BI? z;r@@Cmd3drl{2Eq#%ycbcS2Cl4}JUC_skfMtIA$4GxSZd?}<6|-H=mfhrUnQ7e(@Z zg~;o@UyLt%b}r4+Q{-8^$aC1F4j-3zMpIOyGF7inpuKc^Dm}Tv+J36>jbx~jCr_;*p02kdyo!`aEo(q(xiythEAmdMdX$nz!J4K= zKmOTYsG46__ii_aXWuqJ8508f%qO`~Df&xt=}BHnAzsjFu5Bp4+mh-8mFumbU1{71s9^iP?MeS;yVzU_=w$lqR_p&x zt;F?WEx&9q7vJ#B_SOy>f0=p4FeAtZ)>>vOF_euR- zNq!T9fB*=9K#Tz5LktHRAOHdy0mKIl22>CLffxbAhZqhtKmY_Z0*DV945%Og0x<%J z4>25QfB*<+1P~uI7*Ig~1Qdb(r<(G7lxU`U(sYF87(a&%0Ra#gcLLZyjQa|}^@G6f z1YW*>*u6hIAOHg6Lje1S@tJE}DG0nOfd~A}Z4vq2Jp=?m00eXbhz~jq=pXkz*aftBwaS!4EhHK^*TTR literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..795a5791a8c8626dacee294a8d1e63057e14d50d GIT binary patch literal 26123 zcmeI5&u<$=6vy9sO-M;ZE$AVFN*It(1edm$Hi#10*qdlm+iR`YiX{%MX_jCqSzESK zN-y~jDh`}DwAYFQQf@u^z=0nZK;p(BLL3l6C%}-5oQj>dXC$m|PltnP_SKjwsmk zyuj_KyPr6RzWIv_*iB5;gWR6mAu4dQs)oLlTZat@T zgNqL&X~^3}YeiWk>7RR|YrJ0)`)5hEMROF;Cv=%^&~5tb!5>Gz{qpnSz@OB-yv%ULS6oboMDy1XKBXPbI)lMjnC~`h8gSDzV9~$iZ#tBZ5>d=UsE3%zT z>(1ZCqPAOda~ZuY33tA@&X@YFPoM*VS1o@OdZDRV$d zdHR)7%W`(H@)9MCytznUfB$FiSb@zkSAboECH2s1)JANd4V$^~yjx}M-E_BhXp z9W?hk@6-cYbKFANskm8-T&tYR=cy9ZTYg(4JLveW9lv&?9&D4dwnp}DZQJ)gYWlQM zvepa!7CEiDA9QM+dLy8c<9cq!_9)xf*{OAW<>LD7#?GE!34f!?>8@_v*^G4-bD0L{z% z9ySsPfWWL1!2V&@X8`&S0;36>ynh&Nj}{OBf!QH|{ln~xHF^pH;}W>ff4MDbVO&Sp zg8&FjJprr_Q$NJ$AP7uM0PDlV=->zfQ%?Zv!_*HkItT(26Ttc~F*-Pcz|<4K`Y`oF zj1Gdpm;?^`-&!p8-=CC7or7;W2Wy7tBu=cfCRDW8GxR$3LOyTMlPZR=sou4Wv7H?E z=_z$DlE3iUrL6Prc6&L!eCFzv3o6R2XH`A@|5L0vMX#%e4;`~7$QV z=X{ov#}SMrLUEgtROIqQE?tgXyvtmIV_-bieb8?^)}*RgC-2^7X+E8h;Kl!AlJLac zZkP7=_bKFKpAU&x%vBCv%%>`c6_GmURtj<}n|9HDl}nk+67>5gzUTu!-(p4i38H6M NE9Z)Y%VwQHe*;qOJ|O@A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c390b4760e0b66601cd0af1b5cafd3c5b66c297 GIT binary patch literal 26024 zcmeI5&u<$=6vyB0nlvR5wP+6!RKfxYMQ~}01wm~i8+#LNYJ08qTCv0dOtYkxlC@v$X*flW{LcdXrc^WMy6 z=QHD{=Xmo{$+??f(!ZBfVu=pPB!diNuHT)-#P6B!?^-FMcb^_zUY|3#iPx9&NXf9=ABaDH?g zS|9)dAOHd&00J{Yfa4K;rY0A?t;Qj?!W{!+*5^6 z>YBH{RpX84;_)e3H9t2}=88EOU$nE8HupUqQ{7`-nvZ@=MI>#WF>O+t8q}qLYbl?c z@Bg?(XFJkflXq?@63Viwp=B}G<~*^p5J2W4AuCJWT-PQp4vmaTu-I&E>cBm*#lC`TT>~uQ{E|6U#6r{uomf? zAO7rbRL!o7r;i$gs~;&K%m@K}<&)f~6#WgEdXl%;|9PJ{VQa7ZP9vl>*DF@ss+Y6L zvn%;RfvRDn9dtx;!fw#s3FjN@^dJBN2c&+l zB%j1kKmY_lV1xkt!w3#EKmY_Z0`LzS42U2A0wVmlzWSfr$yg zKTM1c13_Tw3BW&0{Ssq>ATTij_=kznVIT-hJpuTKsb6AD5Cp~~a1eiMu^2x;DVaKl z-*gUrhUg?utXPvOTkJV{gZlZHH|UhgA#ABzuE*tIiER&vNlOgRx{N?of(KTz|y1 z$C-sSdnzutTX5_HLE%f literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_notes.cpp b/src/importexport/encore/tests/tst_notes.cpp index 3429363e3485e..bc5421843bb92 100644 --- a/src/importexport/encore/tests/tst_notes.cpp +++ b/src/importexport/encore/tests/tst_notes.cpp @@ -1266,6 +1266,56 @@ TEST_F(Tst_Notes, scale_no_anchor_produces_no_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) { diff --git a/src/importexport/encore/tests/tst_ornaments.cpp b/src/importexport/encore/tests/tst_ornaments.cpp new file mode 100644 index 0000000000000..bc83b7bb57a02 --- /dev/null +++ b/src/importexport/encore/tests/tst_ornaments.cpp @@ -0,0 +1,632 @@ +/* + * 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. +// =========================================================================== + +// =========================================================================== +// 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: 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; +} + +// =========================================================================== +// 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; +} + +// =========================================================================== +// 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; +} + +// =========================================================================== +// 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; +} + +// 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; +} + +// =========================================================================== +// 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. +// =========================================================================== + +// =========================================================================== +// 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") From e7b7460c151b019283b3cfe58d2ab1f5b86fa35f Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 7 Jun 2026 13:00:00 +0200 Subject: [PATCH 17/33] enc-feat: import dynamics, tremolos and ornament elements --- src/importexport/encore/CMakeLists.txt | 4 + .../internal/importer/emitters-internal.h | 1 + .../encore/internal/importer/emitters-orn.cpp | 555 ++++++++++++++ .../encore/internal/importer/emitters.cpp | 2 + .../encore/internal/importer/import.cpp | 3 + .../encore/internal/importer/mappers.h | 4 + .../internal/importer/resolvers-ornaments.cpp | 461 ++++++++++++ .../encore/internal/importer/resolvers.cpp | 34 + .../encore/internal/importer/resolvers.h | 101 +++ .../notes_grandstaff_staffwithin_fermata.enc | Bin 0 -> 23441 bytes .../notes_scale_string_numbers_anchor.enc | Bin 0 -> 26024 bytes .../data/ornaments_breath_and_caesura.enc | Bin 0 -> 26056 bytes .../encore/tests/data/ornaments_dynamics.enc | Bin 0 -> 26156 bytes .../tests/data/ornaments_dynamics_full.enc | Bin 0 -> 26568 bytes .../tests/data/ornaments_dynamics_stacked.enc | Bin 0 -> 26090 bytes .../data/ornaments_graphic_line_skipped.enc | Bin 0 -> 25996 bytes .../tests/data/ornaments_measure_repeat.enc | Bin 0 -> 25984 bytes .../data/ornaments_staccatissimo_orns.enc | Bin 0 -> 26088 bytes .../data/ornaments_standalone_trill_end.enc | Bin 0 -> 25956 bytes .../tests/data/ornaments_tremolo_orn.enc | Bin 0 -> 26174 bytes .../data/ornaments_tremolo_orn_crossvoice.enc | Bin 0 -> 26057 bytes .../data/ornaments_tremolo_orn_no_tie.enc | Bin 0 -> 25973 bytes .../data/ornaments_tremolo_orn_tied_from.enc | Bin 0 -> 26017 bytes .../data/ornaments_trill_alt_standalone.enc | Bin 0 -> 25956 bytes .../data/ornaments_trill_between_notes.enc | Bin 0 -> 26057 bytes .../data/ornaments_trill_no_end_marker.enc | Bin 0 -> 26057 bytes .../data/ornaments_trill_simple_on_note.enc | Bin 0 -> 26458 bytes src/importexport/encore/tests/tst_notes.cpp | 81 ++ .../encore/tests/tst_ornaments.cpp | 706 ++++++++++++++++++ 29 files changed, 1952 insertions(+) create mode 100644 src/importexport/encore/internal/importer/emitters-orn.cpp create mode 100644 src/importexport/encore/internal/importer/resolvers-ornaments.cpp create mode 100644 src/importexport/encore/internal/importer/resolvers.cpp create mode 100644 src/importexport/encore/internal/importer/resolvers.h create mode 100644 src/importexport/encore/tests/data/notes_grandstaff_staffwithin_fermata.enc create mode 100644 src/importexport/encore/tests/data/notes_scale_string_numbers_anchor.enc create mode 100644 src/importexport/encore/tests/data/ornaments_breath_and_caesura.enc create mode 100644 src/importexport/encore/tests/data/ornaments_dynamics.enc create mode 100644 src/importexport/encore/tests/data/ornaments_dynamics_full.enc create mode 100644 src/importexport/encore/tests/data/ornaments_dynamics_stacked.enc create mode 100644 src/importexport/encore/tests/data/ornaments_graphic_line_skipped.enc create mode 100644 src/importexport/encore/tests/data/ornaments_measure_repeat.enc create mode 100644 src/importexport/encore/tests/data/ornaments_staccatissimo_orns.enc create mode 100644 src/importexport/encore/tests/data/ornaments_standalone_trill_end.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tremolo_orn.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tremolo_orn_crossvoice.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tremolo_orn_no_tie.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tremolo_orn_tied_from.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_alt_standalone.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_between_notes.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_no_end_marker.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_simple_on_note.enc diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 9e1690539afcf..b64b031645f09 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -72,10 +72,14 @@ target_sources(iex_encore PRIVATE internal/importer/emitters-note.cpp internal/importer/emitters-note-artic.cpp internal/importer/emitters-rest.cpp + internal/importer/emitters-orn.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-ornaments.cpp internal/importer/import.h internal/importer/import-options.h ) diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index 248fc87b19986..3405a38087c3c 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -124,6 +124,7 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); 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); // Resolved MuseScore destination for an element: staff/voice and the derived track + lookup keys. struct RoutedTrack { 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..ce60d959ed540 --- /dev/null +++ b/src/importexport/encore/internal/importer/emitters-orn.cpp @@ -0,0 +1,555 @@ +/* + * 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 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: + 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.cpp b/src/importexport/encore/internal/importer/emitters.cpp index 297ff9465204d..5ed9d6c95334f 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -963,6 +963,8 @@ static void emitMeasureElement(BuildCtx& ctx, MeasEmitCtx& mc, const EncMeasureE break; case EncElemType::REST: handleRest(ctx, mc, ec); break; + case EncElemType::ORNAMENT: handleOrnament(ctx, mc, ec); + break; default: break; } } diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index b49c443b26742..f634813eebd7c 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -25,6 +25,7 @@ #include "ctx.h" #include "builders.h" +#include "resolvers.h" #include "page-layout.h" #include "debug-dump.h" @@ -211,6 +212,8 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp applyStaffScale(score, enc); } + resolveAll(ctx); + EditEnharmonicSpelling::spell(score); respellTransposingStaves(score); // Assign MIDI ports/channels to every part. The file read path does this on load, diff --git a/src/importexport/encore/internal/importer/mappers.h b/src/importexport/encore/internal/importer/mappers.h index e4072e8e436e5..6f1e36175a4c2 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -51,6 +51,10 @@ mu::engraving::ClefType encClef2MuseScore(EncClefType ct); // 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). 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.cpp b/src/importexport/encore/internal/importer/resolvers.cpp new file mode 100644 index 0000000000000..fac2d46241d89 --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers.cpp @@ -0,0 +1,34 @@ +/* + * 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) +{ + resolveOrnaments(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..3893a99ea492b --- /dev/null +++ b/src/importexport/encore/internal/importer/resolvers.h @@ -0,0 +1,101 @@ +/* + * 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 resolveOrnaments(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/tests/data/notes_grandstaff_staffwithin_fermata.enc b/src/importexport/encore/tests/data/notes_grandstaff_staffwithin_fermata.enc new file mode 100644 index 0000000000000000000000000000000000000000..e993e7726d0bdb83fdf2e0ee1198dfc461d234eb GIT binary patch literal 23441 zcmeI4F>ljQ5XUb`ftE%hLLCsQL=|oXQhquCpuo@ zbKGYyM7)JV$9MmaJ#uqBxbQMJ{#7;aX&SnJKYE=>pLsiWHOY#8k4qoE)|#f^EeZeI4|Zx)@7-8`}MT z(bUojzLq?nZJp26pf;BmeC9Y+s7Myq8TPEx7H#s?c&cUe>WD&fbp8ibB9T7pv_Kyg z6Z19JE>R^M=34edDK*#O`A?^*N%JA3mZMAEXW=R!8NE=tPO(rf5~VNSf9(Se^cWfo zhw9+yhYlz+LcqWBF*dBcy(UwS@hx_mpA+3{Zgn2G9%T(PZy6;st&yo&nOu%ap4)QT zqUm18Y1N(TE7#i~Bbz0Cv%2BfZ<-FR6}5+Xr$$E0b-Yfs<2F1h8m4WgbeqzRdcE3l z)DY8YH|kre-Dpa6n~K$@)3V&R4&^l4)=cT&XyqF3ei+{$Eg%2_ zvqJ#S53{q@m?;RHl)x_k=eEe=Ne7}o2!O!U6F_{J`X$B$L11D6hz}E^!$1(2dIE?K zQ@_NRAP7uM0P$gBbQlN%Q%?Z#Vd|F{69j=164*I-YY{#8ep2$o4BvDPV}|%7Ppq`Y zRJPb9xs6diHo#Wtp|LiaoD- z?C-Kh!S4Cfhx*#iUQKZ}-YVzo?Wn!W`&ky7GZ;&T;x@&p!1ZgcP0n1r$y~eUO)-=dH_|WGrTrlNWPQbm1Q#_DZ=I;f%}d3(;S;#K6G- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eeb0500f5141881f0fd7c6db3c8d88187b2d4faf GIT binary patch literal 26024 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a15b8094d8988b1c5dbfd8464fec6cdb08cb9d6 GIT binary patch literal 26090 zcmeI5O>7%g5XWbCO-M;fEa)MEN*It(1edm05JZV=>`k<(?X}ix#ZnK&G)u6QtS#Fq zrI*~`R&n6Op}kfdkRoyD(FYEET!1r&3UNRPp&SsZP^D$&*{+}6Mqrnd{eRZ(n>RCW z<@ek1=q27vN*QtYtQGl}a(aVLzjE zgNqL&G31eS-nJ&uV4Rj5cM?z619O#8IQ zaS|%aXx#9wKSpyuQN^-2EDSn9pDiTkw^>@E3V#$0b>^F=qOczkkJ@t-sV2R|fb{a= zR!Z%ZbqeJdC~oAeMf&!~zq+BW*>&;6{Xzfg_Z1Lkgn)kM<2)$k{Y_bVoI~tUJ||At z*l)jG3u)c;@+G(IWo+{7QZ|>Pa#(8yEs>nC9W-}?>Xll!L+<)IIeXQe!2hrj&}Pxz z$OqfxHfurHuC{CSkczJ7duhj~OnrB^+78qdFKE?w_m$mkNOzWs)ke@P)vg7Uvwh$8 z#J}Fm*S7;YmwdO;_^-EC@_N3K-7=Vq@A$dDJ$>dOEvw;aT?rWkKmY`o0P+LE00cnb zI0DEI$B}^^1VG@B)bEw#Cowb-009sfB7pobgaZQ*00E5v@`DBgA_#!M5CPL^9QrrUNuD@^jc| zAOHfhP5}FdS&sm$9|Vpk@bLY^@&2%Y00_(u0qh@U=U!u_ATTb0+x(Z?(iX;@2!9X& zfvG2e{4n*m7z+e}i3uP-OpFc_L15|$AU{m~Eye;tU}6Hu4-=!qL=c#I0>}?je~Ym| z5EzrdVeeau#oqIiQmJ$BP3ItIh)(LniZ!mP#h#*9saw%`lO9(!ge~=~rOAHsx=l~0 z_af)#UU@y^UfgM|q*hK{x^PZanf2|8umAgrb+_Pm^zxx&u4Fv-G*{!Ta=qTRI@kC- z%gyHs#!{iUMF}c!{{iv2d#?Q-&d$J(7YZ{DnS zK5zDE5^r9rd1vcu`tOoXF3}Ms$RNX*8(a9 z76^a<2!H?xfIv(L@P0&}X@ZO1*5xoNx=!SDRI*W_v!ODXqs*IY`Hf<|Ude4(&neyL z;v-2qigwvrRTgRb_nzo|-Y<#$vn1Q1ISS}=xuf`isN8{79miW?0Csq@~@g*x)eV1*I@2PIHF2l!QPemkco-u7vhnmzQpKB?V zWm;Ck38&AN2OGIX>AUo)QOORC?is_rnz&(2?+Zh@CzOYCu;6FX?{_ugp+wC=d2s#A0G7P(fnP%Kg{Xm&B2l00d%10P!JK?#5(6U?KvD4-?^nF$lzpz#*yM56MqrC?Eg=ATUM%@nH-H z8Xy1y8Ue%y4F*II0D&8ug^`_|v%}e|qHVO!UK->vn{}A^Tfcb;K=>$&RKb&rl9uNS5_z=MU zAwIswOhI5$0(bc@x1}shIuQ0C00J{l0P$hwU5p8Wz|;f~AEri!fgmvR1P~u)-o=<8 z2uw`?@nLFo7zhG0PXO^@=3R^lg203X4x?`^7NhqkB~$0O0n@ znt7+_-ehS$o08WrJ;Pc#RynzMj&%n81GXbF^8f$< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..578aa9c663aec449613ceb9f983b8e3ddbf88821 GIT binary patch literal 25984 zcmeI5&u<$=6vyB0nvjx+TC|4QXb)T}4oJE6=mQ6SoZ!p>Ar1&3lmkK)LR#J%uj|>g5w+Ws{T*v}-g`5% z+4;=)={erK)C%s_*Yy7-lUkx9GRYvrm>YCwG4*Hehg()g>C5p&EUt}MCR$p*B?cb( z@N#FNXqmLljYrhCQmJIGZEGY6qG;uXS15|+zkP$xcfKmJ<;HEt@-Lr1AI>Mo zK?4C0009sH0T7rG0vwO%Gc~#Bx-Lg?(QP7U;*yICosLwOIm*7ZT39dD>(%_a{k*b` zEs`;gnF;~pt__Cd^ZE)Y?G1YChW%(GyR79fljA@HH)TAB-TuZ5@ z=?f*qr%%rfe*B{7m-4cjgV`qi8{YKtPd51Hv?_krXyY@*`QPO`9gMno2AoK=QoI< z&O~{&H-;Zlu-k`^xa@_YDwc zgn+*CX;x}wUtArfd5b;H`@{*``@MIYA+5Pyx#n(q1)Ds(RxFihGi-K(u1HSU3p%?& z<9ajPA$M(!oV~_Q;D6i>XrpScmxFC`JIx^MHG0ifNLAPKy}aX7p|!i)=mlzs7j#>@ z`>Ne-OLc*&jdswfH9rZcWc$ADN&i--+}aN4V)jbA{isVT{<&N)ZW+wQcl^>no<8!B zmXvxi0wPNel%9KmY_11mGVMIG}(4 z2xtW0A2b*cK>!321mGVMIG}(42xtW0A2b*cK>!321mGVMIG}(42xtW0A2b*cK>!34 zfx)+$@_dv?rh3wJgyv;_4jTmoKw#DhAbyzj9f0|Rz{vz29zUGiA1xpN0<%K^@x$z_ zHD(F|;}W>bf4MDXVcdb}4+0=C^#tG_rhbVrK@gaj0Q|$m=r9ljrk()&!_+S^CI|u( z6M%o17##+Jz|<3ff0+6u#sooNOah1Tw-$@>^OKUPbM#H;$Y+R6^2ADOT4jqpORv)) z&+-O6t#Sxk>RC&Z`^oDrJ)`bL&M&_9X2E@Lr@Ne6KKuUViz>^kZ`XbOpF*s;6~C{i z4;^zR<8{w-Hr@v3>+Pv?lh3nUe9mAj8H&4hH%Q$)|PRgM)-=$&VqLH_~kCNoa} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..961094a259aeff4f53f02f0232cf42bc64347cd3 GIT binary patch literal 26088 zcmeI5&u<$=6vyB0nvjx+TF^rTm9RiUfnM5TK@cUfu{Y7Cw%1y(6-ykzG)u6QtS#Fq zrI-8%6$efn+H1uDDV%!rfdfA-g)@f;aX<*691yAy((>MT9gjmJu*=E*&T41gd-G$|g<_$~9@Z7Zep<@h2NmqsiTEv?@c1Gjv5 zy1S6KOxohgBkEa+#HiO-N{yoAFK=2=wDRnW6h-r2zsmXD&+}}#amTUzs~0bZ^P}yc zfdB}A00@8p2+Rlp-jC=#H96_JE=O_FZ6c@Ql!+6ajg-qArC(potru#wa(3N*M%jiJ zA4*bDa7y-y@<`D?_eAgVeo6eFCfOFvQAnTA6}nEh=&uLAAN~6CPlvW$`0p&>8x4Aw9pv(pjqUgJ`In2T#qx;n+T!#IsB#DP;~w zDUY@?YFXYXRiCG%QLq;2oA3YVPjszb7tb6kgR37KAj}8>edLp@REqwFbUn#a>`|T* zCv5F?-)e-k=6c17TlI1_d3GgVC{Q(Qw1bXFPS_3FJ3;+=Bitr;ZH=7W`gY)d)Cy>$ zY_AuCEppq9Anew=jb=z?*Ymxs<5RA=vs3Q|s)-kLnmc={-fc;7iOTg>(5^IY22`+p z-}c16*)BG>0=k@juhsgmLo440inaWv!CZXDFZ}80Ee~i(spoYgWDo!W5MTmWA21C- z00d4Wfc4=tGO&XH2po|5J(T<;h6Dm200JWfus)37fC2&_pb^0OpuvC$0w6F#0PDjD z4k#c10vZ9V4;l=JAOHd*1h77g;D7=GAfOSz`k=vp2m&CW2=qVIl=q`VGS!==Lo_e& zd)P=I00Og40Q-kop8@DU2%Jvf;roZv{ZRt~ATT=wuz#4Hu|`ipU|a(C_%F95EsWa` z{vZGXQ%?Zv!_*HkItT(26Ttc~F*-B^fvG2e^3-zg22=h!1^%t zLyQiBz?cLM;!iCW)SP7|K}5HZprWI;X}vl$$0Krj>cQ(c)eYf zH#nc=-A`Vf1e^g_W6*A#jJAhVm?(lY8aUFUbQH$QOOU=NoLOJVx{s NTjgAlaM^4#=x-i~KLG#$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..415673a89226ab8bc6b338ebefb78ca76eb1b8f0 GIT binary patch literal 25956 zcmeI5&u<$=6vw~oH6bMtwV;OxDq(?yB9KsN4Fpjt8+#LNYJ08qMzO>J+-3=ulC@91ub%2ZSnww7eOw>$PcA?Dk}T$J(9u-ps5$ zpP7Ani8n9RytDlk9$eDN6&xc01BS8C?=E8UkL>q1&9v56ql=hQ8<7mGY}`}^8F~M5 zd#PY1up^Df=$XmnXs@re8iuLg-ZsNmTh{M&z%bvN5@ek z0TLhq5+DH*m=gjLkHlx1kP_G9au}7kP08t~WTS%9q3*JP%$t|;8^v0!lH0JJ(YE2m zhmvY2+GT4^_ekTvG12=HFRA`dLxn{P2=Ey$;u>z?pSyn^|NiSQKMlu2$3>8yI)u{K zy#AdYZ#)@|PvcVJ3nQIaOAN-BtX%aK={p`%-4a_yj($u9C|zcZZM4xq7rxZ0R8n|N z3(@J*v;DVUR{BMlm2A`?QJ8u!HJj<)i{mSgDWhmETj~~PLY1+2z|-)Zh0=ys>2s#j5|xBPas@rjS3 z<$0E?`ZwF9=8li^nJcZ<|6N+;gG;r-wjo?R+bjO<#z!7uMXP7yM#>~W0wf>==pVQZ zNPq-RB0&Fe5*gY_fCP@9e+Q&~5@R6&5+H#Q0`w0fIH-^S3B(A{Kg3{ABmoi_Awd5y zf`bYPkU)$8{X+}}MG_!^5d!oNBRHs#013nh&_BdrP$U5o&;l$6jslFOUwz9z|;ijAErjffg~{V1n3`T zeu+6j5}1&{QS`0Fa`gP9%G5dhrgP{sluhM{RjnzVE%pp9pr1>56OZd0!lr)KG9mrc z>kgjKpGBRYd-;vL^X_hEHM@G|z02oymRZlLdGUYzuxPvsxQhy}1E18S;g-b97#uM9zQQKIPR`X8Ly&=+KHl>mmKfE=>FPtZ139#LOUCgWCLtD^6xetT2*{k5lGAm3m7<`ur*{Gz~`D>qEtxp3x;w-|4S z5(t0*2!H?xfWVv(;PXgyrU_2ba#Z$%6xE5G4pKHqbkbKQ3zT{7e14-?uUB####2f+ z?0hImMbRu9Ysw-`$InFX@_9+@pCmaJEs#f_(i?P%uF>Cj|2+Er*I#ZA+mkvM5nHMw zlBL$hY+o{R)yr%A`7kS%#4NNULN+%i9{)sj=2nU7v<^-zUPltH`3-z3;T+?duPKG?AZm1iK3mExZnJcf>iqH6)SY*s+S%bYeAqm6 zBa+J+N-hp#wBLeRu02O7t*9^4w?Ez)e0)ZYg7S%b`mpzV3J7yTAiDA?E>ug-rc6D> zTkK&zC#Kil@4eOZXx*|)RjX#_4YG}Dp;)Av*X+1mkxZ}Wc6QyyrKYz-*7`b`dyO5} z`LONMX2sYjx!YuQny%Mt^qMV?DwgfoIn$whYj?NNb5#@D?Y4IJRlU`g>?~CpZMRcx zUUjKxIF4b9eXCPyZM$?X^Ip6CZJ`1STbLi~n+4 z($b_2VGjZzF!Kc9A7*}uF+mWRngIO6)acL<1ZJK9{KL#IF(wECQxkxHm>L}#g22oZ zfPa|zCB_6nU_t_i!LJs}!Sj<6A7=PV=g?<}PU6H$ZAwLpJw-3mAWHNmJ*r{=>*`s{ z1l!5$7ColMBKK!rdNpsoz0+OIuAX}5!Z{UX)-mc%^j})6TVdz0_;oE(l|ED?&kl%_K0mpQjNa`7H>@kYQntotCS8>v#oyj8TXv9y>?N$}$T zFiCjge!ou#2M6Ty>wsSpv6zb-yqGVQ#}%QvVAo1Alr6JtzR0=kWr-91Gk1EIuh&^o ReT3)<*2=Xealcuo(LZFcMz;U} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d6a24c7f00cb133146c4eaa52b0d668ed0f3e669 GIT binary patch literal 26057 zcmeI5&u<$=6vyB0nvjx+TF^rTl`tTo2rg~0Aczv#*qdlm+iR`YiX{$Unk86D)|Ty* z(o6UYDiEAFwAYFQD7PMc;J}XyaOO}U4hSKXLxn1Yw7fT7*W-`~>~gZd&)S_gZ)Vn> z&&)o(#G98&&fWZy{#{auB|0RN3^I(les@+Azh}O`VWm`kIlhR+r4j2yOY1kpz$1^B zTMK#1q%Cedq@I;XjQ09URih~R>uXjNtvvZ0MbZ4%FY)=-=XthVz3Eu~rE}-P`O$Hx zfdB}A00@8p2+Rlpjz{#Fnk;%<*P~c;o9L-nGO^I&pi}heLVA9SwbNANJIzpMUOctQ2CM#{CGR37Qpz5XQtn-4)J}P)RDFh$ zM!{O7Z+`frzmv6gT|ITbGPwGE1C$vdps#$AE0v-dyw?(EdNfg0iko#xJ-YIj>woTqZV6|^gjYXKE(-?u&K-)t9~TLE22 zzujv6*QFK5U##Uf4d&uIe&J6~A9+Aas(My8LI(j500AZd|A1`(0w8b_0r-cL$iNN) zAaFqH7fP z5CDM@0`LzbI8Xrr5YPy~KWH$Zf&d680{w3_<@qSlO!cJc5Y4mv95w_5Kw#DhAbyzj z9f0|Rz{v!Tj~`C%j}{OBf!QH|_+fU|8Z!lfaS7b!zuXpC7+9DdU|^ciB4Jh9T6RM}$B(2LYh*t|gxs~p0Xde+k9e)76akEqWg=citHIp@By z-C52opLz4r1(jviw`;!s-zC=ElHb$QhmJXu@w#U@8*iQS^>)>{!RJ{PpEDRshT;yT zsKn)aTzZ_jc$c|^N5J@OdpB-7(xj?6x8Pl8Z9bEfpJ=GRU zUGw&1)n9u*=%1uZvG28HY%aFbzhGp_pR;fOp6Wi^QhapxR79flh-sOcRHHVzTub@p zc=zq5MY%wFDJ_DOr&{Sl`mrnTdiH&Ja~a-nZ;t*Y+sLMP6I|daxyBPxrxqXAd3usm zp%Rt3&avehZO}STnWtL%r;s<`ahiBS67NuDX7wKtJ%>zH{~01_)z9AUyL4R>}ou zQHGx2CH6M=iRm>q+F#T>nz!si*;=x*2H8eAm(SCZS8KX0kxZ}cHrL$h&6>AL*8Dt~ z>(y1)xzliIv1BY1+!eB#HP>rb+qJq!CChf~jOkFezP46vyQ+)rw(4ses@-Zx^%<3_ z4YygYeeF`-a2&%H|9Z1fUvcSF>T09$f2UT0bfJ=4)|iW9I{BA&xaStlDD`;Q2pI%G z00fu-;scfe2!Oy|1P~wgA_F@JfWQ{1UlPerVkjU00w53}fcOx>0R;p=AVdK1Ap`>= z2!KF@0OCUg2NVzhfe-=2hY$>iAOHdp0*DV098f?21VRK5A3`u7f&d680^M&l<@qR) zV(LlL9-5E&IcyXV0D*BQfc?X`uK>tKwt}#*&7?i+6 z{>yDCQ-gMdKL~)p$P++(82Ks206}1A0*DVoqeDj!7XmscZ z0wYfV@nPhr7y|@>0SRmc-&#xu&reFG&fYhjy_g|3$rCHB36(AO2%V;Ga^*$ZuW|_M z>RHPe`^oDey{A5loIm*VY}Wd6wKbcbJ@VD%Q!2}>V^o~*e}b5|icTk-K6Jp54CbEU zY`j&@*V|U-8lT6xIGn*)G8DHcNky)2acy(v;&tZY^?`BNb|Yx(Yf@^~%G-BYnoK7o zdGR|elAgHJ>CoopCi(o@fgCB61DzUT`+ V&aq!>s_Z2*yUt@pSAnu&6~H~ z_jzwelXx?&s z@qr{A1*>E(D~l8zjft*syd?IgNW!8S^667rp&N9Y{(kW1;qSlx^3z~Eskn&PQiV|J znzz1F*Uly34vWAHA50NZLGO+N2iMsY4#uQa(A; z`{(ARvC^e-Muc8jO42`lSt2&JRT0}cQLbe4f$cS|{0e*m6QP@85 zSl_?;V+DjMArM{pBsVHWcU`8Q^wIP_vQzo{`7 z*K!LFo#@B|T2Re1Q6*#$009tS0`L#m1|R?e#}R;kIF1bTAOHdfq<*0!KZ&7$00@9U zi~#&Y3zh z%ere}-%yuo=InxVo2A)IQj!<{k44fGce`EM-`}TzKl}WVjK$pK# zQwo>-2NefS9NKHe0V%g0ec-^46P!69!~r3MazLm;NXwh?x?Y<`V3(8q9cy>qdo#24 zd}j9PCEmPL^Un4c`1g`duHYC67%+^5es>X*zh%F@W2Uvf8ePPc+K6OeW#f)A$jC>R zdrJi~fgNc)M$b$pM|*v#)i6x`@}?PvYtO%oFkJlVf}HPtUJ%QzySC|FJ9jQv934lE z1W14cNPq-LU`_}~JQAO2LP}hZ%VAXFHYKN{l8p*Zhq}uGGMBFAH;T1dCAVQcr)|TF z4<*%5w9D3-?vchnW1@E@UQ+#^g$j!n5a1JB!A;!8Uk`pi{`Kddei)92j*B2YbqJ-e zdFvZJ-gr71pT^b1XGS`)mKcmLS-I+K(sw+jx+}Jf9Q~LIP`b<*+i0VKE_|s~sibgG z3(@K0v;DVUR{9l~m2A`?QJ8u!HJj<)i{mSTDWhmE+2z|-)Zh0=ys>2s@GAexBPas@sW?B z<$0E?`ZwF9=8li^nfF?)|GKow2bXGvZ9}+twpaYqjgLISidN6Yjg(1%1V}&#&_8e+ zkN^psM1cO`Br>#<00|sH{|-p~B*sDlBtQZq1n3_|a8Mxu5{MC?e~7`LNCG4cRfk(#=C--Ly36Q}25McZ;KWojIlE9<{?#o|ptFkod zK=vmA5}0`c^ba$?#GD`rOih6PVQO?7NCGoYfc|0TmzWbIfvE}5KTM5|14&@!3D7^x z{1S75BrqX?qv%_U<>>iIm8o<1P3O>OD4WU?t6Eb!TkILUfqpLKO+2Y{2%Gv@%Y^h( zulsmPe-?Fq;nj1{_nc(otxLY% zu0A*9JkQ0G48|%$aR+IXrT#!_S27px3zuLFj3>4mQQKIPR`X8Ly)DvWHl>mm|Bpqb zC+_unI6OQ=D6d0#sf@*GaNx%UICH2F2ZRvH0imiwTHYJm@i=J&Hk|D5vv%jrn|bTZ zXJ$vw^7^Hmaksvtf0jgSnU2ULgA8M?*PYea@2T%^SqW8N>0iX+(&+0%%j>tqz@876 zI}2IMq#8FKQP+yaMtgmws!SNMGA^DJBTZ#$NM_0pwqe$)>& z5C8!X009sHff*sd@rXWBlSQxVdej%)CVHYTslL!sq`J&e^0l?hdcIOArPu8zm2G(O zp(YJ^r)aOL9tk=f6J6tYN%}uQ5*E!-NT1LZx6SQW2W+cp2bKqaF)8&u3@5z|zHrtYX^kOO^sq=_wi&|8t4h39_d~~t* zc8^KrN|#D0QAGBs^bCC=+LD}3%h9p(ip<5D?tIS}TsSAeU`lWM&K$JW~4A) zn>@Rm&F5(|tha);XinG(T024YMm^jnH<=0Ys2~6W zBLuKNjNm{81VBI|fc-&(0Tl#5KoRJDt0~V%iDs%NO^0Znfx=Sk2u{uBf;;R|=t?l+o zYURS)S1+q9v%X#N_5UuBbBlggPaitwNXF})=4`xG&ez*f=Q^KfS$xi5EE$U1l%OJ) z?{n#K=HgxE5}p9#v+ccp+leMs&A55*CTsJlxFj$BA4Sp=ce`CWI5?n)UkCh>jKy5# z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..65fa351fe005055a0d647ed6ef936ecb08fdfee1 GIT binary patch literal 26057 zcmeI5&u<$=6vyB0nvjx+TF^rTl`tTo2rg~0N)RQou{Y7Cw%1y(6-ykzG)u6QtS#Fq zrI-8%6%w2{wAYFQQf@u^z=0nZ;LM>y91ub%2ZX8$X?btFuE(Jk*yUt@XSF+T-g~q1 zd}j7(5^r8AId}U@`uCDbtk5x;WRPJj^xauZ{GR##rj=6d)%YeB*G4Q8t!&;D1^0Y- zxxJLPOxj`NG4-rOV$|y^)r_L#uWwpWwD#;v6h(_)U*zlUFY>IpcFVE+%jeF8i=+L} z0s#;J0T2KI5SSAJydTj=YI4!rx*Wwt*NL2pOC~OKI#MnRlzx3Zw^^vw%Gpi(8KoQE zd?<;b;FRn&<&mO)?up*z{gU`UO|mUoppZVLOLUcP&|mldIR5RIpYIO)liC*%d#Wvz zy63fTRe$5jxPOY)&CiXLxn>Uf7wv526ZSp1r@F)DruJf$9LI?);p$vdU$3zRep)-rwb z!=L??tl4$()Pu(0?hh0Y=7fMg^GR-0ivE@iJ;_V#QJxbgY#nspYJ{}mdc}%c^>Q|O zb|qgZP&I6{gN{f}*bUlyLH%kY+$DEogPi^PZs32^3TUfrZx(|ca@&m{?AE)DW=Lh% z^S!L&Q?9wUSMLU@ix+g7dk4zywxl{w<$5b%# zmAGE4<+lyy;yZrfzNhy*q7~IVt1BUc00@8p6F_{xG5`S(7)Jo{VH_FgK>!4fNc~<( zeiB0g0T2Lz5dw%0BRJ3i0T9p#AUL~Ag22oZKzx|_DaHUnU}^%04^yKzIu%+I$G}%v%JM@J5EOLGBmDh6a+q<3B%<7qU zE?-btW_`Qn>;GM1!!7weJ$>keBbm%S%h`DAoUga5t_{AM=if5^4R znTz+COLzi|&$x00ck)1VCUy2=I9%JW`!QxE#)VK?utv9}7Yv2z1I*Ri-HZ%3^XQQ!W>lR?H`q ztlxP*CyGouXD+B3G5Y73=rW&|r2gY1$D%26=_XyIt8|0@y#L$YFF*Zwuiu{3xrn%? zIzp*FJ725z+G9cc7%l1_YcYL6@3qgGOU3uO?(muFHp}9C`DZF3Df5VFjha-WHaVP1 zC`9Qql?kE`&iY?JE9Hw_DkK60pV)f3^MZacth=Z6bR{|aCBnLI^q%gTbiEYTd7j+= zP5uhG{|=S)6T#VTgUb(d|J=7aMb6S+M6X`v`@J5liw<2s?dzVRN7NuXL!(tAQmy?F zNo!S+qYW96(k0bhhjnVSN>`F9$*|5J(NSikM{B(L zls@W;`ai5NUXvq^$Dhxmv@cj@GS15+%lqyM@47m*_9~N(4)MxkFrnFZezRs zM$M(=yp=8HOIFe(%Pgic87jH8rqhyK+HE_{O{a3T=B`tId708%m37B{ui?;Y!Cc8Y zRmwMOj@z!ZYju|jdCRtz(l#aQo12xkqncPwtG>Cd^!bJ)=c!O>IL%`1nnM}WwoOaw z*PGdT)u9XVw;PRzom#Q`*>Y-4V=i{u&irA8Tkg`FDxM7skp}?~00AZd`+#Kt0w8b@ z0oaFw$UqJPAh1j7?@sb0h5!N}00IL9U>^o>pa23O5F!Bk5Q2d$2!Ox<0oaED94LSQ z2!sg0K7?Q(3j!c8Kmhh(00#;n00JQbun!>^$btX}C<6Ykn(}^>X&=qA{2n#} z2!Oz(6Ttal(pLb+4*~}h*#G=+aD8Y%00bt70L~ASGuId?2ppEcUH;E)2{VUni25J^ z0%K1A_F?R&7y|@>kqN*)jEoKqL163&z&?!q6k~uOFfswyhmp~tAqb2;0oaGJpJEIU z1P)1HH~7_JHh6zhJazVe>FnDKk%<>t(MFZm*faDZ`5v@a*$cDy6gJemmO9sy&s};{ z-HXIezwmN0|K@sYJ~4mhtxFe_ms#5^+ks1HRc|?;v%6vUp-TE+MN90Bx5EB<+bXVc zJjuak560r5xJ5C_ar!Q&7JDwr#NBR} zc6N5isanityCheck()) << "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) diff --git a/src/importexport/encore/tests/tst_ornaments.cpp b/src/importexport/encore/tests/tst_ornaments.cpp index bc83b7bb57a02..664002e58a0e1 100644 --- a/src/importexport/encore/tests/tst_ornaments.cpp +++ b/src/importexport/encore/tests/tst_ornaments.cpp @@ -238,6 +238,144 @@ TEST_F(Tst_Ornaments, articulation_combos_expand_to_two_glyphs) 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: Fermata anchored on segment (not chord); direction from artic slot: articUp=0x20 (above), articDown=0x21 (below). // =========================================================================== @@ -401,6 +539,135 @@ TEST_F(Tst_Ornaments, double_barline_lands_on_every_staff) 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; +} + // =========================================================================== // 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). @@ -436,6 +703,36 @@ TEST_F(Tst_Ornaments, fermata_below_kept_on_non_tuplet_suppressed_on_tuplet) 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) @@ -487,6 +784,99 @@ TEST_F(Tst_Ornaments, new_artic_bytes_stopped_inverted_turn_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 // =========================================================================== @@ -541,6 +931,31 @@ TEST_F(Tst_Ornaments, trill_accidentals_set_interval_above) 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) @@ -574,11 +989,270 @@ TEST_F(Tst_Ornaments, artic_byte_dedup_no_duplicate_ornament_on_chord) 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; +} + +// 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; +} + // =========================================================================== // 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. @@ -630,3 +1304,35 @@ 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") + +// 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; +} From c5dd45719549bf804dfdebe21a32b56dd17c434a Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 7 Jun 2026 16:00:00 +0200 Subject: [PATCH 18/33] enc-feat: import mid-measure clef changes --- .../encore/internal/importer/emitters.cpp | 6 ++ .../structure_clef_change_mid_measure.enc | Bin 0 -> 26212 bytes .../structure_clef_trailing_cautionary.enc | Bin 0 -> 26496 bytes .../tests/data/structure_keychange_to_c.enc | Bin 0 -> 26148 bytes .../encore/tests/tst_structure.cpp | 95 ++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 src/importexport/encore/tests/data/structure_clef_change_mid_measure.enc create mode 100644 src/importexport/encore/tests/data/structure_clef_trailing_cautionary.enc create mode 100644 src/importexport/encore/tests/data/structure_keychange_to_c.enc diff --git a/src/importexport/encore/internal/importer/emitters.cpp b/src/importexport/encore/internal/importer/emitters.cpp index 5ed9d6c95334f..5ee0ecb0eda85 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -965,6 +965,10 @@ static void emitMeasureElement(BuildCtx& ctx, MeasEmitCtx& mc, const EncMeasureE 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; } } @@ -1029,6 +1033,8 @@ void emitMeasures(BuildCtx& ctx) resetPerMeasureState(ctx); + flushPendingKeySigs(score, measure, measTick, encMeas, pendingKeySigs); + MeasureElemRefVec sortedElems; prepareMeasureContext(ctx, mc, encMeas, sortedElems); 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 0000000000000000000000000000000000000000..c866e00f7fd6771c7c6dc379c6a77573342dd9e0 GIT binary patch literal 26212 zcmeI5&u<$=6vy9s(~uI2T2Q12qGExBB8pH_EC>)H8+#Kr^)Id0iX{$UnkBWAtS!eW zflK%cDh`}Dw3i+#4oH!D=rIQl{5ZjxLxngXR3RS_su0rlz45Qv&hW$$b`zZmRF_FgytbTP%~mSK)T;HA zk_`qQ)I^cB^VXv35vRYOiQeP$lJtLqIkLA-1|=18;^DE@Op1#3(^)zd<1$K*5WPK5#pEX<=Cst;J<0Q;Rb98&*PWwN zM3(535ofphx;f%oO^0={SZDIH>IB&i-Q5CZ+5U#nySsLOqy-r~rH}NY(Yy24MVHid zm;1W3=$3WejlNF47A@(zAQ0BFn!~Y%ebKSf310LZ@4!pE|LU~E*9W{C<5Zy{mAK5X zk*+n_?T=J~B^t5)}^Sa4h?WxJHFZ*5guo-%Q~o%+_U(ia+1EKsr9@S3IC z#~x)Z*R>q!UvK8>8y;Oqyx(a2+ohFnLAgq1U0l9vyV*Y--SU8DRC87rq7DKe00K+^ z{sG$n1VG?80`L#Vk%1fpK;WSBBQW_Sh6Dm200JQb@DCvzXn+6+XawLNG#IFY00@K# zz(0g=paB9Ppb>z7&|shn0w53~0RIrefd&YGfJOlRL4$!R2!MbhpdRe!TE{;`)RU$I zG|%yK*hnA%0^?2q=ZA6M0hm7s98ciE=ZE9_LjwXJFg^ruei)y%#!Nxrqy%pBUv5j9 zKB*!4g8&GOJOTKJkzZm=5Cn!M0RJ#FIv9e$$P<8n82Kf}1VLbE0`Lz*qk|y`j64DO zhml`mOb`T4NZ_FRTZ?G-`ALb?IrvTIz-Nd|;>3zJrlQ53rI)D_OL~o?DN77tQ$1@L z;ePVEO^>R3k^GtSuciy{Z0^h@=gz)+@q&sn>sl38|Hl_Ag}i&HhYwZO|5ujcXuMU9 z*V|Hgjq`C%E=Mqy2*o=Tr#zP*a_Mm7;%(;Q9|7aC>~dFkM5UVPLe{y;wW(xGf)_u; zB;kn<4-aX7f1d(=?ej|_7ITw>7xSgcu%IW-IOUw&%DSDmU*J;qvP2XAi3h#M*BdM- Sad7X)St{2e2Q5#r%%JWtdY`o6H9{SR34_V#tTlB)umMQ6>nQ_9BIA)>QBN_1Lp zr?X~TT5tE@tRE*jqxY(_X4_f4SDp0~(K)@H&YCCJqHYV=%1YVbqel-rkN^MeE4GzO z@zgEx>b%5jvq>F3-s6>;q#9MI%4wcUF3}Ec^Xm1KWpp+3XR$aE1VA7{0RAC@0}2R$fJOlRL4$!T2!KF@0Q^G)2NVzh z0gV9sg9Zax5C8#1K>g@}bAf+|sV7Z`XkOswupuA-0^?2q`-gF#0T@3B98Tc=`-j8p z!vX>zFg^sZe;A*8jgf-D!xFf|zuXq#-_S!q00clFLID0Df&&T&fPh8-{y~F*EC_%= zgaG_Q1P2rl00E5v{DTGqSr7n$2m$zq2o5M900R8fWbm!Ubol(FMCu%V(>e4RQYLX? z#TtZIK2EPu5NCUZBTY*TU{gJ78RL5Lx?og6eIeni~n1A9s5Dy8FV5R* oCApOK!fN3qPGv4jr1kH)(|dfJN-D*ylh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c65fbc141ca727e24268b3691308133141ac9c19 GIT binary patch literal 26148 zcmeI5&u<$=6vyB08j})>TF^rTm9RiU5nTGif*_!>u{Y7Cw%1y(6-ykzZI)muSzESK zLNECbDh`}DwAYFQQf@u^z=0nZ;LM>y91ub%2ZSnww7eOwGD#0njgP8w-ia%j%Q#P7N9ZyOm|Ukxu}aIS@AqLq!?LclE_ zp6)Ie4V|`Gc}xQ%k%(G-C96S@{`GAm2-co|nSx;H>(}^v_lqJ+4(?cnd-cKve<|7y zH4p#+5C8!X0D%P|!21z(q&g?Hu9kx^sWMT{geey$IvYrnBxT>YQrIXp8rA%U`J9xE zFFq~_Maim|YtkY^r}sqf^L|O#KTBd;lq8=%rMKt?-J-uA{Biu-FF)TOw9Bo${9x-iEms->(k8>$W z67QxyIY0c;6!Dg$q;%YcZR%vHt(JuR3NS%L}B;E9g3}pEmXQ_<&bah8k3*8s|lpT&I27 z;}zyhO?<`i^Fo4>KT^Xm_;%Ci41KnoUE1c-S!(c2tjRM^s9e9Jb@-_H$XSS7%%RAI zTZmt`XjSSjQd%n+Df;GzKZiF-+$gM{IgyX9ej-3&K?tZbpXN%f>~4yor+JAz%6($_ zo&EkhEuYqHr(Cn^PQfI{tQAWos{5_3*Apen?|a={uX&^8?~uK|PS##?$8$gKc(hqH zH_F~N+1-}s_nZAz+o!7SxK7@3snFitZT3Cc#PNFV-F>OIJ0iPC)n>=*)>=0`Dw(cp zI>Nr)Ew{Hlx|F@v>HOEJO|fCQQQXp)i)*>1zZ|vY5v|DTc~yuq2!H?xFah`nECUb# zfk_15A10B390Wk%h{W@Ud=di;5C8!Xh!B8(h~Pj41VBI`0RN!CKoJB$AVL8CA%X)H z5C8#%0Q`dj14R%3fd~QkhX@W-KmY_30`LzC3=}~C1SEmsubSfhC{fboo2FwlFY$ZW zNFV?Li%tOhheclj7(WP1Ch+k6!(@ACKmY_5hXD2ui!;|4DF{qU;2!_wwn)pO}fLlBsI0`L!WKgAdz2+T|X{$XZxXb1vxPXPX5?xz?7 z1c50D9EHDHq{8tk9-qwAgcWnTD~TH|a?k1K5!7TIy^kKKJM; z*%xts;nmj*_Paa1)!gd2_pV-&QD$AU;i~_*V%@H|12uf;lp~qWJkQa1n;fsVFV8JL zFLH7@g0YBD+@lOtIRA)qha(s7F&94u#%0-up)RJ9)q-7eZgFWTmlnZ`A7c{Xi3fuL z9UdN1z@J0@h=|2p<>1A9$UG_-iHlCXEV{B~RjgMy7i(EW1OJIH`hbr&Sx|d|=oyxZ MV@-s+W|>C+07%|Po&W#< literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp index 4183c65412f3c..85fff0efe3860 100644 --- a/src/importexport/encore/tests/tst_structure.cpp +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -365,6 +365,32 @@ TEST_F(Tst_Structure, page_margins_no_wini_uses_defaults) 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). @@ -597,6 +623,75 @@ TEST_F(Tst_Structure, fit_spatium_first_system_measure_count) 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. From 1ae5a10c1f3fdef41c7b2fb9030d4ffba6aed8d4 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 13 Jun 2026 10:00:00 +0200 Subject: [PATCH 19/33] enc-feat: import repeats and voltas --- src/importexport/encore/CMakeLists.txt | 1 + .../encore/internal/importer/emitters.cpp | 8 + .../encore/internal/importer/import.cpp | 5 + .../internal/importer/mappers-title.cpp | 126 +++++++++ .../encore/internal/importer/mappers.h | 1 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../data/importer_to_coda_vs_coda_marker.enc | Bin 0 -> 25968 bytes .../data/importer_volta_coalesce_and_text.enc | Bin 0 -> 25996 bytes .../data/importer_volta_overlapping_bits.enc | Bin 0 -> 25968 bytes .../tests/data/structure_jump_marks.enc | Bin 0 -> 26281 bytes .../tests/data/structure_jump_marks_all.enc | Bin 0 -> 27915 bytes .../tests/data/structure_section_markers.enc | Bin 0 -> 26314 bytes .../data/structure_volta_repeat_playback.enc | Bin 0 -> 26316 bytes .../data/structure_volta_repeat_playcount.enc | Bin 0 -> 26248 bytes src/importexport/encore/tests/tst_repeats.cpp | 261 ++++++++++++++++++ .../encore/tests/tst_structure.cpp | 134 +++++++++ 16 files changed, 537 insertions(+) create mode 100644 src/importexport/encore/internal/importer/mappers-title.cpp create mode 100644 src/importexport/encore/tests/data/importer_to_coda_vs_coda_marker.enc create mode 100644 src/importexport/encore/tests/data/importer_volta_coalesce_and_text.enc create mode 100644 src/importexport/encore/tests/data/importer_volta_overlapping_bits.enc create mode 100644 src/importexport/encore/tests/data/structure_jump_marks.enc create mode 100644 src/importexport/encore/tests/data/structure_jump_marks_all.enc create mode 100644 src/importexport/encore/tests/data/structure_section_markers.enc create mode 100644 src/importexport/encore/tests/data/structure_volta_repeat_playback.enc create mode 100644 src/importexport/encore/tests/data/structure_volta_repeat_playcount.enc create mode 100644 src/importexport/encore/tests/tst_repeats.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index b64b031645f09..6f157fbfe4d02 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -44,6 +44,7 @@ target_sources(iex_encore PRIVATE 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 diff --git a/src/importexport/encore/internal/importer/emitters.cpp b/src/importexport/encore/internal/importer/emitters.cpp index 5ee0ecb0eda85..e321a70356592 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -1033,6 +1033,14 @@ void emitMeasures(BuildCtx& ctx) 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; diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index f634813eebd7c..a4cdbc3af4461 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -224,6 +224,11 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp score->setUpTempoMap(); score->doLayout(); + // 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) 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..efe461283a0c4 --- /dev/null +++ b/src/importexport/encore/internal/importer/mappers-title.cpp @@ -0,0 +1,126 @@ +/* + * 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 { + +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 index 6f1e36175a4c2..83511e2f667c1 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -72,6 +72,7 @@ void addInitialTimeSig(mu::engraving::MasterScore* score, int nstaves, mu::engra 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); diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 047c1bb2bba54..719530acc235f 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -36,6 +36,7 @@ set(MODULE_TEST_SRC ${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 ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..403fad5ccb2e12ef48dc3da62535a8dfc1358b75 GIT binary patch literal 25968 zcmeI5O>7fK6vy9s-4L3nSd>FlP;0eP710Y&iw04UjlGFB*j{VBQ7mx)LzdK1vbJo8 zuS-rGdO;j|>H)5(hf29|^q~jZp5WX=RrOFIROL`1R3UBO8*lKgiv>=4sr?^ociy~t zGtB&E_GuDt9_6gH`~$r?CF3)+O)=6)(=1oT1 zp#%aT00JNY0w6FZ1o%7>o@tCzxEz-KAcb`zCxes@5*_fB$pocNoy{)fE0t1a!T3Pw zdY$(qsmPl}V@_El>ECCfOMG4u`}awXMHA%FH}oZ4qdWBS#b4Xc{`mdZUVBpKB4SH* zgi?Lp_*u2r-V53%>1^yfEg74O?X)i#nevBh+kd9I&$<*}kLdQ)H0M(08PhT~sYY#b zIhSLmn1zwFSycTt8 z@pY9~CP@`4QJM1`Yp&1+t@CPms-}PS`0O30iC?Iq>%4U}+E3q3r6yNcIzSbEAT)I+ z%Ba22CA#>00JPu1mGXA4L|?{1`&XN7(@no5CDNKQokRPpTv+r00clFLID0Df&&E*0D%wz z_=gY-h#&w05d!cJ5gaIh00@K#z(0gwKm-90h!B8(h~Pj01VA7}0RABa10o23fFjWS zR#V=O5-FzMH0`1Jgx|wP0s#;hcLF#+jQbA2{6Szaf!CiO2HT?s1VCVX2;lrMK5LDc zg21o@9`avqOPU(CA?!f_1V)|!{KLpEF(wECyCwktuxoT^2m&Kd0RCa*mlzWSfn5`T zf7mrTGz5W>CjkF2@=J^fg20djwt{ahri1qXE+*fmE-lc)xE~|aZV0LFqR0#ElN_6^Xr`39JzR%xp;kG9M*jm)b&-VV%Eyr zcUYQCCnR|Bf0!gZai`Ov&CN~n`LoF%iCD}<4qnWc%A<-MbL=$!tomeS|+o%TgDTX~!7#`jb=w7YCe^V!=|5sA(tre$hTojT-k zF6o;Sy&pF%%2_hX84)A}!R~!${>Q$%`aykep&n<_WAJh&y>Y+F_GQP+rFm~$@CK@N}+z}g?*>@7X}Dp zLLfTxNmeRFcTt9(ves*>p8HkPqs6khQ1n*FZq+@%Q|r_lK9z09b+VRAxyIUBt>dXCj@NFi zZK!g)DcMIi#RRXPI474Q7)ylWHl?V<`8CcRj$FLXT>JqrF59kzWdlV@ z&DjO#CQFl`yWK8rZf;V*$0i>Vv6xj3Ud*S;Fzmzjl literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b74d617ebc435883f26c927ede4a20d00cfdaf9 GIT binary patch literal 25968 zcmeI5&uHR4gpP_Avkw%&}(XGy6{O|NH_w=OFXM;|3&b2@$npwCf25$N8 z^k6Ed$7qEMx2dDYIQ>D7yWw*UI`kKcRkN$rb>YpN}j z>hsRes=fAM&^}3*V?Sug*j#L9PNymU3%Pv1|aCRbQGNELn{G<7A) zsI{P`bq_8(51!;Q2a?Mp*57r`EG~UU2`#Tr(@(!X?=HChN`b!rjeV#0HwFk}LLfZz z30BGlXHkZp;3f87o)gn+Y_z|qc{Fd?g|f9|XAQEAaxR~zC9l?WTOyfW+ikA7)!Q|1 zm8|)BGS{oCuJd)nrNxr5P;ghsYSvt@U2WIu9+fQHu`{Ma+4|aAwe6}Vw%e+&ZK!gq zA=zmvRU2-zT>HkQyx};8E%ocoLVd-hGpR2djek3};-3qZ+_J`89Mj3aw8Jg8Xhx|= z!bZp-00JPu1mGXA3_t(`1`&XN7(@nk5CDNKQokRPPhvh)QzjW zNc&U_U|qdy8RL3#JfZ{YUgUc87j@R2(*BW2PIXN7`SRxd+C`m=mzvA5H$i?f-#p?s(uov|td+O# zvNV}aNbur4OcI{B)9KLW<|g@kZ1N!yi&^F1#eAwfYS=O7?4^R-%CcEBPjD`4St4sc aja~!eB{8`G literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9bab6af5cac9ded2d72fd6196346d994b2c2fa1d GIT binary patch literal 26281 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fbf88c9b8d5ef10e92e0386abf65768997cd4420 GIT binary patch literal 26316 zcmeI5&u<$=6vyB0nvjx+S|x`FDq(?yBDl1{f*?v{V{f8O?X0z4E0#EbX_jCqSzESK zN-yCrs5o%q&|WxH9FTJB(FYFvxBzDk72<#pLOCE*RY=QwW4oRWjT$FNAp1Mk?!0+# zW;*kk*{4aoc`fJMjW6lZDV3O~12V}V!-LhWk)nT|iQeJ!lGJ~SxZ=qlZyzwZ5U@Y^px-yOCmbuJ>V zsg6*p&uibR_QsR__9uCC;!AF)5qs~Q=XNYm%3ES44NF!z;;oV zds(;LH3qhd3_PRTzB325>(X^rxAkTyv)5-xiLPPMg6tcSGXdd0F^@p3kK zb~#@tP$jIlg0@Ib*a=$OLG5Zi+#+{ng`Az*R^Wfw3}~%nuNH$%a$EHv?9@8-Mo1;s z^S!L&Q?9YSUF!s@i5IjR+q=r{HYK}ArCKv+mFw37D%iends4sADmFF)T1>y&Y#w)M z#g7-O`3-})_>Nz=@98b~Xq^KV00JPu1mGXA4L|?{jv@g6a1!5yN&Oy4 zeiB0h0T2Lz7y00?LV;2$&?5J3P0Vg%qH zVmMF$0T9p#z&~g(Ac6o0C<482HRb&%kxccb=@8BH{2n$E2!Oz(6Ttal(suyn4+2LM zc=-9@==!jL00>MD0h}KuXRR?)5Ezxf9sbL0Ni(B1M12qdfw3n5|1kDTj0u9ki3z|z zoERM%g231lfPWbKCB_6n;KT&rA5M%84MAY+3BW&${Ssq>ATT0<{rCyOi=CsFsTZ?)jUHDqcrEp=rOEZ=bBCT#W0Cu_FTI*`-`Z*~WESS$ zzPzZS%=&iK*Z+f!6?fh5>fu92oXKd_vmA}L#_@VP>R#vjBqyIE7)ylWHl=8t^A9-p zICAk0a|sWD@mcqN-*(8PiaEF7-C$`tla%1a|6-Ex#NBR}_V)HD;?Ev`Bw{faIe0N& vDvv7$>bzGe%1}0(b>~ISWiLzA@}IcVdwjjlit=Mb=U6M(l7#bSok4#Cvfx*X literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e549533c195d5e3a5aa40d62e7caaea2f6e64847 GIT binary patch literal 26248 zcmeI5&u<$=6vyB0x~WSdYPCH?Pzehp6v3rGEC`}RHufgk)b?8IwPJ|_xXltQC2Pxe zO6ev4LB)X+hxS@=K+3I0A2{&i0-QNihyy|h<$zFyke2txc05iKQHM%U`}qPS_cf`Og z50`thIm@JVF5IK86^o75`ch@1DDlf%RunBh|1w2U{Hxdac<=KZTW;QUEdT0-3t@b; z9daN50w4eaAOHeWLV(vJdQVLjJ+JFgUv!)3$-bofLZ>5DWrk93EM-^n)oLlTVn3&B z!;=p+DaboTdr{R$(xYpl_jtV|_0N(li)JXKPv{EWpxgAQKO` z$j9e|1kCc1up3(aWOfuLijW&4t6XH)!KP~cg5o#$wS+PuHdGd4+8Dp8r!99yo_ zHf`}N4pqs)St3V{v*uS|? zoUpmwdAlCcvg;MfZpF*mBmS` zlD$$0*2!(vgRoQU)Egm{T+jD1j!)Uf=4P!Es3u;}ZftI=az7&|p9X0T37=0RJ$80~rti0gV9sg9ZaC2!Mbh(ECxZN3qXYy%V0s8({V+Xajh=$QxCHL=Uv7)cj@uCR zK>!3Mo&fyA#1An#2m;3@0RM1obZ7_y6HfsCVd94v9Rz`66M%m>Haau}fr%#o|1j}G zj1Gdpm;`qF-&)M|-=CC7ox^WBhdx7W5+_zl6DnHlIl4@}c+;zNQpNDK)Vr1@*OTo& zJ)^EgjxW6Wde(hsqrH$`IQQ<=ODf8&Z&!W&pJOb$MZc?u4;`~7<5kaaG~OD=>+Psx zosZKjK1VQ?2*qtmQjyaSIrTVl@fLFl4}kI6_CdewK#|I3-Mn|3wRk!q!Hb`uNO. + */ + +// 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_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp index 85fff0efe3860..5d95aa4920b42 100644 --- a/src/importexport/encore/tests/tst_structure.cpp +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -596,6 +596,140 @@ TEST_F(Tst_Structure, timesig_v0c2_common_time_glyph_uppercase_preserved) 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; +} + // SystemLocks lock each Encore system to exactly its LINE measureCount. TEST_F(Tst_Structure, fit_spatium_first_system_measure_count) { From d4d5facdf18174362cde7eb6c098b97e1879c6fe Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 13 Jun 2026 13:00:00 +0200 Subject: [PATCH 20/33] enc-feat: import hairpins --- src/importexport/encore/CMakeLists.txt | 1 + .../encore/internal/importer/emitters-orn.cpp | 76 ++++ .../internal/importer/resolvers-hairpin.cpp | 186 ++++++++ .../encore/internal/importer/resolvers.cpp | 1 + .../encore/internal/importer/resolvers.h | 1 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../encore/tests/data/importer_dyn_dedup.enc | Bin 0 -> 26034 bytes .../importer_dyn_displaced_to_staff_above.enc | Bin 0 -> 26085 bytes .../importer_dyn_snap_back_by_xoffset.enc | Bin 0 -> 26019 bytes .../data/importer_hairpin_barline_clamp.enc | Bin 0 -> 26202 bytes ...importer_hairpin_endpoint_dynamic_wins.enc | Bin 0 -> 26202 bytes .../importer_hairpin_ends_at_next_dynamic.enc | Bin 0 -> 26123 bytes .../importer_hairpin_snapstart_at_barline.enc | Bin 0 -> 26202 bytes .../data/importer_hairpin_speguleco_bit0.enc | Bin 0 -> 26090 bytes .../data/importer_hairpin_xoffset2_snap.enc | Bin 0 -> 26169 bytes .../importer_two_dynamics_in_one_measure.enc | Bin 0 -> 26184 bytes .../importer_wedge_snap_back_by_xoffset.enc | Bin 0 -> 26001 bytes .../data/ornaments_multi_measure_hairpin.enc | Bin 0 -> 26314 bytes .../ornaments_wedgestart_at_measure_end.enc | Bin 0 -> 26057 bytes .../tests/data/ornaments_zero_hairpin.enc | Bin 0 -> 26057 bytes .../data/structure_pickup_caseb_hairpin.enc | Bin 0 -> 26143 bytes .../data/text_text_block_latin1_decoding.enc | Bin 0 -> 26019 bytes .../encore/tests/tst_hairpins.cpp | 408 ++++++++++++++++++ .../encore/tests/tst_ornaments.cpp | 72 ++++ .../encore/tests/tst_structure.cpp | 29 ++ 25 files changed, 775 insertions(+) create mode 100644 src/importexport/encore/internal/importer/resolvers-hairpin.cpp create mode 100644 src/importexport/encore/tests/data/importer_dyn_dedup.enc create mode 100644 src/importexport/encore/tests/data/importer_dyn_displaced_to_staff_above.enc create mode 100644 src/importexport/encore/tests/data/importer_dyn_snap_back_by_xoffset.enc create mode 100644 src/importexport/encore/tests/data/importer_hairpin_barline_clamp.enc create mode 100644 src/importexport/encore/tests/data/importer_hairpin_endpoint_dynamic_wins.enc create mode 100644 src/importexport/encore/tests/data/importer_hairpin_ends_at_next_dynamic.enc create mode 100644 src/importexport/encore/tests/data/importer_hairpin_snapstart_at_barline.enc create mode 100644 src/importexport/encore/tests/data/importer_hairpin_speguleco_bit0.enc create mode 100644 src/importexport/encore/tests/data/importer_hairpin_xoffset2_snap.enc create mode 100644 src/importexport/encore/tests/data/importer_two_dynamics_in_one_measure.enc create mode 100644 src/importexport/encore/tests/data/importer_wedge_snap_back_by_xoffset.enc create mode 100644 src/importexport/encore/tests/data/ornaments_multi_measure_hairpin.enc create mode 100644 src/importexport/encore/tests/data/ornaments_wedgestart_at_measure_end.enc create mode 100644 src/importexport/encore/tests/data/ornaments_zero_hairpin.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_caseb_hairpin.enc create mode 100644 src/importexport/encore/tests/data/text_text_block_latin1_decoding.enc create mode 100644 src/importexport/encore/tests/tst_hairpins.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 6f157fbfe4d02..e4676adfc2758 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -80,6 +80,7 @@ target_sources(iex_encore PRIVATE internal/importer/emitters.cpp internal/importer/resolvers.h internal/importer/resolvers.cpp + internal/importer/resolvers-hairpin.cpp internal/importer/resolvers-ornaments.cpp internal/importer/import.h internal/importer/import-options.h diff --git a/src/importexport/encore/internal/importer/emitters-orn.cpp b/src/importexport/encore/internal/importer/emitters-orn.cpp index ce60d959ed540..9dc2a87f71e84 100644 --- a/src/importexport/encore/internal/importer/emitters-orn.cpp +++ b/src/importexport/encore/internal/importer/emitters-orn.cpp @@ -219,6 +219,81 @@ static void handleTempoOrnament(BuildCtx& ctx, const MeasEmitCtx& mc, } } +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) { @@ -392,6 +467,7 @@ void handleOrnament(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) case EncOrnamentType::SLURSTOP: break; case EncOrnamentType::WEDGESTART: + handleWedgeStart(ctx, mc, ec, eo); break; case EncOrnamentType::WEDGESTOP: break; 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.cpp b/src/importexport/encore/internal/importer/resolvers.cpp index fac2d46241d89..24a92361bd552 100644 --- a/src/importexport/encore/internal/importer/resolvers.cpp +++ b/src/importexport/encore/internal/importer/resolvers.cpp @@ -29,6 +29,7 @@ using namespace mu::engraving; namespace mu::iex::enc { void resolveAll(BuildCtx& ctx) { + resolveHairpins(ctx); resolveOrnaments(ctx); } } // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers.h b/src/importexport/encore/internal/importer/resolvers.h index 3893a99ea492b..67b28ee8199bd 100644 --- a/src/importexport/encore/internal/importer/resolvers.h +++ b/src/importexport/encore/internal/importer/resolvers.h @@ -36,6 +36,7 @@ namespace mu::iex::enc { void resolveAll(BuildCtx& ctx); +void resolveHairpins(BuildCtx& ctx); void resolveOrnaments(BuildCtx& ctx); // A track derived from untrusted Encore staff/voice bytes can exceed the score's track diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 719530acc235f..d43a4422b46b5 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -37,6 +37,7 @@ set(MODULE_TEST_SRC ${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 ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..80a6a888cc8fa5b868dc5b6bd59253b51e64e106 GIT binary patch literal 26034 zcmeI5O>7%g5XWbCO-M;ZE$AUCl`tTxs9f4&K~U4m#@Ar1&3lmkK)LfXzeuj5^l2yA+?|6}dGnR)N6 z_Wj=5(M!CURC4ylm-O$FN-WR;>76D_RX7YFw| zS{}~j4V^Z*@_@QVA`x%(l`?}M`RipP2$o(tOF=OE^;>*?_(h&Qw;x!Bd-eQze>UC^ z76^a<2!H?xfWVXx;QdH+q&iE~j_N@uQJ?6kP%@#=$v`!kq4Yafa;t@EwVYiwPb=Tx z;sZ@83RcNnQY}*S&ppv~-Y-e}7fH57Gvw2!v`jbY9{v6JkAvTS`T55|e^UD*;+AR) zrLKAVTh(8CKJ1^OEBfbJN?+3Z{flO{a+li<@2MWJFU?19Pemj)kC-;7MRn?s$E8U5 z75Z4p*X4AS^c$Q#s#1KPoax>0yco+}DrZCyIXJAlAm(yLAJheb+_|tB@6OS`?3lSU z@1To3pEr0m8`S3G1D@LyRjEuBF7xcULA$iW^X;pe;rZv?dxBYm@Jb?ObD%C_S=S<9tdV|%;S z@l+SbYd5xcRlVJm;sTXxO|Mm{-|?tmx~}O+`$ntS*!1XP`u%3}zfP^h=VCR#p)nWN zatnVs(VqLXpv*H-Cv*@10T5sUh!0o>AOHf#5kP!6jtu-D00R4@em^8Xi9tXB1VA80 z0P!J)0|p3yK!gC|Lj(p?5CDM~0mO$G4j3Q+0ucg;4-ptpK>!3|1P~u$IADMP2t)`V zK15(Z1pyFH1bW|U%KK5G>FQ0>0h-tNJ!}XFfWWj9!2V&{R{+Kj0>=|LdjD{Id(?mc z2uu$F>>s9Qt}#*&7?r>y{>yEVxluc!JqUon#1lY#nD{Bi06}1E0*DV|qeDj!n0Nw+ z4--Gd7$69YO#ty>Y;@=d0uxUF@nPbp7y|@>5ee*v-&)Lv?@vmm&cQdGgP0*c$rCHJ zNtG@36un8k%*<=_tjZy5sCO-OZYQ5d^qjgEIluhI+d2Eat@dJO@zk}e7gd&7*Q~nH zeqAUvEd9>wKPOaXEvrWGHS^ib`DG;?m*F#XHQ!9|GgD z@58Wes7jeRyWreoZ8npXBt3Dr+oip|Jqq}<#~;a9%qAx<=0lZnN8g=y){Am0 l8&=6W$EB=g$<6%}U-U5_m)KExhUf+M%CW>Lz_aYr=n0@QG_U{w literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..626e7073c3ae9acadecc24afc27fb8bf88bf43c3 GIT binary patch literal 26085 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 DZl*nx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4f6b0a34879804939e00ba8bdadf1b690420dd1a GIT binary patch literal 26169 zcmeI5&u<$=6vy9sO-M;ZEa)MEQVd8af=ekD1Q8+|dmTBoz1Di8Sa1N-EWuK;wrr;a zF8L2CBsg(suN4QR+_{c*3P_nZ)P>~ znc1hYym=|7?d>nD|QS(&))V%NsXD!9DMt zZY^ZY2<@=(ggRz48gBKass=&qm)Fc7Sbh9i3WE8sUgG@L=NZ-<-prZql}nfW`EWnf zKmY_l00ck)1ZIQ)pGWkWMmXtpT@HFl*NGhOrDQMBQlOg5QR0>L^hUl?DWx{7$CPex z^MNFWe6DD%supoNdnS6D&r8z&QIccP9QpJKtgFd;R0I9{J3OM^+>K{sk*l{+QbipQ&!LF2Ps#OhqJho-u7xlWNo^k4s6V zr|1bOmn7q4P*#pQrS5>p}uWRR3Y0Pqi=iTjP2V<-*$M$76dXO z8QP>1>figK?2q+P;~lo?;~lo?;~lnj$16)b*?9l|{Y#FOPVhm#z}xC7Z>l=A_3zCtOrER!p4JxwtqZ!Xf;-~Z8lax}Xxo;$7d?|!O)Fe3!?m5*_y zTyQsK>M`D8|K&N!`Hh42>ouRUwo@qETTa>{$0}#?dD`-8O|Kz7&|p9W0T56Gy1#15 z`%xlA)SIRQG|%vR*hnA%0<%s4=Z9I}0hm7sj3#jJ^TX)&uz&yv%nkvZA7*E*F;fs2 zm%wfQ%WX*u<90-Q5CDOxCjkF2^-GKig22QC;2$PNhmIgH^#tG_rhbVrK@gaj0Q|$m z=+F@ark()&!_+S^CI|v!5;*StYO&aRe^TPZ41Vbx_zclWoLI5ORJ7Rh^dfZwL~qiA zDh9Br-nEQyJNewEhtyakfAaa4)ApOYt(D};`M0iIR#9eMtK#ba%_3_T-HskUbj+EI z*F441c&i+*x2^IT=d+w#j$kYiidz(?BA4%T>2T!Yedgj1fpJ;)Ue7jUQq{DbcdoNE zpNvWH;`^8+JaMPfp`)WC3ixxxABkAZRSsUvmny@GzB=P<6=W#exnk})E@dxEi0~iz Zrg!;zl@;X&h#q0BT&o-fJjFVL{szihNJanv literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9cdd12a2ae890afdb6129e858e61f52eaf4a0882 GIT binary patch literal 26184 zcmeI5O>7%Q6vy9sO-M;ZE$AVF3YHI{h+djtK@cUfu{Y7Cw%1y(6-zyUX_jEgSzERf zN-q%yt`!GP9NKHu15$21As;yKae_043UNRPp&SsZ5YqDAcwMhU4A^k8|6}dUn>TN^ zp5Ht_P2$aKDPwPcPXAnt_!1pcj5N};xt=+T@n2Hk+|&)#Uk-1gbE}0i(bDEkQSi*u z%k71%9-|!|Jf^N5j}KdYp}Ikk_~~^$2-co_fr4QE%a^&n{b`mp*KS$5d->u;e|~r# zdLRG-AOHd&00J{YfX^e*mBzS4`%yUvOH?Pa5tdX~=xm@&<|z5#;kU5nGG(=jY9I=`*(NovH3=vG7b)W?ho6-kFN%mb#xo+ti{O zb;#pZO7R)`Pzj?dHh5~(n9_fAzW0U~rFW@I#gtNWbxi%S71^xy=vY_h8-LKZJf&3z zfAcSEK_F`vx|6LP^NWs|NwUA>*(0v9KQyS#*E{ST234p?C2q5aOR$-}Gp!Xl~}c9kN?B z&+k+_wT4ec+i{(=*4VO-Jk-t$bt0 zqt)cw&E~(|T8Z%aN_JagF0SR~es`iXk7!AC&qtMzK>!3mfC(TzzzskE1V#}+d>BOr zdJq7CBT~O%lApv-KmY_lV2A+X!w?R1KmY_H1P~u0Fd%{e2n-QGd>F!k4hVoigaG10 z1O`M90D&O_hz~j3#jU`C+s@MnC`rW`_XI53{q^SSbjMOW-d5<+hZCaVNqa1VCWw2_Qa9 z{T5?^ATTij#D|H|VIl}jJpsgrso!EO5CkSBfcP*mI!pwCsV9K=F!fuE1%kks1dhUQ zEf&M)CnZzo;G51t%n+UAiIvfW$`*T$UZP%Z=Pi0z%fB!&3NEUrDPRko7RKLNUdJ=g#M literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba7696ba88f1a5030831847f68d00b3f768e2bf8 GIT binary patch literal 26001 zcmeI5!EYN?5Qk^ICZr^y7F23PTh$Z^MQ|y_f>c0dV{f8OZLhW7D3*Ev(`-{q$=b4= zQYxYJFQ|Ip)I)o%I8@55M;|z}3LW&7$rPndpUE!eE0t1a$#_fY z`jhu1>ByT!V?kLY>EC;zFL}Qt_HUAGi>An<@90yyMAzuay0lMnesWd9o$phVqJ=l?w*QB+Pq>~r6$#=O)l3` zt{?5b-C?ORSG7`FsrmFs<36=R`*xqc(%W=QEK5D};GF_GzAqn48vpNuVSLFpvMI)2 zV0WEoXVt02#|3s>k}8b7%yo`6=V^;J*`1#18FVji&f_%okSe;)*S1Cn=*CQHdX1%n zRN*^DQ)l+Px>)vnIi%3LGo_YmKx(-M2dB-M#g!A3(DM2$J^13g&9 zDIiP;fw1Qj+$a~EWm$THFR@|n6Vq#KwLh zZEm>LOEqtuti?q#H>>Ne^G(C0<&v>faM#Fc)?BY$ZP)4^l`Pw_Gp0k?`o>1J?W!TR z+p2GDseY>=)dy6nHr!^pcEzQ<;W&mZ_Vs3=zUI=Y)K`the|7CF8x|_LRgJkgrj!52 z4v*ZSIn_KGRzd~=5C8!tfcSvR00cnbSp*Oto<#{3& zy$O{q_7HtY-Nehw^s34stgB}&V{9j{yY!m+baFoa{>NGC+o7eb$`^y4ruHUi@_tN?Eelpq) zB@h4s5C8!X0D&nXz~>QlraC9Jtja-{RGpC1VakSyP6x8e3}xO}&KFDddNo%xpO(5& z=OalNN>;^Ok~Pxw?=#W6d|ndu&yYA4&5%!@(M7sOH|YMoy9a;#_UrzrJ;`$saZP!I zl6_wPPPW&c4BMw^S^q*y>r48uec8;_KIOXcGu8L3%kVWgQxV;g{iSK0I@F{dd7O(> zeV(2b`D5b7Nv9_U!tnmdS!FyIhgy*zP{g*Z+V-`GEvwpo&?B~-Y72ssO=w>mPG~;t zRtu+gxLYEBJg@%o*n~{g7MoB}wZ$g%j%piBNU$Dvg(BmP^c@>-WK6N~M&=S5Z-l{M zykgIeH{M6zzUr9y41cpI^FF`IySznRzTW3OpQbuhsm6JMHCJhew)r{mr6vAc@VA-- z&HPMt!(gYN(JA_THj~_7=`_{Z)oAigl#@@Hy5c91hnrO7VhuztoL9Whf>l|2fl^w@ zn4@oh`fKng$18>9Q-}It?}rKqQ$j#p`4kswWp`CfJ;ht>B+rTEw|9DPH+@>MopQ}y zbMhuRX01>v(VE}vcwHe`e$VS{d5vpLf0OK$6|%M)o1Xho+oRR0SuA@SWOtgL-)r=m zEuX5k<2pIZrF?5^tI_jh6UXbewsxf5Zj0;{sy5nQr`G(~qmt>mrX%XNI_1`eM;9{h zwcG!9Y3I0Nxn5Y;n2T$3LpRi3IX^B1qMVA0D%Yr_=gA%6hHt36aw%M3Ji!K z00I#L@DC9jD1ZP6CfoUgz^TV|7 z0L&i*jwbNn^TW~gVF3XUm>vQ+KTOYBW2PW*TmpCaKet7iJ#ItP2LTY6cmnVb6Tifm zAP9_20RCZYbZ7_y6HfsCVd9q<69j>=3BW&$jSdY#VB!hDKTP}*V}c-XOago1uNHIR z`;#I*%;=ZSk%)6}PX34^?arU(9hd-Ui3(?a6zS@6()Ij$kYz6n802 z70y58+~LT@+sws}fpJ;)L1>GaWHE1-oEt19vndh0_#q|{p19xd)9&sr1^n6NkBC^z zMGjufm&~Jzp}OF#mBmojt%`MybFr62%<^Bj(|dfq$%-0>CVz^x;#%U+=sDJD^bZVY BMC$+m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4cc45f68ef3be7e89019ba3461058b13ea398c23 GIT binary patch literal 26057 zcmeI5&u<%55XWb|CZr^y7VRN|A`D0nr&(+SzESK zN-y~jDkL~@Xs;Cqq}+P+fdfA-z?nmZI3R>j4hU5h(lYbB8}Ei%V7Djx`>fsf-pss> z-{-v@jpNOvnzuH;qW>=Gu2_&b1b_iI&!Hi-1Qy zy4+nT=n2|lbLiF-(Pv*HS+!WZ{Fhb-7gC)xpBwTor`DBdh_vd z=z#zTfB*=900_(o0ggwaGfi-b_M>e-C{dZT(?Q7wg--g)WR5cLtmfB?^?D_@ZalAK z!;24Fq9~eWV?|k{>EAKY2OKYn{V9^LXpTJkj4sf1xqh*BLGR9A7eW)lb>>cuaMNWf?vOF%^;YdB(I!9cogKT&{^@ zu*%EyqEZE?5bDt^KRGjaKZ{~;p-+`;V96m|s5>WXu^QFg*Fs%Qg28N5_kAMN-IPn` zqB`G~3CRziY(f}cv5kC&1M3nm^A%p?7Ipc!%1fT6I#sC3b%7;UXpeSz8+b|+-xjjM zT9W2|qPnj0J)zMF`g|cXzs0SSROdTQQ)ga0waLOA@wj!k`J|RTAhq1P$FEy3%e7Z1 zr4{u>`u4}aWG9a-Cyq4X)sF;d%m{(#%BQ$fEjb%9^%QTh$GJ~Tuf5lMzv;=wq57rwo4loW4+{Vk=1FsUa!$> zwmhm>wqxf^hw`nRokq`9Lu|L(+SyZjt1Z3rtH zcKiP>t@!m)y|Ae<7sqsp5AEp416oquGf^ShAOHd&zy#nQunj-}1V#~ne;7puau5K4 z15&?Nl22kNAOHd&5F-Hp5W|5E2!KF@0Q^G)2AUuM0x<&c4>26*fB*mlzWS zfr$ygKTM1c13_Tw3BW&0{Ssq>ATTij_=kznVIT-hJpuTKsb6AD5Cp~~a1eZJu^2o* zDVaKl-*gUrhR7sOtY}jzTkL6ilLiT!H|S}VLs(bOS|->|Uiau3^5oF=sNKd5*L3HaK5zPo0~5p5@|j24l%k+@&;? zx&DZ2n==>hG8gX{7>8xo0^Ko{>gKJYeT!T3*_0$Neu72P6ZiXl+TY(NpI`g@l8nXN z<>bYDs5&kP#Ra=olDD#Hmd)3>mc1+~yMN}3KH%eB7F3@idXA-XtVp_SmTB}4BSt%I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..55be83911e69153fc4ad20b304cb12e7faa83f99 GIT binary patch literal 26057 zcmeI5&u<$=6vy9sO`4L3TF^rTMHrA!1edm05JZV=>`k<(?X}ix#S#ZF%{H}^tS#Fq zrI-8%6%w2{wAYFQQf@u^z=0nZ;LM>y91ub%2ZX8$X?btFu4mImV3(8q9cyRakC~O{ zGvlX8ym_hSoz1W4-%C2VM2D0hgA8MC(4EEPAK4#nnrWpkM>jFKH6od4Y5k@cc;utY z?S+Dwpe=SDQr}D_v1@swRpsRF){=WC;;qSlxa(6VI)VYYbr#eEZ zdtUoajW?c(#;0jD@r992tR#lxOIEJ>Dfc~krn<$p3}1sY6_MyXW7?z+HK|8Fw^AxE z((}?jA(bj4NvWTl8N8oG(Ju65-2V(7arx6bLl zFq8=ivdJcd@fFv~XZT=U;$^}1Waizgo_!@EB)K$sB%`pTzRsg}GAnR<%1*yDUo?4Z5Zd$$?Tn&XzL zPR-3*F0I7%QoXQgFc;7EiuYZ8ywZ_o*qL)cW$S|+%kyzbDG>b*$)!fS8lo%go8 z%h~1A?_WHxvdnr`-P8ZO#F|s~`g;1%F=sMf_Z(;AZE(Kco~oN%&vNlNgRx{N?oyh{ z+v-2Rg1B(Neq-&-oA+i` zGoP7#n#7xzO3q&Y9Dkoui3J=$hXzfX37V6b_%-wG4I`!Mi{VWSDz#7s7FKVtf_mOR z-I~iAIyT67fS!>^#9Mu#s{MZQ=huyXf9c8R(eKZG`7-6VKFgD4_oijI7tfycXXE{( zCIp0l5D)@FKnP3;0XmOFXR1?*)}wMiOi>-nsW4^2gp+;MWCrP1mvgIyTCJR2HJ?zr z;mwB<8wys*Tv9Dk`0JVI5}lX0{o~+c(F}ZigbTQW>-gi|-Gg6#`ti=NKh(Jh)KVRx z)IE2z`^VeMukmFPhoPRcbqWrn*VGG+n`&3g9};80%=Gfi65MIc3fa zInLt@X?9tmVVITc7=tCP⪚nk?SeeERG*WH^u~FsRDBjntzgXKtM7gbo{!N+ zVg1CResK3gg$PqZAiDBNs#J>Z8c&@ZruHDuiRHI;x^Famtk_PmVppA<3CFDD3k6jD zM%(MKWcgjMz2()fH2h82D=Vzkf?wdG;0Y_1l)4cP65=XdMfM$<>xc3dZGxyUuQ zw(4C^b#c5-b8AQ0?G_j3P_DPUcBOI6L&0=i)8Y2bcCoqP;e7g?R_mWGE&JtSEx)c2 z7uRwNzdO;M`&dxb(@~|!LO=)z0U{v&LAHSq5CTUL5dUx#8PN*?A+R6(!is+qlOzO$ zfDnig5dRRvAr&DY1R?~)KSW@NCWkRFR@He2#ifY{KMGjq@xg+cmm=dCVq)!ft z_4dW{D$1;D*4*g-tXQ#2ZZ8@>RQ3L^vMfd8ty8?-uF4yfPjhl9f-y%Z?jVH{mG4vO zP~_rm;^L2haY^@HXd5x9YR)b=*GZbqBsqBTznC~Yaj)0I-rgSi^x2~iM=Yi)1uv#c vm2t&Dop-859?H5^vRjI?|$QMBwV=`{QaZI?k` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2092346f72f80befc3ee5f65d8b84519434aa99e GIT binary patch literal 26019 zcmeI5&u<%55XWb|CZr^y7W5E71q(=31edm05JV|9_9oiY_FC(WVu=HoW(k&@wPib@ z^pgLe;=qYRd#!pv%B@EqIPl{H=N_u62ZRvH0imiwT4tWt^{%M}b~)MKXYIau^X9Gn zKJV>l5^pAzoW1=m{d+NzD|Ac=(n!-5`sOSqf6x4MT{l#FHN1$GPDGP)C854cX@sij-PZAa_kWXLH1v;Q>^w)zwj(_{*{@vktQgIQnr3#_c zHLv`r#%s@p;|;o)xTP71wZve2(acufXWP-3>IUo5eDq@~B5CuCX`9+qr!IM1ODVrX zFDYT%>C4mo&$BEB7f3H>29`k}Z!XiD?#(g2?3lSU2f;;NlFPgxP3rLRDKC#fRVq`3 z>pW{N(?0F-lKHA8wrZM{k9(+^7`YO__R% zx7d^1CzjvZ?|xACY29{;6?@CcndF$2e4#*Fe!cB=M6&#@*KT;VgSx*<_WC+md$nE9 z{i5a3X4%{*dOKvd>z?1Ob?Z%^%C_S=S<9tdv(cz^JvGGfI?cwu>bF}`ouhKC<+UsI zs~#0h*EJonZ?=og9gohZKWVl8>(Yu3E>`o~8gp?ixA4%3jy$3j)jS!34f&RCe@_dv?3H7Au z5Y21+95xCFfWW*HK>RT8I{@q!2A$E{4hUjjhTYLqy%pBUv5iT znsgxSK>!41o&fyA%r7w}2m(_RfPa`89R`BH%oBirnE55f1VLbG0`L!0qr*TDn0W&5 z4>P~Sm>>vDNZ=^^)?zt)eo``Z4!`Lf`V7%Yo>=Kkscf;Q=uPS;SKg$jRSscYJ!_d@ zJ9*uvXVhnr^UH6%owGmO?W|^2PknUhyvj1`npHRYpCH!llG}@>51nu(lbL5Z8*h#C z^>)>{&gXe9E@v>748Tsxe(c#paGBVb(CeHQkObg5>}E;!d%TFj&*dGY^P zBt3Dj*Q3M3LkjqH$S=uQ%uP;S%!jJuih(-sY!&6BY+EJkb*^PEOM2~le9;GdyvvHp qQ$)|PR*p5!;+. + */ + +// 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_ornaments.cpp b/src/importexport/encore/tests/tst_ornaments.cpp index 664002e58a0e1..7776d8d3210b4 100644 --- a/src/importexport/encore/tests/tst_ornaments.cpp +++ b/src/importexport/encore/tests/tst_ornaments.cpp @@ -89,6 +89,18 @@ TEST_F(Tst_Ornaments, beamed_triplet_capped_no_beam_assert) // 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. // =========================================================================== @@ -538,6 +550,27 @@ TEST_F(Tst_Ornaments, double_barline_lands_on_every_staff) << "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). @@ -667,6 +700,45 @@ TEST_F(Tst_Ornaments, arpeggio_attaches_to_chord) << "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; +} // =========================================================================== // BUG FIX: articulationDown=0x21 on a non-tuplet note must create fermataBelow; diff --git a/src/importexport/encore/tests/tst_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp index 5d95aa4920b42..542bdfcb74f84 100644 --- a/src/importexport/encore/tests/tst_structure.cpp +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -730,6 +730,35 @@ TEST_F(Tst_Structure, section_markers_and_dotted_barline) 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; +} + // SystemLocks lock each Encore system to exactly its LINE measureCount. TEST_F(Tst_Structure, fit_spatium_first_system_measure_count) { From 694c6d9fc49adf300e12b301befba2117d1d49e5 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 13 Jun 2026 16:00:00 +0200 Subject: [PATCH 21/33] enc-feat: import slurs --- src/importexport/encore/CMakeLists.txt | 1 + .../internal/importer/resolvers-slur.cpp | 552 ++++++++++++++++++ .../encore/internal/importer/resolvers.cpp | 1 + .../encore/internal/importer/resolvers.h | 1 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../importer_slur_cross_measure_fallback.enc | Bin 0 -> 26169 bytes .../tests/data/importer_slur_pixel_span.enc | Bin 0 -> 26057 bytes .../data/importer_slur_pixel_span_6_8.enc | Bin 0 -> 26203 bytes .../data/importer_slur_xoffset_unsigned.enc | Bin 0 -> 26057 bytes ...ornaments_cross_measure_slur_precision.enc | Bin 0 -> 26281 bytes .../data/ornaments_multi_measure_slur.enc | Bin 0 -> 26314 bytes .../ornaments_multiinstr_slur_routing.enc | Bin 0 -> 23881 bytes .../data/ornaments_trill_cross_measure.enc | Bin 0 -> 26281 bytes .../ornaments_v0c2_constant_slur_count.enc | Bin 0 -> 27102 bytes ...ornaments_v0c2_multiinstr_slur_routing.enc | Bin 0 -> 23809 bytes ...aments_v0c2_same_measure_slur_no_cross.enc | Bin 0 -> 26011 bytes ...ents_v0c2_slur_firstnote_xoff_mismatch.enc | Bin 0 -> 26011 bytes .../ornaments_v0c2_unreliable_slur_count.enc | Bin 0 -> 26088 bytes ...ructure_v0c4_slur_zero_length_overfull.enc | Bin 0 -> 12358 bytes .../encore/tests/tst_ornaments.cpp | 37 ++ .../encore/tests/tst_ornaments_slurs.cpp | 452 ++++++++++++++ 21 files changed, 1045 insertions(+) create mode 100644 src/importexport/encore/internal/importer/resolvers-slur.cpp create mode 100644 src/importexport/encore/tests/data/importer_slur_cross_measure_fallback.enc create mode 100644 src/importexport/encore/tests/data/importer_slur_pixel_span.enc create mode 100644 src/importexport/encore/tests/data/importer_slur_pixel_span_6_8.enc create mode 100644 src/importexport/encore/tests/data/importer_slur_xoffset_unsigned.enc create mode 100644 src/importexport/encore/tests/data/ornaments_cross_measure_slur_precision.enc create mode 100644 src/importexport/encore/tests/data/ornaments_multi_measure_slur.enc create mode 100644 src/importexport/encore/tests/data/ornaments_multiinstr_slur_routing.enc create mode 100644 src/importexport/encore/tests/data/ornaments_trill_cross_measure.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_constant_slur_count.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_multiinstr_slur_routing.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_same_measure_slur_no_cross.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_slur_firstnote_xoff_mismatch.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_unreliable_slur_count.enc create mode 100644 src/importexport/encore/tests/data/structure_v0c4_slur_zero_length_overfull.enc create mode 100644 src/importexport/encore/tests/tst_ornaments_slurs.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index e4676adfc2758..17476061917bc 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -80,6 +80,7 @@ target_sources(iex_encore PRIVATE 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/import.h 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 index 24a92361bd552..5486ff9720cbe 100644 --- a/src/importexport/encore/internal/importer/resolvers.cpp +++ b/src/importexport/encore/internal/importer/resolvers.cpp @@ -29,6 +29,7 @@ using namespace mu::engraving; namespace mu::iex::enc { void resolveAll(BuildCtx& ctx) { + resolveSlurs(ctx); resolveHairpins(ctx); resolveOrnaments(ctx); } diff --git a/src/importexport/encore/internal/importer/resolvers.h b/src/importexport/encore/internal/importer/resolvers.h index 67b28ee8199bd..aebc331d20101 100644 --- a/src/importexport/encore/internal/importer/resolvers.h +++ b/src/importexport/encore/internal/importer/resolvers.h @@ -36,6 +36,7 @@ namespace mu::iex::enc { void resolveAll(BuildCtx& ctx); +void resolveSlurs(BuildCtx& ctx); void resolveHairpins(BuildCtx& ctx); void resolveOrnaments(BuildCtx& ctx); diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index d43a4422b46b5..f8dd185fafbba 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -38,6 +38,7 @@ set(MODULE_TEST_SRC ${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 ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..2fe779260ff426085981bd694486ee20a9e6729e GIT binary patch literal 26169 zcmeI5&u<%55XWb|CZr^y7W5E7C0vkD1eZ2g5JahL>`k<(?X}ix#ZnJonk86D)|Ty* zQVHchsF2{qp}kfdkaFwM2M+wW0A~&r;(!oBIUri0NZXm`wY{4(0-LH*?eDX8-^{!> zt9_sMJbJb_lXBKt|C;{2B;yOTPchO+(`I_zS&aXg{_%#MRQh6Y5uIx-kck#nZ-{|C zA6#zD<@6YBaN|C8^>{qm>l>x|e&YAHb>Clp{$=w0*>7Lx^R2IPY`J>V)SZiG&w8^_ zKWHET0w4eaAOHeWLV))p;gQC;g!QoO2PJG1IT@66Q0Szuy3A1O%?sJpe6?E2tQyZL z+wkH;NgDEI(O6bJl5})W^d9e*r2n%d+oBor=yN(xSLiw&-1}?)kKca1GxR65FCy-# zwovMtSHD;O+S7r5k}kx))RM8~Sl_>3WXhj$-@|*Vn`}$*(c4oIiOwUYb!t(aI^=RK zrF4eQNWDY{{wH+T*VyBrhe6ClpPugB&%7w-yHrYxAZ35hc23kt+a0a%S2@!5L#*F+ z?MR#N%Yfw8#vM@q+K=*nWs1kU-`;p1XaACIWK+D6FYvOu%!{f)Z9YEWC6=Trm8i^h zjxCpIhqigyd8*~`GUN{tahmy=s=CgLPoop`#awE3gQb&H<#)EG&O{ltsAbtajG_e> zky_?JYKawrc~IrdV&z3jXnB2}zWeF#-aBW|8Wm0q)c(~61_)C^AUyL4R>}ouO@^M} zCH8;bC#Kij>AX|-XvMM%WvgOm4YG}LE}y50S8us(kxZ}Swzk~bmAbb{*2)T*+qF&C z`MBxQTFF=~xEo}(>aN$Rb?Oa|N|x=|8PlO`V{5C{ag~ehwi{bJs@-Zzb&g84rrRpl zuep>r9LKPwf1_1sY`C$5C8#1p!ch$JRc=eOg(8j zMDr3qhm8UPATaF&uz#5L6@c-B!0`kgyni^pKUzQl1g3`o_7BrD*BB`Xj7s1(|K+xn zxltX_9|S;P;t3!=O#BpMfFLk70mO%~(ZLY}CY}J|!^BT91_%OU6F__z8yy@$VB!fN zK1}=+V}KwqB7wc&SBv@J`ANytIsBz_7&F8sd19qCp|ZuEqSvUGAbO3SR5^fk^{i!# z`^oDzJ*DnN&M&+Ps>ozK%;9L``Y8H(GKq$1ZJacy(v;%(;Q9RlO9?Zcq$P?J)#R^GnO(rh{*$%`Li zk@UpfZkKjB5!*z0Ih+iR^iid7C^nkBWAtS#Fq zg-iZ}3JFde+H1uDDYqVd;J}XyaOO}U4hSKX11c4&w9GuOOf2 zEf4?!5C8!X0D&nX!0||QraG5sJ1PfZiRwg7g(VXfIvJ=gGn9VwVs52at5vcq=CevS zy!lWPL(wXm%c@6;j>bgqbG#(|PmzR0Gvw3fbb+qX4SI0@ul+xM`}OW{JgK;dxTgxC z)IG0%uf}UnhvQRpQU6j)>C5_He96pKKjXfKW2#%MOY_-}sfeV_Gp2QFQ-iwXaV@2C zmd;9Dpa;Qa`kMjxEQuy8pPuPI&-2p0(4$HwG;jzvuq_?2-PH!R!V%jK`oMPih%E?Y zLV|3(31NK2F>`4StV_JiS9p<|)Zz0(Uh)*xs6th)^Q^f-JG9N)z*m;T+k%7o1kL@rg%8({)Wp`ZwF9=7vX0=?_}1 z|GTvF+_6;4uWQW3wcO%APITlREvV+1s1h;=fB*_1SXyU{KLdAF(wEC zV-tXX7#kf1g22QRfPa|yCB_6nU~B^L4`ZXlKoFRC0`LzLzr>gz2#iQzFZ|VFK74;t z^1}>&=^XkD(Mg_Iu_jfv*wgen^%FL)(vvEOu%X_y)VZG=cjzhgEOLF})$=*~-ObKo zX7TiUmzGqPS=X$&(f=+{u*+^Qnm%;InT*yw%h`DAoUga5t_{9Ub8$I?v1BOjP>RZ2 zf5f%JnTxlXi+>1=%et$f?T|?|b9T|W!P0CdDanf;W0Ca4yJvoIuvX4x&J3PqoksrxF0(;+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..003b77987fbc7833d8e1a4778eb4f91e027000a7 GIT binary patch literal 26203 zcmeI5&u<$=6vw~2HX&^yYC#VXRK*2>BDfS|K@cU_*z0Ih|I&J+Smi)Xvm}<1wPiac zaLIpAap1(Ey;evd<<_GQ9Qbho&Kx4d0U?BZKv1Dd%X{NrW2jXeda~cMcIVBTdE@M7 z#?Rw;^HNF`How4wB^q8r2PO;{#$2~Mso~!e-`xyFReiZPNQg?KrvpoCH`zd*_m|rX z>5z#nYV4pL3WrB~eW|KJ5c%beP!Oy<^*n-L{;OB$eEah>Sq^U5A@}mdi~jtmpVWka z5D)@FKnMtd86iOX5&cR{61}eLK~HoW>(QPhdV({7>N1D;YpbcXY`I)au31ki+i>uq z#tm6JZ>^{vQT%&Pbd~l?-2VyiwrCDMKE@?n#|_-O`+MitpMN?Y`a|uDKt0tKN{xB& zjq*1h>-k5qYJO@&%@wonpR?K@Mt zXW8tJ>f;f>(%XW75%VPe6Y-m5doW`0FFIB#PRMh#@UGFatD#B9d$b6nD5HoHm1(kE z!#?(CVfw0NbfFSz`uu^pA5ad3Xn7hqg-;gZ^IN2yp;_jSDtx9Tq2`49KMbMOql8Ny zolA};%BY>vc7Ed-M2u``5nq4*2fwz)%Bew1fAoPtlo=tQ&wPX`rJTFYmrk+N_&=`` z+pq7p-m3b@6r5bCu;HXEI94g0&0@o^HoPWlw%_s^yI$pb)!#-TlYzZg+4kHI>K@jM z)>_WnLZMOh{8pt^t@$Vx9M?(OE>gAK-Ac<-E{@l%?e44gLY<43P^{FwMydLthpgqg zmc#vPja+TZLoEJoz5ZXPmfu)%<@6@MA-J}i{maokkFcbw=X9g!LO=)z0U{vrL6(6K z5CY=}NPHMaM(jdB2pn~PvBjUnM1+745CS6vBtDGbkctox0vZ8{4;lh$;kxzz6|}4z{$YH7X%PZKV0H+|{$Y0JTCP+GOiJJm{pB{x z!laJUUkC_+sV5-uVd|$?E>H-Zn1IBG6QdJHAu#m>BtA_26w3t)ffEyu_;6x$;wS{B zo`A%Msh?uGKp`+8fur8H7K^?6CplB+@SDzI%wQYmiRIRa$`*STFQc16dL56d9Ks=W zucb--_;IJ3J6WBde(}{*;qC3_a$@=HJC|cB%dBgaUH$)EWD0q=t)~y2@Jc4@o}_HN z70TD!Qs*k2XIWgzV9Xhcn}{M$<@;1Rl(~41xNx>V)Qu1OtR5@zso72ulj(?O*J8!>0C9h>pBK{){dY6vZ U$xxyc*^iTzj}=bBO*R961L2)W`v3p{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4109c53c6a03f20554e12552fcff3ee9e4e8bffe GIT binary patch literal 26057 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3edfc341b69339cff76a2e88891ef811ef3cc752 GIT binary patch literal 23881 zcmeI4L2nyH6vzK-o08H`ig7z1DiISmJ<8y`+|swPic4 z;F4T=MI3tK!e{8MM;|yKjvP2~sQ3gq0YXUTz42}`Yb7nH6&L@XwL5R#%$wQ$&E#p5 ztY5q0?f!u0%VtjcoH%fh$vMyO{y+Ap*P8CbrTNag!t-p#%``YDEsbGU^}f%Q*teWC zHr#Ui7cut5aDrJjr`SZkmt#wgAGIQ)bzl>_7-N7bLMc_g-9T2$bv@mlp;c04A*})m zs%7!_Jc~c(Ku)x{QKw6si^u>a+xe-Jv~|P@m1D;q_|Zlwp7a&q1NA#T9W^MF;z@Hd z@nx~+bc94skT@6ds@$UO{M1R>I_h{sCp}Iapa9njxS}^H_=54Z7Hi_xr;*_$WDk%%ks_uoE$Fwif%S5Ywifq@2+gE^+kIf-AqPG zJp*O4&rUJFpyI^-TI%Rp&P8JLX;G~;JMSRx)U)gO>F3`UoB6agt>`9NNHTg-XSpf_ z?3K?;rQHa2^xpHb#a`s+q!x`1ryma@YveRZ!8n|#R*RnhWm%6 z-5Y69M5{Ln$L+z_A?j`rxW4uujvK>%i0fNlj7I-ooD~)Jc}7>(>$@-nm5^Eui`_zWeDy_ zLf*b;S@W)CM*8XJ2_zM-h@Rhm|3=08Y;SU{bnWWrpI$dvW&^hyteGB*)3(USD?DZ901?L8@VFKgEB b-(=7~<+vq=wq(hEL#%q-lyuT%u{rn$tCa07 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bbb5966aa6160687dbe9028e9df0749d0bb83968 GIT binary patch literal 26281 zcmeI5&u<$=6vy9sO-M;ZE$AVFN*It(1edl%C5RH)*qdlm+iR`YiX{$Unk86D)|Ty* z(o6n>3JFde+H1uDDYqVd;J}XyaOO}U4hSKX140!-THYJ4<5{;6*i=H;-)HU4n>RD- zna|9=jN{EqCFg8?PJb`ygU(ZFkF551q#FYFE8@>_GfvvT)SnP-sN-Wg86tm z8))Jo@>BMSc(7tG8D<5&)(LL2Iwyp5d-%}Auo<~eu)TRb?$>&t0 z{48BmN_2{kj*TCl?e}LC zVJHKVu1z|i!L=9fdtbf3qwlNtmv^N(y-#|7A0e{zvj1m)Ws)c3+aK?L*}m*rxfPzG zA}`}Byoj6B;p1Ii(rKzunJS#-*>Z*UX^)qAph`xUJ%4CP(%kn{Gflqd4LU`iEUe6L zvv!(l>`4rD=EbRQB3bPZL-;OHBB?NxRD6>%YNxzisy;_4qhK!5SKs~Kzf-kxT|IT2 z8C?C?0A)r9=rf<P5C8#1p#QC=JRc=mLOp3ZMDsj9hYbM%5SVoW*gwqr3c&b5;A8?1-anjNA0;3F z0<%K^`-j1d6 zN=1u3LoZW5AoV6as$%$>>RHPK*OS*BdQA02&d+Py@gU_=p9!D^i2*n*rQ;E~}IdwU5@g8#tM!(Rl%0fqcJ;Fm-!<}3#<=0l}%!@!((t3~O` lmR+)6QXs;Cqq}+P6IPv2GoHfRYH8S+yJ<+?oUy}OgNVY{&6lbzmV?JU)K`PI~!eY{$E z#4nFRl$Ym@B$c&PQd!VBNl4MEtiMQUtz^v7w?FqGF{hnq<*VYZf$yWG5cP-{eP!+mMfO)#SM+QxRzV`+ljZ_C-Hkk z&&7?!3mfC<1q;5GmO5IBwi{KIi%Ux;Kp;T?{vm+_84v)07ydGwKTOVC zW27MPs08lvUv7&eVtPDkC#VksATX8$;2*|vS}`aP7zqLRhml}JCm=AE1mGXWa#}Gc z5Euyo_=k~TL?<9HmIUA*#&TLQC=fUSf&J)Pi`nS;Nr?|L{HAm0GsGrwVx=^#qQ%b9 zB?_asuhHWw;JBflwbZ$uyzWME&L!IU3$I=&*l%xj7jg@8?_9m8qRhHx-HrdtnH9U@ z_T%A0PcW*7H_dZ2-Ui3(?WuE<&yy@JM=+KM#a+r!h0_l?bvSbI4s-F3fN|OOK~#36 zNM#Fl$+^kebS^Eyi$^FDp19xd)85`51^n9MmqaY)EC(;z+80J%W^3jR>gXS gQ@NKVj{ncx>3u%lU_*_g-ao}wITj_VKHD_<7h+t(7ytkO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3e221e73c06b11bddfe3e86c828a5c1b22a4d524 GIT binary patch literal 23809 zcmeI4L2nyH6vzMTH6=+SVWBEQ2#EwP6$lAJL_tN!#@n_2lONV~eU=cfOe$@3K3*%xAip3Jb-#Yotw`_oWi|EhUvT zr_{VF&W(LwXp)cV`gDZCJo;y@5lsdvO>3I zeR>$m@tLgFCR%8qDP=`Gw{e1FS;5HE%&#b5<#FX_v}{{&TC#fIUfo&gi1r*>xtx@G z21;gs=+vHu2r465>eaQJh!|(;RB`M3ub^O6?REV4)34J_n5s{!W^)-wa`kjA^<^NC zUipF;&04Uh^Df91`#0|sH|n2^KI}%=_551X+xN>3e5YBdRj`qfvX*?L}yKe&Cnf0Oj7%QF|1cE`B)d9i5nZ zudl^hXtev`pxOO8MAZob$Jh3~L9N#bvAy$YzyEjFbzK_PT9pG!xCCxcedMQmp5X;! zzLa)SCjk;50U^Nnz->SRB(R78{Q|0vQ5~4;dUZNPq-V z1Q;JuFsPCM31kQ`K4fsvAOR9c5ny~s!JtY4B#jRtg9Jz*MS$@k1%oOHkbogD z{Z&(+k5VmXo;01Ic~zdnrjP&$EIR>yepvP$!1_T+iC9I?FTZ}H?0tMVyk5M1?URqTO_tffX$6u^_d<5PdN59>551`Q zFO-sG<84d6-jO+X<-E)iNCsogP&~vY>Qa6wr7xL_kA+J#2PP2T=W*R!l`+d+)xRs+ zO0l5Hi=U!sdgAeTjMLLoOyqScFU?pirX(+x!<1P^;;#7nHNBMsx9+|srS4_Tw*8A- Z^mjRKilZqhs-G9H9`8!>=#uy>{0aNm^5y^l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..434d68d34510c0057204330d8fc91ab7684ec1cf GIT binary patch literal 26011 zcmeI5&u<$=6vw~oO+$(!YC(k}K*9wHMQ|x4f*?v|V>^*kJ8P{siX{$Unk86D)|TUx z!X^Jf#eoxt_F8d3%B@F>6F*LH=710fgb>OBK~+^+-WzY?-Ovb<_JaL=*6zG{GjFo< znf22+-n`Tc&gz%=cgar7U=Jx+u&l{JcTp#P&wY2p&YJpcG>9#w73sjt;te&3=l$hY zx@f1cCXIXO+Y=MXUSFAN7-oKb(+z4|Pl}4kMUo1UkY{S8a znl@C*%cXhKBa46TiQbj{lJ=%R%ld?{5vd2;aM zPHW|2AGMq+wR1S9Wg7RwH2xAS)1s-x$lih5Dc$Pjaw&BSL%o<8>G9wfBRj(a?BS>i z@noY8uIm5nUvo=^IRR3Yg}Wll*2b0`@5>@)(LfD#DT`vcf^BTd!Um@0@IuQreFBp| zpkdpxkQPqkv-I55nrLUxkoS&d&OqMWHfEV0!pVxp&{EexOMN#+=7XYGUS4?)8LMJX zZZGda|9h(I*gJ`HWQRRc}dWosnDYLEb0jptIe3s}*3uajSJ_ z#VwTJmg>by1uH?T>u;%64tjof!*5<~1?zAY7Es=7uKV6c9Un`z(qh$LgVSyKL9f|s zwFA@~*K_k_4~6!|MziM|7uVlvZ)}@(r=!Kos5Lu&x8C~LN2TO>C0F~myVdrZj|+3} zbvplbRi~w6wNYHPgo{`9DtFzu=Poot1JA^b)JcE@NI(cMKJYdm0TMWh0OP|^WN0S= z64;er6{*N4F$xKg00|@rFg_%3utEYP5F^0&5Q9OL1V|u3fbk)LgB21WffxbChZqd1 zBtQZQ0*ns{9ITK43B(96KEz;9B>@sJ1P0$~>hn>mrOcD2Lp0CJbJ!FTAc1iw!2QFx z-vOLI2^>w}{`-fc`?G}vNML*jaQ`qqYt5OGz;OxOmcQIqNgvmd{YiiXMxFrU!^kf& zCrAP(CcyY`VsvyQfsrS`_%QNI%n6dfi3u=1oERM)Nnqp&Fg}d@5_5tia7+Tb(YF@U z(esmM@$Z2+dONTl79NSjYrM3==s@~UMo0n zuW!xfX3xHJ`GUzZ>y;W_{QpHPILlr?o<8)LGdW)Oykz5TO1|EnIk)6I&f-Z1W6e;! zg)Ek({7_0)G8bPi!AVZHJmnwcu3T>!MBNGMc>jA&RCa?)UrH+1WuTuN`@5 z#$u^T@?tqmnKbO1i|$HQFJ-m7Tz*kXy_Yo!_fInD9XZ|*L;V!+xLEa=mt5a@v03;V DMy5Jh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48c273897b717f82e07c685ae22ee4e1ddc55a6e GIT binary patch literal 26011 zcmeI5&uZxl;Cv@Y4EmXft) zI~2I2e*xmai33~_2c+CMTAcWCYR?=H;!sspl|uyxA#L9qZ|vQasEuha_IIq^dGlu8 z#PgZ)(~G=$DQB(ayY$Z`8K0(YijhW|Hr5@^V*HQv&)4;&YR?3d=v-@oOf`~K_)ACd2m-~Wv7H*e=ybK!=mJ7-Ux^v0uh zXn_C-fB*=900@i-0ggw)D~)jpx5Kg@l(0_ZWKhyUp+mkJGDfK{PG=YLl}ahIV7#w% z{mJ{1bmYyVF{?%->F+Vow;V6Y_;*RdqA~L5D$UV(x=gQL{JH)6uTLNM?McNNM* zrRKcwP}ys52lh!i9lNC^W3#ax`+|`vyF7MxOm%~GDZaWf6_K=g#k5RKs!^LD2B-BfH%^qtzlc@#F{iMlczAtZ4 zrXTDr!oFl1*%XJ$1>U%Gylr)A@%24#Vv;IUqB7Sx)|{gaTIY@RRL|~><}f`@V~?n! z>%5T~P0+Q;)c6WZhp57z9ZlVdGV0iL_xa85S}=vwat5T9Ph+6&RXMY`bes}eUZ0`| zzdY-n&0cR*nCLZkX74E=j0l17&L_B0E;x&_^aLNVH~E~HUSp$ursmPSWf#iUlASfk zHp;nto|e2?(`|`ldTqD4=2p+wyj8O1=gC~JuDZ^}hD(bjW1--#kkzcYUc1_^)jcX% zwqs{ZhqCpxwQAc{CbrwEuWhJ)t0C1VRH`=IX1R9BrM%%dhArdk%|d;}rBkWz8jXLu zwNpH>P{}Q8%*8RC{7XA*xkb~ec_gfa3<4kk0!#q@0mlFYKwvKd@DF>DfgS`vV2kAU z5A~ZEV1NJ!fIx%*{6ho>8Xy1yAp-CZAs7%r00bfg;2$D5&;S7t2oZpP2*H2|0w53} z0RIrdfd&YGK!^bRLkI>$5C8#1p!=<+JRc=eOg(AZNAn>+hm8UPATa6#5I>Ci3BdY6 zU~dAik016Pj~);Jfzcs=_+fPR8Y=~X{Svstf4MDXa=(Tc4+0=C^aS7^hJK5&KoA(1 z0Q|$i=wJu}Lr(zyVd%FQ3j~3I3BW%Lj1Gn%F!TiAABKL5u|N>mCxNZtTZ^gS`ANwS z)BmQk?=wUvd19qEp|ZsurcbGxf_ahNQaOZm^{i!#$I0ssCn=U9_oE+wp0&PSZOx=- z4u5m@l*%&e7!@b{|03qCqSFbd4^`Ix*Db@@c&nVRx2^6qzK?QoID@fdC~i@bid_H5 zwauA}*O`mA3yj0MAA-JJU8xNl)DAbZB#PlYD+{@=G!nbCZ)7 x^QG#jVn>~`mkRP$md&Dhf@?X;l7#ySPx^wdFIiDOK=cl4<(lPG*%{Vp^cSj)JRbl6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..23653495ab47a11f1c9710a3b71b5c7fe718386d GIT binary patch literal 26088 zcmeI5&u<$=6vyB0nvjx+S`eWKkZ?gl5nM{JAczv#*z0Ih+iR^iilrWkX|};qvbJoe z6fXG>Dh`|wYOfUsq}+P6IPv2GoH-l0gxy(8{gM0Ox{*=(QZ$IcwHC=li)+`#z@GOm zH|L6mPFvh~NCP94O7{9%sX>tb?HwZsR-SvAf?)QWH~4<@%OYE@-mnb!{MobqY|;-J z2!H?xfB*=9z?2Z+^GJNAI+wT}mxHLpZ6ar)l8XwR4pf&J%D%N)SSvRgwfvg-tg?*; zA4$?swyNfe>XD(l&qVL>c}e;|O>!)nA)h{_b99BS(LZ5sA(-rcLTni~8hoE#;GE z!Y_A9lyd{Be|b3^NJuw~^ft5%8+ z_PsuIE6H2Qhi*Y2Gh5`22U$q?FHGJkz(acZOFbv)t8l9rg=CZR}ES;tXe-Jfw=fzZe zFuvO3$g*{$NG)q9wS2U>N|d5i-FT7GTG^PVZ-4wN+~|oOBSr0dr4C0QwLzK=0`Zkk zvr@0P>oW5+Z?SPcCzjvY>%ZIbsbo8qy1n5POmfV6v0SDNzt#16B3XXl>+X2XD=mMU z>{5xW-R8FEe$?@3y=Jaeye+c3Ezj>a`>nQ5HQRBWyya4%y|dHod&1@JK>!321P~t*IG}(42*d~=KEz-^1OX685I}rL;D7=GAP^&f_z;5u5d=U$5g0yA zAI1k=J!v{Z^AbOYjRFE7Fzp0zewg+hfcb;K@dWODemK5AT0j5Om?;Q6 zEP-46m)lapzw>`sZ}bNN5SU~Fh!2yzx|kaXj7I?RVLX2D27yT?fcP-UtBbjTz<2}@ zAI9ScZxEPd0*DWjyt;k<`VLs<| zdR*lIHq^70I`@;;EqX$|A-TWs>YD}o!gg;dx3qBa{Ibe2>zWNW{?8{$cGVrk(}$j9 zIuGoV=WM)9&ez*l_ZHu$xwxFcSTYp%C_`1QKjhls%*DIR#XkbZW!nc)+mR-v7VNTf zjiuRKT9Oz4k44fG4+aC;-{0pv+5zqJOEMO-8Yb3Ob<)5Z_aZ7f8x?sgn^x6&g=^W% dlF|ET9`p`hZ?mEP7|~N~m1{-PXR}SCe*qu=KF0t6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dcb9eb2bbac468dbb37a98579307e30c3c27de0c GIT binary patch literal 12358 zcmeHNU2I!d9sgfn+i8S+{7tqw`y?{ckyuS z$WBVu0X5PXWkM)O1662(2u)~$?E#+lFri2wQ6whi3Gu)~iqK#Zf=KjX4?y_+|HtRv zV_&E1zEA`^iF5Dy`hS0&6OVKaec@O1{>=$C&?<$=Cd+EW@_-evEQ|iO{Q3LIk?s4- zKtgz0<#(cYPTeo>0e7VGrL_G43Q#|F0s4|14ASW}EMSU8@VQl9{8f1-qNQ^Gt-wO7 z6};WAeL5N>Ooz*@RBo8w%5pGUn2vOw>^d_r zHkOE;>DeVyo>aXzp3o}-kgaaso4F#m?uvoF!JebC`5bB82GLhUuR`w@1*z#h8)K~V z?@)r~>09*A_wKCz^)G)~@%9tOxdmmNywdMZ$bPE{7Nz#TL!U!fB;2~5@adHX|w#ImWix~rNho=C`4^y61=(5>Dmp=y5|x<3 z1ce}bz_we^s~37ppjX3&BzBV(6wEw-5PFMR;5$H#Z6w^t&Nku3+!?hr)Pp{#EACtu zZRwz4bKi<(V(g+kFwU@~f$_Xhu4p|^O&x8?CD3|9cqN*#ficK@>671S->AarNNz`{VB$HljctOg4XV9;4lzsj(!jeI7ESf zwt!Z{0$T540f+8l0kIl$rUk_43NSil3rNBO5D9Fy-6VxOm;#6OE>?KO2TSR_Dmq~= z5li9?d@3b#g*J6ipJ|VPEmCi$Ewppk^t)w9Pl7rPO9WwC)RI+r-vWF?Bj_o-`wX8{ z8X-E4IJdLku`~>gcMB|Ncf!eP-W-_abhX(8f~>$=)c5=8*@`1prhl4O)p~sXyP&mkYOds zCCuL7t~6x9U+~Kb+Ddm&C1b7V@qnx?!Ls#Q&vV9np0gYPoUbz+m95tIq%UL5Pw;u0 z7o%~Xv+o$k?I1;oI8u5vBfC*s!t*eqDb7P;;}JI-YY(2XOizl?X$l74veTs7~EDUW4R*o69VhGd2HOUv@+2D+8 zCV|}`HPebE*d$UOrL7$_T7v$*DG-S&^X^epg5i*kx^W}##>I|#r`Dzye<#<8zw5<8 ze>CvqcucI`)E>v*+S+UP$-Gi~?bM5C;AxNh+?4i?8QN1=9dM_$;AyYdhsD$0h}iMH zY43SpHX{f3$-u}^LD^lj*Xh$irS>`z$-+Ucy+K2JgCbSdp61Wf-W6X4R~EPzMsHXk z;)U`%HmzTC2Th%u)H_EfE7%<6o{j>@woR zQkh*wIJ*RUIj;&-#H?~2eF?E{0lAduCc{S)PZZiA-|{&_%U;R1zEZxj>l*&Pj?ZtT zN5p-WrX}_0!E%)%HX`x3PCR@SQ5QbXhhLnL>Zk>Jkr*ce6!;ksHrhFpOv zfiFRFtb-Io_B_qHe^7jpCle+$lL;sE5_ze#D$Jtqy))73sMmV~ub- zg?rbK{W3>(smRWv&n-YGWU*Buy?Du4pO?JMSwQASL<*+edlq`p$8)6>Uc^0v?Vd5x z#u+2+<5}HJa9+s!c&=#;!VAZYH}JV7J#}_u-Q!jQBZG|PSP5^eC5HW}HTZ=hetIH@? zmtkF9ju_SDh_5a+tIO&WhqC*h17j^tYvfA!^%=TIGt3;*I$n2f$ z+C>VchgasvcHHKMjU35+f$z(aowakMP|)p*@yl{g#gLvZ*Jg^}1&mblTRRhZ@t)5h zHq@*AyulZlpf%$zp@HupLyP6R1jo2baD0(z(S@q+9L;F#o#DG-O>J@W_Nyj09n5d- z92N{rxpj!-4i_)(d8#r*B{z)}p76}bfZEI7a__!`PnGaHZ5e{JBtHc*}~F@LUN!ZZEiZxLH)q^Yj1;y{)uE<`hp1rGh8m^&-xT4gHwED(lrB zb$`|zO#`~vBD>KxjAI7 zxC7}xHnRIkH*zM1d%E}^xQXu5U4?XNHeV>Cs(M&K3co^6*&}~1K_{E(`wd4nT@;B- zp)9me72E>`f9T~+t1HzkJq&i^q|J^Io6oRf)~Kf^!#)DUlgPyJp^ zlRJTElQ?42{u|<6<>l3@iT?bv{sG<-f_8A>@@e}&l|Z#+a6L5=Po8CQG*{g6{dWbF z>`M+@!!eleG`J<`9IApa-c+?FgZPt77iVcM>%`Nk;#?tJbYwu!-pf;175F>E+aIjh zB;Y2~&e?qNT_>K(&84%>$nD(3RQj3|&u7zxe8ut_o$_t(I`r4r@BxQ#^p$pwvn(3t z_>Z&U-wM(`DfsQ5Fno2NWJCVVqF_$LgXs{#I^ z%R2Ir*^lx^)xGxteiP&$;M&1xk91Sy@S(_IW9S3EYIx^R0|gKv*nf?v=ER(=Y0O&By{h?JnLoCX27o( z`)o9+NA)zBFFIL#y3>X9#1vkJ)5Ub*YKq5aQn2283{4E5n~oRK*#LhuJsXuiRgID8 zXCun*(XtLVs{C|;yiw^pv7zPXW!=g*K9@AZCUwr|=*{)~4veX9u5QDdvR(eq;3Vd3 zR5^U2Q9pVwLx@BNlH-%Z{j;H(OQiqZ~W64($NGB<*pQ(PnM#bIP`_5NgP`f|SDJm(}c)0b~& zu_o4Ttl0$|XH)Oy5T!dF;}qSK10;dl5s4gjg7gD$q$qj{JY?}XNBj?sc&aclm2wh= z{6xB#$>&l`$@I(|O2ri7eP405FgKmf&EB3&Nd(Kn3GVT~F`CI4vozEQ+nr|0T(tZ}wYc*Dg^b-(t z7P3thA-K~+gUMO&&n;HSq*xXoUxR)?#bEkN&TxJ@m8$~WH8e2H_W+xs&E}W7t5$uO p?i#*ayR@`K*RNm48Fh`WsanityCheck(); + 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: Fermata anchored on segment (not chord); direction from artic slot: articUp=0x20 (above), articDown=0x21 (below). // =========================================================================== 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..d54ed75803641 --- /dev/null +++ b/src/importexport/encore/tests/tst_ornaments_slurs.cpp @@ -0,0 +1,452 @@ +/* + * 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; +} + +// 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 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; +} + +// 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; +} From 90dac5d0a6085d52a781992116022bdc4553325e Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 14 Jun 2026 10:00:00 +0200 Subject: [PATCH 22/33] enc-feat: import grace and cue notes --- src/importexport/encore/CMakeLists.txt | 1 + .../internal/importer/emitters-internal.h | 6 + .../internal/importer/emitters-note-grace.cpp | 225 ++++++++++++++ .../internal/importer/emitters-note.cpp | 25 ++ .../encore/internal/importer/emitters.cpp | 1 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../encore/tests/data/grace_ornament.enc | Bin 0 -> 25968 bytes .../tests/data/importer_cue_mute_flags.enc | Bin 0 -> 26024 bytes .../encore/tests/data/importer_cue_note.enc | Bin 0 -> 25940 bytes .../importer_grace1_0x30_normal_notes.enc | Bin 0 -> 26024 bytes .../data/importer_grace_after_contiguous.enc | Bin 0 -> 25968 bytes .../encore/tests/data/importer_grace_beam.enc | Bin 0 -> 26100 bytes .../data/importer_grace_trailing_no_dot.enc | Bin 0 -> 25968 bytes .../encore/tests/data/notes_grace.enc | Bin 0 -> 25996 bytes .../data/ornaments_grace_slur_to_later.enc | Bin 0 -> 26057 bytes .../data/ornaments_grace_slur_to_main.enc | Bin 0 -> 26085 bytes ...rnaments_v0c2_grace_slur_to_main_coloc.enc | Bin 0 -> 26033 bytes ...s_v0c4_grace_after_main_grace_to_later.enc | Bin 0 -> 26085 bytes ...aments_v0c4_grace_after_main_in_binary.enc | Bin 0 -> 26085 bytes ..._v0c4_grace_after_main_preceding_notes.enc | Bin 0 -> 26085 bytes ...nts_v0c4_grace_after_main_slur_to_main.enc | Bin 0 -> 26085 bytes ...rnaments_v0c4_grace_slur_to_main_coloc.enc | Bin 0 -> 26057 bytes src/importexport/encore/tests/tst_grace.cpp | 292 ++++++++++++++++++ src/importexport/encore/tests/tst_notes.cpp | 40 +++ .../encore/tests/tst_ornaments_slurs.cpp | 262 ++++++++++++++++ 25 files changed, 853 insertions(+) create mode 100644 src/importexport/encore/internal/importer/emitters-note-grace.cpp create mode 100644 src/importexport/encore/tests/data/grace_ornament.enc create mode 100644 src/importexport/encore/tests/data/importer_cue_mute_flags.enc create mode 100644 src/importexport/encore/tests/data/importer_cue_note.enc create mode 100644 src/importexport/encore/tests/data/importer_grace1_0x30_normal_notes.enc create mode 100644 src/importexport/encore/tests/data/importer_grace_after_contiguous.enc create mode 100644 src/importexport/encore/tests/data/importer_grace_beam.enc create mode 100644 src/importexport/encore/tests/data/importer_grace_trailing_no_dot.enc create mode 100644 src/importexport/encore/tests/data/notes_grace.enc create mode 100644 src/importexport/encore/tests/data/ornaments_grace_slur_to_later.enc create mode 100644 src/importexport/encore/tests/data/ornaments_grace_slur_to_main.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_grace_slur_to_main_coloc.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_grace_to_later.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_in_binary.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_preceding_notes.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c4_grace_after_main_slur_to_main.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c4_grace_slur_to_main_coloc.enc create mode 100644 src/importexport/encore/tests/tst_grace.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 17476061917bc..62e1cdf980b30 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -71,6 +71,7 @@ target_sources(iex_encore PRIVATE 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 diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index 3405a38087c3c..d9a9a7689b67f 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -120,6 +120,8 @@ struct NoteElemCtx { }; 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); @@ -145,6 +147,10 @@ std::optional routeElementStaffVoice( 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) 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 index 0f95cebae6920..7590e1fb2124e 100644 --- a/src/importexport/encore/internal/importer/emitters-note.cpp +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -106,6 +106,25 @@ static bool isCascadeFilteredTieReceiver(const EncNote* en, 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 completePendingTie(BuildCtx& ctx, const NoteElemCtx& ec, const EncNote* en, @@ -644,6 +663,10 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) const EncNote* en = static_cast(e); + if (tryHandleGraceNote(ctx, mc, ec, en)) { + return; + } + if (!isValidFaceValue(en->faceValue)) { return; } @@ -694,6 +717,8 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) } } + 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. diff --git a/src/importexport/encore/internal/importer/emitters.cpp b/src/importexport/encore/internal/importer/emitters.cpp index e321a70356592..5353a878cef2c 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -1056,6 +1056,7 @@ void emitMeasures(BuildCtx& ctx) } 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. diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index f8dd185fafbba..0a28d83303047 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -39,6 +39,7 @@ set(MODULE_TEST_SRC ${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 ) set(MODULE_TEST_LINK 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 0000000000000000000000000000000000000000..cc4933c8ee93eee64c013d2e149d10b77645d272 GIT binary patch literal 25968 zcmeI5&u<$=6vy9s-H?)qTF^rTl~{pDh`}DwAYFQQf@u^z=0p9_S^$P91yCi94b^Hq~*Qwrk)Ln!0yfdjLrqOIsYHNgPPahiwwHMUmWBW;Wh^F}~{Ag)E~i^J-k;IV=M5#Xa}@bwO7l*Cb^wv5cV3qW-Fws>-k>Z@u|?--fr{)HN*?Lt?gaa?zW|R zi>i%w(5W@Q45(!LzU@i>R;S$B4CrF^lXm<6F0J_Ia=o}=Fc;tPOMiL#$bDK+>N(vA z83aH81egH)1GWJOfWS!v;2%yR13L(Sz&@$p56LGn6c7Lb5J(V!e@Nhf0s8 z;4c5=wv@S12cka+fWX8PfPa|yCB_6nU~B^L4`ZXlKoFRC0`LzLzr>gz2#iet{$Xr% z7zhFrPXPX5;+Gf`1c4C=?8o0)%*W49N~X@?H=RSDAvVbqE3FxoE%q#3qCsBeHF{3v z5H{7bmMQKhueYMKr+>f@pi@C+KA78nsvdsE+-PivK#EM(-`+EA&5oa=5 z_dI9gZE(Kco;o-AJk7=D491e7xJwpQxc;1Lk24qVFqiNM7@uvQ#cfBLlv;30-c6Qf za~YL%`nW#h*(5!2zu%|5y*-NfwZ|{XSj;LXFXltlNyDMJ=&hIKt{YCpInTB1Wy!4l aD_`^pAMdlFc8cglw#u=@3B2=cGw2^PnlMTL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b626c8b60f2db3622093169f4902f817eb4be4c5 GIT binary patch literal 26024 zcmeI5&u<$=6vyB0nvjx+TF^rT6|6u)5nS40(NsWXV{f8OZLhW7D3&-B(=5SKvbJoe z6fXG>Dh`}DwAYFQQf@u^z=0npICDUV140PpfKY{yw(pIddN$MoyXnRLK5J**y!oM> z&y1fY+09Ee?`?iX|1EZEi4MsmgA8M?-<;LdAKCA3S+*)K4+gO~HwHS<(%LOCaLE;!^N2oElSMD_F&rteVVG=F;W-TCrBEvMf4fHMc3&j{d4cn!{2}X<)>kPQs*M#mg)$l z#=P;J>Tf(f=x@_y^9#c^SIndSB_~&Xf!iKGQ{84;hOhpaib%>lW7?!PHKPxV9Pbyqg`I*NYxx)bw0DFXzoX>S^9$kMccnqt;&cokm2fo?ohZ8-Cs)->DXgMcRlO?XV-78+F6> zPFTO*h_=aFT_tz7z8wZ1w?bO4IBTVFi@bItjJox1qZv`f^8-KU29$5^?9{uV>f(o; z=FXm~_ga#jr%JsQwyTX#LMl2z;P}$M*)BD=Lb{N7zt#G`OS{MoOSQtL!CV42DE{s1 zJr8I}70>EM=pX>iU|KC1_1#O0D%Mn_=f}z z6hHt3Gy?Ds8Vsl)00IdD@DB+bD1ZP6XawLNG#F4p00a^Q;2#n=PyhiC&=4E~l8v+6#FzWgz2uw@>{$XNt=m-K+PXPX5>X#T3 z1c8YOz&}ik4jn;Y>IuL#^frG(Qi^akFlM)|h_@r~_GsGrwVx=~%qQ#z} z*Qpg zdV2WKF=sMf^BhOxt#iEIu1Xu6&awm?!B`>`cgUtP=O1zIbL8S(<`NwP6R_>WLEW(` zRm^)u|0Zkm*|Y>NK0%T2#JygR_V@QG=FdKVBw{faIe0N&Do+}Y%msg=B)78Zmfcr5 gm%S_zxqs$C@A35}8>$?3`x&;%wZehFb8Iu{K3(WD+5i9m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8b8fa0ebe344d5143c14cebb0bf3327fa9f7d0a6 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwMq{WRImaGMQ~}0Rf2%Z#@$j_F@He?FWa?Z+Ak zkN^pg011%5j1Z9XNOGnrNy&Orj^dQmDcOosE>1WdX_Gl*-?&^@E7j}O{F?o&)(rI$J$|OTBf6~IgEJMN>M~<&qJt)S2&7c$lXEaF zA(?_4#tJa2Ijxo~iqzQZ(@!=w`&T`?kQJuTy5*VGdd*gds_Xe)-tkdr?d&vqf$ri3-PX>YZg<(VZYVYy!1G=z)q_@%$RWX}UE>FT+pk}?U900{^I`Uh?U5+H$- z2+%*AM2312Ab|s@-yiyy7-5hA36Q`D0s4m#9ITK42_y*6KO|sKBmoi_Awd5yf`b(j zAb|t{`iBGziX=b+BLwIlMsTn~0wjv_{L6HPVKob}|)l~0CDJiAjG##RON#4U| zA^{SZbpkv;%=!-C{7K+s0*^jFoNUh)5+H%uA;9y)?5s6sN&@2&xF>(Pt^=pUwji8(AWfV zEGJ(g7^?`yU0A3{`Jt4aL@wSHF5xjSzUZ#UZO58)wcwV#TO!TpGAelS|Cm&G;(ou6 z{r!DJa_!5dA{I+kf)~rJ%TdLVy6A0`)koQMD$XlXs=chDXa6LF-k1ALQPiFQ&WKjs MD-xtTFFFJN01u)rqyPW_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a3dad7a858919b54e37d157b8314c7b99ba59d78 GIT binary patch literal 26024 zcmeI5&u<$=6vy9sO-M;Zt&&3om9Rj92ri*mkctx7*qdlm+iR^iiX{%zG)rnJSzESK z0+;*;6$efn+H1uDDYqVd;J}X)oH~gZdW9`hFH$SrT zdE=+CyndFiY76+G$NU3as9R^c;w^L z-MNC9plxnErkZE1Re?1JcWl$Uy1X3B#>b%s z0w4eaAOHd&FeL=|Jfg2O!AY;{au_9DCvrMU*(lNZP<5H1%;nYmda+ikRd$JQyrny zoCn{l@y4^!_%y91zBJN_mBe6t$;wsR-1qdE>JIBNeD%*%L{jG!(-yUl=Rs} zN-py`Os(q5HL7HlnzMtGwgo}Gs<$1Vw7n`LvwGVNV=(7sx$E40V>o9R%KggU8t;BF zzT#T>4BM;3R=mzOY*L4>kJzGVs!@fiTozb!o%U&utsJP9(^ls*dy-~;qMB*)mN)1e zeKD7r-Dc@L)%cBMsGNs?jT_^dJBNM265fB*<+1mGVu7!W}K1Y!i>A7VIA z0Ra%u2*5vRFd%{e2q*&mZ#CulD3KECNz);km-#ttBoF|BX(xd5!?f=JtRDoyeWr64dWfqVSRZAo*Z4n%(t0D*}o0RJ%YCdL9mU~B^L4`ZXl zKoFRC0`LzLZ(=ME1jZ%+|1dT>3O zh)&|fN^43*i(Q~MsUMGdgPv9~giZCVWrF+3>mEI$?nUw!Uw=FAyuZ^~$}TN@aP^Xk zGV572Pygo;Yfjng>ET01T*+wNa~zGg&hdJ?DsON;&B@~k#uA~pLuo2=`7xI+M=suD zF2N}<9_wyIZKs-4HSZMNTP)3HQxd%Re@qgdxYz5^;o%{L{5s^9L@eei2QTJJm2t&D qU2r!`aw}VQ*?x^n*~=1<`)8i?0blR1qWTok1=h;7BH^-GXV61+EI4HV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..140f1e3fb2ab64c7a31a75136793964d660e5fd9 GIT binary patch literal 25968 zcmeI5!EYNy6o=pLnvgaTwIGKGDzO5oir`XO3j&nN#@i1#y*D#! z&o{GAlkDcDns+w7rGHOmYLN~}Cyg|1rr(^!)F0U&ZW*T1m!gXpTxyX_w77ap4BYeS z>CRli&}ow^52~0h(Jouds)b2U?ujn*eo5NDM6xZKp@44E1-eSt>CwYK4}bslmj{FXr1nL`E!7rE zU32ey)n9ud>Tl9T{cFwCm-VCmB`a5bkJ}FKscy3^!)Jd_MI<_pm^P?Q4eFB5rKB6D z`yV$i%7x2R$%-IpPwG-8@6i^Bv;0^3pe_vMb7f@XeJ=V}Tq~bptRhSp~ z&x}bms!)~70$Z-oF75Da1*&Fv_IU4|qM4tlW*EG5H9A3G&ShpdSvo~Ez9BS~6Xn!e zP}BMhvZEVMN?8L^$}Kj0>4IHee~r>w(U_<2e*CMy;D#GT`iaN(qpLqQK$sE&@tIGv zQZ0FFGW0YrvHx?Q*gFk!w{8#UiZ-jkezr$qu@Hd)u#HZ3J87 ztgMi|Q{VEvFIqmWRjk#Lze!HJ;RoG%x6ur!;<%ohvpvc;x3}wEUv+W)PIG%#)jKUI z-l9sq<+rPiYd#e%&$C=<-)xtfn?9Y(e9~(D*Qpi%T&fi|H0I*jUh!`?-t&MKm3lgE zgbV^800K+^{sGGX1VG?80`L#Vk%1iqK;VGX?}y|kF(eQG0T4(KfPYBffC2&_5F-Hp z5Q6~`1VA7`0RAC?0}2R$K#TzVLktE)5CDM$0r-am4k#c10x<&c4>1@JK>!34f&NoX zc|S@dUA<{KK=V4khm8aRATaF&uz#5L6@c-B!0`m0zJEBrJ!(Jz1g3`o_7BrD*BB`X zj7s1x|K+x%xluc!JqUon#1nvjnD{Bi06}1E0`L!GqeDj!n0Ny44--Gd7$69YO#uF3 zY;@=d0uxUF{$b*$7y|@>5eXbbPc7!7_a`M%=io`_z-Nd};>1dAT1AUpp!3v^tGq_f zs~EzDde>6tc5>XMlj^%j{_30Wyi=SbV@Wj1dkM{TXDdc0H4~baJDhDs-Qk-6Zmm*lH#*k$_-E@drCWbL2% Zq7V6epAFULh+br?oXZ@*JI6MS{sF8bG1LG6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..47f80b6ddec5d33f7c3b1b52d01d0a77fa18d023 GIT binary patch literal 26100 zcmeI5&u<$=6vyB0nlvR5wMq|Bsmcl@6v3q_7ERR>+1Q(~sqMAaYsC@=YMLdnl&me= zDS=D+7gQWLacHj<2c+D3LOyWd#|h3HD#QUHgmOTrLP*PdW2c^^jUc96?C-O7=FNLE zE6-=fPa}KtQqFoSU(!E|otU9LGRYvrnCNw9HSt^eyE~Sx+Oz$eSX>)@ooHt1ju?34 zljYu2&N6A0oqN=^5{ZMozEaINPX6+y702^0yh3q2`SlxI-upbqmhHQ)60T2KI5CDNOA;9MmeWoUh-q!WFFS<>1yD#a!(5YB;nV{6=h3rzkS}kRkoU_U{ zxcNX6N8T+u^QwnUkDrOI^La`7KTmQjnxKe2p{sP0Zqr{6f8YD{=bs)7#*;c15%*L_ zD0R>6Z`F9?x&C;Y7R=8K+nhJ|#}}MT`3vrQ_)K+|Z7IHbXDT9T^NeYQT2!YFgFNi_Y;+4B5*)}Jg z7W9$p#=b8rr^K}Gd!T%B|BB2vqx-7nt<$|@e2>7+><8Y>L;uz&qjw z?}rAp`FfvshfP%~QJL!;TW-)6ZSszZl;`jc;zdu;#1B-pEdKZybdo-uN=>e^c8aR} zp)^#<%c=IGTDqrD@V-(awd?_@$m6s@Kd8lq{kuOh zKp7JP`pPG{Q7#0_GIfq-#((*oxKVSf^Hx2gMb9sky^5c8$al)Qe4Z*%y%n}abE8h! z+6Zem>(Lr{i;LuL*4Dz{!)8d!C1x;jg49-R73o* z-PqVtey=Ij9F=O#uvM;q6jI&^0>_vBjaH$t8q&qod(Gy*U0M$9RH)`wB+v(LkpI)y zNAA*$YM#@L&_MtMK!6D#K42Sw00X+Q@7 z5D)^rUp3|VDA7#yr0Ian%lsTR1Oz}}+zH_PFz!15^9O;W2|W4yaCCooKmY{BhXBqG zu-pIDV!HqQq~wPg{L(py8Df(>vEogtY_VtP zb?W7IUgq4#l0(>1&sv(?PhR)wS@m9|eDSqQS?`^-_H26g%)8evsw}gCQw{WgM6u`< zgRY)FRE_?>iVSDtt#Q8IjwT)?>ppD z&8(OAZ?iU;PD=9Przn!1xZCa0&dv_S{MzA{WGv<;Cokqp)dvmx=A2(C$Xi))i|(sj f%U+i3-aqn9AM$mD4drKuPP0|6c}bwnHiP~KTsl7e literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8194eb84438b745b8dc2b043bbe551fbc8b9fb16 GIT binary patch literal 25968 zcmeI5&uis*$>3&M{gyY?p9V0*3gMzPdGYseBTC2Pxe zC~(PtfH?Hj16)xLm2%_gLl6CNYR?={)dQ-kDu=2<2x)n5yurIJ5w*+7{?2M=-n@CU z_IzgcX{_D6RC4y}_w@Q?BiGq8+ zIz5=l>j_%p#$D>_$z2UQJ%)E}SfL9p=N2?~PgAJ1_9;JZ9)Zr-rpufQ&cCi5vEL-=y0G+rYL>xQf{eGt(LP(=DSMQ zzj$AghJsZx7nFrT|K1aQ$@?X-KSHuCnj)XRri*lg?$Gn6fA9YJ<^-Y8jk8-g_r$>Je3SotLgg2kD!c^z<4_hpEapgr;($ ztXd0dTHipncjHMZYd}i5#Re~(w@ND?Qc5f6v-H!i|MV8zpix*q_)@=j^_L0=6G9+5 z^C_-Wite%uJ;h7x|J)~*-`whaR`+Sqc8V2y#mSlEn3a5?Kr4Q|<+Vk!{EpY!@M<^e z{yN!R;7O1qk`$WrX%)^R(dqP+X7j&Jt@!6+HNUDc7uRwN&zxw_9hy_s zV^Jk!5C8!XU;^+DSOy>f0{anwf7p)<^dJBNJ0!n9)Gsl>009sHffxb!hZqi2KmY_H z1mGVcFd%{e2*e1$Kg4jL0siI00N3Y z@2RG|A0<*ky=mGjQtd2fFLj=ft~QF#ccTgq{N5mKk4lI4ADuPSZPhEXt77>6Y9lP zUZ%HH3}IcpYnfm>IUdp>^<5`x5=OqmtS$|aOC1m=Hd^4aanggY#V4&)tp^$ z?yxkSNlEbHH<%@>N!?l68_xS<4bx b`!~MmQ$8=SqH=)f9oEXZzyZ9otkdWPFiJ8A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..db400a9995d6c71d4afabadd63912fe95217c3e6 GIT binary patch literal 25996 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 RIY#s}Tjg4jRN8FQ=x>NrKG6UG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fe9a7451f790b7d5214989084d5cc24f3e0a7320 GIT binary patch literal 26085 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 DEm$@q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7f357f6edaf369a6c602a918b74d0d1c19042486 GIT binary patch literal 26085 zcmeI5&u<$=6vy9soirg4wV;OxD&c~JBDl20f*?v{V{am-w%1y36iXbaX_jCqSzESK zN-y~jDh`}DwAYFQD7PMc;J}XyaOO}U4hSLS1A?lmw7fT7$Fpf8u*=E*K5KX0yqUN5 zeCByPi8n8mtiAOW{ktUNOVpzn8Dto<{qC&B|49FE%S@{Ja(EGwOC!{Ymey~Hfju8D zcNTJHjJCP4M_n@>kM{anRf8b$+uLRktUUiR1;N}muk-oN7df_EznwST%jeJgb5TFk zKmY_l00ck)1g3-l???2J##r>at_Pv$Hqn!zq(h<8f$B0#sW(@%>xF8yoLRS?Q?}v7 zhnh4L@+E6U^+?jAd!j46Uy}aMl5C4+$)`{05?!O4^zif>(#E+{T;H`)+oPM+wt6w zn;vbHt@Wa}O?Ip9`JGy)-tehxJFb(-yOeG0?$$bFg*mYf0&-R#z;Y6R04PTFSkV& zMs-Ag5CDORCxG=~;-?q`1c9*$V0{=H9UMVm;t61VnD{Bi06}1E0$3l$Mh8a_n0NwM zA0~c^F+dO)k-%a2t;J$^|DH$x&M&^kYgJwSKToXLCAX`m4;^tNqjk@4Hr^WN z>+Ps>ozK%OE@v>748?6qQi;otxO6yk@g8&WkAZR7_F>p|tVva~cEP#H+FUvz$&3F- zk@UpfZkG-Y4k+Nq0Y4;TF;_WxF(0ao8V2T^vssjvvXw98U*S?>S<-p`%on}S$6IWu R%n&`pRykHAT{hbc`UmopM412p literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d84e5f20d43d5f0e8cfce9744d8a1437619e4c88 GIT binary patch literal 26085 zcmeI5&u<$=6vyB0x@k%xYC#VXRKf)bMQ~}01woX^#@QXs;CqP;Nc?z=0nZ;LM>y9I6VT91v7hrRBZxx*n&EATC_&?^rwY=Dm3< z&u5-TlX&w|$+_!a(?6F~VuAL_B!diNrazp;#P69OuUjeAUyN^Jacjgf(ZcF=QLyLZ z%gwpGWzq&4_o-(k5~HKOQQas?-hJDOqNV3wrYM^I_I0jrewk;@l^c%bUp#v@oE`N; z4+KB}1V8`;KwwG;@Oea^smVp}>v9wqT_Eq4b-}xz$3gR?e>4&neyT z=0iyg1*c>$sSzo9^i1?VpO<9(vn0o&84Bq$xYi7=SN_J+v44t|%`c3UxnvIfi*~m16^}i5rn3lu(SztX{lx&DVo5Y{`Sf)E{me`MLXXOs*uV|j5-C2!nR4LE=(c^asd=1}bz0VK zw~c|V`iS2T=D>DU+_So06v<=-=XjHaf7!EhXaxi50z@MO*pQ&b994H2zpwH*hvl}d(q#Az;4OR2vsQ?^C%achQMXE?E zF(9pcu9Z@&@=mGxA|;K2HBaCD^k+ZHHM=gJc-R@-{hWjgVGcuUK)bUd|@ZuH*{^s)mhr&=JWAyFq&^s9$b`o8+#nkh5Li4E#@8 z0j-to)nc$gZo3hL-Fmmt45{pTzL#};$~CvP>fJ!OctNMRwWI8AOPX_3uD61ArExW& zg6;dZC*zy#Vsj&)^XU&-t$(|;vga3T`E`T2_>Nz=@9Ca!5yNd3-9eiB0g0T2Lz5dzpBMsT150wACf!2Y1YfCvI0FhT(P z!w3#^KmY_Z0@xok7!W}K1V#v8e;C1m4hVpNMgaSR1_L4pfPf;<|5j6;j}pmLPnr(V zyu{C8qksSiOgjObAErG5Fnl0T7sY0@xoWeu*(b5Ez>P_J^_2!4U)|o&ffTiCSHNVzwqiCIrrVo&SGZq)O#1tt1Pp=UGw$-Jh9@I{GOgZRC)hjRhF~y);V8q zSJe%!r@8o?!B{dBcPK?AZa?PM^s?e6YU#IId`NycLCa`IxnR6D8|sPkU6C~sxmDLJojE3qu;yno@F-s9^nR#c7= PJ;PeLmLy#^>kRrERf|J0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7f357f6edaf369a6c602a918b74d0d1c19042486 GIT binary patch literal 26085 zcmeI5&u<$=6vy9soirg4wV;OxD&c~JBDl20f*?v{V{am-w%1y36iXbaX_jCqSzESK zN-y~jDh`}DwAYFQD7PMc;J}XyaOO}U4hSLS1A?lmw7fT7$Fpf8u*=E*K5KX0yqUN5 zeCByPi8n8mtiAOW{ktUNOVpzn8Dto<{qC&B|49FE%S@{Ja(EGwOC!{Ymey~Hfju8D zcNTJHjJCP4M_n@>kM{anRf8b$+uLRktUUiR1;N}muk-oN7df_EznwST%jeJgb5TFk zKmY_l00ck)1g3-l???2J##r>at_Pv$Hqn!zq(h<8f$B0#sW(@%>xF8yoLRS?Q?}v7 zhnh4L@+E6U^+?jAd!j46Uy}aMl5C4+$)`{05?!O4^zif>(#E+{T;H`)+oPM+wt6w zn;vbHt@Wa}O?Ip9`JGy)-tehxJFb(-yOeG0?$$bFg*mYf0&-R#z;Y6R04PTFSkV& zMs-Ag5CDORCxG=~;-?q`1c9*$V0{=H9UMVm;t61VnD{Bi06}1E0$3l$Mh8a_n0NwM zA0~c^F+dO)k-%a2t;J$^|DH$x&M&^kYgJwSKToXLCAX`m4;^tNqjk@4Hr^WN z>+Ps>ozK%OE@v>748?6qQi;otxO6yk@g8&WkAZR7_F>p|tVva~cEP#H+FUvz$&3F- zk@UpfZkG-Y4k+Nq0Y4;TF;_WxF(0ao8V2T^vssjvvXw98U*S?>S<-p`%on}S$6IWu R%n&`pRykHAT{hbc`UmopM412p literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aa8e3e308f2870782b4fd7918888ff4d360849bc GIT binary patch literal 26085 zcmeI5&u<$=6vy9sO-M;ZEr?JAmDqxWBDj=dK@cUfu{Y7Cw%1y(6-zx7(=5SKvbJoe zlwR^5R2(>QXs;CqP;Nc?z=0nZ;LM>y91ub%2Sh7WX?btFuE%L3hzl3{JJ!y;d2imz z^O@(-Sl+x;a_+{L^v}ggF46%d$RNX*=?`Zy`FrO3>!zjpOYu!iZjD$bT3ov>3if<> zxjC0N6ST?31L~Q{Z#&!0IH&W`$_ z2Ld1f0w4eaATT8a_&lP|G{Hsh>v9wqT_>^?mrPvfWTb}7Q2Nc4+*+YlD`(g2XO(Vv z^Pwb$f>W}W)d-6oJQKak=Or2cG|91OhC=#;&e3JMM)&Uiaq!zOKi?kulR6g>k5oq} zb(mABh^fk1I3^d^yyrBc9W%(RO3&fp=w?{6@bHNc{GWmNEK-% z2BekGwNh$T-YHdIpp;QC=jodt{_ID&X4l0N_dA2T-&a7G5(4_lr?^uo`s*_F6o=TO zd`_IOwcCBC5z?yb6)SGl%h}}Fm3*N<)v(bHIwCn?H)wAM^~;TLi`>;!a(3!lf&Wn} zp!Kr7Rtz@DZ8w6jTkkfSA(dUv_p**px#sqEy&EVOFX%M4ca`03N%Ja|>#d+&X)$TzJP$0^@*4(o@g2YLm#2H~)1vC0(v^@w00cmQ31ELf z7=Qo>97O>8!%<|Q2LTY+C;9%NzKH<_2!H?xj1a*7FoFXe5C8#<0QLtB21F15fe`}O zA4YJX0|FqR5y1YS!GH(?ATUAz`@;whbU*+EGy>QkG#C&;00b0){;!(ye3VED^`z+# z&5QgTHVO!Uz_b&<`C-~40P_cdqX|6x{BZPmSU>;-riTE|57V>Om?;Pxm%uIl%WWxh z$92Sb5CDORCxHE7;+Gf`1c9*$V1F1J9UMVm;t61XnD`~e1VLbI0@xqMMh8a_n0Nx% zA0~c@F+mVGCV~Cb(S(q3m4CyRas_zyXNcvd1BQq`8_>-sPg{5sw`*Yt#iKK zuBsbcPjm4(gRx{N?vO<#Za?JK|HC5b ziF>^s?d|PR#IHSmNycLCa`IxnR6D8|sPkU6C~sxMDLF55E3qu;yno`G-sS5pR#YA% PdWyAjElav=)*19SPGUn! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..701a05159d14634b82d42503d1ab4e380974bb02 GIT binary patch literal 26057 zcmeI5&u<$=6vy9sU6YcCTF^rTmDqxWBDl20YJ(_|jlGFBvAx!MtytpFnr53?O4gR` zl+sK7g9-^w9NKHe0V%g0ec-^43vlKTAr1&3lmmhaRa)K~Z{ppw5yXXy{e9NXym|A( zp3lraP2$ZLRE2KQFN5);&viSI(XDr{euk z0s#;J0T2KI5SS1Gd>)C;G{F)rNA)0-s7-Vulx!$;Do{;QlzD3*zf`Q(E4d}}MP(Zf zKGdY5XqC-*)xx01&qP=Gyd>>kAUPJL$fr-}GTo#*^yuO5d%ynt)9$c8sdEu=OLc@& zW8V5k_1B&Y`x~^7_)Ier^NIcbB{Nt3nA;A{RNp4_@Jw}^Z5h4>XDXt5YCMBhsY6Zb zk;l2nM`s3aHzS41eX3+d5!pMaJ1?jgqIJ7kge(s&Zan%Qf1f zP2L8+syVzZ_)wpu)DKkGbzW4BPSU5-nW;6_PEnojG)<*MIkm~cMStpyx0w>jWe-R$ z_pUNVl7dyPy+UcNsL#;X-~Tb#$&vb~dh(#KKl*_I%7hSzu6&vc)snj`Q&002dz$;i z^4nXzcbh&f+D@rz*POgbj#(`fi&XQQ9j_~z<@dbKhS#{+^w-H=TqJ9=vF^Dawmn*| zm`f#ZjqFa-^Lve6v*lC8c3dZCxs-2hY&3eF>f(6a*2b2qx7(7Pr%I#kb*jzV9u-a3 zH63Z+>Xce*9$m=1-){fgr4`3tsuxx@=Hgmz@h>OZbBAVC@l4bR9RxrC1egH)1GWJO zfWT1%;2(}613L(Szz(TjJjqXD5D)+X5Qq_ge~95g0R%uGLID0D0s|@tfIy4@{6h=} z3LpRi5d!cJ5g1TG00d$L;2&Z*PyhiCh!B8(h`@jf0wACW48GNr=c7bRs3%Q_XkO*# zupuA-0+UVv=Z8t(0hm7s98KWK=ZB-)qXq;(U~&lH{4hCdjhTYLaS1%&zuXp?K5j>} z2LTWmdjjweW52|hAP9_10RCZQbm#~IV^0A7VeFR}69j>g3BW&$j1C<^VC)INKaBkn zV}c-XOaeRMw-z(u^OF*(bNEf?&}WEE;>1dAT1AUJO>fd5VDmBuB$gP$x_Z_!!R_Sr zfSy&KMbekwxRkfwTkp+Pwu z$>}7E%MpwvLUET2Ds%n;=MG0M-efNRAuul6ZiID*s#Gy=7o9t-O=Z&(y!by92~XVb z_i1~3n*x4q^GhNYbCH7=^QH2*Vc%SEY9;w7t5(^1opagC60-Y89`qq!Z?mC#g6Mg+ N$~DiC!E. + */ + +// 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_notes.cpp b/src/importexport/encore/tests/tst_notes.cpp index 1cde34657c3e2..26185c95e611c 100644 --- a/src/importexport/encore/tests/tst_notes.cpp +++ b/src/importexport/encore/tests/tst_notes.cpp @@ -437,6 +437,46 @@ TEST_F(Tst_Notes, note_rdur_80_stays_16th_face_value) 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) diff --git a/src/importexport/encore/tests/tst_ornaments_slurs.cpp b/src/importexport/encore/tests/tst_ornaments_slurs.cpp index d54ed75803641..86fc99aee1ddf 100644 --- a/src/importexport/encore/tests/tst_ornaments_slurs.cpp +++ b/src/importexport/encore/tests/tst_ornaments_slurs.cpp @@ -269,6 +269,268 @@ TEST_F(Tst_OrnamentsSlurs, v0xc2_multiinstr_slur_endpoint_on_note2_not_decoy) 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) From 8f5afabc87e28a34594750a3f32cb3f247e3f306 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 14 Jun 2026 13:00:00 +0200 Subject: [PATCH 23/33] enc-feat: import fingerings and bowing marks --- src/importexport/encore/CMakeLists.txt | 1 + .../internal/importer/emitters-note.cpp | 25 + .../internal/importer/resolvers-fingering.cpp | 321 +++++++ .../encore/internal/importer/resolvers.cpp | 1 + .../encore/internal/importer/resolvers.h | 1 + .../data/ornaments_accent_nonzero_voice.enc | Bin 0 -> 25956 bytes ...ments_accent_offset_tick_nonzero_voice.enc | Bin 0 -> 25984 bytes .../ornaments_accent_sibling_no_spillover.enc | Bin 0 -> 23425 bytes .../data/ornaments_accent_tick0_xoffset.enc | Bin 0 -> 26028 bytes .../data/ornaments_accents_distributed.enc | Bin 0 -> 26156 bytes .../encore/tests/data/ornaments_bowing.enc | Bin 0 -> 26088 bytes ...rnaments_bowing_tick0_xoffset_mismatch.enc | Bin 0 -> 26056 bytes .../tests/data/ornaments_fingering_orn.enc | Bin 0 -> 26132 bytes .../tests/data/ornaments_new_artic_types.enc | Bin 0 -> 26204 bytes .../data/ornaments_open_string_and_stick.enc | Bin 0 -> 25968 bytes .../data/ornaments_tremolo_r8_r16_r64.enc | Bin 0 -> 26062 bytes .../data/ornaments_v0c4_orn_be_accent.enc | Bin 0 -> 26400 bytes .../encore/tests/tst_ornaments.cpp | 858 ++++++++++++++++++ 18 files changed, 1207 insertions(+) create mode 100644 src/importexport/encore/internal/importer/resolvers-fingering.cpp create mode 100644 src/importexport/encore/tests/data/ornaments_accent_nonzero_voice.enc create mode 100644 src/importexport/encore/tests/data/ornaments_accent_offset_tick_nonzero_voice.enc create mode 100644 src/importexport/encore/tests/data/ornaments_accent_sibling_no_spillover.enc create mode 100644 src/importexport/encore/tests/data/ornaments_accent_tick0_xoffset.enc create mode 100644 src/importexport/encore/tests/data/ornaments_accents_distributed.enc create mode 100644 src/importexport/encore/tests/data/ornaments_bowing.enc create mode 100644 src/importexport/encore/tests/data/ornaments_bowing_tick0_xoffset_mismatch.enc create mode 100644 src/importexport/encore/tests/data/ornaments_fingering_orn.enc create mode 100644 src/importexport/encore/tests/data/ornaments_new_artic_types.enc create mode 100644 src/importexport/encore/tests/data/ornaments_open_string_and_stick.enc create mode 100644 src/importexport/encore/tests/data/ornaments_tremolo_r8_r16_r64.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c4_orn_be_accent.enc diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 62e1cdf980b30..468a8a0c32d00 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -84,6 +84,7 @@ target_sources(iex_encore PRIVATE internal/importer/resolvers-slur.cpp internal/importer/resolvers-hairpin.cpp internal/importer/resolvers-ornaments.cpp + internal/importer/resolvers-fingering.cpp internal/importer/import.h internal/importer/import-options.h ) diff --git a/src/importexport/encore/internal/importer/emitters-note.cpp b/src/importexport/encore/internal/importer/emitters-note.cpp index 7590e1fb2124e..98e02860f305f 100644 --- a/src/importexport/encore/internal/importer/emitters-note.cpp +++ b/src/importexport/encore/internal/importer/emitters-note.cpp @@ -125,6 +125,30 @@ static void attachPendingGracesToChord(BuildCtx& ctx, // 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, @@ -739,6 +763,7 @@ void handleNote(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec) } 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); 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.cpp b/src/importexport/encore/internal/importer/resolvers.cpp index 5486ff9720cbe..32fa08b0d24f2 100644 --- a/src/importexport/encore/internal/importer/resolvers.cpp +++ b/src/importexport/encore/internal/importer/resolvers.cpp @@ -32,5 +32,6 @@ void resolveAll(BuildCtx& ctx) resolveSlurs(ctx); resolveHairpins(ctx); resolveOrnaments(ctx); + resolveFingeringAndBowing(ctx); } } // namespace mu::iex::enc diff --git a/src/importexport/encore/internal/importer/resolvers.h b/src/importexport/encore/internal/importer/resolvers.h index aebc331d20101..3a73f7c8306ef 100644 --- a/src/importexport/encore/internal/importer/resolvers.h +++ b/src/importexport/encore/internal/importer/resolvers.h @@ -39,6 +39,7 @@ void resolveAll(BuildCtx& ctx); void resolveSlurs(BuildCtx& ctx); void resolveHairpins(BuildCtx& ctx); void resolveOrnaments(BuildCtx& ctx); +void resolveFingeringAndBowing(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 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 0000000000000000000000000000000000000000..1e298035a9af1b41e464f46444aa1cc6f7d8a4ff GIT binary patch literal 25956 zcmeI5&ub%P7{|YpiM4Bs8*~pv7Yqtg6l8Y|T5(+xlWCc5k_nS34R~nZuZVdj0F=Y8Kv z-p~8a!_#K^w3>Iezr??nbaDm9NWg$$EDXAfnEXBa-AyyC_0{kqrqo6#11lRhl|e>6 zxZGYUmG8ymnl~()x)UR)t{r=k1&!gX8{Q4C+-~PNHmg~1{)4P1`T(B4) zM~wtXfCNZ@1V~^`2uM5)t992cuaLmY#BKQF%_V6nK8D}Mgv{=QmgXu z*}>Z_E9D~0O7(x{<= zs?-IsT*U$QWrYIWGQMIGZzr+v18Sxz+tt7+e7clb+!5(CYH|}8`V8ds`j6J{;iTcs zrfRhZRIP5S@v9c>^47CR8AWp$-+ce)V3Un^3iVU>?Zd0zH&B=p0@0OEiBc_jn`-JQ z*s?L_1x8PdULa~Ugpwad_O0t8l-`?}<*BZetob`3s`}JMl z`=I4xvtn(O{2e&$h97k6-9|G&#c@41XM4yu_x9>tUk`EpPIK=-w>vFWokykK^4rzM zhdzpy=UJ}m-)xtfJ3cOC-f6Y|>(VNpT&fke4dLS1UhywCI`RlBT0I*zQYHZsAORsj z|G;fP0wiz}0s4oN$k0v#Bya@%J0bOx7z+uI013nh&_Bd*P$2;lh!CKEh`^vo0wfS4 zK>rZKL4^cJAVPrtAp(OU36MaH0R2M@2Ne<^fd~QmhX@RcBtQb1z~Ea=^?a0)68cHg z5t`@ZIcydZAc1)&!1!U_cL3*40w)uAaQtv`f3}bS3Cs@x#t-we)|@E`OiJL6{N=VP zOOp;{e-a>pnI}O1F!M{y36j9n1n3{8M#q69F!KcHA7*}uIYAPbngIR7)aW>n1ZJK9 z{lm;JF(*g@6B0NIzqMEnpPy8jI!E7hj(mo)sXVc&HKnt~p25o) zNI&(ugU9q|QRin~d^PX9wcAbpOeX7|lWe?o z$=BP}=Z2i;xpA1_Oi;R{gYhut{kt6 Tq525$gjm(FCP}+0ygc8`yJgu!=HKvu)lk|OJ_sz_k zx7qi3Z--}VJ*njE)oo1ga3!}?s8-9_CG&)` z^)KF+q@iGy%mw9An@&*>7~p$GKh`JcPL|N6^M{r;r(MZ}(J z3#G1k@0seay&3jT(G~qmEu}B$d;N=MwzAB=gL|q+Y)kXe+fxyV&LgH(YEhj!yZXKX z!h{fr&U})UO3{^2M@e2{uk)N(esimHweHiR?G!8aijyR;B)lM+MV$O-KA2tzu)%qjTwxn$7<@wGyw3)%>c)TwKd7{Ov@0 z?$Df4PeqN8K>!3mfC(TzU>Se_2pmKJ@!=pcu!8^y?2!6>k^Cfv0s252 zKmY_H1P~u0Fd%{e2*d~=KE!Z90Ra$*5I}s0z<>w>AP^&f_z=SZ1q47KLICk00s|rl zfPf;<`&Lulj}l2&Z<_YeoaXniQ9u9$CY=EG50kzEFn$m?n82&|4+s0B1q47~atL7m zFgbILk%GXm1fK9;ZcCXNwj=yO00hRK0OG^gPca4v0wWVZd>9!WI)cF16F__z`zgi% zL11J8hz}#9Lq`x8djg0LV?V_hAP5XeU?=?6Vm5q#QZjY+zv=A946#X`SZPhFY_Z4b z1M1~jUZyux4q-#RYpJuJe4fw|buV&$`~CAd`}%r&J~My(#NlNnK|FKAV;%>J~+uPd|@MoJplChXoPF~E1s^f+|bKY4g%B`$gCF?z| fWi3ml?VtIg&-u8>hRPwLx7aGj0w?s&vQ49xNrW|D literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1ed011aa1587452c49da534f80408b3ab3076cf1 GIT binary patch literal 23425 zcmeI4&uE(EAXFZdT2 z6K`Jp8+z->aPVL}diLaB;K>*-IP;)omz31f#shrc=DnFW@5k=@yk)YK?bsQ!{F#nU zi3RBs;UgN29Nqmt_Q>l6O&>eoVOMyVZ8S_)HcW<=wl`U?asElY*n(O*(MPT)Ie+0S zN$NxiDpQL*>X6G)()%@viCmH59>t2OH=?QZZY?rwixQ=zZP>t~7^hF8TEAbkv`muE zZSd?*dF}?aIX~kwC8$D0vRP)?@|1RHo6qj6mhr3MODLd^<@K>=)-byvBug3 zszjqKr6fwLwT;eyGHoBGNSR$^pVRoPS-nuYO7TcOwnAUO{Tcuq?=-A09oYA0KQKU< z69U1Vk8{N~9QmkHoNuv{d`@(~xzoAt`IIv)!!}D+Mk7nJv-v!ge6QuUMbrI`+p4?O z7oNXGW-dqic6G~jUNv3XENTx7w?<~mbNx=W<28ILnwDdwb%!#IdcE3l)ey^VH|jg8 z-E4}uNyTc@ZQ0&ym-3q9XqNPEw2Vg0rH$m1X7gXSR{V`o$(AF`#nGMoA1gR=moBO5 zTF?j`1V8`;m;n3(ZUYbifk_15A10B39RxsNckuTP`6h-00w4eaAp-CZAsncH00;yK zz&`|FKm`F12oZpP2;o2l1VA7_0RABW11boBK!^bRLkI^dAOHdZ0`Lz37*Ig~1QY@F z7B_1H{}5GgnvT$1<)jmSQ)-QDUmuyzjTg#hS(%dthB~ewAfX;MVHmt zHtC#-#T!%aT1L5_9D8(LwMu#A#_f#xXsf-RT3>zqa6?6zb+n4JsCo>FoLO*s!SJDh zw$oSB9F4ci@p?O|^thbo;KiqyBs_7i*Q0K?OMO1Nd`QG%u5$2V&Ps+2`{t}wGUQUqdO^R=QtoAm6#auI Y{muC<8|*Vg7uhQL4o87bvn@h@0o9_tLjV8( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0153bf4b6b5bbb7db97175b3809cc0d2b9dce460 GIT binary patch literal 26028 zcmeI5&u<$=6vyB0nvjx+TF^rTl~{p?sNGFXnZKiL|V(O3V4|fbxl^3H<49>MkCR$j%BMNT$ z_;hcsVCb~Tl}FSwQmLfX*QywX>EEsxVYu}2>lB8w-@L=;dtVe-bMvlkcvsJz3ucq; zPyzuE009sH0T7rH0_=~(N2+s*m*a96rMOOHGfLSg(dkf`%uwdya(=Z~t5tHV)=Nq^ z?0hImMbR!>OUlBefBQt&*k2O+7fIZr84BoAx=ed?n;t&+^XT_qe>ohsC*>CrTPhby z^?CC<)n0o(YH!lA{<&uAOZuRF$;wst*tYLeeXkplPj!oR89w?x713SQpGh0krUrG% z=Uh@HO0 zc>m#ed(?mc2uu$F_z%-F*BB`XJS~Cy{L5`ggJ1AJZF|^*00>Mj0qhTxJGU4a2#iYr z`@^^d(H;aQmjL#M$(>t_3K8)%{s#G!W6y4h_&1TaQy!d}i5}vr%>(T!HK85_*=Z{1z<{}3# x=0oL4#Xwzf*GqCK8+O@#gL7HS5|#UB?(_j4msnAKhUf*>%CW@3zjLh9=pVkrIGg|g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..799bac8b0bf09d4e15a9731cdba394293758ad79 GIT binary patch literal 26156 zcmeI5&u<%55XWbCO-M;ZE$AVF3Jgdnf=dY&1W_s*dmU|Rd#&|GvBUvPvjj`Y+OnNe zxa2>mIB??7UMmhrx%Gs6;J}XyaOO}U4hSKX1FEVDX_dpAfG;=i*%K))1UYLIR5RIpYII&liC*%d#Wvz zy5_ZSRe$Z#uz!j!>7QvSeO({)FPYiuUG^Q_Q{7}+nvedTib!-GF>O@+$VLuy$AQcHLk zy;|NXZ#_#%t!OOMH$U9%KP;n8p?>DXKDhdc0m7URh|YYHm1@b|l%XeiiT#)7#PVAQ z-8UOP6>O(ewYQv{Nsd{~7mKvzH`-oDB+Ku5?LDu4wc+oQT_}*XU*GlI4_h8>R?Ll( zw?lTj;rZQqx6$;eVmq#rwOqtPjzv;PIK=-wc9PJUZP69<+ZDgk31@xu4_8t z-)xtfJ07j2-)*)2?bI%_W2u(k)|iWHxyAcVwC546DD`~Q2pI%G00fu-;scfe2!Oz8 z1P~uiBLh1KfWQ&S_Yd_=3@|_d1VA800P!J)0}2R$K!gC|Lj(py5CDM~0mO$G4k#c1 z0ucg;4-ps;K>!3|1P~u$IG}(42t)`VK15(Z1OX6G1p2>f%KK3w>FQ0>A)4p-J!}*Z z0D*ZYfc?X~uK?fbwVQyo| zaQyU(ujcHxb~~$?)pKuOUQ=0SU9;v!{{xGHU3PoX^q~`uWHR?GXXCANzTU1nH~2iy z#pMjflA*XmDJpaQ0oM*^F5YJ@{sv}dc0=t~-?^rwY=FOXl z=QFdDmw5A1%{!Z4(!Zy4a*2+}B!diNZeY%0^7ribw-RYpUycSzaA`y`(bD=YQE<;k zr#lOUgh^Z6ctrg~G8wn}T2;d^_3N97FkE@=B?`m&Z(im6&gTWzT)%B6yesF=2lMfM zsDS_ofB*=900_(o0rp4qnVOvRx-N%N(sd%Iqm+#joeq`B9A#c#&94{h^-6BtdRFO1 zgO4O>DB5LfMOmcjpFYvM>@SJ^GbC=&90l|VU8ZYvlm5E@$I)-U{Cqg-Ps%SMwp1>Z z8uR*hs=x7c)IUwD=4VFQTrr3JOIEJ>1=}9?scy3_!`Hy4B9c1Km^P_HP3n=)rKC!V zzEVPzeSB{4kpuFqHWU(aGkE{uS5CXV~v9@h-T^d!R*KzTW2@k)}FTsLEx5HCJhmc6pZs zs^$2uVV^%qb3al&k>Fd#pi}heLS}x8rPEaB8_`fXFP_?i!yWXvCGRqoq?A1%rQF&| zsbvMbTzi31MlrES-~RCDV54j8x_IhgWjOjn1%w$Pps#$2E7g*>AyZHB7JHog#17hf zy|eSr4MXpsX6pK^~njOC@k{$H?&W_)>)(p1ESz9A}x3TSeAGLkjs95VI ze~X+>(+_%$Ub7WY#c@41XM2=y?d&vqzUt!o-PX>YYIoXFT%by$?RTop8$J~+&$C>y zZ*@woEuSuC-fOr2>(a{ifl|G&X)qVh_KFW&z2^Zfsp>gh2^j=H00fu-&IfD*5CDOb z2;h7;i462000IZ3eh(!-i6Mai2!KG00M3UP4pcw@1T+FTA2b*cK>!3|1aLmYaG(ML zAfOSz`Jlmo2m&AwBY^WEh65E400E5v&Ib(!L=XT0MPTr$raT`dlBu3F9ie%VpTkB1 z0T7sV0`MPZeFtFvAaF8)NBa*a+oJ^pKwx$Vz<-#XwZ=?AU|a%s`7gI6EsWa{_83;8g22=h!1*xsONyvg}2 zCyyf-ON8PsrK!y2hg`ZGxp!WNAK|lHkSvW0LU1{eGYJ z_xCB}*FL`_Vlh`acrjn9j4Ouff?F%eTiLYB_RCz#UY3a7Kk=aV`FevD)hCF~uvV@W K375?}gZ>8ZuR(19 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bc051fadaf15e80cb2f901d4e938149ac6c40008 GIT binary patch literal 26056 zcmeI5&u<$=6vyB0nvjx+TBU~wDq%oE5nM{KN-9cZV{f8OZLe8x6iXba+iZiSWNq0_ z30(3YR0wh6&|WJJNV)as14#V10A~&n;(!oBIiRYlke2txYk3?RflW{LcdXrc^WK}a z=QFdTXL<8d$+;U}(!ZBfVu6mxB!diNrsvLL;*ZP^x2=?FFUA+KxHe*$XkqoXD7fdN z%iX!WWzr@akEm-U62o3!t7a4>e|yV{qNV3vrYM^I<|3c(evxO*-W|vCube+0&JOoO z3j{y_1V8`;KwwG;a6F=q)a0VKbvcTQt`j*GmrPvfbfjEnD1B);w_2!H%h^@?S*06X zd?1OT;FRno<&mO)#za>+UK0OjNW!8S3h7h2OgHHk{q^9_qu+o1<#5oSR9r;tsX{1q z&Asnbf8*)6e~OmP&yAG1WcK?P?QG=}_B|d`-CU4jvLhG;|9-oimFtm3fFnoT&G>y;Z+bS%kfpg zF@1t&exj;n@hxG{Df(H)kTtt5s&%Xs{j2L%$kRYTpZO#=Dn)-y#+~FP_J8gZCv5I^-mZtV z;(Eo3yYA&|^6W~!P@wg&-U`|xIbkPgZ3nfR^>B;al@)S!YFmN-VKbn$vb|aiHpy+( zgRoQU)Egm{UC;Njj!(J9_I9lks4ia6Zfx%=yW5oNH7eJdL90^#D4>Gv`?e?kjaIR- z8PJ9FwPy3b&g(oo7OVLUgSq&QUwG*0Jr8I>HP7iv$RGd$AixB$KVTVv00^8!0Q95CDN80@xphaG(JKAfOSz z{-D8t2m&B5L;(B45Dqjz00cAw*dH_)5J3P06oKBan(};<RR0bB&RLz^DZ7@n3FBnH#ku{6PQ&CY}KH zhl!tJ3=jmyCV>56Y;@=d0uxUF`@_UfF$M?%V-vvsFg7}L1c8Ysfc;_Orx*hSfe{HD z#J^h1$Inknewe{8or66?bdo1ltVxwE_AI?ly?o7U^rXrmY^i50P4<)5J$g!g7CArv z>Ki%t-L3XwX7TKMS1zb5v%X#R^?xq0;+Fibo<4NMk&Nb^NtUady literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59d23fd22b07c95e7524c4c3c205bdd250229dde GIT binary patch literal 26132 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 + 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). // =========================================================================== @@ -776,6 +822,257 @@ TEST_F(Tst_Ornaments, multi_measure_hairpin_resolved_from_almezuro) 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 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; @@ -1040,6 +1337,47 @@ TEST_F(Tst_Ornaments, trill_accidentals_set_interval_above) 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. @@ -1197,6 +1535,290 @@ TEST_F(Tst_Ornaments, trill_simple_tipo_b6_places_ornament_trill) 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. @@ -1225,6 +1847,45 @@ TEST_F(Tst_Ornaments, guitar_bend_orns_skipped) << "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. @@ -1414,6 +2075,203 @@ 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 67d4bcff6b82e5313981fb9bca9790f20ebabf5e Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 14 Jun 2026 16:00:00 +0200 Subject: [PATCH 24/33] enc-feat: import ottava lines --- src/importexport/encore/CMakeLists.txt | 1 + .../internal/importer/resolvers-ottava.cpp | 85 ++++++++++++++++++ .../encore/internal/importer/resolvers.cpp | 1 + .../encore/internal/importer/resolvers.h | 1 + .../data/ornaments_ottava_two_spanners.enc | Bin 0 -> 26168 bytes .../encore/tests/tst_ornaments_slurs.cpp | 31 +++++++ 6 files changed, 119 insertions(+) create mode 100644 src/importexport/encore/internal/importer/resolvers-ottava.cpp create mode 100644 src/importexport/encore/tests/data/ornaments_ottava_two_spanners.enc diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 468a8a0c32d00..eb8c0f3953ce6 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -85,6 +85,7 @@ target_sources(iex_encore PRIVATE 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 ) 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.cpp b/src/importexport/encore/internal/importer/resolvers.cpp index 32fa08b0d24f2..e3bb36431d9ea 100644 --- a/src/importexport/encore/internal/importer/resolvers.cpp +++ b/src/importexport/encore/internal/importer/resolvers.cpp @@ -33,5 +33,6 @@ void resolveAll(BuildCtx& 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 index 3a73f7c8306ef..350ceb6cd520d 100644 --- a/src/importexport/encore/internal/importer/resolvers.h +++ b/src/importexport/encore/internal/importer/resolvers.h @@ -40,6 +40,7 @@ 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 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 0000000000000000000000000000000000000000..457d41e0a5e3619b515271f4dad7891af9452b0a GIT binary patch literal 26168 zcmeI5&u<$=6vy9sO$a2S7W5E7B`lCo1edm05JZV=>`mCz>$TQv#ZnKgX_jCqSzESK zN-shD0Tl;M9NKHe0V%g0ec-^43vlL8Ar1&3lmkLlg|xgkUd!V&Q5&}$u)kyN%$xUS z);ph>eVUawFO|Hz{xv-~rIQP^PYE)}FlPGASxo+!{qcsGR`tdBBqoD4O&1-A{k_Zye38i@f^d zG&uX9j&fQE=rf<v1 zs*4wNnp-={?zW^jL*;raXjdB70xDX*Z+X(b*>;*60iDgf-)cSV+|F}Dr&d@un2T@w z#e1IKbB`8O^^~rJ3<4kk0!#q@0m}dcK;S3>@DE3kfgS`vV2|YaLp_NB1_*!v2#gSb ze;C1m3J8FJMgaang8>l)KwyLb{KE(iR6qa(Gy?Ds8Vraa00JWf;2%bCpaKFQpb>z7 z&|p9W0T56G`oC(*`%xk#)SIS5G%xde*hnA%0@F?a`-f>?0T@3B98KWx{ln4iVF3XU zm>vSyKTOYDW27K(TmrY{um3sC9k(Oeg8&FjJOTKJiJxK&5Cq010RJ#HI&=hqi6;R6 zF!5820fNBT1mGXWMu(0dF!2Q7A0~c^F+dPFCV{>9SBv@h{Yi-rGyJ7<=rcqoabm@q zQqf{h((BZZ5530mk|hSPsou3ra69?jmWaokB!BU>H}md=&CX(W@#MwxXH}G0->Uig zKeJeIOMXueAF6u)S6Pmu@zyzBZ&&3F&Zjx~9Kl#36n7|1B`!bW(&Na*+sq|A0LEwC zhq3K|NmcW1(YwylY&Ipqiyvc>@Wj1dk9K!=DdNvAed2+6 k+a>!|E@drCgz#VZq<8svn-!HOh$Nu497_`Jnso;K3ufy|VgLXD literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_ornaments_slurs.cpp b/src/importexport/encore/tests/tst_ornaments_slurs.cpp index 86fc99aee1ddf..e70846fe2bff6 100644 --- a/src/importexport/encore/tests/tst_ornaments_slurs.cpp +++ b/src/importexport/encore/tests/tst_ornaments_slurs.cpp @@ -656,6 +656,37 @@ TEST_F(Tst_OrnamentsSlurs, v0c4_slur_cross_measure_fallback) 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) From 02dc01cf0331ed4071ef5fcc0c0e919528dd8de1 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 20 Jun 2026 10:00:00 +0200 Subject: [PATCH 25/33] enc-feat: import staff text and titles --- .../encore/internal/importer/import.cpp | 1 + .../internal/importer/mappers-title.cpp | 129 ++ .../encore/internal/importer/mappers.h | 1 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../data/ornaments_tempo_sym_followtext.enc | Bin 0 -> 25784 bytes .../tests/data/text_duplicate_titl_block.enc | Bin 0 -> 47162 bytes .../tests/data/text_header_footer_tokens.enc | Bin 0 -> 25912 bytes ...bpm_suppressed_by_orn_tempo_later_tick.enc | Bin 0 -> 26047 bytes .../data/text_multi_slot_stacked_text.enc | Bin 0 -> 25912 bytes .../text_orn_tempo_3_8_dotted_quarter.enc | Bin 0 -> 25973 bytes ...t_orn_tempo_eighth_beat_not_suppressed.enc | Bin 0 -> 25973 bytes .../data/text_orn_tempo_equals_header.enc | Bin 0 -> 25873 bytes .../text_orn_tempo_mismatch_suppressed.enc | Bin 0 -> 26001 bytes ...text_orn_tempo_misplaced_multi_measure.enc | Bin 0 -> 26057 bytes .../tests/data/text_recovered_name_latin1.enc | Bin 0 -> 25940 bytes .../encore/tests/data/text_staff_text.enc | Bin 0 -> 26304 bytes .../data/text_staff_text_first_block_wins.enc | Bin 0 -> 26127 bytes .../tests/data/text_staff_text_multiline.enc | Bin 0 -> 26103 bytes .../tests/data/text_staff_text_multirun.enc | Bin 0 -> 26115 bytes .../tests/data/text_staff_text_placement.enc | Bin 0 -> 26162 bytes .../data/text_staff_text_two_descriptors.enc | Bin 0 -> 26116 bytes .../data/text_stafftext_tempo_promotion.enc | Bin 0 -> 26235 bytes .../encore/tests/data/text_tempo_changes.enc | Bin 0 -> 25912 bytes .../text_tempo_orn_explicit_quarter_unit.enc | Bin 0 -> 25973 bytes .../data/text_tempo_orn_v0c2_v0c4_layout.enc | Bin 0 -> 25972 bytes .../data/text_tempo_orn_xoffset_downbeat.enc | Bin 0 -> 25991 bytes .../data/text_titl_empty_second_block.enc | Bin 0 -> 47162 bytes .../tests/data/text_titl_headers_footers.enc | Bin 0 -> 25912 bytes .../data/text_titl_latin1_small_varsize.enc | Bin 0 -> 28374 bytes .../data/text_title_instruction_copyright.enc | Bin 0 -> 25912 bytes src/importexport/encore/tests/tst_text.cpp | 1071 +++++++++++++++++ 31 files changed, 1203 insertions(+) create mode 100644 src/importexport/encore/tests/data/ornaments_tempo_sym_followtext.enc create mode 100644 src/importexport/encore/tests/data/text_duplicate_titl_block.enc create mode 100644 src/importexport/encore/tests/data/text_header_footer_tokens.enc create mode 100644 src/importexport/encore/tests/data/text_meas_bpm_suppressed_by_orn_tempo_later_tick.enc create mode 100644 src/importexport/encore/tests/data/text_multi_slot_stacked_text.enc create mode 100644 src/importexport/encore/tests/data/text_orn_tempo_3_8_dotted_quarter.enc create mode 100644 src/importexport/encore/tests/data/text_orn_tempo_eighth_beat_not_suppressed.enc create mode 100644 src/importexport/encore/tests/data/text_orn_tempo_equals_header.enc create mode 100644 src/importexport/encore/tests/data/text_orn_tempo_mismatch_suppressed.enc create mode 100644 src/importexport/encore/tests/data/text_orn_tempo_misplaced_multi_measure.enc create mode 100644 src/importexport/encore/tests/data/text_recovered_name_latin1.enc create mode 100644 src/importexport/encore/tests/data/text_staff_text.enc create mode 100644 src/importexport/encore/tests/data/text_staff_text_first_block_wins.enc create mode 100644 src/importexport/encore/tests/data/text_staff_text_multiline.enc create mode 100644 src/importexport/encore/tests/data/text_staff_text_multirun.enc create mode 100644 src/importexport/encore/tests/data/text_staff_text_placement.enc create mode 100644 src/importexport/encore/tests/data/text_staff_text_two_descriptors.enc create mode 100644 src/importexport/encore/tests/data/text_stafftext_tempo_promotion.enc create mode 100644 src/importexport/encore/tests/data/text_tempo_changes.enc create mode 100644 src/importexport/encore/tests/data/text_tempo_orn_explicit_quarter_unit.enc create mode 100644 src/importexport/encore/tests/data/text_tempo_orn_v0c2_v0c4_layout.enc create mode 100644 src/importexport/encore/tests/data/text_tempo_orn_xoffset_downbeat.enc create mode 100644 src/importexport/encore/tests/data/text_titl_empty_second_block.enc create mode 100644 src/importexport/encore/tests/data/text_titl_headers_footers.enc create mode 100644 src/importexport/encore/tests/data/text_titl_latin1_small_varsize.enc create mode 100644 src/importexport/encore/tests/data/text_title_instruction_copyright.enc create mode 100644 src/importexport/encore/tests/tst_text.cpp diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index a4cdbc3af4461..9e95ff75f73c9 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -216,6 +216,7 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp 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 diff --git a/src/importexport/encore/internal/importer/mappers-title.cpp b/src/importexport/encore/internal/importer/mappers-title.cpp index efe461283a0c4..dbf52ffa5459f 100644 --- a/src/importexport/encore/internal/importer/mappers-title.cpp +++ b/src/importexport/encore/internal/importer/mappers-title.cpp @@ -38,6 +38,135 @@ 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) { diff --git a/src/importexport/encore/internal/importer/mappers.h b/src/importexport/encore/internal/importer/mappers.h index 83511e2f667c1..6ab944ee69bd4 100644 --- a/src/importexport/encore/internal/importer/mappers.h +++ b/src/importexport/encore/internal/importer/mappers.h @@ -67,6 +67,7 @@ mu::engraving::ClefType applyOctaveToClef(mu::engraving::ClefType base, int keyO 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); diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 0a28d83303047..583e16795541c 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -40,6 +40,7 @@ set(MODULE_TEST_SRC ${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 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 0000000000000000000000000000000000000000..c2c6c15e40df18daabbdc6d198926de5fe2854d6 GIT binary patch literal 25784 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

%Ju6ma;D>$w(@VZ@{KJQE6I18&3`+z zi{hBCW;YGt;#f}ZPdgZSfF-S-2^uMr011$Q5a9a2X+Q!ba2x@y566+AodihW0P6FH z{v<{iBtQZr5F)_!A%uep36Ma50M~~A42mQ`0wDriA3`{&kN^n;2ylG}z@SJ1BoHFN z^&y0V3JH)vfB@Hr01S#GKmwXT?^{jve3X(R`bpCvniu3bY!(tAfmtWO{ll!!0QR2* zjwkTw{loG8Y#{*>m>mM#Kg`Zpv!^65E`dAp%WYK_#to!D36Q|l6X5zV^+U`KlEB0S zxIRpbj)5dF^#r&+O#KkEgCsC90j>`dqhlZmOg#av4^uzH>>vq@N#LOWt;J&h`AL-@ zX829#aLrIQl_yrU#&x#X(|85FG|d}$T&Gcv>Srw@;-_AB@PvLZ>iq1>uVsqwY`2$F z%cn10T+vx(9kc2L|EI)SvEX!r=|jit$$0K*$;Mlge7zlguFH9riz69~Rfgg=5-3Rh zfz-BSF5VR`-Ut{+Z14BmMw+ymDdy}OBF(4bDtYn$SX6rAZnum5{eAfI+LxEgSS+d} yFP1~sVZ*?jwJUk`RyM7I^^(-Ryw`W}lU($H9B+uB{1|Xb+SIWs`M}d+Gw?TJh&Xrv literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2dff21c5bde0fb1622bfa328e4d4042f311865f4 GIT binary patch literal 25912 zcmeI5&2Jk;7{;HqO_Zt?wct|0WyJv@B%mM*g|8~Ey@?#Wwza!ye8g?G=}OMpa-6hq z$$!ujCl2kk;=teF1BU`9ICB8R0U?BPK&Yya%rj#raSSL(E#Qb$61&Zr&#!P`RhA**QQ^ZHj-!TOl9E0>K&tC&A$)#&Q|j| zY%=ozqkN%ovebQ>9FL1Xzn346m!5wS?O5vTfe_ z%GP(Dp4NA9Dfg-4=9Y4Y^?k1#ns=D@sqRu&;x+N90F!2qv4H^+3=uO1JuB6JG=8nX ztCZ-+`lNb|Oncbjb)AG=w9!JyxJu16>|&eLQ=4=0DVQhY6tM6u+W9<(po25`%Dn(~h$9DadJY{rm@%~xf7QNjBS-5 z-syFBZGN+FqSw*t^y5L8d=#VZMUfYn^1XrI+l=u>>1x0KZy)VB7WCWI4ToH!T2y}+ zoLYKmYKmdV6X2vPuh9XA5ZTm0wVA~5-4-^ zN(X(;YQBoMX%9kd*%{7|Go4-Lq>L3rKm@W$;Ig#=#zwfb6vkul@sKMa%!;dtu18%3 z0X>T*Uc(hiVKz~z*CHSSA|L`HkXZuL9g9c(kNEdPpB_S&9z$yVhn|g(3P=zE5y%XI z)9u3$H}#uCX2yd$Dgv_v=8h#moj${4N7r>)1+)8NvU_f}j${!Kfou>sxdRydwK3t0 z#SJ@WAq$a>F`!_dHqd zER^&VZFA!>_4RZ*>Gid$hGFKnxAid0J^K=c;rutR^YPA?CAQqSZRy^|rAxtlvL9+7 z00JNY0w4eaGeUsZBk_@@SmO1#9!3(kiEc!akA%*L%4LqSZ(b>`mFsnvWUu-~03U_g{bcY1E(8x`^0QEuqvo zZ+xfvYfnb~4Z4#0LNiji)UbcWEY#jnzC^oPF0DsvqYbuYc^|B)h;Az%gSMzcP3n=) zrHJ!9ttjP$gCn+2FAlC}Q7V_O>P}u1kua1Gm*EW-bi3N{!{ve={^98FxMnfSi{J`R z$u*vk7Ik_5kf+C>IyqG1vc#5abU^z&Wr1oLpF&=Mr)lm-s_Q!6zZ#vR&lj@u+pL|Z zIzJVfIuaGsoXE^Q3K>3qQp!Cbr95Zj=Pp^*%@-)6mGwpX_J_X)cip&CR6qB?K0Nya z1C$vd5TE%BS85e+U51|FTkQWlCsxot=)KzvXw`NrHG9)7n&g_bQn^fyEir@wdtDH2t90=rvmbIkxM$10OCUe z2Pz-{0x<%J4>1@}K>!321P~t*I8Xrr5Qq^#e2Bq-3IZUIAb|Lgz<~+~fIy4@;zJAu zR1g3GMPTr)ro0~|T1vfXIzsazzlRM00T7sV0$4xH`W=ArgTUzoPF_Ep?vEA_0D;*d zfc3-d%r!;|0+SNB%YV5ovM^~!_=5llOg#a_hpC@p3=jm)OaSrW%;?Y&1g4$<;=|NW zF$M?%XC{F7aAtJq2m(`20P$h!rx*hSfe8s5Mc-O1M(I*5>mWNnZR2MbZ=Z`+Yh*Jfx7fL*67~F;_WxG4HBO8iwYQyIGM-*|MtE i%UsI6Ea|m>=9Avz{Vg`s9wT~+t+MAhi+6!-8vO%|ZYrPv literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..811af277c0382309c8ffb6c987e7f2de06dd71e4 GIT binary patch literal 25973 zcmeI5&u<%55XWbC-H?)qTF^rTl`vF75nMvB+8|1?u{Y5sw%1y36iXbyG~3itvbJoe z6fXG>Dh`}Dv>YoANV)as1BU{q_ROI|98guE91yB1q-ExLUGJJitwT@tcdXqvZ{834 zectY95^pAzoW1cQJzY|XIXWPnG}5%GUUya#e`fC9FjA^MA5LO$sf9Yx+{z6x@W{jE z)=b{eX_FfdsB0t=@m_aSH3*UqJ~V=0>9w~g2&R8J$LCu=m>Z z00JNY0w4ea6GDLFk?2ZwmS{by2cbl5qNhU1ghHnR)n$s(?_bQV6spy7cEx;E*#?sj zG-)VUC38vjNYQ^|qR%*9lK!udghf;2(>HX1uF`e-=h0sW5C8c6{$M<*xQMu?3Zc}T z*M3pswU@&2DY~eCuch=Qy+6KaW-A|Y-|zT1psIc%jE&Z~p5~($QxV-{omWg7)S^0d z$m3F^{01#5<&cxiOJASqeV$pVT)3~5GopwDfxLMiNA%5Q;M3n6*tFrVYUt<5~KC%4fZs+5=Ps_Gbtk~;L&LqdIB__vR)+OFkRPlq<^DTY;1b8nEt%keAcZMA6%^FH#FwrT5jR76CJrv zbEU2 zpa}H7)s*L>MAOxirUNu*`8jL|2!Oz(6F~eh=~n>O4+2LMI6Qthx<6V#00bt70OE(q z*=wv61V$xroBwiKWMMSbW64n5rWBR9{DMn|GZ*hL7k>zh%eE_F+fb9L z=InxVoweyqQj!-xN0Icz-ENom_Vy^?*B-wlV=-4bc`+ZVj2rsqyt7`EkFsHvthc%B f<-NW`zwxAx`1pVgl@mlSbDJDXoW(oKHjVxTV3aB! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1c3c920a5462c98ff06281ee5ae102ee484963a GIT binary patch literal 25873 zcmeI5&u<%55XWbCO-M;ZE$AVFN*It(L@#Y>K@g#`u{Y7Cw%1y36iYpT+bqFSvbJoe z6fXG>svdgc&|WJJNV)as0|$Pb;LHIbgb+f=2ZSnAX*=`0iFchw)GiW|{e9N%o0<1! zz3=m$M=$YaQpwqyU(vspRAPa8q?1OPHq-CUV&c!t_jinx(ig*v7+hMrYPKKgqqBGGxov`HCFmSMcSIZn{bk5n}bURxTSqR;2jvs)~k zrYf&kO`VCdYO;=F9?x?4$&gy+Kx+963|}p8l{Q|Zq*gHI>DwRv?yrd9PN9D4$bNYB zBLjpfArPJUBrBDoyDmdd@)CQR&xz$X_d4&_eOk4hV#VHYawa)uC0{7ehF@=aZILX$ zP4?<4S-Z7u&;7LN(R$fjD|%aGx9XnXsdefNpUSr5I$6u5Tw`aa*71~! zK8cjlNbsJfB*=@2p~Sh za6kb85Qq>!e2Bn+2m&AwBY^l2!vO^ZKp;W@@gV{OA_#y$i~!<830G$xj$M!00gFo z0QL{lGuId?2#iYLKL6#ml(|tI(H{grVB!fNK1}=+V}KwqHUY$kvC+X11SXyU;={yG zF$M?%V-rAp7#kfNL15wuAU;g|6k~uOFd~72@LP-d@cBu})H(R3a}YDcCV66|HL0@2 zo}r7>Pkg*i&#J`4hI-ag=YI0KPtU1)k@G9(-p<)qw%d!D#WNpVUQ$_RU9;*&|4YNF zU2?n8^r0h;WVG&C&c<8ge7zlYuJd`Ci^~~|B|~wWQdHvl6RsW3T)fL%{2?$d+ddB4 zhMJU`vkT5mmS!_aNnZRP7D-Rs?RIH@f1d(=?ej}A7PHF9i}_G>+;C{lI~zs0l})Q; iy~(w#Wl4SgGhg%pAMdiE@(j@nY?Wh~Gi+zsrqLq;tsCEiTRS!?ZEI=Cd`bF@b>(n!;$y4_if|CWAoM^7q!KA1%3S_@>Nx#c@z;F0^6 zdowvbM(fY7z%lS&Blvy@jSGNA- zeMuVfX3k!*{m$fK|5JYAz(^w-nh_kR8Pryu&`N$rb>d#Wvz zn)AkEHC}rq7@wqzv9GmcY%$gwUobM|kGStMKEivdyR4`9=s8wu{5C8!Xh!8-0 zh~R(%0w53~fcOxC0TBd1AVL7~A%X)62!KF{0OCUk21F15fd~P_hX@WRAOHd(0*DVG z7!W}K1Qdbpx0>>Nlt?l4q-h_`+v>^pz77clKwuIHVE-_QUdNh2;BW-?-#;8~EP8>tM4zgQgz3`5{P|K+xnnPDcuSP%e#u_S=_FqUq`qCj9I1P~uaf)SH|z*rJM zd>Bi&Vo@M45(0=1Bf*GCKwvBhAU=$xTd^n*7=plV@U6vc@cg7?>g<2h*^e1wlRUA~ zno!wdkJAU#O}xB9FR8@Hx_Z_!#{J}VpI%mlhU$ z{9huLtfJEirw<)sRR<5va5mm5=j&~&bB)iFTpZ3|EE$Shl%yip*SWSibMY2)@dm&+ zY`Yq?4Kyh=Yvt`*EKR2qlDv3?MbZ;@Ivv{C*&&}_JN%N2#jJAjVm?$IHT29md$k}R mWz8&_?{eMEJNEE{V{f8OZLhW7D3&--x7ntalC@91ub%hpH-7rR~h~rrtHF62+D(+26O?eKYfZ zc;Dx}9lgYxNjYn+{7Cz!WPFBpC`KA-+C;ZHi}8QczueK2N}mln(K*)wnP_J5ju^P* z-s#>{PLI(l7w%9;kH@369w^oK6MuiE`~LjfACT`)K0M3Udq3pZa`UdKJD1L!@g}3~ zpn(7gfB*=900@i;0p5>7}#?l6+r2{2i7K`{A#;ZyDfWpzU|xGUbQsyUTQt z_9ffMrg≺Ay_flU%13pVxTGlT_imDs!G=%T?N>4PFMGD(PPqys3}V#BWs5b$%u^ zI!fP7r6yNdIz|&gJv8 z?A4lXOC-~4yUlgCdZXs8k+rZu=0RX;)rQ+F*KWC#Hyp>XrGC9xsIR&-m%7qu9CT{MuNNx0 z6^*$#rjvhahg)vbj8adAjgUbA1VDfZz&~IafB*=*i~#(@%gDeE0wAzW>i0_WlNb^R zfB*V2m&AwAprjn!2tyXKp;c_{viYdA_#y$gaG_Q1P2rl z0D%wz_=gY-h#&w0ia_^UO?f{`q?mfsw1?(>^>%nqhXev3FpdPUe;CJE$CyFjUqi4!ZO2^B5&IDJgrfXz$vnhL0_t9LD9Tu+Ys^t!q)xxV}1Ct2%@wbpEU_V|~V z=2Vnf$EY~r|1Pm$6`f8veCPn9+P`Urqw!WbUT<4nYkVE&~u{Y7Cw%1y36iXbyG)u6QtS#Fq zg-iZ}iUTJO?X}{7lv|HJaNx%Y&KwZpfDl4CAXFiw<;{2#@4Ag3rd;grvv%jbH$Uq6 z%=l>%Z(i#~XY&jEvt-jtI7A8t3}bHCT-5Y$`EPHTS#2*zgP2kpkq#`a-BJ$e`Cz%T zP%=~4lFCC2%yc^0>Pu~gVdj@N%rIPe`UQmH{8z8Y{m$nl@!Ys=o8Hw67lL`f>$W@1 zWOrI5KmsH{0wh2J(@sFnBk?JwMB;W_4u#^X`{EUu(JGqS0b)KUM6Rj5BBZO6}4x5bx}Yj~ytlr1yHCc0>$4_`_p zADvp`S$qHPodniRT*j zuq!JP=$i2rlJj*MbKj$3nzB6&oWZ9Hx%n;8&Y~fk-q3fTpx0Y3-9xw?eYjMq_JAtY z$7p=$l3m?+4jH3tF5>I&{upkm@kWt-=GcEU`muw`j1Y*gd`66V#amZX&&U@0FP{@T z=Bf5=U`d0l zP9nhga1t5%Nq__npnnIXeiEaQ011#lf&k+~0tXEeAb}VG#)lXTsw6-H2?C4{2^=&? zfCOR$7$0IVsFDB)BnU7*Byi9m0TPH2V0?(dph^NHpa~4W)l|<%DJ`X+G##ONUY^6I zkN^qHIsu*^W_<^6{v>cRfd`)-PHxW{5+H%uA;9y)?5s6sN&=@Pa993vTgk#{JF-0q zkigUvV0@VRCFTT4U}6G{4-=zfM-rHN0*nt+zr>s%2~13Y@nK?g>_`GrPk`}Z>X(=k zB!N>BIEcQrSd5;ZRGB(Q-*k>*hVrRAv8pzsv&Ejn%NXWPUdN+4hp?%iwM zCHGktPcj&*48=WUQI+xoDP752yenLSF)*I^-jC|Ws4l( z46whyk5FFw@=_U##gyd5a_KVZIC7WVjf#3Jn|9THQA)L!Rrc&3Wzc(ay)KUWBft~l NRo99n=`M)Rz~5)zFo*yE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c61f8124aa79ce6aebad8f1cca33f378847104db GIT binary patch literal 26304 zcmeI5&u<$=6vyB0nvjx+TF^rT6&R4Jh)PvkEC`}RHntNv^)Id0iX{$U;w4ye)|Ty* zQX%CZKotj09NKHe0V%g0ec-^4OX18RLL3l6$cL(`5YqDA*sjMWZn4YB{?2M=-n^N& z_I&2;WE^i^^0DmpH}vQf3an6vOftwY7P{S84E!1S@rD&r_0`@*EG~_nOti9jLlkUz ze7dz1w@fN?V~5&SATZqPTUGV_;O}o+zQ6YT8S?$b?_THp*4J^?T)mmJoXh9WdyB(% zsDS_ofB*=900_(p0p5@3BQ-hcbzSy*N!N)S>ZM38(Mex*S)lNn>#@yLv6zc)CZ1Ee z!NmuXG^CQ5#G2|6qJQs+-sk<2^naFQTeLtPeLjy77_m@K-jEjJ|O}wv!Rv_>TE- zW9yFDH@+tCKB^o0zA%$nnT{Fkb9N#Y=DmBGJ!yyis76gbKV+{8QIT?#=Q7Tk9onZo z_B2nmjCvk_ZV1rA&s4N5zU>S;L0>L~7t1W2q$1zUhRS*Jlpptow6i5c53-9r&V7>f z>ieyCDjscQ?Q}j{uwx0b6Zv>5MFp?YaGN3}y_Vakx}_@>ZZ#R^3^g9H@W*2xtVbK4>r?f&d680^M&l<@qR)O!cJc0L?4> z95xaNfWW*H!2V(0Jpkhef$;>6-#?7+j}{OBf%zeT{lomsHAV^olM=Yif4MDbX;MS< z2LTY6c>-7;W`2q>KoFRk0M>`8(ZLV|W}X1nhnb&Z3=jmSCV=%}YIHCJfte?O^2?-qbzO`8HJwGXtItSl$4%Q6ONt{?|4XS9dr|326#)RIYCshn#OFe68az8n4 z(^KlbNdDs4H)7d$cblt`)l=_XzNn(iI*FpA{{xGSY{qHp;X@}J$z9jb5}~+BAMRymnACrFMQGae7?ns{1Zga zuvX4B375?}gTzlilg<>NPdPuMh7*?v(L#8K&K=uc VR&!8c-re@@y)}3piB;Zj`v*3)OhW(w literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..108aed5ddae9d65a6759a477d0238d4a2198b4f2 GIT binary patch literal 26127 zcmeI5&u<$=6vy9sO-M;ZE$AUC6&R3E1QKeCRYEDnuDy;nwY}DQqgdilOtPewoV8^; zrEtlAP;ubIp}kfdkaFwM2M+u=!I=X>91ub%2UJxR((>MTUGJJ&tzAy`cdVUx^XARi z^O@PFNxXTfWUZ}l=+P+|U!fz4kw%)f&^Ko>{#W|Pn|e~!SA#)xF10`=T3Np-3hw#v zbbBeM$7qKekEo}|<58<`Rn_+sf4ryr{@M$#k?${lcaHPhU*}kJ{g$aamrtMe7Nh-8 z0|5{K0T2KI5SSAJd>#qUG{z}h56ga#!a9+YK}rXSj{C}Ffl}{W%&r%z)pBOtIH`2Q z!H1GG6wH#brYw^5-!svNd|nd!=Shx53*^xkbb+qX4f^Na-$#G`{@X9Z{-n-D#Fpv^ zrN+GegX*t68}v`o#n@L`GPV{Q^e-Bj%KL0PdZxO?x)fjiGZm54dB(IwZK_k3TrMS* zU#1t8FfRN2RR8lVi@^ob%jtoo@5`IZ^rm}r^e@{+HpPeFA}`4{FG!O*e0|KzlcXw@ zslsKBHQTgLd%R?xY8hQZK7hw*;b*GqI`3bNj?tG(sl^?Zj#HKI3Qgri8MPq>tM{bk z;O>)B_JEXfpN)>2GfSJVP(mx{%k=$Ecl*0;)F`MQd!QeT{y+g?P6&ioKEahr(b^5fJ8O__RC0v^ZF=>#+Y!n1x^BDS)~?mPU9$3dGWTk` zuJdWjrH!(&UUYZJYS&$_TkF=F9+fTIu`{Ma*=D0r>$<9o?RJ`tebsKYq&P$6TFY%$ z>Yuq(FdWCQ#lG1tHg{Y)oBF8LdfcTIA6%^FwlwDAm`>ro9qxHZE2?@btb_~#AOHeP z0R92n00cl_90B-;ab%ze0T4JO^*bQ>Nel@DKmY_H1mGVcI8Xrr5C{=~e+a>V2m&Aw zAprjn!GQ`0fIx@<{6h!^L=XUh2m$zq2o6+000cq=;2%OTAc6o0C<6U&HRbszkz(pe z(;=Ey`8jMP5CDOBCxG+AyzcpCt)w%M7dU`TBYW0;WhGFX0H}o*vc=CA)!{x7E;p?q0@~pXf)6(6`=g$YrqwP=v z0T2KI5C8!XSP%ldABm4N!6{yj%VCt_I+2YiWuioDMmhwhGm1IlE;(p>)H} zhmuqjtdhB*EDZYRp6FfPFNyu*B-^4T3g}b1NLT1O{dM<`liz;%`Nv^S2@hRjDc#@WWpsKF(`qk(ZeYTQbuCsKSs(e>y>PnPV3t}*P51J3| zKFMVbNG|u;c)xk8wDT;bw1U1$-+X_kzv{+~qVlPG`a$pa6c84KKz!y?T&NV?Z5eus zm)L{cCsxop?7rm(v}rrVioN6HOmfUhzEGf@z;AmUk*uKWwHsdTiXZHgy}3!&L2ci2 zKW=%nT{gFhUY+c=?*-jj*KYA7U^df&d680{y3&@_v*^3H7Gw z5Y2P^9ySsPfWV>?!2V&;R{+Kj0+R{cfB!Jq9yK5U0*gZc`-jDuYm5{GrX_Hj|8iT> z%Crq(4+0=C_XOY{=6;GXKoFRj0Q|$u=+F=Z=AHoj!`x3X1_%N(6M%o1866sez}ypn zf0+9z#sEQJN&?5xQ;XH;{Yi<`IegMN^ckX)II&WjQqf}1(96^huDneTs~Ez%de<_+ zc5>XNN7Q$b>(l36&Dn47ch)oOXWqGdK}DH$&8i#!PY|1S$?e6%hfXCCenjkm_} zdb{fC^L3Gv%MpwvLUD%-Dslc1=MG0MK431v7#NpzA4GLyRjQb?3(j?xmNO{{Ui?2M z2~XVX_2}s6h(bP&_>hRjT;$-ze5!m@F;M57ouYh|U8`ih$hoX#39tPVcY2r4*I7|{ zi0Coa%DKT&ytAy+NY?2!F=kmpy^e=_EinrT$Sv&7@wvxzgxWsq48AW8IWlyOEqt~M lD9K^QyKLdJw$Ak*a(#{Een@YF^CoMhhXzNIeu3?Y{swqxLWlqW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0dfcd98f2148b282c9fdcdc5d5334b68fc206248 GIT binary patch literal 26115 zcmeI5&u<$=6vy9sO-M;ZE$AVF3Jgdnf=e4L2%nk86D)|Ty* z(u@Ct3JFde+H1uDDYqVd;J}XyaOO}U4hSKX1431Ww7fUA>)B8X>~gZdv)Y|E@4Z=h zJ~R6?i8n9hth4ha{d-BqSLm2xWRPJj^xavE|DOK-rkPaj)xk|nu8o0Aw6b+m6x{RS z<@Qp}jL|L|kEv(I<591#R5J_{zrJmT;rg>LQy4CO{W@Q7f01L&wOh97T|9R#Sd8{V z3j{y_1V8`;KwwS?@P0%eX^e~B*5zlD)8}!$`KaPL<<>$Mj{-pLr#GYyk zrS5s{Th-rqa?n3X8?n!gWNbY)>|d}lr#C7_f$mE<`L5lwWv-V^0}5$ zdV!u(!nn)FXZxRLSsX6(sFWUh9@EfvUgly$x7{^{wu)>9)4J`u*wA)eZk^F>VJHI< zWYY}@{Y$QuP4ULMz|(w%C%HjwKHujlPg0diROULznk#fj2fPddWjVPlcvByzg&(PE zn*2-{bc#M(N-gfPbegLCq#5eUlcyHha78?7&8tWisbvjFEzhn}YL}c{th`7GBX2I# zH$VK@U&)$X7f(HC4DbFx0bx!E=rf<-M!Dc^%g__N#2)22v4iGe=bd^$n~qy3I~6x; zk!zK6`8-vEddqK%WCtC;weQ!i)`LBAHaE#WsO|aQM@^r$OV(Dw-zBG2_k&KYQ*Q)R za$L{N*dApY`}?(yue!K?yRm<$>`qgv^Hi!e{Z_et-KV_ed6p~wjaH$t>(g55y=L>j zPOZfCLN&KzFc;7E^7mc6=Mk-_=2=|{83aH81egHg1C{{@fWSBch!5k)Ko0^Sa760& zO7fE!3J8Dz2t)`VK16V!0RkYP5kP#!e2Cyc0|Y=oBY^mz!GH(?AP^yd z_z=N?1_*$FMgZ|ag8>l)KtK`bf2%3)M~M_uZ<>zKyu|NeqksSi%sT<>ALe}pVEiC3 zp1{NR599q|0Ra%09|G7v%+Fk7q#!UUfjj({+ftS$?FfGm0D+k&fcP-;Q;Y$Ez|;f~ zAEri!jvz4e1P~u)eu^^sb-P^(`RMW?8H>5e$&2|^ zbyP7_=iEv`KFW?=v|r&`*0QAR{)umTkI%PQQGSf*Db~ulF6pvaXOOJZHF0KH(vGik XChh33x`wP{k25qEnVZMtL?ZedTCzVh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5376e73b604e458a74f5d6f9aa15b255fe77766d GIT binary patch literal 26162 zcmeI5&u<$=6vy9sO-M;ZE$AVF3Jgdnf=gR02%w4BS0=t~-?^wI@=Dm3v z&u3J zUG6O8%ouI4@tAsMJRa@!wQ2@I;p7(x zUVJEtA#WG071bk2|J)P3&-*3m|18P2XpVgPlrGXf-K4+n|9SlTufH4({YmYMhSM;P7it<)4+CK=3-U19T~$5-|JHyxT)GWG6v37*)67Z z=kH?!+YR~I8Qm5HGGbvm*@)p^cC2iQcishF2iJHNG^xYK2fQYdRHHIgxX!WW8tu_8 zuM=NcM%NAR`r|bB6V*(Ur`MoU^w~mcev75kRO4IGP-mVzwF)D1{}>HkW~xXnYd~tb zxs_5o-}Tx%UVXpeZ&PV)jqKg}w&#A_@@S)M ztrxs4DzzJ)->r8WO`pmo$8|EcOWEemPQB|X7su-~clMOM)RO8vmFq39U1{9#C~vv0 z0T2KICV=>WWdH&o za1sH;hm*)a4+0=?Na_zy@|ze62!H?xL$nhw#t!p~u& zfB*>0Isxn-W_<-<{2*{Lfk*EjPVNs22!O!s5WxOncIFx*1%Yu1+~xn=ma;IeBl?2? z2uwWz#D}S$Vhj)jCMJORFflqfg22=hKzx|`DaHUnU}6G@4-=z=}B4`Wc-!=t-4B*i_G2#<-un?uL1g zB^&aKufLToUEc02rKF*F6%xFZ6hYt%$D-bO_t`<2}xf3KNd+(-0St| z;NXA)ejV^jG8S`_lNa-$>ZoF%&NJ6Xurm_tYyjR{WD+mJ|FL}qVfdMGpvvGg9x=v)PS5mz~$0Ox3N6EKVGwZo>xsYDBpHsTQ z%?FYga?Yl`qC716=b7j|J}-&?vn0o&IST1hI!{;WCjIr`{^4)G{CsaPp47RB*i#*$ z)IG0#tHv8o_r_ba8vER^Vk@!!_`IDie!{*-&s4Wrm*lH^rXrF)&zQETK~-u|z^#9+4;ecKJWby~MYkxWRC zO*SEnFL-t)$p`B^FY^^%eAG5TyFIls-)aVqmC%}_Njp4w#n9r3s=?;=&Cl|3M>e0G&mtFq2!>19e7IdhS| z`QeZ5PS)(Ycy}_BfvtC#>(c-mQkT=6d;}TkyFuk@HQXV0ZH=70%1+>4uLrbIu-Eg!Ho1*z5Vk6-k>V@hMZ=-L13&HN*>=wcUMXck9xep+cn|G>X+50p)Dpw>|N%HS)FXfX*f_*X#dv zX(g`b%h@f1x%iHs`_t1$9?+8Np463)K>!3mfC(TzU>kq{2#g|t_%MnL^dJBN2c&+l zBtMCvfB*=9zz_k%hanv3fB*<+1P~uI7!W}K1cnG8J`CYN2LwPsBY^mz!GH(?ATUG# z@nHxDIv@Z78Ue%y4F*II00Bjy`>m!tA0<*uJ!v{X^9nzQjRFE7FzWK{4q;P0YZ+rddEKR_)O(To#n<1=xEFVt%cSo-Wcax?0R6>#$|Bpq|6L&ftYPZ`I@vF@*$ym%?PF~EH zYKIklb=E88<*jTvo6c+8%3hX~-9Pb7AMo`yD~eALJ;PeLRwP|E>kN{8dQO~KKE?GZ ewY!yqtF|F~xLao`A6A;yb<3~pU9NFTTKXHqPD5V+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61476e4ac90c7aab92cd0b301aea867865168002 GIT binary patch literal 26235 zcmeI5&u<$=6vyB0nvjx+TF^rT6&R3EM5U@N76efu8+#KuwY}DQtypjX(=4eaXKmR| zDZS)Bs5o%q&|WJJNV)as0|$Ow0Erui2ys9NAs-N`5YqDA*sjN+rr70Ve`mEbZ{EDO z@qA`>GLART#gw!CIXyVVA}iD-lMFJ9g`PQ!k>3*E-n3$>zS_Tt#ih}giB>jmih@1w zpKdRuEt7V*u}d8*5*fAnLRG^s`pX+u7_L42B8B1Nm#=Vs`?EA_uHDL5-lcQrg2hok z)Ib0PKmY_l00icQ0Pjcio|>HWx-N(Pr0YbE^;4ps=wzr&7AXGOdTKLUE*FxU_ESnX zy!cR(hHNHpuPKWd{c}(BF7KDb{z;N;(E?c;7!p>*l9M%v>`E{y95Y{GM%(@2PIFF3x9fPemkk?lEmsld9AvpG!%F z=ja(FjLSYc-FrRDVsNoTg~Y(J`v7O7O9SWgGFa=n^O5oIKiNDo2F@kfP9}8cZ_Gj4 zb$M|~y)6s{i9nwY3H%GLor?3;JjWB|GEb8_wfKCWCrymXRG=c4Y1Uk(ecI!R6sVTt z6N}##L}=j$Dq9xcRR*1)PnP0~J1m`~GT*C)%6ahAG#$*Thb?)UsU)S$0V(C~S4u5Q zXY!@zDQaY`W%}y7KYI6q)~<^u?o|d?zo&pOCj|77k8-7$^R}exQJ!KCbDv~_#(w+F zYCs!~n=3jcH)WG+7t`4+m4a&1Z;6x%+J3X3T)$PX?W=aDA;ozrR2qJ>SiSC3*7iKx75jQK zSKsmJLj1i(?tt%me00@8p6Ttd_X#fHsFpdD$ zhjC<}2LTW`B=wgmc_)Sh0w4eaBLuKMjNm{81VBI|fb~Ix0TBd1V1xkHhY=j8fB*<+ z1h776Fd%{e2#gTG`Y?h66%YUcjR4jM4F*II00Bjy_o=2lA0?8ho-`ezd4Zq9Mgjp4 zn0Er$Kg|0KK>tBtJc0Y~AI96G1q47~eh6UyFh66Bo`S%n1n%&EZcAF4)DiX|00J{l z0PDld4>39j0#g&f`Y<&*ID){;6Ttc~^Fxddg22=Sus%$U4vru&^8~Oy%={3egCH;= zfy4f%7R&wTCnZwn@RQErnjt!g6DzGz6)pA@y-dB3&s+4UiXm*NXDv;(lj9COre2HW z&z^ZT<-EPyT1~8;dgsyw6=l}5%bx!I6dO+7>*(P_C+x{&=1GpmTj6-UZIxF!pXcOp z1Y?O%+@cuex%_}jmm?SNF_+*N7>{*V`fbOWR5j&f-5V?|CZZC&_*F5VU4u!QG=O^ F#ox#UM=byV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ed3c4efc159f3068835c369d89f8352c688e492a GIT binary patch literal 25912 zcmeI4!EYNy6vn^xhTt|4wV;OxDq(?yBDj=dK@g>iYj2`WZLhW7D3&;YX_jCqSzESK zN-y~jdg`f%_F8d3%B@EqIJ9trGY5n?AcRm32vrDac{5qpyP+1ujf7->W9`nHH}lpz zzZpM0%j;JwI2&K#-%EC80S8FIfMLuY4i|Cex8-l|m|5Lkj4onIZA3D#uzE)+#PZ4I z-dxd4VN*H}&^KphlA|8zW*BCEdEX4frI%hq7^WY-E$4e*7DaRYu5Ef(&Yus`NjtVk zfCNZ@1W14cri6gRBk`H0q{Q2CIgCnNr{ru@mZO3*p&l}e+`E?wtEGCqnqReE)Vkrt zhmz_j*%fO^kI3SmG0{g7FRAe_K!ruK2yhD*aSb=|*Q4JLe*O8UABOhOaS>#s4x#il zuYaTMjpri!EH0(KFtVwo)WE)Mc4XMVFJ~Fs1@jTV{+6bkIZ(zSNM&YG8j+ zTJf0dxc2||`>T0A&HaySUv;fQPU6_IP`xTlTjhtb<>nBXy6oX&*jpaBAr29HnyS9Kwht@8h;XH@Bvb_+C!>VAC;rmF4~p# zSCBDE<~+Xs?hmzbMwU~Ldj?m3te`L@1mY{7kw&fTt*NPJWQ#q`=fn=$yS>ZJ04t7L zt~u*&!Gdemilq|PgJ#F?D#;FderMZnTx$kfa8_1e?=-f2@AI~gwW_sR_BY{lntsr0 z^qQ>zRmb(*yzQaT+TL#Td~M?T-PZQ5?swX%dK1+~+watxH++;V&$C=LzSSwWHho;k zecW#U*QK48f#rH}!w@c>?Unv? z?K^<;CxOufo*X}n9?u>UAc5&2!1!T$)|xXVf#VXmFaO+DW$w6!98UryF!2QFA0~c@ zIYAN_n*ja8*yw0T0uxVw{$b*mm=h#{u?f&WjE#{+~p!~Do=cvj~iHubZXDH*3;_i zSyZI{Olns$7w-s{;0PE`be~3jN4j*g;FR2(BBhrzDtYk<7L}g3-|u5@Zx5lo_T;59 z7E4o-7t5jRq+*~hy6a_iD;svjeqCy{msN)BALXKt)f*?wsvbCTXs=Zdm2&IR2ZZ=>f-?t%I3R>j4hU5UX?btFu6Nx=#c5CWcUHUe=FOY2 z=QFcUlkDcDRJ7sE~r&b3e`T3Ed)3U2x6 zbbGF7=(NR^htxL`iMZ7_su%?6-`_KWVEM(@CgR^RJYSqGs2qeTsuMXArd*ilRG>_flzr!7VYO7RSM#gp3raWY zd?ZOl$*P#k$|6G#?}E!7rE z^?B`k)n0opY@eZv`W-E!FYCkhWiwxUpKZV9^NQ9^CBwbZI+wG24)#<;w^-&8(s7msEY3mXz?&MOS^&jkBb-Y4c)mp`WbghL%AfA1=ci|8V(2kNBun!Vu^OmfUxu~ecBzuED+B3XXV z>+E=qt4)8K?3ER=b{pHC`&rwgwW_&V_O{6GG(Er9=rvnDRc*(0@|H`5*3M3&=cy)+ z*KO_Wsd~FD*_%{tw7pKP`MF0W({)Wp>|333Ys;gh?4@@5|4yy=;BvjVsWBJVa!da> z(Uu3apo(XsO2{Ap0wBNy;2*FIKmY`eA^`tz6dCA200a(5{SHWe5<>z35CDM~0r-a) z4irED1R@0BA0jXyf&d7_2*5wYaG(GJAP^w{{}6!z5d=UWMgaaHh64o<0D%Yr_=gA# zh#&w0iooEhro0~|lCIt~9ie%Z-@`@%0T7sV0@y#y`U=4KLEvZtkKR8VZI2od0D;*d zfc?Yl%r!;|0+SNB$A7skX>QVnum=GUn0f;64^uzI7$687n*jX7vC*L+2uwWz_=l;V zVhj)jj!gjm;n?WV5Co>40Q|$$Pca4v0uvHA2%lQahwo2Hq|VWk&XLa$oy3Wi+O&!m zdxp-_Ah_}xJ*$Er8|qz4o$chfM<>;Hk?Tuuyj`$A-0m*s7SDXNzNDhex@O&t{wIhP zyW;kv;X@}J$zg;Ic)dM!ZSr-Nlgkl|B|>qRGF0LGQ_dZZT)fL%{4p>t>#l@# zV^ykHuuIN$mQuO21TTJyNx~EN`+eHq-=~0&eLf^&F&8;_F`p`rD~9T#vr(3>vT0SU i*EyH9EaA0(;ZE=K`7SGJ&k#M&S~-_Fig%uM8vO^!Gb|zi literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ee1460094452c08b7deef296b712419e81671272 GIT binary patch literal 25972 zcmeI5!EYNy6vn^xnvjx+T2P?~kg!0iBDl1{f*?v{V{f8OZLhW7D3&--(=5SKvbJoe z6fXG>Dh`}Dq}PfAQf@s)oC=)a%mE<|2qBaMLKQ+XZ^r9**KI_daGIhZq0gL>xLH} zN~)u1m#t-Ok;a2D(KU&el>M_%VbKf%e2y!)fm`_J{-4Lc|N6^M!|~8@5yVo5Q2LrT zztiK5r^E4StR%iP(uw87V0_8SRadlaJf^xWx{MtCm0O&Y0CCBa1LM0WoEZTI**#%6ox(nIlUiFmh>@& z!Of>?wFgwKZnM$L7VPrI%SahTa~|LR@K=A+jT(jRb0_-2)lU=@ri4IrnZrD(v0* zw(ot?^08jA)=K^soOZ(xy7g|O8KC00o}05h1T=yEx0>qtC?zHI zlcqy7ugG)QEF?ez(@uc#!?f=J&YuKMC-Ct2;dFcUkN^ox4*|vx)3er`DG7{A;I91T zwkmVu4x~K^kif(fpnsV7CFTT4;LHT*AI^-914&@w3D7@G{1S75ByeT|^bco7$AKg; z@dW4}CVq)IK@u2~z+w2U#eDeuq{`Ge{HAm0Gn7u{iB-KRoh|kPUPnK-@;aW>xsOf# ztYt#%)ax#u(w{}0UwZA$yz}06XED2Y;r**iI?JqQ)x79Gf>?FR-cdAt=$JDZ&papD zcdQ(Y+P?`K6>)nmzT;|EKNyXEQhY+ih;V|Zj{tV*|f{{t5Wy#Uf;vda?$&8 Wye*3A6Tmalr;cSw;++$nfqwyF$}Nfj literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4ec7b7e0d859e2ed986ca024d83f8b3e8b6014aa GIT binary patch literal 25991 zcmeI4&u<$=6vy9s-H?)qT5S&z6tRGWB6s`|b;=;xLjaen8^JX`i|Te^F7VZon{_k#uk zAOHd&00JN|Aq4n55}j#+Q&f-2L71X8k&Q5A!bE2R)ntm&uV2Zn6spy7cEx;J*#?6T zB&jG^C38u&FzD}RqWAf{B<;_U9E+yNr_bmz-JqNF*Zn_^e*5L;9|!$Oor{QDsw0#d zbMIT#UwbO-Z_t&*7n+e+O7!~|&1~fz)s|?Fs)%5~iI!&L? zq^CDnI>TE;j`flgWz`lLTE~&q{_`lMj4q`-dBX$dt@D zwT`E{I9|K4y{qc&rWCJGxz_YrmHNjX6-?JP9ckZa6&o8KEv7$cHvjF?&T_+IHNUPg z7uRwNe>%~g2Q;VD3sECv5C8!XU;^+D*ajc~0>=@6e>jc|>>vOF2PEG=)HgA}009sH zffxb!hZqhhAOHdp0`LzJ7!W}K1Y!i>A7VJ5fB*e1f9~RIIs4ts_Izgk{CigyRg_uR zth!^vq%U;~;L;8saz0cPhY^aCwVShsoI}|vn+hHI{CIe=VV7h> z8~0>lm&-?c?H0v6ul)Rho#%^Ju1cQIfBB9+KYZNKnp+P%JO1d}wQTiI<=K2bjoCVrcc!sC4Y`~bUFM|v?oxfl?{?d@75BBG?r`$MoN4gA zmb+N=aOCf6qK|dGWct4aoA35;0|c$FeRXNo6dFrlKn=#=n?f zJ96t)UB?EhdR*4pb*v3&cIeD&?`=EC{M zxbUpyVD@JfWX_4e?3u40w_Wy`Z9vSF&M_<1;QgI{}PY+*nO)Zb~+h%<71oS`v0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009KfSRm5-o;|&T;pqL#ZM|Rdncffm*PDoE{Q69X00IagfB*srAblzpJ3Bj)>tjbBX2)VJ>gL6I z6zS25gX%`K7MMR}-D`PoYP!FZ_pbb)lRnksJ+0`xB=U;3nPX8m;;w0(b?Tj3br697 z0tg_000IagfB*srAbUVa literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8180a63b41555b46402e8499353498eb6455667d GIT binary patch literal 25912 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 literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_text.cpp b/src/importexport/encore/tests/tst_text.cpp new file mode 100644 index 0000000000000..81a512adbc4f3 --- /dev/null +++ b/src/importexport/encore/tests/tst_text.cpp @@ -0,0 +1,1071 @@ +/* + * 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); } +}; + +// 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; +} + +// 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, 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; +} + +// 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") From 26f9e0225d8130f2f39ee08acefde41f35d3504e Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 20 Jun 2026 13:00:00 +0200 Subject: [PATCH 26/33] enc-feat: import lyrics --- src/importexport/encore/CMakeLists.txt | 1 + .../internal/importer/emitters-internal.h | 5 + .../internal/importer/emitters-lyrics.cpp | 344 ++++++++++++++++++ .../encore/internal/importer/emitters.cpp | 3 + .../tests/data/sintetico_all_features.enc | Bin 0 -> 31561 bytes .../data/text_lyrics_6_8_offset_ticks.enc | Bin 0 -> 26144 bytes .../text_lyrics_grandstaff_routed_notes.enc | Bin 0 -> 23643 bytes .../text_lyrics_hyphen_across_barline.enc | Bin 0 -> 26060 bytes .../data/text_lyrics_hyphenated_words.enc | Bin 0 -> 26144 bytes .../encore/tests/data/text_lyrics_latin1.enc | Bin 0 -> 26021 bytes .../tests/data/text_lyrics_variable.enc | Bin 0 -> 26142 bytes .../text_satb_short_names_voice4_lyrics.enc | Bin 0 -> 30708 bytes .../encore/tests/tst_instruments.cpp | 49 +++ .../encore/tests/tst_notes_ties.cpp | 95 +++++ src/importexport/encore/tests/tst_text.cpp | 210 +++++++++++ 15 files changed, 707 insertions(+) create mode 100644 src/importexport/encore/internal/importer/emitters-lyrics.cpp create mode 100644 src/importexport/encore/tests/data/sintetico_all_features.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_6_8_offset_ticks.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_grandstaff_routed_notes.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_hyphen_across_barline.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_hyphenated_words.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_latin1.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_variable.enc create mode 100644 src/importexport/encore/tests/data/text_satb_short_names_voice4_lyrics.enc diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index eb8c0f3953ce6..e658a114c7af3 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -75,6 +75,7 @@ target_sources(iex_encore PRIVATE internal/importer/emitters-note-artic.cpp internal/importer/emitters-rest.cpp internal/importer/emitters-orn.cpp + internal/importer/emitters-lyrics.cpp internal/importer/emitters-tempo.cpp internal/importer/emitters-fill.cpp internal/importer/emitters-overfill.cpp diff --git a/src/importexport/encore/internal/importer/emitters-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index d9a9a7689b67f..ff883618ec668 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -128,6 +128,11 @@ void applyNoteArticulations(BuildCtx& ctx, mu::engraving::Note* note, mu::engrav void handleRest(BuildCtx& ctx, MeasEmitCtx& mc, NoteElemCtx& ec); void handleOrnament(BuildCtx& ctx, MeasEmitCtx& mc, 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 }; 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.cpp b/src/importexport/encore/internal/importer/emitters.cpp index 5353a878cef2c..4329acd2daea9 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -793,6 +793,7 @@ static void finalizeMeasureAfterNoteLoop(BuildCtx& ctx, MeasEmitCtx& mc, 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) { @@ -963,6 +964,8 @@ static void emitMeasureElement(BuildCtx& ctx, MeasEmitCtx& mc, const EncMeasureE break; case EncElemType::REST: handleRest(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); 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 0000000000000000000000000000000000000000..5da434ecbd17af7d74f922b3864a5b28dbf49467 GIT binary patch literal 31561 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..797273ce30087d7e7dcdf27544a76211ac1823c8 GIT binary patch literal 23643 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c2105f7d7896d92bc335c4e21571756f15766a17 GIT binary patch literal 26142 zcmeI5&u<$=6vyB0nlvR5wMq{WRA7OGBDgfgf*?v{V{f8OZLhUnE0#DA(=5SKvbJoe zlw9&3R2(>QXs;Cqq}+P+fdfA-g)@f;ai|bNJ|I*fq~*=*u00Noz%D2IJJ!y;c{6Xl z^O@P_ak6=-{%h5>;F11J|TH3fH6m0qU zbZ;SV=(NL)ed-yB#AvUtWi<$rzr14v!Rqs`P!P<2^9JYlzRa`c)?LeRuV1<1&yU)n z1_B@e0w4eaATT2Yct4_!ROh7DRXK=~suOZ5N|`9pg+O+hqx4&Axs5`tR?cpi&q>|z zg`6s(fDDtn~ppL?Qh-Y<#%&yv^{&5=)^(KWhBx9P7(zxRKA`18rop5(rWxToAg z$#dTNPTFhFMD{6K)4$MC`l=q=7tL(tA@@DKr@G6!G#}xfib&LX#I!|iYEYLvE=AgY zK^C+a7MyQVbs&FwIlLIzdaXz0Ox)C`?NlhQ#40L~MRru{Q*65;CTdN!J<+y9sLDHq zW+z%~RTX>4jB52jkFWJ`@Q%*l{T}GC<)OIbtZEqqf|8hr$tc0T?3lSU@79aFc5d*> zX;O!e4|yG>s77U~aG7V#4LYEGUR%CwIlb2Sn?i!-ex#aV@QtR?dHQ@IJ-@@!1*-Aw ztI3=vPp;g-BtN0Rt4}6TiZvig;Si)!$?{gI`Vu9zg0V>7{_sb5dnoOycz#fc&puE< zm=OZ%%qO{0DY~0t=t*8;Pja7Fe(Rw7Zo{W_+bLG;s*^LxF)R5(fvSF^?RA7?`CYHQ z=hbgE{9Urw*U8$i?|SYhEsr+K=0?%mA-mo1{BFJ5X!=yP9oNZPF6Ekgd-bj-O&qV& z+&hr%c1sj1RIazYcBOIXQNeUw(-Hlf?P7DsqpRr;TCM*&wc_)lSj%r|%*D0b!k><6 zc}z>PdRbLM1_2NN0VV+dfMoyzAaE7|_=mH|Ko0^Sa2)+Ki@%8>fdB}Azz6~OhY=j8 zfB*<61mGVO7!W}K1V#wJKaAi&1q47qAprlNz<>w>ATUAz{$T_MDj)y?3IX^B1qMVA z00BuLenvm^54wEPbcp6ni}`2Z6H*JbwRhc7L>h00_(u0qh@U zXRa|)5Ez%heg4aBkru`^M1K$ffvG0||1kAai~)kc#020UCPoKC5SV%b@DEcz#TXz6 zOiTd&VPbSJ1c9k10RJ%cQ;Y$Ez?cM%qu*LAa(ub)Ny?-m4Sj}a6>(xkYf?swy+p55 z7_fMg&dC_UhJ4mi=YHaKpPrW2BJvksdoyRhx7%6HEMI#6`c)Za)-`Lc`Y$Wi?ULJ5 z!-tMJlJUA{IT~-Bs< ftYr}g{3kx?BR<|{Mdc}?i>wvLstA|OI*tAYe%?Yi literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..779064dfa9c2683eb3a8ab5705607aa6520b17df GIT binary patch literal 30708 zcmeI5&u<$=6vw}7n-EAuE$AVF2rQ6L1(!GkNYxT0wVmM9j?H?lSmHoTvjj_tZP`vK zzJ$M^;?NU^_F8d3>a9l~IPl{VICF>)2ZRvH0ijYV%$wO=XEte6K|rGN_eHz&X5PFV z?`LOUHnY}CDOFhg91oU6Yy$g;z(UL#@w!{EV!zCPb3K~S^)voKqEcGE4ooavR}JEM zf4Mo9jz+L9#(ngnu~@j*7rNT-$A5Yw+V9Uk^CJ5F(Jx<<{mswP(sJcSHtJlQnyQZi zrLZe&BtQZrKmsH{0>eN+o=42njEIrUrPn;Orvl@*Qi%I0H%8qc$J z)e_j8X#=iJM*Z=A2d2zp0&CBDD@#JfmmC_twmV5% zU$ir+8F>ermpk&3T+s&Fvb`sFX95)zPBOi&aRZ7N8HLq$MMy- zzj;B&u$!txAYX!`2X&UiLcqN8ajBH@&ayi9xJxw&&X)>hJC%W* zDW!8cl%-ZuN4lzJbExBC=c64cGaw>0-HwSbam2fq)q}PKmtO5@qyET1W4dG0*nvGkzqRtkiadq znkOQEiBU*^1V|u6fbk)OgB21W0fPYJg8_po36MaD0OLal2P-5%0tNxb2LlFG5+H#P z0mg?A4pvBj1PlU<4+adXBtQb1K=5sRFFr){H%$-FyddAhrjP&$3_AfnKMeZ};Q5ol z@dWPw{BV4KT1bEdhKB&3ABN{z^Gr$L?-IDJG8>3UJ_QO1kN^pU2rxc`aIiuGBw!F= zd@x{8B>@r$5ny}>;b4UXNWdV#_+Y@GN&+MhBEa|%!odm&kbq>B^WIvFix%XEInWMb zhH6uJV&l>;uCv9S#w$?&+e=bCOD<@YLpZ9xYZ;M#>gTq~<}9N2&%OM5s_^zk`^@~A z)9+lI)>&qqOvRBr(F0j5tT;V0edxgQUnmQbjkhZKdOLbwll?G@BN>dhBqMbj39Lx@ zft0pnF5VI@^+RABX?x$d9Wv=^s*tm= 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) { diff --git a/src/importexport/encore/tests/tst_notes_ties.cpp b/src/importexport/encore/tests/tst_notes_ties.cpp index 2e4b1b7eea0a1..f330c088e2e87 100644 --- a/src/importexport/encore/tests/tst_notes_ties.cpp +++ b/src/importexport/encore/tests/tst_notes_ties.cpp @@ -483,3 +483,98 @@ TEST_F(Tst_NotesTies, sf_tiestart_not_filtered_by_rdur) 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_text.cpp b/src/importexport/encore/tests/tst_text.cpp index 81a512adbc4f3..c284350b8546f 100644 --- a/src/importexport/encore/tests/tst_text.cpp +++ b/src/importexport/encore/tests/tst_text.cpp @@ -67,6 +67,216 @@ class Tst_Text : public ::testing::Test, public MTest void SetUp() override { setRootDir(ENC_DIR); } }; +// 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_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; +} + +// 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) From 7c23a2ea0e018353044d4a00c77c74e8ce294b8f Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 20 Jun 2026 16:00:00 +0200 Subject: [PATCH 27/33] enc-feat: import chord symbols --- src/importexport/encore/CMakeLists.txt | 1 + .../internal/importer/emitters-chords.cpp | 99 +++++++ .../internal/importer/emitters-internal.h | 1 + .../encore/internal/importer/emitters.cpp | 2 + .../data/notes_chord_symbol_fretboard.enc | Bin 0 -> 26398 bytes .../data/notes_chord_symbol_large_drift.enc | Bin 0 -> 26074 bytes ...otes_chord_symbol_nearbeat_subdivision.enc | Bin 0 -> 26102 bytes .../data/notes_chord_symbol_snap_to_beat1.enc | Bin 0 -> 26074 bytes .../tests/data/text_chord_quality_table.enc | Bin 0 -> 26164 bytes .../tests/data/text_chord_sym_latin1.enc | Bin 0 -> 25990 bytes .../encore/tests/data/text_lyrics.enc | Bin 0 -> 26144 bytes .../text_lyrics_offgrid_nearest_chord.enc | Bin 0 -> 26026 bytes .../tests/data/text_lyrics_two_verses.enc | Bin 0 -> 26266 bytes src/importexport/encore/tests/tst_notes.cpp | 136 ++++++++++ src/importexport/encore/tests/tst_text.cpp | 244 ++++++++++++++++++ 15 files changed, 483 insertions(+) create mode 100644 src/importexport/encore/internal/importer/emitters-chords.cpp create mode 100644 src/importexport/encore/tests/data/notes_chord_symbol_fretboard.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_symbol_large_drift.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_symbol_nearbeat_subdivision.enc create mode 100644 src/importexport/encore/tests/data/notes_chord_symbol_snap_to_beat1.enc create mode 100644 src/importexport/encore/tests/data/text_chord_quality_table.enc create mode 100644 src/importexport/encore/tests/data/text_chord_sym_latin1.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_offgrid_nearest_chord.enc create mode 100644 src/importexport/encore/tests/data/text_lyrics_two_verses.enc diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index e658a114c7af3..8e6ed9ce35b4f 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -75,6 +75,7 @@ target_sources(iex_encore PRIVATE 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 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-internal.h b/src/importexport/encore/internal/importer/emitters-internal.h index ff883618ec668..0c968fe6ed5af 100644 --- a/src/importexport/encore/internal/importer/emitters-internal.h +++ b/src/importexport/encore/internal/importer/emitters-internal.h @@ -127,6 +127,7 @@ void applyNoteArticulations(BuildCtx& ctx, mu::engraving::Note* note, mu::engrav 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); diff --git a/src/importexport/encore/internal/importer/emitters.cpp b/src/importexport/encore/internal/importer/emitters.cpp index 4329acd2daea9..e0f769a3f85e5 100644 --- a/src/importexport/encore/internal/importer/emitters.cpp +++ b/src/importexport/encore/internal/importer/emitters.cpp @@ -964,6 +964,8 @@ static void emitMeasureElement(BuildCtx& ctx, MeasEmitCtx& mc, const EncMeasureE 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); 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 0000000000000000000000000000000000000000..ae4ca26198c64b213d434cbd3873ea731e4f4df5 GIT binary patch literal 26398 zcmeI5&u<$=6vy9sO-M;ZtQXfGTp4oJE6=mQ6ST!1r&3UNRPp&SsZ5YqDAcpdMWTCEKglKmZPciww5 zv-W&u=4CeCyp*%n))(~fl#DOYF~vwDO`GdgXEFX;`ny|tQt8XVMRd-!Kqgw+xFrT| z`QUVCA*aV^n+uPrtHJms>&RtUR%p<x9(_!g={nt{zwZBj{Oiv@-5azgwJ##B zskTt+nm4{t?X{iv^!BhnhHo7lj2^t8ShvpY2PwkxlX4N`cqTRbDv_ zYV&c8*HMzHRH8EHIksG-ecI!-<*Ab4wZ?Z!ahm&qs=CgOMx#^o$wF#=o2AoKW%s42 zGcN$OavwTWk658Uza)sAo>+59h)plIx!=_7{C1a!DZj;rjyI!Z(sW&_-S+-+mOoy_K-Q8NpRZVQS-Pqk% zy=L=KhgN=?7OJ@|jk!3clmF8Ww>+XH zrJf5LA%g%2fB+MKf50>V0T38P0RCYV8Q4Jp1dd4kHc#%vkU#(gKp;W@{vm<`3J8Ee zhyeUU2nIwD0D%Yr_=gA%C?Eg=Ap-CZAs7%r00bfg;2$D5pnw1fgb2VtgkV4f0T56G zdf#fw^HCzj)RU$IG%xaV*hnA%0<%s4`-fSd0q8#nj3)5l{ln<`C;mMxKg`Zp zqo*J+E`huJm)nvS#%+lDAOHeWPXPX5>W3H|1c8YOz&}ik4h=zI>IuLd zus7*R6~k9o&sxT~p1khTQ|hzG`T18aWv#b&+RN$Xv+rD4QBh_cqw0kJWyiWzbh_d2 zp=0)Byy_W_##`ffy&ZL~^Ldt&!x4-nLUEgtROI{v&TWodyvJO;AutZx-Ve%#ij>2~XVZcIoi&kbHg}@=GEXv&zAX`A~V(&^PDoN7#U<3bN%it#(zzJd)G{=`f@ah$)yp=L`xfYMZqH< zpYAW@%ouHP<019Tczm?i7pfYDiJ#vv!*KP5S11hUzkHqZ`=8}lbL*aMde_dM59UY5 zp#}mV00JNY0w6FW1lS+ZR~qA_*L69JlCBdu8KrcT=ya&M%u(v{T6QB}tCcbv*7Hg? zoO~!rL*6c0tExwm{_YdK!~T-=e~!d0nxlX|qN{Y1?$Do)emnf-rym~<$CL7lhz|Y!8jWSE`72nAY3Ai4EFr%bUsQ zZDA-Y6o!+n5aUa(l})jyUEqDc&b!{E4qxx_8Awu%N>t`D$C~T3N4tD30@ZT-obXAA z)7QN~vWzyI6UN z5=P!!q_4jFy??Z|c3nJmR2j^Eq<}CZ1oWLxaHU-EHf8AvK4SmnKCy$=UiZyLK#!8uCIo;ey6#!r`nyC6c?yeZ~5(V5C8!t0Q-Pr00JOz5&_tUlgL01 z0w8ce>NipHlNb^RfB*=L5P*Fc!GQ`0fPh8-_CbRI5d=VBgaGWr2o6+000cAwun!sx zh#&w0BLrX{MsT140wACffPK(lKm-90Pz3s)YRdCbBE{5`rb9F@^K;lpAOHfhP5}PH zte*g^9|TS&@Ob~>~M_i?Kiun3w?U!^G$?5Co>40PMrmZ!s1K0%H<5h(5Jg zjGmvANS(t^I)^qxbP^|4S`#W->{+@<{RqyR^t6g0Y^rB1W86<(59k^7UL=3dj@R2&d4uy=P98@vmI%ci zN>Y)__qlXAa`7&6366pBSa&08JJzJCStsw_VQD^{kl@AtW0LU1y_N!dVS(X^yKk%fF_`1T1@>4`-SS#16gv(}~L4N_3 CfIDXZ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f91ad026996f846cb2ee636f7bd6c3416d3f40f1 GIT binary patch literal 26102 zcmeI5&u<%55XWb|CZr^y78EIhN?0JF2rg-`Aczpz*qdlm+iR`YiX{%zG)u6QtS#Fq zrI-8%RS%pvwAYFQQf@u^z=0nZ;LM>y91ub%2ZSnAX_xitDZ(Za@UF>uev z%iX!08KZ4>9#Pkf$D^&jQPnU^{QjO9hAS_w4H1-6ndnFX_I}`A{{Pq0~ET*^PX)TFPu#FDl#c z;6qIudAn$>s1`|jbWL=H*Gtm=d6H$(3_Vb@az?zuq7AC$%mjZmE_~ z>X_HRSN)A=`~8!&7W>jj##Um3{sk*j?sD6cYpOeJOYzoQQxQp>M@(DPqB?cR=ThX> z#opV^OJ#M5N@-Ck*}XfFurwOg8Rjhf5DbzfE*ogqiZ?{rC4hv}|%wEM>3EGlv` z8Qu3oY~Z^=yj;xczA%*03*YfZkNzds%BC1=fn(w-M?{0#ynW2^k)$e>sLW-KEmvuu z_Bdt&rNn@g za-%Dww#(VY$}5yG^5#5!_tW3Ku-E*$dhWO~IQp>x%9Ie$uY7_l<$|{f-wC#_qoIJ54DrQK{DSTjlx*6f13L(Sz#*yMQprzZ5D)+X5Qq@K{1Cx`3J8FJMga4J1_LSxfIx%*=7$Il zR6qa(Gy<3(G#F4p00bfgFh4|apaKFQpb^0QpuvC&0wACW^uE=U=c7c6sV7Z`XkO*# zupuA-0@F?a>xXHN0E{05PABmA^~34y;Q;{vRHKTOZH#z;Y6R08+-FSkYJM(v39 zAOHdrPXP17#9v|z5Cq01fcar;bm#~I6Hfs1!^B@=3=jmyCV=^2Y;@=d0uxUF^TWhn zVhj)jMkH|9|JGu@|NNw6>KuO4Ih-@ZCV67Tn^4(eFVLIR%kI2MPpcflrh3*g#_i;F zkDgKIBKw!$csuKSu+v^lFJAcY^0LY@>seJ#|5p_2PSNY?=|e{x$!N_poQ=1}`FcBQ zU+4Wai^my^B|~wWl2qjKQ!ZW3T)f9zf)ijowtdp~op7mY*2%j!S({BKBzf`wD3YGI z+wIc9!2yN*I^dUNEaoaFFXl~^QNzHTb1MZol`Xqyzs{w^vgG&vg%A3Gw|Ci4=6v1H Ou~oJeNu|v;gZ>2vbUi}= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4e943f2485b5d687d01a475962370a188c8cac6 GIT binary patch literal 26074 zcmeI5&u<$=6vyB08k3TUTBU~wDq(?yBDl1vMN_pzHufgk)b?8IwPJ|_m}W^WC2Pxe zO6ev4LB)X+hxWpu;((M}k3MkV#|1cZs1OH)5Xu3e3L!1;jqP|Gnt}}{`#aXoym|8@ zp3ls_yu_QAa@O7aoc=i_V~cc1CK+TH@m_ZpW51@qy=x^^eW^c*#ih}gi5A!Iih@Tz zKHZhY`=8}lbL*aC`Pa^$59db5 zp#}mV00JNY0w6FW1lS+ZS88(7>$)8Eldcmv*-zS5%KA{oN;ehy5k#{~U>16sM3rqN{Y1?$Do)emnf-rym~<$CL7lh5xD^FfWGqyu9OS@hAch7N9@1cCr;Sh>%3VHY0dQt zWw+vGZSw4LE}y4LSZ@Vwk({s-w045p&3d>^?%EnTyS44W|DYMrM#)|;1Y6{`>Ot74 zb?S|fO0MU78ONt=V`rz<3DgiTXg7BDRJ+@h;sTXw&7f7T-wr5m`@Zc-|3<6O*a~Pl z^=`BIZ?{&y6BMesO@q1kj-UU-(?=fAqN<+Lm5@OI1VDfZz&_v@fB*=bL;&{TBr?#0 z00`IX5J3P06oKBSn(};ggTToI9`8S#+#f9<00Of^0RF@5>@`*j0^<^Rz<;?dX@1;+=nn!QF!cmrAEthb zu|N=*m;mg<#ON>(1g4$3-fg22=hfPI+yEye;tU`zrB{ZB0x z`p-{Fq|V_dokN=;I*Ai2tqBz^_AFhbUIgb2dRoO0w$!thCij!q1A0ci7s+3I?NZi# zYrDOaUOM~swPh7$*0-y^{_iH%+@jyr!-tN!lJUA{I2vz_qV zl2qjKeJ(wYT)fL%!ed~3*4^m09cxn6tef}lur!xWNbutSF-dshZnsPO`}-8}YoA{d zv6!nIyqGUljw%M~oL4ExTiJAq&Z}I?S(X^yKk%fF_`1T1@>4`-SS#0xgv(}~L4N^~ CzdIHH literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2fe538bfcd3db51d4c6ee5d4ec87ebf02653e882 GIT binary patch literal 26164 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61c44d16246fca900a94877083c332cefca5f222 GIT binary patch literal 26026 zcmeI5&u<%55XWb|CZr^y7F240s;odl5nM{KXe!#s#@jlHKO64{ z3j{y_1V8`;KwwG;@O~sZ(gaJ?j_N@uQJ?5WD49^`WT2YNQ2OF>ZnaRWm9wkn%gQ&n z_&}42f>kn?R11Uty(ik>{gSkQkz`vmLq2^*m*_g(qQ4*haroOWKR+1sC$%pkZmG6V z>Y97sss7sYVSj^`6JKaXVkvRdzi4JFU2Z$Pr@G6&G#~vv6_MCHVp^v*HKKsq|;WE#jYqU!{%-vTt!}HF&_9V^x zNHtyOMX%8b`g|@uyUE%~s_~C=`i-{N5zX?uUVGcCUvKzZWUs7{wNu~n+)r8_t(DEyqPIzQyW#oWdbiQ^ zscbv0leJvRHMh6xT~Bp!yiRj_SJm4sDc+`Xz2&tljZZx)n67I&(!SX)Ha9&wpT62^ zJ?Yd+WG~k8>l$-$Ew}KO6YY6G3(7nbbwURL5C8!tfcSuA00JOz90A0KNiXBlNba9KmY_{1P~u$IADMP2t)`VK15(Z1pyF<5kP#1;eY`GAP^yd_z-~s6$C&a zMgZ|4h64r&fIx%*;zI-mR1g3GMWFw!ro0~|T0*^PIzaO}zlRM00T7sW0@y!H`wGDL zLEv}-kKaEW-ySs}00PrP0Q-mOnQM#`1V$xrpZ{`OWNy@sXb%D)F!2NsA0~c^F+dO) zn*id&*yzv^1SXyU;={yGF$M?%V-rAp7#kfrg22QRKzx|^DaHUnU_=53;kOp^;ro-4 zsdMm6=OAW?Px8b{ZAxW}Jw@+OKO^%RJ*#pE>*`(01hq5@{V5_s3 zSv>XOmGdggtZUZX=zkxvVwc=rG=1oZBN?rEmb3BJIbUyAof~|fW^p-#v1BOjkU=Fb zZ*b{w=HeaZ;tzpw+4pf+H&ms}oLz8ku{N7YN%G?VQ6xQauh*mf{e24fv(F#NSj;9T zFXls)amSH6?^KI&E9+LtdXr08%aW7(C%))IKHg(TxOHC3S!?Sn{Ci2pm(Yg}4VpGL=uTq%_w@I7V@cUw4xGfO)&dz=+PEteH1hG~ z{z5LMqfW*?da-zXwAa_N>HCRa-;MeH>I<*J_vgQPlkWGw%#r5yJu~LqxO&x_A05Xn zCcp%k025#WOkhR`(0N3isZK?0tFj*yRVU_)y?EoC;0=cR7w zd?*P+-YgocvPTk+o{6^Uyd?TR2XQQ#gNM&>9k*}?e?R=A|JyG=pAN@Eo{K;|v+ZST3YTMcg)*7`ck{o1bUeA05USu!>X zZXH&;;d_-6_jc%w_R==y2u-jW7wj9 zvt4M`U0h4uY_KUJfpG~upx@jUWntWb z+@A?BfvG3J@nPzhm?y{tCMLk~VPbSVkO@pZ0gew-zr;L2CNMDpjt>)~Iras znEEB=2{M5(2^EuTf)UwZwmto8nGXF0um`GXtRWR_XSs5sanityCheck()) << "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) diff --git a/src/importexport/encore/tests/tst_text.cpp b/src/importexport/encore/tests/tst_text.cpp index c284350b8546f..b6e283f7d4117 100644 --- a/src/importexport/encore/tests/tst_text.cpp +++ b/src/importexport/encore/tests/tst_text.cpp @@ -67,6 +67,40 @@ class Tst_Text : public ::testing::Test, public MTest 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) @@ -145,6 +179,41 @@ TEST_F(Tst_Text, lyrics_hyphen_separators_dropped_and_set_syllabic) 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"); @@ -205,6 +274,35 @@ TEST_F(Tst_Text, lyrics_offset_ticks_still_attach_correctly) 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) @@ -1237,6 +1335,152 @@ TEST_F(Tst_Text, title_frame_headers_footers) 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) { From c62c8a45365909ee57184ab0f1745a19c7847f23 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 27 Jun 2026 10:00:00 +0200 Subject: [PATCH 28/33] enc-feat: support Encore 3.x/4.x files (v0xC2) --- src/importexport/encore/CMakeLists.txt | 2 + .../encore/internal/parser/readers-v0xc2.cpp | 238 ++++++++++++++++++ .../encore/internal/parser/readers-v0xc2.h | 32 +++ .../encore/internal/parser/readers.cpp | 3 + ...ruments_c2_no_tilde_compact_names_midi.enc | Bin 0 -> 23767 bytes ...nstruments_c2_tilde_primary_block_midi.enc | Bin 0 -> 23551 bytes .../data/lyrics_rest_does_not_shift_notes.enc | Bin 0 -> 26030 bytes .../tests/data/lyrics_v0c2_compound_meter.enc | Bin 0 -> 26998 bytes .../data/notes_v0c2_chord_cluster_5tick.enc | Bin 0 -> 26152 bytes .../tests/data/notes_v0c2_dotted_eighth.enc | Bin 0 -> 26008 bytes .../data/notes_v0c2_size24_artic_pitch.enc | Bin 0 -> 25960 bytes .../ornaments_v0c2_cross_measure_slur.enc | Bin 0 -> 26055 bytes .../data/ornaments_v0c2_orn_c4_accent.enc | Bin 0 -> 26352 bytes ...ructure_v0c2_triplet_pitch_in_semitone.enc | Bin 0 -> 26030 bytes .../tests/data/structure_v0c2_triplets.enc | Bin 0 -> 26104 bytes .../data/tempo_v0c2_eighth_beat_unit.enc | Bin 0 -> 25992 bytes .../data/text_tempo_orn_v0c2_bpm_offset.enc | Bin 0 -> 25970 bytes .../encore/tests/tst_instruments.cpp | 41 +++ src/importexport/encore/tests/tst_notes.cpp | 80 ++++++ .../encore/tests/tst_notes_tuplets.cpp | 82 ++++++ .../encore/tests/tst_ornaments.cpp | 36 +++ .../encore/tests/tst_ornaments_slurs.cpp | 35 +++ .../encore/tests/tst_structure.cpp | 63 +++++ src/importexport/encore/tests/tst_tempo.cpp | 36 +++ src/importexport/encore/tests/tst_text.cpp | 109 ++++++++ 25 files changed, 757 insertions(+) create mode 100644 src/importexport/encore/internal/parser/readers-v0xc2.cpp create mode 100644 src/importexport/encore/internal/parser/readers-v0xc2.h create mode 100644 src/importexport/encore/tests/data/instruments_c2_no_tilde_compact_names_midi.enc create mode 100644 src/importexport/encore/tests/data/instruments_c2_tilde_primary_block_midi.enc create mode 100644 src/importexport/encore/tests/data/lyrics_rest_does_not_shift_notes.enc create mode 100644 src/importexport/encore/tests/data/lyrics_v0c2_compound_meter.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_chord_cluster_5tick.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_dotted_eighth.enc create mode 100644 src/importexport/encore/tests/data/notes_v0c2_size24_artic_pitch.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_cross_measure_slur.enc create mode 100644 src/importexport/encore/tests/data/ornaments_v0c2_orn_c4_accent.enc create mode 100644 src/importexport/encore/tests/data/structure_v0c2_triplet_pitch_in_semitone.enc create mode 100644 src/importexport/encore/tests/data/structure_v0c2_triplets.enc create mode 100644 src/importexport/encore/tests/data/tempo_v0c2_eighth_beat_unit.enc create mode 100644 src/importexport/encore/tests/data/text_tempo_orn_v0c2_bpm_offset.enc diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index 8e6ed9ce35b4f..de53f2266f108 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -40,6 +40,8 @@ target_sources(iex_encore PRIVATE internal/parser/readers.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 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.cpp b/src/importexport/encore/internal/parser/readers.cpp index 8ec7a5b8dafb5..ab4874d5835ee 100644 --- a/src/importexport/encore/internal/parser/readers.cpp +++ b/src/importexport/encore/internal/parser/readers.cpp @@ -23,6 +23,7 @@ // Shared block-skip/clamp helper implementations and the EncFormatReader factory (version to reader). #include "readers.h" +#include "readers-v0xc2.h" #include "readers-v0xc4.h" #include @@ -76,6 +77,8 @@ std::unique_ptr EncFormatReader::create(quint8 chuMagio, const return makeFormatReader_SCO5(); } switch (chuMagio) { + case static_cast(EncFormatVersion::V3_4_X): + return makeFormatReader_V0xC2(); case static_cast(EncFormatVersion::V5_X): return makeFormatReader_V0xC4(); default: 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 0000000000000000000000000000000000000000..d91ec8d6add85ec8f933eb5a7c72097d08ddc382 GIT binary patch literal 23767 zcmeI4F>ljA6vtoU0xhjXgsKV<5)~{JOod8-U}#9($|a5?+fgGipwOCNDRm+z1;Ns9 zfI74A8M<|hm=Gf~6Q7_HLM-9EOWN2`qtdhs$^TjBd-v|n=lk9D$*tYzWUTcsbh6A( zaGQe!B}mf}CpZ7kS}YE6zpLARvmA!yA-dA_VLF|m&*Oo8b4FgT^wl1>zQ@^m-DF=3 zmINgaD@{G^WVx`(>3~|=glsF8&zzPxeU$=>l!@kPokH@dNgn4SyK|k9W~A_xw8F9| zyK@OO{)`N?+_NUTRvskNv9{XZPsauK20x8ZnAcTO35VWawbZvS;;+~hp6fBg(R1&OWtPTEGAha zU%XAJL{6KbZ{L4)(8XHx3R6ew;nj}}P{xEnbmdcAa7=eirk>(0c9x$LBdBjTpZNi; zShnd{MLVODtvlIVj*5XFdJWNxpy`EGul&{zDrBv!kg-*+c<%eUM{5Or)$}&W3VknV zmYaSppn_$)cG_?$Q>#|XO;2^Py+*CNt?I41WQ$ZN*S*m3KX{bWU01iIeJwO=n;zX? zdR4Fg>(Yv+F-zHX@i4C8=Kk2xo_lmn6>mn3&_MtMK!6FrKVTbx00;~s0RJ$E4D28P z0(+$X(jmWzK|lZmKp;i{{vn0~1rPv%2m$zq2n?to00J=r@DDK@D1ZP6L!=ZC@VQ3C=XFg^tE{4hRijhTYLX$kD}e{PFRpSB~~g8&GOJOTKJkzZm= z5Cn!M0RJ#FI&=hqktYEEF!D=`34*}T1mGWrMu(0dF!BW8A4YzOF+mVGC4s%}TZ@_Q z_mdK-v-hU6=QG46abl%5rJ}{o(gV7oMq8sxDhjWrzO_tpJ3030vZ|Hyt$PnM){9DG zVR>Qp<@5V0%B-uG+)34`H)x*n zoWJAT=E%ic%q8dpa+oa!P_1pQA{4;#R9gJ3Bkn=3|EsiCD}< y4qnVj<#EHIIcpb9xs`PzZ`|cv_Oir?{>c~p&FKjnoC`!(*(&Lg#u1mwY5zorZH+vBzkG=$x5P$9*VG-tt?A+7a{Rru&i|xx)7imJ;;Ax zJbUqP=&eWM$$0ea$-kf{W4z#dLtA!9iI(C8zHc)#Z{Gaq&S$o-+cJH2+AMvhze{3{ z`|L-EuSn$Y-9tUzv8;tG|D*Ec6q8;##n{OX&FOhc-|| zt5`m9N^<%l6&9%zC8$JA@~BNNmm(i7@vgEgN%nwZg>|A{uV88EB+q1U-%ajWrxvFd zJT5_HDv-@(hAo@4L)$!;uUdxZCW_3_+3!@2#d@sM0)1Re&R1ExK;>wZOUa2+>Q3bb zCLwA$Jdu<#yOcc^E<%#g^Tit!kK|%Y^!3}%K8{e2L1p1cJ)Hf>0A)r91b05p729w& zWa)8!#7^^lqWg`V_CwF7tZ5mxS+vp`S(=^6<*4X;O}8bQ?zi1$&8@uh{4FxGS<<&F zTdwn_;nGGydt|s(GMk?3w<~S0?o+|E94nbJ!3N0D)O2fcJ-4e*&<65ExD1`1gm={m}vfATT=w@cuA6dySQXz)1=0@qccM zES_{A`hx%nOg#bkhpFFUED!|7CIJ61HaZLhfvG0||1kAij0J+g*aYAo#zu#MATad= z;2)-bi?KiuI3a=EgHJ7%4$ei_?8J*k_0? PvQ^T(7)M-YTZH}qhkm_0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2d7219d03846362eeb4352a5a622c094b79d0b70 GIT binary patch literal 26030 zcmeI5&u<$=6vw~oH6|qywV*-~pkP2k5nTGif*{(;#@O1h5aOq6m_!qHGQNg@08*}_7pUa#gCtml<( zH26s3ijrNi=2VLe{_7L1kiW$3p9Q-`DFpZor*Roq@z3pBJAeH4>(8V9P<|1prE;Ov zn490J{>IZ${|wG1zA!S0xx{Y&vX!rGQd{<^E*jq_Qju@ergD~!flmcoCz)o9WpvO) z4?g9buB4&7uqixArA_2!MqFobf^<8qFfhfnpFW3VnshUC;PeWrIj-N;a%-HqVvyGO z1S>Cc5qNo!;Sw5=6o!1&%w@kP$X9v)tCs#%*D7Rb-O99<=V>Kd=+be4)-{7Vs;E(3 zB+Yqj5O<&U9oOt%ds@~cQa_j-pPtlcCN)Uad*Acn_Qz(FmOL z9^jmB&`3SZigsn`6{L-lIfZY3{A;jXhqZf!Bg5kE=tG4FF(II@e3}ZivbV@nCwpZ) z$bDi5?Ty~M%>eU`Tdp}vZoz_U)rzGOmV#!-@3LeEJ-@T=H!e4WH8}J0urD>%eD9;S zkHxCBQ1(~hbeew9YxJ6}09D8J+`R3f&{|(_^nBID^}DV04OQ>7IXi)BqwRNU&5wPQ zEYGuCZr|#ZTdO`!W7B&0ii-;%|;52>~G>1atyoA9NZb3jraZ6A=5L(-2t* z2mzgd*aw}4$U;B}=mf+*=rlwY0zyD1AofA0A+it<0y+V)4>}Ezg@6!X0)wZTz`#Bv z)SIRwGOyBm*ph^R5QsYg@gL&817!X};O+$O>_6PSz0?Q+ArKz|;y=V^t!1V{;GhI< z(l56;O&+wPv=;(GVC)HqeHi;CmI(@hLlY4DaA@A#i8{Vjm8T zPC5#Ku_qw*VeFS!CMX0BNMJj9YB3ePKgsc7Mo&6NHiLB>Czfl|Dq8F@oWdX~^CCqp z<`}}Jde<^R?f7#OPpNC+^Gk2MRdC*0>(1t8kG+5Hq>3`@S#?kU7ZUSM#p~}SbF6L;_!M;oPNOv)++gGKE z1*ha*C21m;=HSH-F>!d}e!q{ctu2J~*`g0eET$p_FQ!A~dlkFtqPtY)OIfxn_Un}M eUgo&mztEt!>3D|}wI_gQNXy3@h5ycz&cMIL!Z#NH literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..03e78ea197f75cb46a03c547e943e3627550a79f GIT binary patch literal 26998 zcmeI4&ub%P7{{NP#F(|k4Z28CS%ZQUz3i?*E3QjoGA-#QnJ}5qfCn2lV-rY|kfdw% z(*I!b;K{@G=;A@>ZI8i|KQ8LoLlF-mA~}dGB8<=TW+s``{b9QWG5NkB^FHtMyw5xH zerEFIoo4!!(&ok&^xzT?FVT>6(n!-HqwXw*e@lLMGZa_tWp9uW*P162Ev?-Y1$*AT z+*-(lbgHp&NP|!~Jl*R{)pXt1FK>oicjcKE$#tV&y~^8LpJ!OJyPpl&S1(*>MW_AH z0s#;J0T2KI5SSAJydDY8ROb?G2W8hQL7m8PuOz)fXI<4LLW$R}q}OtlN-?!&Jgsz- z!6%X!a@m5hqI$&XpKGE`UN1@iCrOq?5o*!LbeXQx4f^ZO@55hz{^@YyPikF6+*2)~ z)R?>9D1Ys7&p%FA^v|@ozM}j7c_US7G6`8z)wOT+h_^QSl-mh@kJeN~`)a&6ZBUmQ z)F+2)DarE_j*E1WHA7M8v?$h(o}zG!Yt^{CNyVgSH=mWQr{y|FRn;ci9ebP?jpwmD z_E1_jbgAum496a_Z{K4`n_gF?WkZiWnx0Qx3m$cv>a4Hm{!!O8|ESZ_-#do7<{!1v zvccgoN9AzrQ5J>gaX9u+S~hGmxlyU;{yt>izDGs(_o1|GsEz08x;RrfQ*yERR}MZ? zLgV2-Qxac~-cjze?!nP-^qKrG{fm~7PVilDo+HaO6%{4Uy~pu6P8BLriR%n&uF(#Y za`^j0EJtHF-+_lI@&i>uAy@9k&(J3eiD-?bvm6)YE?%}oDaC|^Qz;y8Rgqdoms-AP zl`^o&WDDizDW>H@i}dyPe~i9%0`{PIX58>cA1fft34!35k8z`vx7TIrybjSG=5vy5 zb$0r1HCnW4TKSS$w$cV!Mk$laQMuLVIz5rHt-jN3I@Rlq))twotCZcYZaMY`9f#J7 z##-K~k=boHt$wxNXt$_nTDFzS+LUfLo7KLfTr8*8Ztf_%*^%lZ6{{VmTWWmhP|mPz z!;=2(ZoXY}=u+a{PUqjATKUe%S27zKbFs5_?oTV|c|c35=`GKKCdeQF0&`9P@nOy{ z08AeQ?n40a;XZI+0)aUva4`Biu-u8EfB*=9z!U+*hbbIrfB*;t2p~QLU_b-`5SSu> z_%MY74G;i<00G2@01SvA00L745Fe&+paB9P5Fmi~5P$&@1VBI$P*38sG>Q+pdeU@) z<^_HZ8wCVFVBQH}{V?xW0Ok(@_a<=n^~1gU!vX>zFh2yaewd$gjhTYLaS7b!|J;_c za9l_92LTY6c>;(JGk=OPK@d1G0mO$Bqk|&|%sc_ahnYXcm>>w8m;mC#iP6Ck1ZJK9 z;=|0JVoVSOj!EFa`_*F6dwxBZ}paw%je#?dP!xOwT+4$4IJ&NS+EDe^q~RU>5D1O##`ka z;(fJkjB}u${+|91Bifw7STYp%C{6{g-{;!m%*ETxrFCQiHtXu1?TAS=(`L@P!BR9C zljOyxSR_61U@)M)y*+aIwZ|{XSj`PqBwJiDdf8;^$ X@cRu`lpY~^g0=Eq;WYj!)@k%N>(7j> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..53209f829783ac047a66d1ab5748c705457a0748 GIT binary patch literal 26152 zcmeI5&u<$=6vw~2>zb5A)U*|fNCg&1D1uAcS|x~3+1MLvQ`>8;H;NSwVCrqUlC!pK zrxY&fUr=%2#G$=b9FTJB(c;996P!69!~r3MB2=LYA(l7eO*~FoflW{Ld)Dr}dGluE z`RwfTB;LGKvi8Oo_;*Rh=CKbQ8Z<55cPBOWTl(8OMpD)nqd^QRwMYl%^LK=SdOlk2 z&E^apb!yy4&xpl_y}p#yFiiaNmJx=_3$GvyXTG{b=X;;$$a3?pWw=+CmV%k#eq3V$ zOn?b60Vco%ri1|PN7R|>Bx+sN!$?${(36p*Bf+Uqy2O!s<8n4%s8-9Fy!pJe4F(@* zqM=}w%w_43#PL1Rd$eB?{%0VzMR5f91S`0K+xY9@gZ*EB{^^H7f5?3i$Wv~i!2_9a_)P;G1bpTCXeC z7&fzgtFKt&*vvP`_ZICAX~Sm2EZ?_=q2ML1$auWC zf7vm!DZ*T&RdAKoP!kK$IdhNTYJfS*7)tkkAUo9KQbUkN%CK zw5#gL!^*+vhXz)rgn)YG6I7`b-8C`w1TC@ubDvm2Yp46RA7IsXiWPg^$(nG?O0H1A zdf>Ocj?k>2>$MwR?S>z0!Cqa3wO!lt+z(qG*2-qS=+$AjeJ|+Nx_&c2*>+qfW4XvS z8;x4mlU*FI(`@X>cDp5t^C;I^Uc2IdX1my|d$^E#zt#G$mzF*xP^{)Q zG~(h~ZsAWy?YW0}SrwluEKGn2FaebS#|M=L>r8+Ns027Zs5Dq-0!%<9uqS>=MM6J` zu`mH9zyyW}aC{iT!4)RJ1QY@s9~2m@G65zqM1bSN5Du;|0Vbdj;P{}xV3i3lfgu7M zABJ#ng$XbLg#gC~1qQ23fC)$f{ckmaetgj7lcobSFVl0_EKGn2OgjPIKTP`y;Q2Fw z#}jz;{^9Zd+`r0p19ZRVRv^IA-#6#B{CLM zm68|JAlvl% zPu`cLqhOb;C1qjIzxPC&yk8Rgr%1L%GZfIr^cG#C8}#?RyGOtO`pZxK@uc=e#FlCc zrRLoKR*l!5h{hYVocL5T5=)8W@kJ|Fxx}`;J=J#!J=#-UXI+Mm-kyr+mYUC?4Qf%H zI^=ULC3~*VF-7NCvo8vbQqud;)09keouVXdnwwP4N*x9Ldd^N=N+l`$))$cT=dX>txrr)a6ulrQ6JkN5)zR@Z+wtPCD zxzud_U)M_5E>`m!8gub%uke=}A9+ags`*@82^j=H00fu-;sb625CDOb2p~S3LleY|14s~DS6#^`p^+qGMaggv+>qAUvEd9>wKQ(;&BFJ$xz%T zgGyY#&$Y{$i+7nzFa*Y9-PNdXs7p0Xr{LaTX*QddfIpJ4 zn46rum=9GCDvs58cfBZ=vSF9(m$_EyusH+w&phcpKHg+SHu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..725d2908c246c7d0153d091d65a0c1b88f678829 GIT binary patch literal 25960 zcmeI5&u<$=6vw~oHNhnjwV;Oxim*UJ5nS3}K@d>c*qg|yowe3$#S#ZB;_%wL9; zX7*_;Z(b@nd;2T=dr8HYae^2O7{)^1UBvkBneT6zDXp)B7cr$aLK#@zxTOrz^Wo*r zV&05lM;cGiGvo15udlTl1j%3DHiKa8*_RLmiEmz$^PMmAV!3|1V7iwtT<{a4{iu-u z36KB@kN^qH2?5!UL`NEv64j$}5SFM-$*Hhp!h*Abc3D9BjZ3+WVy#xrZdlJ~+wkH; zNi`G;Th^NPNa3G*qARjrQvOdvZHpG*<1@U4Yq*K~_x?Ef?U$dAhy9`VMG#NDh0@o& z{+;e`JQ?;+;Zp1iBNbbV4f>a?Y-Lq^$M;mX#g>+%zo!C}E+fV^+GwB)Pij>@UGBf# z5^kWEJ2uLh8+a~pYz*vGWzY2O=MzDo-cOBay7y!MvSa1aaiAo#a8>4@iH;l}$c&^= zLm3sR^J2M*1MJHz`MPC%)@08e$HI@OnWijR184C0Vmh%S(pl8xMlkdl$m%tqC;Abb z1~;6l)f!N>y0ykHoiA)vUqI3*noIchhd=uZZQLo;&pfaXuKvJ4VNM7{XFe%PrQ~j^ zp(kaD{h#}!;I|IC?=*a@+fJ!sSDl;%$ExIuMO6Jp+v_N);CH?Do>#xt@ONRaucNSE z-}T&&TOKyc)<((Of!%I+ez)FjG<}q9$91v=7rEx%UcKw-E{@k}?j7iMyQQj&DA!wF zyVCf?L(y_w%TfN#cB#4JVKx1JtMy-}c1avdwfwdrT-<_N{L6{Qww37e{96^2m(BH%eg9J!`1V#ujK8)a?LINZZA;9<$fkBZ3 zNMM8jMvzo{v&e zOh0KlMDwCNhs{C)Brxv;xPO@U6~OV6!07}YzJEB~pDiRn0`o(F`-l0NYmSrzCM9rJ z{&HKD#YsETp9Dx?<_R!9%={E{fFv+A0mg@^(Xk^5%sc_chnb&Z4v++Srxu;-_AB@r3>?>iqmGujlM{cRMSYm2>Z1Ue#G-BJWc!)q=hw@Sxi$#^>#d7F+)G#pTooY#aljgf0B#d XljBV>R2~DK6017aByo3EYzF=Y=|?uu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..280f3f53b75091cdc496996fa4ccce7f8e5ec64c GIT binary patch literal 26055 zcmeI5&u<$=6vyB0nlvR5wP=MRK*9wHMQ|ytRe~syjlGFBwY}DQqgdhqrdd);$=b4= zQs9vPpyI%ZLwl__Am!Gh#fcvm;LM>y91ub%2ZSnww7fUFj(0;MuyexxjYU=`xEw?&t`j*ErCgNgT%b&5DEr!SVYO7RSM#gp(@Hn& zd?-mp$*P!3$|6Jm+!MXS`z5hIPqHnVA)h{>Yjlh5&|i;!Klt_MpB@g|liC*%TdFOT z>htzDs=f9^)ILMY`e#~3U(yHd%Vxgz5!)W!Q{7`-mXB~xMI>b&F>O$Xn$#nYb4edR z6<$tV^ID&(Inh=x(nZNb#BzR2ii31;*i5#Fv>Y~js0};gLFYp4xB5Vvk}KsyZ4k(f zFK~yaz40@|yT}3=a^qpI0(`t)|Nrf)j#{G4g(%#*2>*kDzhHkV~4NphJ( z$>quAERl*ne&|uj zbY0UC`&Osi+Vtph_T6^-zfSEu8Lje1S>6vSc6a+>k@PPkvThiR9 z4Pg%gATaR+;2$P_iZMVC7@Gk6!`SH15CkTk0Q|$mPca4v0%H?^e;6Ab8iK&Y6M%o1 z_$kH!L107z`_Zo!^U?d05+7#xOXtvMh)&|fN^M$2i@iWsC=A!UMrTwEU_-rYsk5DY z9?;|JUgZ4T%dZyfx3;>Axy1`_U%#xP%(`aXjsI_n6}#g0K+oT-M!;>W);YV!`^F66dm(C1CfD-034eUS~y( R<8D96S~->^d^YPe`Wt(5J0Ji6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ede21382208f09d919c9175802d18461cbeaff68 GIT binary patch literal 26352 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 nOWCqY)~lS$UX}>oKk}dt_*N<3QpW)@k%Ng~C5T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7efd59d29ac3e3856bf48483542832f8e3f74c87 GIT binary patch literal 25992 zcmeI5&u<%55XZl}YfRci)Pf2{v=S>oD1u7~76ehM7<&_KYJ08qMzO@9HO&$%C2Pxe zO4>{QgQ|y~IJDQQ2c+D3v^eqW0Gv5ghyy|h<$zFykd~R}^^ceq?39ytp0)eln>X)i z-)D9w6L~W!XPu4D@!!Qv%;OL`G-z6~t4__tZ_D4^HcZuDh!!!Z)}l5rzj~VmH1pBr z?rhG`u}Oo6=opE_QLQgjHw;t1yk~^r(wSEghBIHjMd!Pps z_r6y1wP&LFCNAjrG*e&Fd-DrcrtB(RU=QEw$tX7ZhGc0vx-k`SN9mZ@Kod2z;Zx0} z^a>VLW8`E|gvRr^+I!BT#*u@T6F;F9;R;8Fcf#eUU7zW+a8cyKT+4RgNwN9Zz4~nMF zK&Efwo?V}$?mduP^F6>dKYF8U=IrA7OGs&XV-8<^|3~*m994??C;IZ<>V1JUVnSfp z@+s<+3*H)UJw>vP{1Ep$K=v;L9#7!W@x$ZwWkd)F zf%p)R_#r;FmYoWLNeSGizue|BJLyEx7Xm_H>IsN{n0gb-28F<}35b6WC$ z<8Op|6rF^|@b*|Al&c&k)#+;$J1rtT8KcU*C%*8vzB^UwY zk?iAWY-C7vvrgW8mnQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4a436c5411bffb1bf930371691c571c732b855c6 GIT binary patch literal 25970 zcmeI5&u<%55XZmkH6bMtwV*-~AYlPQ5xtaPl^{Z8V{f8OZLhW7D3*Ev(=4f_WNq0_ zDO~a&R2(>QXs;Cqq}+P6I2Aa-nFB%`5JD&igep|Y%zIwPYqwQ{?34X{*6y2`_rrdl z*&V&an@KtAY=4U5>sj;l!2vq=^-LOP$N=}9)9TuGJ>MnCgy?ZIUk*`!rnGNejZ5vEJ zkW@q7E?TR)M-mV3i9VA3lIs6F)V6320lvfkUg1t=RS-7|IQp=a;?Z~aGeRKh`GhFtg14!b zo{&pyocqKM8VBtUY5~?Aw@`Mr+^hxHD(CWfYz4KZ-%^qtwEgCuU%g%ncHykA!``p% z`rc;^ADbm>qu}qrY1aIpU2WIu0ZNYRxf$C-w!XJlZTotN>$mEA2fE#9sOl}0stvzc zu6^z!Z+V{Os{Zw6p}yl|CG~Nmaaz}^=v%1dwhiIp*_7{Wn?1V|u4fbk&$gCYr#zz_k(hantPNPq+)1Q;J8Fes7$31|YnZ#C8PQA&#G zCrt-vUenLj4|GZ-KmxN!fcuA8^g7o}0;ePJ=>5a##bJSuUPrFjg6gTS%fP^`}z1lDT+axCA3$Jh5F1+eVtSnsxH-O_Ao) z36;Eff<>h#?sPghJUm2KUWf8h8H+`gparts().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"); diff --git a/src/importexport/encore/tests/tst_notes.cpp b/src/importexport/encore/tests/tst_notes.cpp index 5488846ad96a5..6510240870af4 100644 --- a/src/importexport/encore/tests/tst_notes.cpp +++ b/src/importexport/encore/tests/tst_notes.cpp @@ -905,6 +905,44 @@ TEST_F(Tst_Notes, grace1_cascade_filter) 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) @@ -1668,6 +1706,48 @@ TEST_F(Tst_Notes, notes_v0c2_multiinstr_compact_routing) 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) diff --git a/src/importexport/encore/tests/tst_notes_tuplets.cpp b/src/importexport/encore/tests/tst_notes_tuplets.cpp index 8a8093a0edd61..1dd424e9ca783 100644 --- a/src/importexport/encore/tests/tst_notes_tuplets.cpp +++ b/src/importexport/encore/tests/tst_notes_tuplets.cpp @@ -476,6 +476,57 @@ TEST_F(Tst_NotesTuplets, dotted_note_dotctrl_bit0_with_rdur_drift) 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) @@ -540,6 +591,37 @@ TEST_F(Tst_NotesTuplets, mixed_value_tuplet_exact_ticks_and_isolated_partial) 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 diff --git a/src/importexport/encore/tests/tst_ornaments.cpp b/src/importexport/encore/tests/tst_ornaments.cpp index ab64fcf2be215..4f9c122237fb9 100644 --- a/src/importexport/encore/tests/tst_ornaments.cpp +++ b/src/importexport/encore/tests/tst_ornaments.cpp @@ -856,6 +856,42 @@ TEST_F(Tst_Ornaments, bowing_marks_from_orn_c4_c5) 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). // =========================================================================== diff --git a/src/importexport/encore/tests/tst_ornaments_slurs.cpp b/src/importexport/encore/tests/tst_ornaments_slurs.cpp index e70846fe2bff6..530ec42046976 100644 --- a/src/importexport/encore/tests/tst_ornaments_slurs.cpp +++ b/src/importexport/encore/tests/tst_ornaments_slurs.cpp @@ -135,6 +135,41 @@ TEST_F(Tst_OrnamentsSlurs, multi_measure_slur_resolved_from_almezuro) 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. diff --git a/src/importexport/encore/tests/tst_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp index 542bdfcb74f84..f8cc52831e583 100644 --- a/src/importexport/encore/tests/tst_structure.cpp +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -425,6 +425,69 @@ TEST_F(Tst_Structure, old_format_v0c2_correct_pitches) 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 diff --git a/src/importexport/encore/tests/tst_tempo.cpp b/src/importexport/encore/tests/tst_tempo.cpp index 10528e455e212..8339dce3f0a94 100644 --- a/src/importexport/encore/tests/tst_tempo.cpp +++ b/src/importexport/encore/tests/tst_tempo.cpp @@ -86,3 +86,39 @@ TEST_F(Tst_Tempo, swing_timing_file_no_crash) 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 index b6e283f7d4117..afdd3d57bd4df 100644 --- a/src/importexport/encore/tests/tst_text.cpp +++ b/src/importexport/encore/tests/tst_text.cpp @@ -645,6 +645,36 @@ TEST_F(Tst_Text, tempo_beat_unit_from_noto_overrides_compound_meter) 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) @@ -1246,6 +1276,85 @@ static std::vector collectAllLyrics(MasterScore* score) 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" From 7eff7f6d0164a7d7dd67ed10024876c4ed399538 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 27 Jun 2026 13:00:00 +0200 Subject: [PATCH 29/33] enc-feat: support Encore 2.x files (v0xA6) --- src/importexport/encore/CMakeLists.txt | 7 +- .../encore/internal/parser/readers-v0xa6.cpp | 207 +++++ .../encore/internal/parser/readers-v0xa6.h | 69 ++ .../encore/internal/parser/readers.cpp | 3 + src/importexport/encore/tests/CMakeLists.txt | 1 + .../tests/data/importer_v0xa6_boda_like.enc | Bin 0 -> 25922 bytes ...importer_v0xa6_duplicate_rest_collapse.enc | Bin 0 -> 25668 bytes ...ter_v0xa6_grace_ongrid_snap_suppressed.enc | Bin 0 -> 25700 bytes ...porter_v0xa6_grace_restores_face_value.enc | Bin 0 -> 25700 bytes .../importer_v0xa6_header_ends_at_0xa6.enc | Bin 0 -> 25620 bytes .../data/importer_v0xa6_inner_grace_group.enc | Bin 0 -> 25700 bytes .../data/importer_v0xa6_key_transposition.enc | Bin 0 -> 25620 bytes .../importer_v0xa6_lyrics_and_stafftext.enc | Bin 0 -> 396 bytes ...importer_v0xa6_melisma_verse_alignment.enc | Bin 0 -> 374 bytes .../importer_v0xa6_no_spurious_tremolo.enc | Bin 0 -> 25640 bytes .../importer_v0xa6_stafftext_placement.enc | Bin 0 -> 428 bytes ...mporter_v0xa6_triplet_byte_at_offset_7.enc | Bin 0 -> 25720 bytes .../importer_v0xa6_two_verse_alignment.enc | Bin 0 -> 418 bytes .../data/instruments_v0xa6_midi_program.enc | Bin 0 -> 330 bytes .../structure_hostile_out_of_range_staff.enc | Bin 0 -> 25968 bytes .../structure_hostile_out_of_range_voice.enc | Bin 0 -> 25968 bytes .../structure_hostile_zero_size_element.enc | Bin 0 -> 25968 bytes .../structure_hostile_zero_tuplet_nibble.enc | Bin 0 -> 26052 bytes .../tests/data/structure_v0xa6_basic.enc | Bin 0 -> 25760 bytes .../tests/data/structure_v0xa6_fermata.enc | Bin 0 -> 25644 bytes .../data/structure_v0xa6_key_signature.enc | Bin 0 -> 436 bytes .../tests/data/structure_v0xa6_score_size.enc | Bin 0 -> 436 bytes .../encore/tests/tst_instruments.cpp | 17 + .../encore/tests/tst_parser_v0xa6.cpp | 773 ++++++++++++++++++ .../encore/tests/tst_staff_size.cpp | 14 + .../encore/tests/tst_structure.cpp | 62 ++ 31 files changed, 1148 insertions(+), 5 deletions(-) create mode 100644 src/importexport/encore/internal/parser/readers-v0xa6.cpp create mode 100644 src/importexport/encore/internal/parser/readers-v0xa6.h create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_boda_like.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_duplicate_rest_collapse.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_grace_ongrid_snap_suppressed.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_grace_restores_face_value.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_header_ends_at_0xa6.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_inner_grace_group.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_key_transposition.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_lyrics_and_stafftext.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_melisma_verse_alignment.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_no_spurious_tremolo.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_stafftext_placement.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_triplet_byte_at_offset_7.enc create mode 100644 src/importexport/encore/tests/data/importer_v0xa6_two_verse_alignment.enc create mode 100644 src/importexport/encore/tests/data/instruments_v0xa6_midi_program.enc create mode 100644 src/importexport/encore/tests/data/structure_hostile_out_of_range_staff.enc create mode 100644 src/importexport/encore/tests/data/structure_hostile_out_of_range_voice.enc create mode 100644 src/importexport/encore/tests/data/structure_hostile_zero_size_element.enc create mode 100644 src/importexport/encore/tests/data/structure_hostile_zero_tuplet_nibble.enc create mode 100644 src/importexport/encore/tests/data/structure_v0xa6_basic.enc create mode 100644 src/importexport/encore/tests/data/structure_v0xa6_fermata.enc create mode 100644 src/importexport/encore/tests/data/structure_v0xa6_key_signature.enc create mode 100644 src/importexport/encore/tests/data/structure_v0xa6_score_size.enc create mode 100644 src/importexport/encore/tests/tst_parser_v0xa6.cpp diff --git a/src/importexport/encore/CMakeLists.txt b/src/importexport/encore/CMakeLists.txt index de53f2266f108..b1233a561acf0 100644 --- a/src/importexport/encore/CMakeLists.txt +++ b/src/importexport/encore/CMakeLists.txt @@ -38,6 +38,8 @@ target_sources(iex_encore PRIVATE 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 @@ -94,11 +96,6 @@ target_sources(iex_encore PRIVATE internal/importer/import-options.h ) -# While the module is partially populated, unity chunk boundaries shift and a source can lose -# the neighbor includes it sees in the full module; one unity unit keeps CI-equivalent -# semantics without those boundaries. Removed when every module source is in place. -set_target_properties(iex_encore PROPERTIES UNITY_BUILD_BATCH_SIZE 0) - target_link_libraries(iex_encore PRIVATE engraving) if (MUE_BUILD_IMPORTEXPORT_TESTS) 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.cpp b/src/importexport/encore/internal/parser/readers.cpp index ab4874d5835ee..08da3d3795eb1 100644 --- a/src/importexport/encore/internal/parser/readers.cpp +++ b/src/importexport/encore/internal/parser/readers.cpp @@ -23,6 +23,7 @@ // 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" @@ -77,6 +78,8 @@ std::unique_ptr EncFormatReader::create(quint8 chuMagio, const 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): diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 583e16795541c..3b7d31411f299 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -26,6 +26,7 @@ set(MODULE_TEST_SRC ${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_instruments.cpp ${CMAKE_CURRENT_LIST_DIR}/tst_staff_size.cpp 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 0000000000000000000000000000000000000000..953a7e3468d078e96ada35b6e7b0a60d15192050 GIT binary patch literal 25922 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 B{c!*Q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..143d163bba026e27abd396fda41427fc2f5f4631 GIT binary patch literal 25700 zcmeI4!EYN?5Qk^IuA4RywMq{WAh7}oMQ|y_f*{%|#@3KS!6ltVsnZ7%V=|7gfzo#2YUx)_Lxz-|?Xny6M7`W&0 z<^F6@PtgW99#BtDr;}a}l^TZG->&FkxcK}l6oxb3yvgVLUlrN1`?;mN*DhWRW|IA& zfdB}A00@8p2uuk9-jBp*n&J}I<8m04xJ_guDoatJbD?s{Q10!kg_TmhUd^wV&neq* z@S!9PC97gCDi4GHyC?d9_eBsDi?w+OoPOG8@Ri13i7@}6? zB;`{QTUNwFzpur1O4-WNAmu=8>7-v6%5}(0Pri;YtU6{P$5&D&pRQAzn$+Rr26f4x zI$ubQ>mpmO(++Ku$F!tnojrY)Jg)miOH<}Ys_Qy0mquskv)SCt221Ct&NqRk&O~|2 zMgHTc1~;43GEb@HwmLd?(W4yN`oBv%$ByNCab06BuH}~gcH%wvX#Ep21VA800P!IP10o23K!O0`LjngB5CDM~0mO$G42U2A0*XNYsiwRi zB~nVgX*xvn^6-W5p%D@YfWVXy!2V&%797(Bfu|sF{Qlu727oIFfWX8O!2V%kmlhKP zfsqM3;D2sQnH_l~3;+TkFlhu3A0}-@F&_{Z9|6RN@v+26ATVhJ5FaLOMKK=`7#{(| zhw-t*NFXq21P~u4ZACF35Ezxfe)QC0E_#1b^1}?DbPi*N*d$M^v}RSd*bDRq^>YcY z(rJ|^S6A;^rr1wD59qAAI61%g+FJ$tz0JSTYoM$lzqj9(}~M!nM-g4jLWv0QQMIwr55ax zbBCpwrK}_`9%GU8#JygR_V)HD|Bx5nFoV=J1RVNJtbJ1B-8K2j!iuEeH%DqhV ckbdStAMx=H8)~PB&a+jHMNV3sXPZX<0zsw$3IG5A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b9702bba7a25c903a74fc71f341332855feb46a4 GIT binary patch literal 25700 zcmeI4!EYNy6vn^x8k06rYn2`%K*9nEMQ|x?1VKO*V{f8OZLe8x6iXbyG~3itvbJoe z1TOgxDh`MPhjK#|2c+D3^s&H+o;e`I0U?BPK&V1U%bW2!UZ=6Zp(pzrYiHiPc{7^% z&E#n;Z(b$mYqw5RNAf}W?Bm?uSca%bUo}BK^ z=FJ2)rSTA5GnpK=y05EYnEv%GGYprWc@bea^UbT0-~Bo-n!PV<)4O)*QZO^zk2Mk? z0TLhq5+H#oAt3jW_(~I!;`O*3Mk%gSawba4QNsC9o20P#`qkWOp;|3xSFNYDZZP;j zQVj*WWG!in3?97`y)XAAW&ae^wJ3!EpW;p2!fpKh@Q=gae);*wL4W9b5yVnoq4b!0 z-|7Cwxu|~zR})_unZ#0}-@j;OD>uYe-Kq9)(=boC8+~#Xy_?oMdQfdCx>QD0D=Jgz zGl=d9EgsoD80gBnMY40oqvM64nul8H=y`-;*|lWnCm+%3d;(Nd17SY07pPID^k;7iTs_I*+Ox0z>CO7K@Sn<0$%v zO_ge$s#M47_}F>7wEjHOM!}rJw-5g8Px-i+uAe#5_D4TbP?!<|@tsdgrBd|P)Y8*( z#Qx9Ui5)a|J8#zmtT=A5;;g$l3$9hk7YbMp>Mg&mBs=K%t!=+{s~&8@Sy_R-Q`_>r zkD5N#%GPSp--Oeu`$4DHsW$?Y9oKWSwuf9}d%M>0br;ufH@0_myVF#~D=62ReydXd z*hj(gJj+$~jaIR->ErU^2hHYx-P$=ZELQUyhH&w0uke=}?|FcEU41sLq)Y-NKmtO5 z{(;AU1V~^M0s4ngWT+7HMWVt%4Un z#-zd%ce`Ee@9!g&&%S(A#A2yR@M3v%Ijrcb^X__4O=ZI_*)K_{&N6TxKgpmE<#|IC T6^RyoLA2^wlAzUD(HZy$pN;?} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9280af68f75ed8835c18afd0968c68193d75eaa8 GIT binary patch literal 25620 zcmeI5&u<$=6vw}7n>1}AYLyB_6%rO)BDj=dsXR4v4RtvIUvM=1421es6t4~o5?!fHMPK@C;L0A-FffLo6*i^ z)=y)3^U^5U+h5_|OD?m5W29ifFcwbSMa=wO`2JBkr}fonkhIiBBm*lOkCZ`rKEHgu zR8FU`BaO!xq%)bM*AuOVp?mA<)k3oAR8wui;;9-5Psa_^Nq_`MfCNZ@1ZI(d>__6$ zN=b?9aXE}i+@|DQR0>hS#ZbE}AphRY(nh7(tQR-TE7~?1d?cxcid8e$v_}sA+!K8) z`z7W73e>h}0RbN12JYe>{(AQG__tqw`DxT2dS3+b)LSS$=I7t({>IBu{~T_nzAEuOwep42DB$Y9||G}CFpYv4Q{F69?@M7oHkESjOOKoR-K z|3y^8heOqBo~l-l!T8u^tG4w9vPLDnjBkJVV|dHsZdyNoY8{S#YM?MD1mY{76{S&i zH`Ua$@`(MP&xsXu4*DPX0oHA&+OW5rk_pFblq(f%1%B7-Dai`@UU$!H-SvZA*z4=C z_FKE2`$fmYX5HMVdONVYz8CaceZL)`Zac11v|N5fBMcHC0TM_MV0=j6ph5y95F^0&5Q9OH z1V|u3fbk)Lg9-_dK#Tz6LktE*5+H#D0mg>}4k{!-0x<%N4>1@NNq_`2fs&Z{YiiXW}X1!!^|%+CrAQkCcyY`W_0XG0y9s5@nPndm=h#{GZSEZI5RqS zB!QVH!1yrpOUwz9z?1}zqF*hRqxUCOewfiOouincY${KzYR&3wv6t~KPICWl;+)Qt zo7V4Iro>Mjk8wfYi@Lu4_Vtqe;cjoWuzLBUTi0}!S=Vg3@qbjXZr9vFJbmbtGnvl4 zDA{;hlCQU~ufAO8xww*%SY;^gA%~jOpGoaV=Hh+f5{!Xy#rA2`HrAxol3j7`iL_YA zs^rBlu&DIJgTVlYhldE|IFv(WEEZLg7t5*Zq+w_-J6lzCE8AAhdP{2cECWyQvkdx7 W&iBR8cnP>9R&}mPlIfz@4Eznh_3!=w literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..43613be5f9939ee019124eefa5b308f72f3d1b60 GIT binary patch literal 25700 zcmeI4&u<$=6vw~ob=|ays0BSlfW!(U6v3qw3xa?u#@j4hU5h((>MTowePrz@aDmz17Z-H*Y38 zpBX=mwa%**?9DIm?_#7EafB2!Xj-OkPQ~;eE8pGL4OL%?2GJ?kqB5|!cAFK{^U-o= zzNn|LMU6-3>FM;S)jd@W!|ZQw>tVS3%!>%axvyWN^v+jB((Hd`>F(tV7lOIbeo_+x zLO=)z0U;m+W`qEpM}}vbA{nj^m%~VgbzC+gS&0PaLS>Rc?u|=@wNkxa&99kHD_uNz zT;hh3RWX;9g@K39MDNpiiS3^PAB!>w@G;)P4cx@v_y0Wl{nuZ9jQc~Ki$Ip@2&Kl{ z|5o+ao{0JzxRm-*Gg8Z`LI1LuuU#WsK2z=Ex~4zkY;@}^`X{ZL8dO=5E@N=j<%H!E zknVJri^q1Kv+mR_);XN8{KUHSs9hNHbMQ=)pCb&bj#cH8%YZlmir15|Ctb@G;rLUU)Q(e+dp$LlnA_Efvw;_MYv8!fM0 z^FQ)XGF{hn*uL2=H@7@o%w1`<{_D~%kYTxA+|-DRYq_Nd$3BQhIK-kF=vkFYA_Rnh z5Qqtge~4K~NeBpmQ3B#0MtMk02nd0gz#;hihx#TKE2Sg^gn$qr0^%QJ8wdd*kVHWI zLlPO$3jrY@{z3MD5D)@M1jIiikrBNR5CY;KWDf`dA&^8s{6i8M(F=juCD4DXIe34O zQol5fX}mzc!xj+&LSWVj$oXN`cYw@a2qY8e`-k!KL$bZJ2mv85I|StXFgt54GZg}p z61YqMxy>>^X-Baa0zzQw35b7~`X!bL3V{<75dUyubkb1>Og#bd4^zLyGC?75VglkH zPK-`E3W2F7ApT+Mmsln!1STYK7`?Sv7>fT8zv+y92J1LZY?kb@Dq8G$yo!D-;dPu= zadLI_Tgw#L@pTtx)w6K=+{>>Q?02_2ODjv~-@ANKMVWQYx;q@)ch#=Az2Weo6V7BZ z^E^f4ZBV@4u1bAMXIWf|NX!w6J1|h8`~%7zid?))T!Jw$F6pjEZDUQUTChvbO)AZ; zWI1^8V-yZg-0SslaBzT-t^>L_Vlh=Icro27A5{$0MQ5YTkFsf1td}U~y$syL&otGxp<|3wlU;gfoJzL`!Zt?uY%7-oKb+YG~{=U+h>&VGGfuD8A_ist&~w&`6ub0(Ng`aQaJ z_tD+#B>@s30TLhq68QfRko`#fW>Zq)?YJC9C9YF)HY&?e!KqN2%pmvX#lmW-Ua#g? zt>?6EIQURf9VNSBEoqA^{=O%APxed7{u!uk(F_87iVL`g8~E$q-GkqL`T578KlHu` zVyU-Kdd%zJYJcOY$UlpVsV|LeYAH4FFI)NAvtm1HPo;+W<9 zyarC-v$@>trbwqymqj!5704qO**}hI@NlSF%~RFtF*rJQ(XOn&gp5%#=kd+=e++K< zsF`k`c%U7O{y;%tN(jVPJ|m4<*;`Xn&&VV8Up^;x(BAF6(+sfUxaFF&?iMV#R;^en zVLfPe{H~JhpyzkC{l>Lsumxvj1@=y3%lAHN`&g@5t7U%^PN(Syy+*Iu3Q%=i&&}H& z3a#z!M$gwSuHS8K@9KW1t*W!AHrjrt*8JE<$?`nQRrak;xwYxzT<-mL``<3@ycm}2 z#SKHac(zyi(~W!XV?j4xh$|_R011$Q5MX@ZV?Y8VFp2=>u6_;74= zbR>a^C&2hH@k`7JlEASEFg_d`9UVzv;t4Q5O#Bjaf+R2|f&J)Li}~pNNtGXF_)F(7 zW+=gBqoyOt@jQ^#$b)c2yUFTVCh!FhM9ySTh~`tqf7 zI?JqQ)xG#XDp+wUUO%2bbj+EIXP%dAyba0M+tb&kT&KBsl95+2xE%Ui=t~N>AMH_p!IPhft0^IaJ1CX-e{9Idz>> t4Aez;y{v9!!>-t`O0Aw{;0}J0LGQ`=mMCgZ08finolBBrIxji{_W=Xq{BZyP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..149d6988facee9da66ce9969b9e8026c607d70e1 GIT binary patch literal 396 zcmWG`_77jiKo|rtfz&WUAwKmS_$2YmG2)UA@is7U07{1CSK(5EQygTdkEfrj3YrQ` z%lQ6)l=!+j20H-h6d?Y<@PLtt1;{`H6Sx?d!9q3&i3BzVhMfH5e2_>0KZ6`X29@>y We~4>DhyYj+;s!Q`l>D6JR0aUag%g7S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..531ba5fdc5ed1fa1e6a98710ffe7a841662a5a07 GIT binary patch literal 374 zcmWG`_77jiKo|rtfz&WUAwKmS_$2YmG2)UA@is7U07{1CSK(5EQygTdkEfrj3YrQ` z%lQ6)l=!+j20H=i6d?Y<@PLtt1tf|JCU7w@gGFp$;u(x^F=vEW0vm&ML26=hDo`kq Pjlm%)zbKD^;s1XCVfGX| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5041ab7da8b141441af7ca8f8322a6220cea39f0 GIT binary patch literal 25640 zcmeI5!EYNy6vn^xnvgaTwV;Oxkgz~P5nM{KXb`2cu{Y7Cw%1y36iXah(=4f_WNq0_ zDO~a&R2(>QC^uAbK+3I09}Aq|%%MUY5JD&igeruzeKTIyYf}pxda}P+?aZ4uZzemx znLLf<%}X`!Y=4P=PwC_ej*)-?!&vB>iWybx&8rF!k%ZW*Dx$@G8P^@te0Kzw>oLG&jGnP4Du#bHU}I%ql7b|Hd#RC?MwNMVy#xmZCKB1-Ei=s zq#BBL*;>;UY5em{bXCqv%Kka1W6=Tve1?m-fm`_d!5_!J{qpnuVSngz5yVm-q4bzH zztjDVXQKXTTuOXpq!Vk2LI0AKtG*_-{WH~lBQbWSQp0?DIvVn#9#pd9p)Nbng`pZt zJ?-SN!m#36`HcLG5*oOMHk#xq(yod?~ZIBhnevWFZZm136>{kM{)1!G=+#nx`t&))*hUV3)UEM#?Cf%lP(( zKL?LIZl>#}9%%=oKT=Sb69Vy-Pf4X(@;24fQ?kYW&+myHv<|xOH3F zb91(bd~%T7T zvKW?Xg>6H)c(zyk%Z>Lu!iuiG7*|pz0TLhqAwd7YZ9oDfFo^*D!z41)lK=@EL4E$v zpTr1*1W14cMhMV9jNo8}1V|u8fc_x{gCYr#zz6~QhY=jCkN^q92+%*oU{E9h5*Q&s z|1g4s6%rtU7yTS$Ne=7#{!5A(CuoGA%ROW>~j<+e&o({`ji36Q|d6QF;X`6cEAN#Mi; z=pRmujvYy0<_XY0%={8_f+TQa0`w0jM#qjMF!KcHA7*}uIYAPblE6{)t;KTm{-lZz zGyJA==rfc~#fep|DIG2LEZ#&v9`Gif)^T!8{jOy~?9_D^r}eX_{H50~{RS8}! ww=PE&19idODyc`=w#&AP5v}$za1TGppbzAJM-UUU5Z8zhpk^Qd2QvdZLvm4Sak3r*H$!T10gzdoS(eWL0O`sYqyPW_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3000b3c4192e9859862c4686ad19f398e7be24d2 GIT binary patch literal 25720 zcmeI4&ub%P7{|Yp$=2?+xIy<&R4^z=(aTzcR@9Q1Ov`lhi^-G*Jh*W?)<8Q6NxIg( z^gmcUc=E7%lEs72+a7bQdeXB85f36FJ%}tKjL-Xal9}u_XzQMm?;B>`=Xu`eozDAt zrw>ob^r_`5n_uAHTRORbVnM{s)9ceWPQop`s2Ep>vFCYkJzkXHDcfTr#W&bnVbgx~#>d%hOqecQG zKmsH{0wgdc1SB7cuQVYouE*sdY;l{C(_zbo4VMG$GK0+PEBUozy?q=clof_|ixxmJ@^dB`a6ECB7=B+E+Q-DY?u2*glVJL%*ZQHuMVub#H3*16F1O73tc(hM-OS#QAJJqf>>@~ z54-S$pc>f}&!$M8^e4?oV&(_bO;fhez(ssImzmuX=`!l_U>N!g# zok~}aT4)}21-rcQ98yNnoX0oc|2eqj;dorQI8+8#9~vl334!>|r$nih+;z3|lssbp z=l8_++k3sYn?6=6PN`PeaPk%$t5zr$vEes6URO!B-}5>alCG8XHSn;+NygQ)kfRv)S9Fw&g#<_-Mu71l27@9AkiZB5#)lCcR7ijXVgwi;VlXI@010RUC*NwS_oI}Q&~KVX zXr7n%u-Qm}1g4z;^M`4F0=Rw>7*F8z{9(L5M@WDKriTFYhw0gCu9O7MO5mRS=eBBd zXProY5+H$zC&2hH@mtIVlEAqMFg~0c9Ve2mkAnD{N`0!iT91Q;LAjgAvZVB!fd zK1}=;bAcpqMgoW7w-)o^`;)3ZO!Q4>6f=}f)rnQ3DP1l03SPlUec^RHrt9RI`d!O} z_^Ho5T+;WV&d+R`tQ_j=eT&YN`Din8-Mp^m~rFW!q@vd<39{}Tu?SpXafg!EtD@EtFNVD0LDqj2u zn<`J-@Aq+VaDYHQ2l7!Bi$#^<#d7F=)G#m?oQ;yYl})>BzbL(WmVqPuBp1Ce#~Wg( ST>zdCt2&mYkabRM2L1s#0Ruq* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..39bf9153d11f9811fa0c56e7ed111e0bbb9f0057 GIT binary patch literal 418 zcmWG`_77jiKo|rtfz&WUAwKmS_$2YmG2)UA@is7U07{1CSK(5EQygTdkEfrj3YrQ` z%lQ6)l=!+j2Im3k6d?Y$jCcT0S`4 zoy(gEY)j=4x@IytX!WHohGFX0x6CkHe)dI#;p|th$@T8%dC}asW1HUP^XG%v!FDW> z011!)36KB@ObG$mkHklskQ6V+xF8yoL#q`(Yk); zeMwam?2@&tEzO@B4oo{r1bxKla;0?~5RodJCod zyzz~0Z#)^bPh%zVnUPK`Cyv_}t!(8Du^rx1eJi?*oV`62a7Xu-#ui$rqXS=Zm2NG* z-T$~tT6J={wHRZ(a-tR?4AtkVk&XAcY+rV*Tt@c4MVXDOG8YZB<@`WqCXFh}s7Rg{ z%~kAUPi8C7HN&$fd+#J>en8bUW$79?gHPu&v)dw_MOAJHLtlZcUJLOVd=&Zk##6ak z11eXy*l?eDyR`W{Qbxg?$JgKg*;{bKMp609iT=3v69t7SArPPWloTpOZ$k||B}?p4 z?h`v`?swj)2Uv65V#V2Xa~52yk}njn8Pr>TTS<1%@msro?Rq`ffwQ&-d#|?RdmlA@ zY?Q6_qQ4ENRriBVty6CVC_Ap_W^E6-#_n#dw2fDvR6>9HT_nl{;`jO z<$0E?>>I6OW824t%#~*IVW)Op42#wLmLXg`+bjI###==pVQY zNPq-RBS8Of8X4+IfCLVqet+m+VuV2gBtQZK1n3_IaIioEBoHG&{}6*gkpxI!fB^l& z01g&NfCOR$=pSM*D3SmP3=p7y7{I{-36MaH0R2M@21ODe0ZpLyR8zelrKE&@)3lG~ z1$hsfi3CVs+6i#~FzqXV<0paB2|RfJaJoHfNPq;UhXD5v(=*o`DG7{9;GX>Dwn}rO zHl#fXkif(fpnsV7Ddqr4U~B^P4`ZWaLlT&H0`w0PKgAp%35-pE{$Xr%Y)AqVPk{bm z;-{DcB!LkL97azq=A-u~ReYHKlg_@+P&ySSR@J6-wAgca6}`C18+csD5H|I@mI<*_ z$2~luzl*v)_wvP@^X^W2X?5w`dzUZhD6^hb_2T~oV$CUe-FW!W5l1qbc~+wF)+AnU zM_=o5o#y081Y;GUxQ#SQl7A?eP>usRRK#K_O7LPibv~##R_EQ#qWUUZcFBH8aNt Qz*C}C=dwi9&Wg^!KjcR+Hvj+t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..51e5731167d14df22b063d6541b955d14660c6af GIT binary patch literal 25968 zcmeI5&u<$=6vy9sO-M;ZE$AVFNUT6Y5nM{KXn+#M*qdlm+iR^iiX{%S<6 zXJ((q+RaNf?{0lT|DCM#0v%I|3^I(FzB!BO-&eo8V_K@b6nA2BZp1Rt!ulOiaLWg$ zyK@CIMcZ6?Og%H5PFj7bicysL^(`}smY;o*qGm!ml8I+3k7t;UJYN6KV|vahe?*Ne4UCAV%rqjZDL z2a;42owB{GEG+u>p6ET^FNyurB-^4H3h5KNLO1C){eAzB&YKCWz_ugrm`GIPt$xGLubM)z4c6OVk^Hk#-!cbSDoLUR|3_gl{a^p!Z zYd~_j#fJMVIOWafDPt7PdHVYMKl=-A*eEWaJJp}`eyV^lB?R=D&v2ny@;7AY8D3(K za-TS1Yrp$;BcwIgD^=Z1FK?4)R|~}=ZHA3@&=JWAyFq(5sNZaaJLImdk+WCd3H%RR z0c}+5^-{1+Zo3hL-Fmmt45{LJzL#@+$~Sj+>)k*#@q$iscVE@JEy-S?O1%}dtBsEW zD%!qpdt%>gmzvuFUCLf-wH|hA=h?7SD{L9e#drMTU!LCbh!#|FQCC6+0T2KICIJ6{ zWdH&oa25ghhqK5)4+0=?MDqJX{SpHV5C8!XNDzR3NZ>#L1VBI|0RN!DfCvI0kRSm6 zkidZg2!Mb_0RBOP0TBd1AVC2BA%O!05C8#<0Q`dn10o23fFjU;swwYBiIh@rnhwys z!0%xrfdB|hI|1w;rhNrq{2*{Pfd}s&&bCJl2!O!!5WxOndgdA<1%Xis+~dF8mNYkN zL)e1=2uwTy_=ky~Vhj)j#wGy&Fg7|g1c8Ys0RJ%YQ;Y$Ez}N)fAI3(9h9EHU1mGVg zeu^G{bPjxm=p;_8)Miw)*bDS3_2VjU(BmqGu&Lg)OtGCD z_vi`rUF7=Q%a`-+J3F1F)ujvXUcIEE%=&iC*Z&E`np^gJdic;0M>3juj-&C`IbLs9 zT^oFz=Hzn(V~J4QA&bhKf55rNk&E}3OE?6^XWfmsZm3EX^KQ|*&C={@MuHdrk4eH4 z_j)}#I5?n)j{`m=Vlfvvcrl+UPbyB-1#hz?UuDZFJ1=oAYgr;||HPf%=krZgR39UH PinVesa{%ug>kRq_!X_{W literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fdc399cea15bd9e9461ff95c1fc5589cee7f998d GIT binary patch literal 25968 zcmeI5&u<$=6vy9sO-M;ZE$AVFN~}Oa5nM{KXn+#M*qdlm+iR^iiX{%i@xe?1mOB=UD!7U%2 z?kp6{6zy>3G4;%JI%)NlDn?P}*LTb)T6z9uilX_i-{9+=FAA)=e%mqqOXtpo^T~E7 zfdB}A00@8p2+Rlp-jC=bO>xr8x*Wwx*NJS!X)R84I#MQclznqGzfr8!D!C2&Ii(wR zK9r=Q=#=dhWnt03_e3A?eo5?~CD|6uQAnTCMY={e=E!7rE z^?ChU)!ukIZg0_Q>T|QslH=fme2m4is-iL&!TN=Q-it`a4zYl zeBdBm90-!%Gj5*M>Q?o-D3Z^WkxllwXkYQ{e3tjWC7z8dJQq#s@cALnj72r7P?hrn zYp&1%?elDfs%CWdc<-I2xgV)!n!I!kIz^u?WaoESI!!gcAq;gT%Bi)W&*0<8gBwqB zSp$;GEjHR`!6|RONExGOF48wY{Mlb{qegN0)C2vX_Xi3HGeSV0`3x7TC4WaxJ&N(Iyw9G-N662 z70_nI-Y5k-q3Wj2{F}Ch+k6!^!rj0Ra%09Rk=t%+6e6q#!UZfxG;d+maT> zZ3uf10D-9|0RJ%cQ;Y$Ez{CXLA0|eJh9EHY1mGX0eu^f5S_${mD-Go7JG(Xr+!@JO?pzr5H{7jmMONA z<1Rg=zKdL6c=bZweQ&q3ytaJi{Y&Rnlv&@d`T9SBSa-{QPY)kD=19gf&v7)~I>+nn zs%wLRB-vhxb(vX&*X_D|gDeLml2MfC}y QXILxe3P;q=vCg1>09uzYzW@LL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3bf730d667651d7b740c406497e9a54a0fd04aa8 GIT binary patch literal 26052 zcmeI5&u<$=6vy9sO-M;ZE$AVFN~}Oa5nMuRK@bqx*qdlm+iR^iiX{%bWABS$S~#x=G;vGmiz9OX{r8ld=ZmtBW@EdZQK$8dp@|_ zStyz*+F|7}_04oTY4xS*Mp5RMH_RwnefoKdqWQ00=JTD;i!8Zu+cEtsD=XoA(hofl z009sH0T2Lz86m*?5q+d7E_z>YM{&_*(zfD~iwm8Pl*t@rUtKF~lVb7q>t%!x=uIgulv6r|N8S!_lEwY_C>^&Y73>V zdE*=9Z#*9RTeOz?%&=0csT2RQov+7QHSx2Ba(le#rcML8qkm(z9kbRAJ%*Y!>I8_run*F}+xMQBYn7Wh{^yO8BQb(v@Q z8qaTwx_rFLvu#nGs#N2;$dYSxK>NG`LZum95xk#I)7%eKH%-1B3_3@jEM(_*xOJZD ze3KdKEE6m1>tU6XMIp7U0jcG-9ld_hscbz<8KYz_(%0YrF<8W-Msfe#sr=;Xrvfx) zgn&Nt8Sd1|{-z8)!%OV{+$T=hKIpyK3~Al<$~AY(E7;`OwPLA6TVb;kbfx8ly`Zxf zG_E(pU2@mg$=Pr02L1=_fHtf4Mmg9ax6=&5UZdA+g;aGt-^)8b6A=! z(UR(3)P-n+00@8p6F_{xG5`S(IEw({!&zh?2LTW`BKiEGK8XPa2!H?xBnTirBygYu z0wACfKzz_(pa}vXkRX8gkidZs2!Mb_0P#VCfhGumK!O0`LjngnAOHdy0mKIl2AUuM z0*b)kTTOXBN?IxPr0EdNbNn1O3J8F}tP{ZgVb)gw#t#B#6L|3c;cR9;4c4iTgt+?j<5#-5SV%bh!0ag#TXz6OiTdrVPbS}1c9k1fcP-=Q;Y$E zz{CU)A0|czM-Z5L0*DV&KgAdz2#iVKDE`)BF@Anh^1}?j=^Vxkkx8Cd(PmV(*b8)- z2DzFy=~0zK*i_G2rr1ticj+3vyp0n{bIA3p1otu1~<>GS&W64n5C5tLtzt6SDnTz+COE?0?XW4tPZbYTJ z1-Il$hV1!VMv@ml#3Jd5`~5y09v)J}uS0%G#$xVr@?t(zofMpii{4gQ-paO9abDnB f*0N;l{*fv36{#S%1OcVEHmhD2#+>0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d77ba95267629e737ce3c51656d27f119a7bb247 GIT binary patch literal 25760 zcmeI5!EYNy6vn?@n~*jUwV;Oxkgz~P5nM{KAczv#*c-E{?X}h$#S#ZF&5~M5Y|C~^ z;FABK;=qYRxuJ>!Qf@u^Sl|R_4i(~n5JEX1R3W78o3Ynkn?~T!ll{$Vciy~tGwb=y z_-U-&yi~Hz_Lungl#H+71SSj^hS@h4G5&k{`&+T3F0T$ciAin@WMCzKODUw~!_)1h zT+GCdRGy$0i^s>U?&@L?Bz}D>76fb0yoexJ{N^>e-u^l#n!_*bn0NKkrN-iTJC;a* z1W14cNPq<9gn;Zvq9ZjWMaxk+7^J99$;m-V4-zf}+GGK#H`cTHLbY1XuT4dSCWS%Kj;+ZP5Z6_zYKY12^&a{Xb5A`{n0*qxR7IB8a8lLg_va zztioFCkE}4SU0~ilIEHjwl7+liYvDLJ=HzKoY+&TZazJazV?dlR4#`j-Fm1y8R^oY z?z%B-n+w6!!d~BAje9_;5)2{oCZmnByHc)n4&&}8#vdz7{TG!W2T))%YJJ9t`OJ%R1TxH0>@ClKLGh9#zX=nKmua~=pV*#us{MN5FtSS5P?CF z1V~_v0R6)l4i-p&1R@0JA0jX)k^l*e5ukqMG_zZO`!i&6X^Q~ zQ$62|yoCCKyob$10wgf+1h{{g_Z7hLlfdZ&9=?A#-JUfhKmzkafcuB}nQM-e1g0f$ zNB(kKrKM>b(w+oJVCD(XKg|3TbATjpW&-pNXGX_{Brx*?=pSZ&ia9_MI5PqIhclyN zLlT&I0`w0vKgAp%2~0`gc<|I>dGP+EiVrh-(mC=ON~hw)s@jBp!SW(rML!PlCLY(Z zeq;JwOH=IBaR=x1XHnPZUVc66yt~_3O|M>j@9Je8W!AH*Ui6my(!XS zI-!CWKf z$$zkT@Z@2=$>KriZI3ywdeXB85f36FJ%}tK%>LfXq?znCXzHGl?;B>`@BQBIz0Ui* z$&X*i^jFP0+h5|}OFFrNVpA}0UHet*+U>-K6ii7B-a$-v6SO{I{Lr0P;aF<2ZQ#})~Y z011!)36Q{?5RiBzzS4x0csnkKQHkr6oQ_I1DmWi%lLchnxSZc8)@qg9hV_)zjV2#S zs-tL^tu<|t#)C1@Rf(6B{gY5((EG8(n(fBkjC%!V$iM7OVe96jHUlv;xQyr?9?UdN)(Vjyl;Q;&aW!0)@TPzzQ`BD#xk;KA} zsF|idv5$wWQUx&S4-}SwZTRt``)<((SfzxjILATy*Gy_x| z*K>2WhkSEyuio|b5ZCWC_YQQw(^AzdsMK42yW04~N73>;%T@NxcB#4J<5K4RR_niR z?Xnn_YK3h>xOlc#{M(I>Ji>}@J`-0`CIJ#40U<#Dz{h|DNMI5H`iDtms3!puID-2A zp}&a{1__V=35*e-e;C8T1__Wri~#*Z31@NNq_`2fx)ku>isAsCG?x7BQ(#;d)O=_Kmzklfbqk;KLK1n z2}~w%di*fio;@T$0`o(F@x%P=HCIXk(-OEX|GBNo(zF9P~TTp$UYnE?I6nbC0|3CuhJ`iGg{VlI#brX+9_{c5osy+5h) z!;F6E9Qh2TQ+Z-lZ%Sv2y?|FS$O*iOM|GZDQ@?AO5Ic3;#$)Yl)ZjDedv@cna(^X*?8-cueYnO4Y|&9@gyU$%23=v8fB?J zlG>Hb#rwh~xDSjcx(}ni`?_>9?-bn|A}wZ9DtYlkEGj*5zu!l%*Fz{rPY#u_SelZ& xSWaD!D~9TVyH!%RvTc{`7o}FuGH?e!%cOVZd{Y$FM}Q|ptIjn^KAjVtfqT37{h|N> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..200caa296aab16f46a57f00a9c77768a4d8e4e0a GIT binary patch literal 436 zcmWG`_77jiKo|rtfz&VpArlbe1047y@yp>fIKed`VArajc};I zFYM##=W2zf1l_S*419mklmNLfQD0ZbU@ag&1&BW|JYZyE!Bjheivc2PgOHoR#Rw5| JhKc?E4*-g55tRS{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..472cacf16da0ae184d867ae77044d649544a6e96 GIT binary patch literal 436 zcmWG`_77jiKo|rtfz&VpArlbe1047y@yp>fIKed`VArajc};I zFYM##=W2zf1ko4~~g5p#x# H{r?XDg8vbL literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_instruments.cpp b/src/importexport/encore/tests/tst_instruments.cpp index 12ec42b909067..6e5124a2dc9ae 100644 --- a/src/importexport/encore/tests/tst_instruments.cpp +++ b/src/importexport/encore/tests/tst_instruments.cpp @@ -67,6 +67,23 @@ TEST_F(Tst_Instruments, instrument_name_midi_tiebreaks_to_acoustic_bass) 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 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_staff_size.cpp b/src/importexport/encore/tests/tst_staff_size.cpp index 8125a7e4da87d..0a9aa80a23be5 100644 --- a/src/importexport/encore/tests/tst_staff_size.cpp +++ b/src/importexport/encore/tests/tst_staff_size.cpp @@ -87,3 +87,17 @@ TEST_F(Tst_StaffSize, enc4x_line_staff_size_hint_size3_sets_100pct) << "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 index f8cc52831e583..2c6dfe5628e1d 100644 --- a/src/importexport/encore/tests/tst_structure.cpp +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -177,6 +177,23 @@ TEST_F(Tst_Structure, key_sig_no_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. @@ -1026,6 +1043,27 @@ TEST_F(Tst_Structure, malformed_truncated_at_byte_boundaries_does_not_crash) 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/ @@ -1063,6 +1101,30 @@ TEST_F(Tst_Structure, malformed_truncated_at_block_boundaries_does_not_crash) 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) From d59dafc73a21071be0bc9a9ee4e66f5c972608bb Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 28 Jun 2026 10:00:00 +0200 Subject: [PATCH 30/33] enc-test: add measure overfill and underfill option tests --- src/importexport/encore/tests/CMakeLists.txt | 1 + .../importer_merge_voices_non_overlapping.enc | Bin 0 -> 25968 bytes .../importer_merge_voices_overlapping.enc | Bin 0 -> 25968 bytes .../data/notes_stretch_irregular_fallback.enc | Bin 0 -> 26080 bytes .../tests/data/notes_stretch_rob_rest.enc | Bin 0 -> 26118 bytes .../options_overfill_irregular_emitdrop.enc | Bin 0 -> 26024 bytes .../options_overfill_irregular_facevalue.enc | Bin 0 -> 25996 bytes .../options_overfill_irregular_twostaves.enc | Bin 0 -> 26108 bytes ...ptions_underfill_irregular_empty_staff.enc | Bin 0 -> 26484 bytes .../data/structure_overfill_recut_tie.enc | Bin 0 -> 31378 bytes .../tests/data/structure_page_break.enc | Bin 0 -> 25912 bytes .../data/structure_pickup_casea_volta.enc | Bin 0 -> 26138 bytes .../data/structure_stale_tick_by_column.enc | Bin 0 -> 26024 bytes .../tests/data/structure_system_break.enc | Bin 0 -> 26584 bytes .../structure_v0c4_irregular_len_reduced.enc | Bin 0 -> 26254 bytes .../data/structure_voice4_rest_with_notes.enc | Bin 0 -> 26100 bytes src/importexport/encore/tests/tst_options.cpp | 1029 +++++++++++++++++ 17 files changed, 1030 insertions(+) create mode 100644 src/importexport/encore/tests/data/importer_merge_voices_non_overlapping.enc create mode 100644 src/importexport/encore/tests/data/importer_merge_voices_overlapping.enc create mode 100644 src/importexport/encore/tests/data/notes_stretch_irregular_fallback.enc create mode 100644 src/importexport/encore/tests/data/notes_stretch_rob_rest.enc create mode 100644 src/importexport/encore/tests/data/options_overfill_irregular_emitdrop.enc create mode 100644 src/importexport/encore/tests/data/options_overfill_irregular_facevalue.enc create mode 100644 src/importexport/encore/tests/data/options_overfill_irregular_twostaves.enc create mode 100644 src/importexport/encore/tests/data/options_underfill_irregular_empty_staff.enc create mode 100644 src/importexport/encore/tests/data/structure_overfill_recut_tie.enc create mode 100644 src/importexport/encore/tests/data/structure_page_break.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_casea_volta.enc create mode 100644 src/importexport/encore/tests/data/structure_stale_tick_by_column.enc create mode 100644 src/importexport/encore/tests/data/structure_system_break.enc create mode 100644 src/importexport/encore/tests/data/structure_v0c4_irregular_len_reduced.enc create mode 100644 src/importexport/encore/tests/data/structure_voice4_rest_with_notes.enc create mode 100644 src/importexport/encore/tests/tst_options.cpp diff --git a/src/importexport/encore/tests/CMakeLists.txt b/src/importexport/encore/tests/CMakeLists.txt index 3b7d31411f299..b5311487369bd 100644 --- a/src/importexport/encore/tests/CMakeLists.txt +++ b/src/importexport/encore/tests/CMakeLists.txt @@ -28,6 +28,7 @@ set(MODULE_TEST_SRC ${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 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 0000000000000000000000000000000000000000..67e8bea687996fce996ad01bf947701cc9ec04c8 GIT binary patch literal 25968 zcmeI5&u<$=6vw~oH6bMtwV;OxDq(?yBDnNN1OZBAV{am-{-yOsvBUvPvjj`Y+OnNe zxP-r;0*Moc_F8d3%B@EqIPl{HXATH)KnS565ULQ8d2hU_*KQ-Q%gO$ZwL9;2 zGyC)sZ(b^y($<&w_mYe+;RrEUu&lX$cM;>ir@p&sCyl-wUc{E#3T0qv{iYgZ@4J5-{r1bxKMuyj#6^&vCWJEA zy#B2jZ#^E4PvUayb1NBJjUA6KIO)o((swkbx+S(1Ir=dbpt{T$TWFz<4t%M#e0;Y5 zc8jW9f?ZCjpyddkgl*?Fo=c@-<x!o|yZ`M=!g$U`g{^=#Bg znFL6H1cU(n1GfPQkiba<=pRlZLpuqOz#+`sj=g9-_dK!gDOLj(pz5+DIXp#QC= zJ|Cr0%sgp2K=YhDhs{C)Brxj)7(dMV4&eMr;A8?1jvr3$&lVCOf!QIz_+fU|nlmMV zaS7a!zueZcFz!J1Cjkwd1AFSVY0=Z!A11*DsSLX zlS9}x&sxT$pT6$kG4omU{LG6lXG(AGw3k!MXWqJU!DN~BoT?Z7ClG6;qSuY44;^zR z<8@C+P6xUCy&yJjr0J8H(FTqA2zIQoEA5cu%+lBVatSy%)BPG#NEh%DXp2 znolJ(dGY^PG(B;*+r`1b0ebQ}ke6mG7FCiL%VFwa!?8K*ZWi>TY~_l%7o^s`teLfc al8fGz<25l<9s!;Zs~)S8z&kBA3;zISK{5jX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..912d022be0721c08bc299a725f10bb1f38718ac8 GIT binary patch literal 25968 zcmeI5&u<$=6vw~oH6bMtwV;OxB4L4qBDl20f&itmu{V)Z|I&J+SmFStS%RfxZP`vK zT*6;afy9YJd#yMi<<_GQ9Qbj9GY5n?AcRm32vrElyf@y|Ytsnga~y!8eCy(HsHI6@2-ENgCHE@J$*^tU(dq-n2&7qO+bLK#?Ezo`ltdH-^I zA!o<1EuBZ`+41dG0|HRFRA_G(6DF@0Y1ScT*D3gb@%t9Uw{7Thv9gbxCmltLMU_1 z>))91)}!J0BreB3vy!pZ*zx$Hlc~NUw&O9?Ezzaq7{pY7wq?fHLK_Wq;Y+RMqvb)c zwy2Fuuq$a5v>f5%<-td}peE^btdjoF^gtpqeP5l~Wbe!I71zn8B>pYRYFw4IXrd#> zJF+rK)KEcH>YQkxgjib1~O(Z zgp2pU)$xs|weA6}^%fhya4uikd>RR>U@zk9@BSEUxN)PfedeD2`0Do*6lR1#bmbG$ zs205qoq9sH*n`|B`JlDmeZ3K2t?U-799_(8YcZ8QT^%C6^T@*c9y-Q9ZEH$z;%)7;%R{pFTc z7f`9U{C2hRfscaYd5)|0&33W5?c-wV?N;l*F0K0KVlB622^TN#75;RiBM-4;n#)lo zWfC9(5)cCP58MVMKmsQbpno`t4D}>H0*5faAJU)1SV({bNMM8j{lf?jHb{U3A_V9k zA}}bD011o`pnn*_!3GJCK!gDOLj(pz5+H#Q0`w0fIM^To5{M9>e~7@KNCG5a2n@c} z)aRpAikT-(hiIOW=df8wfCOfp0ON;Q-vOLI37kye{_(@f_Us`65||wVj2~uatvOQ? zI4yxY@|W9M7EU{m_9Q?8Q%`{YVd|Hd6C{C&3D7@GjE)0IVCo6bKTQ1+bAlu=F#-CA ziP3Q&2~0f!`iH4sVos0*PD$V}{MKSIe11|hbq>Gj9Qq8^X`Wc^O_*%4=kNjsd6hTt zu*o58n`bR!VyCY=c*J}bJwNsQOWE=pJDrvE%DFeMTr^o`J*VbH{|Urex#abt=|fLB zlhc`JBpYvC^7VGjxgqCSE}mpC)(piRBvF$3J*i#ET)Zb-f-x|j=-v(c#=1;1TQ0aa zM4C@0G4k^3O@Fds#DU b|0ox|E61y%s6GTdCR#mKC4qNFbQb;w3g$8b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7fac03a5a7aeb9f3c151ab956b057edc3cefa69a GIT binary patch literal 26080 zcmeI5O>7%Q6vy9sO`4L3TBU~wDq(?yBDl1{qOK^BjlGFBwY}DQtytm!rdfieWNq0_ zDZS*vx#|IN;Lu*H9*}bD(FYEET!1r&3UNRPp&SsZ5YqDAcpZ;JBe3bo{*SdYZ{C|X z@%(0XGKn`Ym7KHr1^s(U#+T@jVq}nE%=MeI82>Hv-5oQj>dR3lCYMGe6D_UZ5e0ib zKHXc$n=#sA<019Tczm?gm#P|uiC^9{!!Z5)%M^z5U%k%xz0dQkxpCJvy{i{41oNYQ zsDS_ofB*=900_(o0X~oDBaLy=>$)69N!N*-j8Y~_bS6|y<|uV}HMd@<)ymm*>p7(x zc0QEEP_Ro@TD3^hKhH$(@_9+xKTC2fnxlX|p(}KqZqZ+len0&6=bs)9{YjmRh+C>7 zlB(&s29=m*T5`rXrF$kC-;8O%3Xj&!wc> z=lfr7Q7Tt@RL+PX>F~&QQBbeywui>RuPSG~jBfij#x@1%CYdXgy{+~6u{p4PO7RuS z>9%xyU^`HLhUOQBGEV7qvT?${>{_`LpSO!V``38xo7Ca!eO?Jks!^FLT;^GGjrM4l zS4W^M$5#uV_2V@61Jz8EZxw@1(`O5*`7M^tP>pXuL*+zSwf599dIA}*K9!`DH6W$j z(n_gidAn47krGD1T%@nR|D(UaHM=gJKB^3QKT<%L5d!+mC%956dK)tI1TV4wbD!8j zYp?rOBcL_MEmoYWo3qHZD)~ZzszIaecSN#-uHWAA>(?8>HaTl+Wbf9ueea`|Pa9=x zz36X|({A`dx87|u11dYN=Von>a?PEcde>JjuHR|y>?ym`lHwwj>n*=sY25UwV0oV9 zO8aKJ*xd4ICG}pb^Xh~Ji>q^KV00JPu1h79~8Grx? zoJ0Wo!%1YI2LTW`Aocqw`AG~31V8`;MhIYk7{P%G2!Mb_0Q-Xm10o23zz6~C4;-W`_XI53@7Z7%2#hOW*A*F4M7c2l=aUFH%T1LLvogUEKwq^dcm;ND_sK9i8(#ZNFvc;a5K zNBjHx6!L4IUlOsHs~o(TFIA2z2I{<9Ey`Qjv`h9YT&iH&9Lf7f?(`8~Z?K~B6wz6> N$(81S;aS!h^f$dXKsNvY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a939b57ab94f41d801c24dd0f0570c9111a5ee8b GIT binary patch literal 26118 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..71746678593f2842d424c35e1294cdcde4ae489d GIT binary patch literal 26108 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b41866258ef5705f43cfa3d9bd758ece2696caa8 GIT binary patch literal 25912 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 ORvhab-aO4Zjs5{nDoS(! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2a7bd062082febe1dffaab08ba48ae4104a24dd3 GIT binary patch literal 26100 zcmeI5O>7%Q6vy9sO-M;Zt&X%_v%Y;Z=&F>95c8`OfEg)?B~snEs`+XT#}( zerSOJ2!H?xfB*>_ychP5;~zUFQ9g^nZ?ITQo%>eL@%M8r`J7?*Fm>+b=)g8~T&l7ZLYV zTPStS>)$GW~n;LRes+|54ze@~kH{@+vx-E)i>_T_6vBSUY*;a0AWvXzUXU!Gbp>19IJ@ zJh5s$4%X7+*1W=0ky_S()N-3ErFP0YrRvL+G79D_ee=Vg{Y9_Yb@9Z*#^CA?6%Zzb zfIjmnZd8i?stldun(;XIi4(SVy6-eXT6Vo+#jSdlO`cuJ7YbAj8||PYk`s1=_Eu28 z)(AJqU0x<~2e{1uEBDLA%np5m3SQecO}%&33W59?-eW`>oc0om%;gP^{(GB;@;!UwGi@o_jQ> znrCz+WDo!W5MTm`4_F2u00Ku5Kzulg4D=uX0(X+Q=65D)_W zZ#CulD3KECNz);jSNJ(>6c7M`Nhg5)!=$eOj2{G!Ch+L}!_oa=0Ra%090J%sOwL?m zq#$ry0(bc@x24P+*Ae|e00hRK0OG^gPca4v0wWVZd>9!W96@002_Qa<{S;$>ATTlk z#D|g5!4U+;o&e&**iSJA2m;3>uor)8F&jTWDfwZB-*gURhUg?utXNYjTkL6igZjCh zS2_2wzuE*tIiERPjc}&gRx{N?ogUaTz|l|$C-<_nM-&GjL*8w*mlUInwDGe zZn89;O-b_N|FKAV;$E*uySuv-@oSe~lChYZoV=J1RUcFg)OoL3l((|xl$_VNmbEO| by?^41-sj^jR#cuMI>lN!7A2K7>kRrE1@=FG literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_options.cpp b/src/importexport/encore/tests/tst_options.cpp new file mode 100644 index 0000000000000..b93bf0bdb280d --- /dev/null +++ b/src/importexport/encore/tests/tst_options.cpp @@ -0,0 +1,1029 @@ +/* + * 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; +} + +// =========================================================================== +// 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_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_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_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: 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; + } +} From b4003f94aebc4cdaa93687e5eecd9b1f1876e5a3 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sun, 28 Jun 2026 13:00:00 +0200 Subject: [PATCH 31/33] enc-feat: merge non-overlapping voices on import --- .../encore/internal/importer/import.cpp | 161 ++++++++++++++++++ .../data/importer_merge_voices_tremolo.enc | Bin 0 -> 25968 bytes .../structure_merge_stray_voice_rests.enc | Bin 0 -> 26096 bytes src/importexport/encore/tests/tst_options.cpp | 64 +++++++ 4 files changed, 225 insertions(+) create mode 100644 src/importexport/encore/tests/data/importer_merge_voices_tremolo.enc create mode 100644 src/importexport/encore/tests/data/structure_merge_stray_voice_rests.enc diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index 9e95ff75f73c9..174062220e0cf 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -165,6 +165,162 @@ static void applyStaffScale(MasterScore* score, const EncRoot& enc) } } +// 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 @@ -225,6 +381,11 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp score->setUpTempoMap(); score->doLayout(); + if (ctx.opts.mergeVoices) { + mergeNonOverlappingVoices(score); + score->doLayout(); + } + // 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 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 0000000000000000000000000000000000000000..370af3150a7f57a67ba3a8e61438e194a3b4bd9f GIT binary patch literal 25968 zcmeI5&uhs}63@)`$Cz@NjEe0O> zYPma;H*{L##$Drp)jC2A8r6-p))IvA)fQz`t$excv5i@aZeRO zscYW&RgKr)4#%hHy#BqG(iil-@kKLRIm>+qW2!rBOY_l-sfeV`Gp1E)QJp&EaVhfk z(cat5O643G<%}pIyYx-ic2eTGOhzwfzGC|YK7v5rS7tWa`(k|AF>`5-e~Y{tS9mQN z)aK(VuS|-nRHh1-dA3}kE!yPO@>R>=>T&FypsC-eY8bqAH9A1w&7`N-SUX5nUE+5+ z6J^z2VCA*Q-i;@vTwO}J#Re~)w@NGTQ&KA!v-Hb@C%p|f*eR?Zcxm6e`bz_p2_X<& z`6O2=MR!@Ip5!g|f9?~@Z*Fxi)O}jConpmaadIX(W+h)J(28Ggd2P`wzvHzwyxR4; zzfShzB3YZYbz8(XT~ zZc1^I%C)A~s?@*qs9?IT=}7-ZtJqlc=v4aiX7j%;t@!6+HNUDc7uRwN&z$JU9hy_s zqfsMt5C8!XU;^+D*ajc~0{anwf7p)<>>vOFJEVR;BtMBkKmY_lAVvWGA%+7L5CDM) z0r-aq45%Og0x<&c4>265fB*YPvF(@!~Xrz0sF2CT?;mpOG%*7u7D-eId83!K0^%QlT(0O;~EMF0Q* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bc8121d3d2b94544044414e4ee12ffddb76f02dd GIT binary patch literal 26096 zcmeI5&u<%55XWbCO-M;Zt z>%7l<`|=WRCY7AM{uTYRq!J5sNIGexX*2!itR{ZXe1F?WDSI)zh{2^6>O>2xx5dGc zkCwZ0c|)fSt~{ikkx0Z_eXYzONdEe!5d_Q6zDPkZ`^~F-zWYUO(G21(B5tX+ zQ0kgDzfWDr*H+aAPzVh(Mv&4Kv4zxj= zYUsJI4?Me}CkUk9LUz13Wm zzN#6WJ-qi%(9DliGYr0UG&)0{&825ISUXEKz9BVrCd#V0sD}43oCY_(lrjgTlv{lC z(s`>?eV&q9!I-CSfB3UM;YS;V_L(F9;Oa*XC{scpI`T<2Dn)lqx}M}I_FwK3%Wv&; z-)i`@Vmrl(U3GFMIc6naC{WdJw7rgKmf!W-TVDM}!`~!(WreKm`ljc8)bePpY_1l) z4YJz}&+pc|jiyg!+i{(&wN>wWD#Yq86&3=jZ;2m!1Q5g1TG00d$Lus+0azyJXd zh!DW~5P<;|1VA800P8~x2MiDZfd~Pt4-ptpK>!34f&QnO@_dwNx_Z)dh~@=;4jTdj zATaF&uz#5L8G!zSz{vz2y?;2lJ!(Jz1g3`o_7BrD*61k+oR+{n{>yEVxzh%sJqUon z#1p{!F!4i-4uZhg1h77gjShhzF!2PiK1}=&qk|wYHUX>;W1~YL2uwTytPc}E#ONRh zoRYwP_^HKw`23_~>KuO3Ib1WuCwXF}HmS13o~Kvnf_k?#dQ#;OHq^70I=7S8J$g#j z%K5pMU(4BVZ*~?li|60Dc3EYab(Soc z9tHf`sHBniA$NwlG*zwzUTu!-e5=N38HiCm1CI` Jh-cZS(cikCKu!Pv literal 0 HcmV?d00001 diff --git a/src/importexport/encore/tests/tst_options.cpp b/src/importexport/encore/tests/tst_options.cpp index b93bf0bdb280d..727c2dd0a400f 100644 --- a/src/importexport/encore/tests/tst_options.cpp +++ b/src/importexport/encore/tests/tst_options.cpp @@ -925,6 +925,18 @@ TEST_F(Tst_Options, mergeVoices_default_off_keeps_separate_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; @@ -937,6 +949,58 @@ TEST_F(Tst_Options, mergeVoices_keeps_overlapping_voices) 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 From a29c5e5cb0ca4a358f82b957f26e8ef2bd5fffc2 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 4 Jul 2026 10:00:00 +0200 Subject: [PATCH 32/33] enc-feat: import page size and margins --- .../encore/internal/importer/debug-dump.cpp | 7 + .../encore/internal/importer/import.cpp | 5 + .../encore/internal/importer/page-layout.cpp | 470 ++++++++++++++++++ .../encore/internal/importer/page-layout.h | 16 + .../encore/tests/data/bazo_left_100.enc | Bin 0 -> 25912 bytes .../data/structure_page_break_mcount_zero.enc | Bin 0 -> 25912 bytes .../tests/data/structure_page_break_spill.enc | Bin 0 -> 25899 bytes .../data/structure_prec_landscape_no_wini.enc | Bin 0 -> 25862 bytes .../tests/data/structure_prec_page_a3.enc | Bin 0 -> 24900 bytes .../tests/data/structure_prec_page_letter.enc | Bin 0 -> 25912 bytes .../tests/data/structure_sco5_macos.enc | Bin 0 -> 554 bytes .../structure_system_break_mcount_zero.enc | Bin 0 -> 26584 bytes .../data/structure_wini_large_margins_a3.enc | Bin 0 -> 25912 bytes .../data/structure_wini_screen_pixel_a4.enc | Bin 0 -> 25912 bytes .../encore/tests/tst_parser_ticks.cpp | 11 + .../encore/tests/tst_structure.cpp | 317 ++++++++++++ 16 files changed, 826 insertions(+) create mode 100644 src/importexport/encore/tests/data/bazo_left_100.enc create mode 100644 src/importexport/encore/tests/data/structure_page_break_mcount_zero.enc create mode 100644 src/importexport/encore/tests/data/structure_page_break_spill.enc create mode 100644 src/importexport/encore/tests/data/structure_prec_landscape_no_wini.enc create mode 100644 src/importexport/encore/tests/data/structure_prec_page_a3.enc create mode 100644 src/importexport/encore/tests/data/structure_prec_page_letter.enc create mode 100644 src/importexport/encore/tests/data/structure_sco5_macos.enc create mode 100644 src/importexport/encore/tests/data/structure_system_break_mcount_zero.enc create mode 100644 src/importexport/encore/tests/data/structure_wini_large_margins_a3.enc create mode 100644 src/importexport/encore/tests/data/structure_wini_screen_pixel_a4.enc diff --git a/src/importexport/encore/internal/importer/debug-dump.cpp b/src/importexport/encore/internal/importer/debug-dump.cpp index 60b4498f9f701..636fce56ac329 100644 --- a/src/importexport/encore/internal/importer/debug-dump.cpp +++ b/src/importexport/encore/internal/importer/debug-dump.cpp @@ -211,6 +211,13 @@ void logEncRootInfo(const EncRoot& enc) // 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()) { diff --git a/src/importexport/encore/internal/importer/import.cpp b/src/importexport/encore/internal/importer/import.cpp index 174062220e0cf..37aeac13d00d3 100644 --- a/src/importexport/encore/internal/importer/import.cpp +++ b/src/importexport/encore/internal/importer/import.cpp @@ -364,6 +364,7 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp buildInitialSignatures(ctx); emitMeasures(ctx); + applyPageSetup(ctx); if (ctx.opts.importStaffSize) { applyStaffScale(score, enc); } @@ -386,6 +387,10 @@ static void buildScore(MasterScore* score, const EncRoot& enc, const EncImportOp 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 diff --git a/src/importexport/encore/internal/importer/page-layout.cpp b/src/importexport/encore/internal/importer/page-layout.cpp index 1b91c3e2469fc..4864c0381124f 100644 --- a/src/importexport/encore/internal/importer/page-layout.cpp +++ b/src/importexport/encore/internal/importer/page-layout.cpp @@ -49,6 +49,111 @@ 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 @@ -68,6 +173,55 @@ static QPageSize::PageSizeId dmPaperToQt(int dmPaper) } } +// 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). @@ -83,6 +237,109 @@ int staffDisplaySize(const EncRoot& enc, int instrIdx) 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. @@ -90,4 +347,217 @@ 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 index 26c1268118f82..5d99adcef80d5 100644 --- a/src/importexport/encore/internal/importer/page-layout.h +++ b/src/importexport/encore/internal/importer/page-layout.h @@ -31,12 +31,28 @@ 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/tests/data/bazo_left_100.enc b/src/importexport/encore/tests/data/bazo_left_100.enc new file mode 100644 index 0000000000000000000000000000000000000000..d104a51460476d5199d320c4a5921796d426aff1 GIT binary patch literal 25912 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 YsEox@mE^_p)MeIiWUhD{HT6>d2B*j+c>n+a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7c4a0f6e5c40dc1047d48fb324e9a2dbec038fbf GIT binary patch literal 24900 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ddc24cf70d0e4b68b687c6d9fd4b6bed986986c1 GIT binary patch literal 25912 zcmeI5&u`;I6vw}gLkYBEBe91HkcdD+6PlfCv4v~QY!MLE0qWmv!m{GL+J_z9)nWXrhjWOW9(+8D~ZXlJbJ*0AVtDGFaV%IjF zLEfmE3;5={Kh(yVm>xN|>tE;egTib#EVK=~f-CA>kEx5gQ5Dr>{ zb|*yL^?a{n`zUvIc3Oi#ckzOLXJ=2>yIqyNh;Ip({nmo0WKEb z?RNj`(k@6tr&-xFgo|(c)jz#t&jT##;!{Z_WfC9(5)cBk58MVMKmwBp&^}BeLp=$Q zzyZ|v5B*JyFi3y|NFYUk_92CX1ri{E1OeKI1PqEKKmsWOv=1p9ERX;RBnZ$xBw$b^ z0TM_NpnXW;V1WckAVGlkApwIT36Ov$FnX$~-j7mJM!#t~M)Rz^hs{I+Brxj)=s(Q* z4&eMrU^0Pw`wx@bvxWpnV0H-5f0&)M=1fW8v;=Nr^#9NEr|ro0BtQaFPk{De>X(=k zB!M#%pnW(qI(8(1sV6}DF!f8!36j8>3D7>A867*4z|<3k7Z9J+!i@HAj!YgI> zt?mBO%F_9_FJIJAW__#a%a1u6r&YJ+50l|Tk8A!9p(N3GTN1B#ps#JY&T{f4g0YHF z+(!X5$v=?XlgP!p!X-Qb#uwfDaove3T`ap*@4862mAndG`~Z^*PdpqBvA@5MNRE9u zRK#K_O7LPib)HrnsVm-wqdv-}U9+E;TI*qdlm+iR^iilrifiI-r>SzESK z3YYu`6$efnnjQ-WP;Nb1hyy=PaOMDr140PpfKY{yw(pHM^=z?$gq-Z}tY+rTn|bS* z&x}W7?fO-6&gSRz?;)LBq(e%OL549iXwG8tx0P?MnQ2vCif&?ZZbUNC;@UM)u;u7* zV=iwdsLqv#)HjpKc&jf|F$`0`ykdso@91SAAO8CDPu~yiNu7&`TdE_J zy62UzmA!F)WS^!BiI0tRVmWbSU$n9n`2_7u^|YAobIfoYoT&z79x-iFhZ@u)pL61q z)d+jmN9~z#++I~_$v@lgYxdVQtD!j3{t>B{{x5Jm`utBtNn6z7e%HD8Z9dsQR$ zKX8oOt12xSV}H-!^WWF{|BcYCik0$O$4V#V2Ta~8Q)C0{7eM$qW^U6Jgd=XaWZ?Q$d7CTDe(?48=S z@4eghX}xT%75zFnorWLuYQ07)pt9q7Zr1iF*J?IvJztr)ez(=!RrO9=vS+DWYx|u_ z<2|1WmgiZnv~P8at-4R=GjF!r|8;8TxnZ%I-!zzuXM2S|UET747FF?KT?rWkKmY`o z0QLtg0}ud#aRjhGj3Wa*2!Ox=$?p&KOAIhT00clFMgaRm3<=0Yh#&w0 zF#^~hVmMF$0T9p#V1LkHKm-90h!Md45W|522!Mb_0Q-Xm10o23fFdw>swwYBiIh-p znhw!C&hKF(fB*2m;3>a1cGUn2+9{6#p>8C!NDRLv-RNR%%nqTkHZoM+5)J>vXU3L2Rmb zEfd^MuA6k9>Wie0KJ`M*xwPF~T3K3n{o;A$W!AH*-mKQ!Rj1_jb@!oK-RX;2_QqRd zf4x1GHaMN;;IRi|@lf2QG?h4ihjW)b7w<5a;4Uy8>)wv)?y6G7oKtYGvNXGr66eLI z7{onszu%|5y*&!~+T%++7ITrE7jsm3Tydn%yBkI6%BEeipX6NDvUrI8k#Bm7<4dfl Tu @@ -311,3 +312,13 @@ TEST(Tst_EncoreParserDurations, computeElementDurations_gap_and_boundary) 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_structure.cpp b/src/importexport/encore/tests/tst_structure.cpp index 2c6dfe5628e1d..73265dea828bb 100644 --- a/src/importexport/encore/tests/tst_structure.cpp +++ b/src/importexport/encore/tests/tst_structure.cpp @@ -110,6 +110,36 @@ static int firstPageBreakPage(MasterScore* score) 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"); @@ -344,6 +374,78 @@ TEST_F(Tst_Structure, time_sig_change_2_2_to_4_4_and_back) // 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) @@ -361,6 +463,83 @@ TEST_F(Tst_Structure, page_size_detected_from_wini_pts_format) 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. // =========================================================================== @@ -382,6 +561,25 @@ TEST_F(Tst_Structure, page_margins_no_wini_uses_defaults) 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) @@ -839,6 +1037,95 @@ TEST_F(Tst_Structure, pickup_caseb_hairpin_maxendtick_not_stale) 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) { @@ -866,6 +1153,36 @@ TEST_F(Tst_Structure, fit_spatium_first_system_measure_count) 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) From 2cf06282db8e832ffeeabc3ed0feeb5ae9e0a837 Mon Sep 17 00:00:00 2001 From: Manolo Carrasco Date: Sat, 4 Jul 2026 13:00:00 +0200 Subject: [PATCH 33/33] enc-feat: add Encore import preferences --- .../encore/tests/data/bazo_top_100.enc | Bin 0 -> 25912 bytes .../tests/data/importer_counter_bytes.enc | Bin 0 -> 25968 bytes .../encore/tests/data/importer_v0c2_snap.enc | Bin 0 -> 26104 bytes .../data/structure_c_clef_key_keeps_clef.enc | Bin 0 -> 25940 bytes .../structure_g_clef_key0_stays_plain.enc | Bin 0 -> 25940 bytes .../structure_perc_clef_key_keeps_clef.enc | Bin 0 -> 25940 bytes .../data/structure_pickup_measure_same_ts.enc | Bin 0 -> 26110 bytes .../encore/tests/data/zbot_from_bazo.enc | Bin 0 -> 25912 bytes .../encore/tests/data/zbot_single_note.enc | Bin 0 -> 25940 bytes src/importexport/encore/tests/tst_options.cpp | 46 +++ .../qml/MuseScore/Preferences/CMakeLists.txt | 1 + .../Preferences/ImportPreferencesPage.qml | 92 ++++++ .../Preferences/importpreferencesmodel.cpp | 238 ++++++++++++++++ .../Preferences/importpreferencesmodel.h | 59 ++++ .../Preferences/internal/EncoreSection.qml | 267 ++++++++++++++++++ 15 files changed, 703 insertions(+) create mode 100644 src/importexport/encore/tests/data/bazo_top_100.enc create mode 100644 src/importexport/encore/tests/data/importer_counter_bytes.enc create mode 100644 src/importexport/encore/tests/data/importer_v0c2_snap.enc create mode 100644 src/importexport/encore/tests/data/structure_c_clef_key_keeps_clef.enc create mode 100644 src/importexport/encore/tests/data/structure_g_clef_key0_stays_plain.enc create mode 100644 src/importexport/encore/tests/data/structure_perc_clef_key_keeps_clef.enc create mode 100644 src/importexport/encore/tests/data/structure_pickup_measure_same_ts.enc create mode 100644 src/importexport/encore/tests/data/zbot_from_bazo.enc create mode 100644 src/importexport/encore/tests/data/zbot_single_note.enc create mode 100644 src/preferences/qml/MuseScore/Preferences/internal/EncoreSection.qml 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 0000000000000000000000000000000000000000..f8f7072ede218b0e1da718b58c55008d7b31cf30 GIT binary patch literal 25912 zcmeI4&u<$=6vy9sO>jv>Eyy7P1Qtjr0#TJ>K@d>I*qdlm+iR^iilrifNtR&ASzESK z3YYu`6$efn8jghnD7PMc;J}X)oH+pEfDo#3K&V1U%X{NZJzH!bAt(DgtC@N8X5M<{ zGvm=%yMEQYv++6od&ndg=#Ua*kYUUWnzNX^wfxO>Go#9j(M?RwjYuY1SiLR^wj3R9 z%ofZ9ZE@ux_0425-s%fg48!y1=d{Mw@vTT`SU?4Zif;G zfB*=900@AuvxU%Z%KE!Jw4+^Y4E(hYAu zl%%3)m#rn$B13;a6TQLbC29X4$+2jL0{VbnrYm%f{=EI$;jcgc^!?DD)VYYbr8+{X zdtUuo*&Fvq_8GdE_{7L0mJ&zyB`a5zPteX(&xq+h$1KOenQBnx5z_{Bs6jpQIVV0@ zjj(5Z)Sd~)?NybQ{ImVOX8)09H55nMKPvUo|9Or_pZ}RCX_H#q?=9~AIaMBMuWH2p z2aa)jRi!0k?C%+T{`*@0KUP+CdX0+QhfS(elQm&jajkro&#@(@y2*WOQJ3ReOgKX| zs!)~lg0k499qxCaYT~mXw>FYA^BvVplb4`DXXwM(Y-)?8vsB}SZK#wer{+|36YcljS zFR}mgJ+XuKZtvAbKr4=0sygd#-Xhnk7K%k$4;mf6E0P`b{7%!aUugu}^dauz6sN%Suo3lO2x0=m*&sQd{-)%K_RlU=e>{+VR+kU6o zc+aPz<$0DX?OUBvYs;q#**Dwm|2nmE+^|$DY#7YNv%TUUu5Nii3##~tu7nH%AOHeP z0Q&=$0SJJ=I0D!o#*u*@1VG?`00?LVus>)pAc6o0#0X%2h~Yp11VBI|fc-&(0TBd1KoJ-`)s*+6L`tYP zO^0Zn;PQeh?T>VD$N6e0$V@00>ME0h}MEXRa|)5I8P@oBYph z3A4vFM0*ebfr%%8{bAy#7y|@>6BEGxaAI^Y1c8Ysfc;_Orx*hSffEzJ{%~S+Fa&{# zCxHE7;-?q`1c74`IEbEF%th}{ihr2llg{CuAv*CBE469mEq0!sqk(_qHM&>%AU4&z zmI-br*G;-l^+nRho_-K5clu(Ez46xB zUvE#P4Nj*yc)KO#E!UgtTX5@^in3C literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..91df98dcce689cf36921038c40d7293e73c0839f GIT binary patch literal 25968 zcmeI5&u<$=6vw~oH6bMtwV;OxDq(?yBDl20f&itmu{Y7Cw%1y(6-ykzG)u6QtS#Fq zg-iZ}iUTJO?X}{7lv|HJaNx%Y&KxSl0U?BPK&V1U+c)D)yf%%%rYHM5tKE6?=7&9> zH~Tc!ZeFT+XX`8cdrBu)aD)U57{R!$x!(D*AetMuZPUAY;X<%D+K(j? zAOR8}0TLjAIUykXk@!p#lH%pK97ZXwQ*t^=*(l*`sGBSx^ZJ$iMzLP6?OsP^|@+h(|s=cS6nNf5nGkS8rQ@YEp+Al zKrE9+9Tik1FNo$E_OU0{3Utl5^WfaY2eEY+ng9SI+Uw3i+Zfkd6*E?;MT|}kP z_B++)Cq9an=UJ|5-|CcF+deL3-fy@6t7*kQqf{?!8N$W0z2aYPyyqcSbn$##Ntpyl zfCPj9{R5W)36Q{P1n3`5BSSq2kia3-?+^V;j4()m1V~_n0R6)V4i-p&1Y!i}A7U^l zk^l*e5TJh;!NCFvkU)$8{X+}}MG_!^5d!oNBRE(f0TPH2pnr(LphyBFpa~3~YO436 zl$6kKnhw#tAn#!_kpKzII|1$==6wZl{v>cZfrsxOPH)c|5+H&3A;A5^{Mec^C4or^ z+?BuFR%vO{j%-f?Brx*?=pSa@#GD`rOih6PVQO^jNCGoYfc|0TP0R_Bz|;ijAErjf zjwCSi1n3`T-o%_B2~0@fFnVgS9KAoO;=>G|bPj!n(y2JHsy3yg#h$|}7{pcH#N#@K zu&Lj*Oh`L*+{F|6yQu4PFTa*|-r4D{W>?R>d-alzGV57&FaA#;)}6A~kB1MPa3+&A z&q*}ihQ#ab>1$K2^PD`1V5}k(cacU}@{c5UC35kea0$l1c%u6-svE1)#k^B=Z;G^- zO{w6;k1(n5#QlCB2L}fTtF^2mYyTvJ Y-k0-DQB)rTo)WD(*Cc>VXr7w#SMCQf@t3J@MlNXATwOfDl4CAXFiwW#;YLUWZ2D(3AartKBzm-jDcw z-rMmw-b||5^42~2_mqq+(;?}kk)|yK%~_27mj3RhkyPcCa1euYEtH9t^EXAoJs+O# zEaeQHnp}BE10xojZ1t5Y`hMb<3x@BnJ@*Rv{^Hkf@b%7@Io4deWf{)JbLYIp$$lt- z00@8p2!H?x%n1SZN1`*;IYrA+*$-1xCvq}O=`hi0Uo}~v)LZM>e4$>iWb)>-N;e*S zEJ;PdDw%7lMUwvM6TQd&lC*!C#4TDNk3OS!=qg>OzaIR4`0LL<-5>WSeN7L2sv7H3du-PaM4Lz;H}=wGqTY>F4R z$h+bS?};|``22u(NRsNj)>Y1Pthqvaw9C83Q#D6-5q}1V(ZUZ@Hw?aoG&)6}FQpcn zES;u0-+(tK$sH((Unhdp;~k{W$J7Rwf}RUSYBtZ|8C2pjj~;=mTPv_B-^a!3I(cpt*+Y> z$@2PccgJm9ZF$>N-q;{(x3TRyA9q~ZteE+t+oW>0<$C=_zt#4rQnnpCV>y&<@9Z@C zuIggDz4p$YsxNmWTct{)<94g9Ph2XPj$_)=zTGXhn=YMCz2E8l*QJ%WgMF zfPI+2fdUACK!gD7Lj(py5CDM*0+q)0tX5p00I#L zun!R!5J3P06an?HJxhUo(AATsV>B=GbJ$2A00Q$)0RF?g?*Pmn1db>0aR1@>_NV~? z5SSkV@E_)9tua#&n3lj@{>yDiOVf5ldk_GDnI{1IF!M`{34*|h3BW#_7#%u-z|0eX zeVF+r#sop&!~|d;PK*v6L15+yz&_0U5@UiOFeQP5@LP*``23_q>KuR5Ikp+1lQ^+b zn^4hW&(P}>#CP7L$5jkrLp^J$b2~Zi(i7^n$o0k7-prOSZTD8vD`zfWJg=h6I%eI8 z{xgb=a>*G)!-q~elj)jgI2vz*YjQk2tqEa`7&6@s5CT zSodLAcce-cv*m()ou$QeLV_1R!X)8|2ZI6a@9&e($37ntv6zb-yqHgwPb!A$oLwu* nOWCqY)~lS$UX}>oKk}dt_*N<3QpW)@k%Ng~C5T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5f1e40502c4006552b12c00901ac84b8f003f328 GIT binary patch literal 25940 zcmeI5&u<$=6vw~oH6bMtwV;OxDzHF85nS40K@g>~u{Y7Cw%1y(6-ykbX_jE&tS#GV z3zz%{6$efn+H1uDDYqVd;J}X)oH-!G0U?BPs8EF}EpNt~*lQX=Lb=%AS?$c5H$UR} z%=qag-aJ?H&c>Jc=afz^VIK(?FpRljcM+4nXTQ5;rgeQen#7dSh-6@C?Uqu=$cLvp z3k5TQO=;Z6z)U9Ny}r`bFiidWmKlaC&%T5(od5bY$?tqo5Y3I-w&`6ycP^L*Y6$S5 z+-+Q!JdYN9ybn+E9{TZVSt9`wAOR8}0TP%N0^*P0-e@riNuyd^3ZoR)0_iAaqlD9; z?lOnW8<+BH#d^JxTeF_gy3yn#Ni`JhvbCanr15v3=!*DD>UBL0z^ z!To#tzy0#_kE8L>ei5XncA@l~H@=ne;$S@)jZfoJ;&UUNSVSJN|io2JN$8rs&Wx#C94FL!cfhvR(rC!Ilkgr`Hc9uB_V!Q zc(=tjZHt%Mmwl+Cf~u4S(Okt2wq-{G-7>jDvi(WS{eZe@%JDRC3ZE@x<~Kz;jk+8z zL+3zF@3-E%N01%(^r}>6K$YrkG&yy_F4tZ_$|#zP_~!dRhKFjhQ&c~7s6UwfP(fiv z2#oK1N-EWox2~3+k|XwC-Y0g@+3COC46y3BrK(eN^A=pIS|}D#3z}WOrzAV*``wn` zxYi7|;H<8~-fnF9-bWoD>lJIQQGy>@Fy zw>uqGoJXb6@w?UL$3BXd=UJ}m-|m*$n?5dN-s^P!?ba$AT&fo~4B_J0Uhz+NeB>UM zboK1Gk}?U900{^I+6Nv35+H%22+%$pMTUA3Ab~yTzX7S=#F$8c1V|u8fc7DVgB21W zfiVKK4`VPWk^l+B2+%&naIiuGBrryR_F)VLMG_!^7y;Uc7!FoQfCR<}&_0a8phyBF zpa~2=)l|<%DJh|!G##ONQJ%wQA^{SZbprGsX8i9uz`lNGYGn7uniB+vB9WC|@ zUd1qW@;V;ZF@#P1tYt#_sp~GD(62@1&%g3|-g#%Mx13!*^X}yfI?AkP)jj#5hr_h$ zl)b@t_|U_a|3k=0G~R~(E(w)4MV{s42>}(MxQ8^#QeKzRmB_{0!X=mh0WKfV61-S$UB(p$>VjJ$J;i(kGh`JK-SqPcO~HoYqsE(D7}4FTSl ztBvcD=h1?Xci~CiLw|f))<}Q^NPq-LfCOfRfcPWvr6eT9>v1`ZQe3Cxbd<7D!r4%F zSwQC1%lVCByBL&%Xne`aRiBc+lRnjL(PiWs z`c#0b%ZjmuE}G&Y`BJL%(fQ$zTUM1zFe_Ols1$~3ZuRb`o15b+u9eS-k6RMrSA}<5 zeABjgseRdpIx472SrE-t>|s}SB+xCBJ0$*f5)0pm%$)frHwx{W5MF4*PTGe{Xla~WTK`}^=vO?HavXO8tpvmYxc%n5<` z&ZneOEqR-2=_xs4|K&chgU(+6jb?y#$1PQznwz)aTGc|ah+5F>`aLDtLErDT{KmCr zumfj(9rkWx$M-(y_}Hvi8zp}mPPgd?{YJmp4p4Di&&}B$^6gfu(f9Qb*YCAkd%E4} zsNzLb8Xdn|ZGPyZXnCIHs{ZY6slDyvV&eiLIN0TLjAF#@y?V>nnL0TPH2 zpnZtJphyBFFh+p(VGIW=BtQZ&0<;e?7!*l>1jY!^K8)dDg#<_-Mu7Gq27@9AkbovI z{8UptAEl&(e$sS==2>|Tn~4NSVBQJPf0*|Z!1a^B$pr52Kb+j3EhIn!^Fx6C!~EC2&{%b6cgQ(+*^R5+H$@CqVl!^IOaXlEBmiXdk9V$AKg;^8{!gW`2vgKoXdm z0PVxn=s1uBW}X1;!_03n7f1r9BybRYYOx$WKdB;hjy~xe*$kyqabi_#N=J)5hnFyn zoxF*MbqrxsKWmwge(H4>kLdTJ@~2;TCGWhs(_781o_p)cMIB|v<2<5deFBP#^suH|dE?tf*j?@LWR#IROW^-8s=2_zURVG>iZM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..73e4d1290a73d825c6fe67eae9a80845372fab00 GIT binary patch literal 25940 zcmeI5&u<$=6vw~znlvR5wV;OxDzHF85nM{KAc#`g*qdlm+iR`YiX{%zG)u5>)|TzG zg-iZ}iUTJO?X}{7lv|HEaNx%Y&KwZpfDl4CRH#Bo%bW2g-gO&6Ou5+KS?$c5H$UR} z%=qag-aOX|_Qsd^=afk;;Q&b_kVwo8yNj6mJ@?%$Bctof;UtEXiBJZX)@~_y zvrsgW*p$Wt>>H_6yw_K{8U*QI-!g(=<(U@|1oL0NCi$H&ilVu3+cMnC=g<4|Kpj3l zl)HoLk{8g1hxg%1-p3$5Eo&q|0wh2JBtQbwLO}cx+#4+>DQQ%TOF@|8S|AgqT$pes z&|T(`edAJLt<-2#^K0hQS~r?}B&mjyRWVm|j|?94iLQvhlspAI1?3jaiKp@@F5(() z;;;Mn4u1RP=O0Joq5UFAPwhhKId6O`XyZ=><4i&mxnJkp7hv50TJ|6_Ql#yf@e(?|Nl*^d+y zW`sa==hITDmECo<^t2qY|MEVu{O-=+?Us*K+bP%Vx>GRWn6+Z5gu37Ad3_~W{=n!+zXf}B71nlh%X2^KdRVWTYh`Z}cCY36gXW;s@lmxM*U4Kh3Y~VlIq>ul$Ln|6 zJG$NOs^S$?n_aJ0YklmYWV){DsQ#T^xwGlvLiW9G_up=aU&sIT+1!}=|o5F zVM$ldMU|9EfCNZD2+%(87?1!797llm;W#qXlK=_qLH`X%{U*jl0wh2JF#@y?F&wOr z00~40&^|<9P$U5oh!LQDh~Z#`1V|u4fc7B*gCYr#K#TzGLktHiBtQZY0<;ei7!*l> z1T=x+r<&^dC?zHJlcpmyFUWJ)Oe8=8vrd5i!>peGuAc;sC-89p;rRY+ApsJY9Rl

m zyozD$`|jlnI?AkTHeC6lhoiJ= zSKR$*_|T)4|3k=2G~TBEE(w*lM4sj33IP?NxQ`4fQeKzRk;ui{!o?p0oTr5R2Q9kS-q4Et75$* gr8>(hdiGB;>3zB16h-YZ;7QS{dqskD=S7#m-=GdPhX4Qo literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e2e9541b92483db150073bb56520aa2006a00ba1 GIT binary patch literal 26110 zcmeI5&u<$=6vyB0nvjx+TF^rTRk1)q5nS40K@cUfu{U8;+iR`YiX{%zG)u6QtS#Fq zp_lL%R2(>QXgO9KkaFwM2M+wW0A~&r;(!oBIUrOaq~*QwIv$5c5K|7=-)HUocyDHR z=QFcUlX&w|$+_#F(ceodu|Nl8l0k+s({Ikj#4nj|Zdxf-UyM&;ac;zAqJ`C)qTrtQ zm)moB%cKo99#GFpBt~0(uBuU#{OMIIik6;ymZE6(ix)Y){b`;xySE(6zkL3DI6K-8 zH4p#+5C8!X0D&nX!21z>q$Z1A*UM2Xx=zZeSTeED=}0x1q4dkkxz$3gR?e>4Pbl5+ z_`}^{e^UD*;+AR) zrOvtgmFjOi8uw4pviXUTGMCIj|Dv6()VS@@J=HDNrTOUZsfeV`Bc^p~Q-it`a4wQL zH{>`^=UB5V3WMgw`oURBBso`=OIN6z5v|DXp-r7&`D1fviz2xzIsdqK#n)93`q%Ag z*FBKyD%%CwWoC5SU1MOY5^pVYy6tOoV7pEa6TL3B_hld~{ri=98t;Dp|NYCJol7&a zBCn0ByfT{9;p1&yCn>5?nJS#;S#y2ULuY?z{F~>*30phe*BT+MxL&d1R=u1}o?Xco3RDdn?Vuwi zC+r68t)PCb5pI&ZvO>;weKYXiYX!7cwpWY62D$A<5O(X`Ml+-k>R@hR8b+NyU0 z)x`@s&8;0}cUzKOpmMzxv@4D40TpcDw>@d!Y!{mw0bNYL)oT6IsTIFmtmW4Y=Hfek z;df8(xlaqKdRAAW3<4kk0!#q@0m}dcK;Sq6@DIn4fgS`vV88$SDEUbY0sz7 z&|shl0wACWsJGp@)b|gjded}><{5qu8v+6#Fzp1ef0*_afboOC@dWO_e>lE9EFb^^ z(?bCJhv}JXj1&Y;O5hIv<+jM&NjsuF2!O!E6M%o1_$kH!L11hG@DF37Lq`ypcmnVb z6FRn5d+sWq+J)-VK(x;w(Dd)bi*;&jio_X`~MHOY%w`;!s zFDX{slHb$AhpOKHRg&dsymgM(+f`|U(`go;BN$7B;tr*##QD3NdmOoVo4JHX!1%0t zC$=3iscOzGcsIB-n@LLW;s+=ap19ZR(ca!3Mf}<0k3=lyDhDs-L*=82fjaM1i*hUL lPRV(Wb6Lw0=lgp;={-JPVMXO3qQ_V(#}Y@&&a%#+KLL26K(7D* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..025cb2a8e5beac8b2bb6f57f4ea54cf04bcf99ca GIT binary patch literal 25912 zcmXZjTdb|;b=L7Y=Xacrq%ev0fX*->c+iBX)`}AYmwacIF z+q#_2+d8>yTDN6gzI*+D|K;U9+rCcc4@~E6S+>(v(|nqz<8+$N)4sjy?q4~~r)`^0 zhxI(UX+BKbwoKb;n~&4s^c&O7(|UgJFz>HS-#NPSbXbmu^Z9rKp&$H*c9#_~Zr*)YQhr@AQ z&x^w>r|sYv>uEiC&~!Q<{bRNDa5|h$r+qmew&{G@&ZqUT+4p>2_ib8F4(R*CahZHz z*UMONcEa;KPrK_Zah{JpI38_tweGyF$J6oPZ2Mt9?z7K(>gl*F$CGQ1%f8OXgUHQx zH{0!YbWAHO)A2a@BjWRJlk+;SN2fln7C)Y*bw5taa`I0PSo?mJrcGYfeeumaIn(jH zZ_C*wyAbZD(|oYW`7EREJsdZiSmyJ*9M9+VbePZXI31fIN4-+o2K1;(=LMTVet%*fs|EnWO848KJU=80}fQm(`+~M>`<$)$Z#Q9Aa57L z`LG>qNo?#q5ICMx0s5&N%{Fy7%H<}c9(}OIZSow@m^?-*kHWfJ@93G^wnM^5-{!19 z123)=@%4N-ZAt+ioI^yy;Mqa9X$JRs-uH^(y!qd1XFl5lkxxL|JrW9~o;vMk4^%?7 zCg5(h^xn?I-ENm@QWuNTInHV%!~#0JJ9jxaLV?2FCG@-H~o)u?s2}sMd9PK*N02&}J>M}RmybF}V+|d*x46HaQ zkc3E?JNtTu2YJU`_O*J572xfpS|gI_ECWgb9m$zks)FU~uayXl_(ag1Wk3l>*TfkVzf*>kJXf&GH2ju}KH6 zREC0j6z+Ak_+j67U;Q2Cff&ZdD)Q8Mg$

tNd5jcm4NHCRX9h5STuIe3CGOXloz!=Dm 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 If9CD~0cpeBmH+?% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cd61e4b4cea5d590aa6b5123e2eb0eb45f3f1cc0 GIT binary patch literal 25940 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 V2>JiDJ-vx0{bP*yQS^0M{y)%Astyle().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; @@ -104,6 +139,17 @@ TEST_F(Tst_Options, importPageBreaks_false_produces_no_page_breaks) // 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; 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) + } + } + } +}