diff --git a/lib/gp2rs.py b/lib/gp2rs.py index 9b233e9a..b5fbcb46 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -72,6 +72,9 @@ class RsNote: tremolo: bool = False tap: bool = False link_next: bool = False + # Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky). + # Display only — never used for grading. + fret_finger: int = -1 @dataclass @@ -255,6 +258,16 @@ def _bend_shape_xml_attrs(n: "RsNote") -> dict: return attrs +def _finger_xml_attrs(n: "RsNote") -> dict: + """Optional teaching-mark XML attribute for a /: `fretFinger` + only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a + GP-imported fret-hand finger survives import → wire → highway. Display only; + never used for grading (§6.2.2).""" + if getattr(n, "fret_finger", -1) != -1: + return {"fretFinger": str(int(n.fret_finger))} + return {} + + def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float: """Get the tempo at a given tick.""" result = tempo_map[0].tempo @@ -524,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int: return num_strings - gp_string +def _gp_finger_to_rs(fingering) -> int: + """Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int. + + Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2, + annular=3, little=4`` — already the RS finger integers for 0..4. Anything + open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a + finger. Teaching mark only (§6.2.2); never used for grading.""" + val = getattr(fingering, "value", fingering) + if not isinstance(val, int) or val < 0 or val > 4: + return -1 + return val + + def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]: """Per-string fingering for a chord template, in RS string order. @@ -853,6 +879,11 @@ def convert_track( if eff.tremoloPicking: rn.tremolo = True + # Fret-hand fingering -> fg teaching mark (§6.2.2). Same + # Fingering enum + value convention as the chord path. + rn.fret_finger = _gp_finger_to_rs( + getattr(eff, "leftHandFinger", None)) + # Whammy / tremolo bar (beat-level dive/raise). RS has no # whammy attribute, so approximate the pitch movement as an # unpitched slide: a dive slides down, a raise slides up, by @@ -1164,6 +1195,7 @@ def _build_xml( "ignore": "0", } attrs.update(_bend_shape_xml_attrs(n)) + attrs.update(_finger_xml_attrs(n)) ET.SubElement(notes_el, "note", **attrs) # Chords @@ -1196,6 +1228,7 @@ def _build_xml( "ignore": "0", } cn_attrs.update(_bend_shape_xml_attrs(cn)) + cn_attrs.update(_finger_xml_attrs(cn)) ET.SubElement(chord_el, "chordNote", **cn_attrs) # Anchors diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index a161aeaa..cc41020d 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -457,6 +457,31 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None: 'pinky': 4, 'little': 4, } +# Per-note teaching mark (§6.2.2). Unlike the chord-diagram +# path above, GPIF stores a single note's fret-hand +# finger as a direct child element with the classical p-i-m-a-c letter +# codes (verified against GP8 exports), mapped to the same RS finger integers +# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4). +_GPIF_LEFT_FINGERING_MAP = { + 'open': -1, 'none': -1, '': -1, + 'p': 0, 'thumb': 0, + 'i': 1, 'index': 1, + 'm': 2, 'middle': 2, + 'a': 3, 'annular': 3, 'ring': 3, + 'c': 4, 'little': 4, 'pinky': 4, +} + + +def _gpif_left_fingering(note_el) -> int: + """Read a GPIF 's fret-hand finger () -> RS finger int. + + Returns -1 (unset) when absent or unrecognised — never fabricates a finger. + Teaching mark only (§6.2.2); never used for grading.""" + raw = (note_el.findtext('LeftFingering') or '').strip().lower() + if not raw: + return -1 + return _GPIF_LEFT_FINGERING_MAP.get(raw, -1) + def _rs_string_order(string_pitches: list[int]) -> dict[int, int]: """Map each GPIF string index → RS string index (0 = lowest pitch). @@ -1600,6 +1625,11 @@ def convert_file( rn.vibrato = True if 'LeftHandTapping' in _tp or 'Tapped' in _tp: rn.tap = True + # Fret-hand fingering -> fg teaching mark + # (§6.2.2). is a direct + # child, not a , so read it off + # note_el rather than the property map. + rn.fret_finger = _gpif_left_fingering(note_el) if 'HarmonicType' in _tp: _ht = (_tp['HarmonicType'].findtext('HType') or '').strip().lower() diff --git a/lib/song.py b/lib/song.py index 779c743d..284c194d 100644 --- a/lib/song.py +++ b/lib/song.py @@ -43,6 +43,18 @@ class Note: slap: bool = False right_hand: int = -1 pick_direction: int = -1 + # Teaching marks (§6.2.2, feedpak 1.5.0) — display/teaching only; a grader + # MUST NEVER use these to judge whether a note was played correctly. + # `fret_finger` is the fret-hand finger (-1 unset, 0 thumb, 1..4 + # index/middle/ring/pinky — same convention as a chord template's fingers); + # `strum_group` is a strum/rake key (>= -1, default -1; notes sharing a value + # >= 0 are one gesture, with `pick_direction` giving its direction); + # `scale_degree` is the note's pitch class as a chromatic offset 0..11 above + # the active key's tonic (default -1, MAY be derived from keys.json). All + # three default-omitted on the wire; older readers ignore them. + fret_finger: int = -1 + strum_group: int = -1 + scale_degree: int = -1 ignore: bool = False @@ -238,6 +250,13 @@ def note_to_wire(n: Note) -> dict: {"t": round(p["t"], 3), "v": round(p["v"], 1)} for p in n.bend_values ] + # Teaching marks (§6.2.2) — default-omitted, mirroring rh/pkd above. + if n.fret_finger != -1: + out["fg"] = n.fret_finger + if n.strum_group != -1: + out["ch"] = n.strum_group + if n.scale_degree != -1: + out["sd"] = n.scale_degree return out @@ -327,6 +346,102 @@ def _sanitize_bend_curve(raw): return out +# Natural-note letter -> pitch class (0 = C). Used to parse a keys.json key +# name's tonic for scale-degree derivation (§6.2.2 / §7.7). +_KEY_LETTER_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} + + +def key_to_tonic_pc(key) -> int | None: + """Parse a keys.json key name (§7.7) to its tonic pitch class 0..11. + + Reads only the leading note letter plus optional accidentals — e.g. ``"E"``, + ``"Em"``, ``"A#m"``, ``"Bb"``, ``"F#"`` -> 4, 4, 10, 10, 6. The mode/quality + suffix (``m``/``maj``/``min``/scale name) is irrelevant to the tonic and is + ignored. Returns ``None`` for anything not starting with a valid note letter, + so callers can leave ``sd`` unset rather than guess. Used only for teaching + marks; never for grading.""" + if not isinstance(key, str): + return None + s = key.strip() + if not s: + return None + pc = _KEY_LETTER_PC.get(s[0].upper()) + if pc is None: + return None + # Consume any run of accidentals directly after the letter (``#``/``b``/ + # unicode ♯/♭); stop at the first non-accidental (start of the mode suffix). + for ch in s[1:]: + if ch in ("#", "♯"): + pc += 1 + elif ch in ("b", "♭"): + pc -= 1 + else: + break + return pc % 12 + + +def scale_degree_for_pitch(midi_pitch: int, tonic_pc: int) -> int: + """Chromatic scale degree 0..11 of ``midi_pitch`` above tonic ``tonic_pc`` + (§6.2.2): the pitch class distance in semitones, 0 = tonic, 7 = fifth. + Display/teaching only — MUST NEVER feed a grader.""" + return (int(midi_pitch) - int(tonic_pc)) % 12 + + +# Open-string base MIDI per string count, index 0 = lowest string. Mirrors +# app.js `_TUNING_BASE_MIDI` / highway_3d `_baseOpenStringMidis` so a derived +# scale degree agrees with the tuner + open-string labels. `arr.tuning` carries +# per-string OFFSETS from standard (not absolute pitch), so the sounding open +# pitch is `base + offset (+ capo)` — see `note_pitch_midi`. +_TUNING_BASE_MIDI = { + 4: [28, 33, 38, 43], + 5: [23, 28, 33, 38, 43], + 6: [40, 45, 50, 55, 59, 64], + 7: [35, 40, 45, 50, 55, 59, 64], + 8: [30, 35, 40, 45, 50, 55, 59, 64], +} + + +def base_open_string_midis(string_count: int, is_bass: bool) -> list[int]: + """Standard open-string base MIDI list for an arrangement, index 0 = lowest. + + Mirrors app.js `_tuningOffsetsToFreqs`: a 4/5-string *bass* uses its own low + base, while a 4/5-string non-bass (a guitar voicing) borrows the low strings + of the 6-string base; 6/7/8 use their own. Unknown counts fall back to the + 6-string base.""" + n = int(string_count) + if n in (4, 5): + return _TUNING_BASE_MIDI[n] if is_bass else _TUNING_BASE_MIDI[6] + return _TUNING_BASE_MIDI.get(n, _TUNING_BASE_MIDI[6]) + + +def pitch_from_base(base: list[int], capo: int, tuning: list[int], + string: int, fret: int) -> int | None: + """Absolute sounding MIDI for one string+fret, given a precomputed open-string + ``base`` (from :func:`base_open_string_midis`) and the arrangement's tuning + OFFSETS + capo. None when ``string`` has no tuning entry. Single source of the + pitch formula so the per-note hot path can hoist ``base`` out of the loop.""" + if not (0 <= string < len(tuning)) or not base: + return None + root = base[string] if string < len(base) else base[-1] + return root + int(tuning[string]) + int(capo) + int(fret) + + +def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None: + """Absolute sounding MIDI pitch of ``note`` on arrangement ``arr``, or None + when its string index has no tuning entry. + + Pitch = standard base for the string + the arrangement's per-string tuning + OFFSET + capo + fret, matching the client's open-string/tuner math. Used to + derive the ``sd`` teaching mark (§6.2.2); display only, never grading. + O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist + the base with :func:`base_open_string_midis` and call :func:`pitch_from_base` + per note instead.""" + is_bass = "bass" in (arr.name or "").lower() + base = base_open_string_midis(arrangement_string_count(arr), is_bass) + return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0), + arr.tuning or [], note.string, note.fret) + + def note_from_wire(d: dict, time: float | None = None) -> Note: return Note( time=float(d.get("t", time if time is not None else 0.0)), @@ -356,6 +471,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: # the XML side's `_int_optional`. right_hand=_wire_int_optional(d.get("rh"), -1), pick_direction=_wire_int_optional(d.get("pkd"), -1), + # Teaching marks (§6.2.2) — display only, never used for grading. + fret_finger=_wire_int_optional(d.get("fg"), -1), + strum_group=_wire_int_optional(d.get("ch"), -1), + scale_degree=_wire_int_optional(d.get("sd"), -1), ignore=bool(d.get("ig", False)), ) @@ -853,6 +972,10 @@ def _parse_note(n) -> Note: slap=_bool(n, "slap"), right_hand=_int_optional(n, "rightHand", -1), pick_direction=_int_optional(n, "pickDirection", -1), + # Teaching mark (§6.2.2): GP import writes `fretFinger`; strum_group / + # scale_degree are authored downstream (editor / derived), not in chart + # XML, so they have no attribute to read here. + fret_finger=_int_optional(n, "fretFinger", -1), ignore=_bool(n, "ignore"), ) diff --git a/server.py b/server.py index 6d36a954..d3879864 100644 --- a/server.py +++ b/server.py @@ -1,6 +1,7 @@ """Slopsmith — FastAPI backend serving highway viewer + library.""" import asyncio +import bisect import hashlib import json import logging @@ -27,13 +28,17 @@ from song import ( anchor_to_wire, arrangement_string_count, + base_open_string_midis, compute_smart_names, chord_template_to_wire, chord_to_wire, hand_shape_to_wire, + key_to_tonic_pc, load_song, note_to_wire, phrase_to_wire, + pitch_from_base, + scale_degree_for_pitch, ) from audio import find_wem_files, convert_wem from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch @@ -7362,8 +7367,48 @@ def _xml_rank(xp): "data": [], }) + # Teaching mark sd (§6.2.2): derive each note's scale degree from the + # active key (keys.json §7.7) + its sounding pitch (tuning[string] + + # fret), only when the author didn't author one. Display/teaching only — + # NEVER feeds grading. Notes whose string/fret has no tuning entry, or + # that have no active key, or whose key name is unparseable, stay unset. + _key_events = ( + (loaded_slop.keys.get("events") or []) + if (is_slop and loaded_slop is not None and loaded_slop.keys is not None) + else [] + ) + _key_times = [e["t"] for e in _key_events] + _key_tonics = [key_to_tonic_pc(e.get("key")) for e in _key_events] + _tuning = arr.tuning or [] + # Hoist the open-string base out of the per-note loop: arr.tuning holds + # per-string OFFSETS from standard, so the sounding pitch is + # base[string] + offset + capo + fret (matches the tuner / open-string + # labels). arrangement_string_count is O(notes), so compute once here. + _base = base_open_string_midis( + arrangement_string_count(arr), "bass" in (arr.name or "").lower()) + _capo = int(getattr(arr, "capo", 0) or 0) + + def _fill_scale_degree(wire: dict, n, t: float) -> None: + # Author-provided sd wins — note_to_wire already emitted it. + if "sd" in wire or not _key_times: + return + idx = bisect.bisect_right(_key_times, t) - 1 + if idx < 0: + return + tonic = _key_tonics[idx] + if tonic is None: + return + midi = pitch_from_base(_base, _capo, _tuning, n.string, n.fret) + if midi is None: + return + wire["sd"] = scale_degree_for_pitch(midi, tonic) + # Send notes in chunks - notes = [note_to_wire(n) for n in arr.notes] + notes = [] + for n in arr.notes: + w = note_to_wire(n) + _fill_scale_degree(w, n, n.time) + notes.append(w) # Send in chunks of 500 for i in range(0, len(notes), 500): await websocket.send_json({ @@ -7373,7 +7418,12 @@ def _xml_rank(xp): }) # Send chords - chords = [chord_to_wire(c) for c in arr.chords] + chords = [] + for c in arr.chords: + cw = chord_to_wire(c) + for cn, cnw in zip(c.notes, cw.get("notes", [])): + _fill_scale_degree(cnw, cn, c.time) + chords.append(cw) for i in range(0, len(chords), 500): await websocket.send_json({ "type": "chords", diff --git a/tests/test_gp2rs.py b/tests/test_gp2rs.py index 9d867057..0fd5c605 100644 --- a/tests/test_gp2rs.py +++ b/tests/test_gp2rs.py @@ -1220,6 +1220,33 @@ def test_chord_diagram_fingers_extracted(): assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4 +def test_single_note_left_hand_finger_imports_as_fg(): + """A GP single note's leftHandFinger imports as the `fg` teaching mark and + survives convert_track XML → _parse_note → note_to_wire (§6.2.2).""" + from song import _parse_note, note_to_wire + note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5) + note.effect.leftHandFinger = guitarpro.Fingering.middle # -> 2 + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("fretFinger") == "2" + assert note_to_wire(_parse_note(xn))["fg"] == 2 + + +def test_single_note_open_finger_omits_fg(): + """Open/unset leftHandFinger leaves fg unset — no fabricated finger.""" + from song import _parse_note, note_to_wire + note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5) + note.effect.leftHandFinger = guitarpro.Fingering.open # -1 -> unset + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("fretFinger") is None + assert "fg" not in note_to_wire(_parse_note(xn)) + + def test_chord_without_diagram_has_blank_fingers(): # A plain two-note chord (effect.chord is None) is unchanged: blank name, # all-(-1) fingers — no regression for diagram-less charts. diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py index d177cb9b..4a8ae156 100644 --- a/tests/test_gp2rs_gpx.py +++ b/tests/test_gp2rs_gpx.py @@ -32,6 +32,7 @@ _inject_tones, _resolve_pending_slides, _gpx_bend_shape, + _gpif_left_fingering, ) from gp2rs import RsNote @@ -731,6 +732,32 @@ def test_note_vibrato_ignores_whammy_trembar_property(): assert _note_has_vibrato(n, tp) is False +# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ─────────── +# GPIF stores a single note's fret-hand finger as a direct +# child of (NOT a ), with classical p-i-m-a-c letter codes — +# verified against real GP8 exports (Open / I / M observed). Maps to the same +# RS finger integers as the chord-diagram path (§6.2.2). Teaching mark only. + +@pytest.mark.parametrize("code, expected", [ + ("Open", -1), ("P", 0), ("I", 1), ("M", 2), ("A", 3), ("C", 4), + ("i", 1), ("m", 2), # case-insensitive + ("index", 1), ("ring", 3), # word forms also accepted +]) +def test_gpif_left_fingering_letter_codes(code, expected): + n = ET.fromstring(f'{code}' + '') + assert _gpif_left_fingering(n) == expected + + +def test_gpif_left_fingering_absent_or_unknown_is_unset(): + # No child, or an unrecognised value -> -1 (never fabricate). + assert _gpif_left_fingering(ET.fromstring('')) == -1 + assert _gpif_left_fingering( + ET.fromstring('Z')) == -1 + assert _gpif_left_fingering( + ET.fromstring('')) == -1 + + # ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ───────── # GP7/GP8 GPIF carries authored chord diagrams under a track's # Property[@name="DiagramCollection"]. Each Item gives the chord name and a diff --git a/tests/test_song.py b/tests/test_song.py index 01163093..655dfe6f 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -20,10 +20,14 @@ chord_to_wire, sanitize_tempos, compute_smart_names, + base_open_string_midis, + key_to_tonic_pc, note_from_wire, note_to_wire, + note_pitch_midi, phrase_from_wire, phrase_to_wire, + scale_degree_for_pitch, ) @@ -204,6 +208,109 @@ def test_note_bend_shape_omitted_when_default(): assert decoded.bend_values is None +# ── Teaching marks (§6.2.2) ────────────────────────────────────────────────── + +def test_note_teaching_marks_round_trip(): + """fg/ch/sd survive the wire under their literal keys. + + Pin the public wire keys explicitly (cross-language sloppak readers key + off the literal strings), like the rh/pkd test above. + """ + n = Note( + time=0.0, string=0, fret=0, + fret_finger=2, strum_group=5, scale_degree=7, + ) + wire = note_to_wire(n) + assert wire["fg"] == 2 + assert wire["ch"] == 5 + assert wire["sd"] == 7 + assert note_from_wire(wire) == n + + +def test_note_teaching_marks_omitted_when_default(): + """fg/ch/sd are default-omitted (-1) and decode back to -1.""" + wire = note_to_wire(Note(time=0.0, string=0, fret=0)) + for omitted in ("fg", "ch", "sd"): + assert omitted not in wire, f"{omitted!r} should be default-omitted" + decoded = note_from_wire(wire) + assert decoded.fret_finger == -1 + assert decoded.strum_group == -1 + assert decoded.scale_degree == -1 + + +def test_note_teaching_marks_tolerate_malformed_optional_ints(): + """fg/ch/sd survive null / empty / non-numeric wire values.""" + for bad in (None, "", " ", "x", "inf"): + n = note_from_wire({"t": 0.0, "s": 0, "f": 0, + "fg": bad, "ch": bad, "sd": bad}) + assert n.fret_finger == -1 + assert n.strum_group == -1 + assert n.scale_degree == -1 + + +# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ────────────────────────── + +@pytest.mark.parametrize("key,pc", [ + ("C", 0), ("c", 0), + ("E", 4), ("Em", 4), ("E minor", 4), + ("G", 7), ("G major", 7), ("Gmaj", 7), + ("A#m", 10), ("Bb", 10), # enharmonic — same pitch class + ("F#", 6), ("F#m", 6), + ("Cb", 11), ("B#", 0), # accidentals wrap mod 12 +]) +def test_key_to_tonic_pc_parses_key_names(key, pc): + assert key_to_tonic_pc(key) == pc + + +@pytest.mark.parametrize("bad", [None, "", " ", "H", "xyz", "7", 5]) +def test_key_to_tonic_pc_rejects_unparseable(bad): + assert key_to_tonic_pc(bad) is None + + +def test_scale_degree_for_pitch_standard_tuning_key_of_e(): + """Tonic E (pc 4), standard tuning: low-E open -> tonic, A-string fret 2 -> fifth.""" + tonic = key_to_tonic_pc("E") + assert tonic == 4 + low_e_open = 40 # E2 + a_string_fret2 = 45 + 2 # A2 + 2 = B2 + assert scale_degree_for_pitch(low_e_open, tonic) == 0 # tonic + assert scale_degree_for_pitch(a_string_fret2, tonic) == 7 # perfect fifth + assert scale_degree_for_pitch(40 + 3, tonic) == 3 # G2 -> minor third + + +def test_note_pitch_midi_standard_tuning_offsets(): + """`arr.tuning` holds OFFSETS from standard (0 = standard), padded to 6 on + RS-XML; pitch = base + offset + capo + fret. Standard guitar: low-E open -> + 40 (E2), A-string fret 2 -> 47 (B2).""" + arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0]) + assert note_pitch_midi(arr, Note(time=0, string=0, fret=0)) == 40 # low E open + assert note_pitch_midi(arr, Note(time=0, string=1, fret=2)) == 47 # A + 2 = B + # Drop-D (low string offset -2): low-E string open sounds D2 = 38. + drop_d = Arrangement(name="Lead", tuning=[-2, 0, 0, 0, 0, 0]) + assert note_pitch_midi(drop_d, Note(time=0, string=0, fret=0)) == 38 + # Capo 2 raises every sounding pitch by 2 semitones. + capo2 = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0], capo=2) + assert note_pitch_midi(capo2, Note(time=0, string=0, fret=0)) == 42 + + +def test_note_pitch_midi_bass_uses_bass_base(): + """A 4-string arrangement named 'Bass' uses the bass base (low E1 = 28), + not the guitar base (40).""" + bass = Arrangement(name="Bass", tuning=[0, 0, 0, 0]) + assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28 + + +def test_note_pitch_midi_out_of_range_string_is_none(): + arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0]) + assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None + + +def test_base_open_string_midis_bass_vs_guitar(): + assert base_open_string_midis(6, False)[0] == 40 # guitar low E + assert base_open_string_midis(4, True)[0] == 28 # bass low E + assert base_open_string_midis(4, False)[0] == 40 # 4-string guitar voicing + + def test_note_bend_values_rounded_on_wire(): """`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision.""" n = Note(