Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ public void RenderToBitmap_EditorStackedAnnotations_DoesNotThrow()
}
}

[Fact]
public void RenderToBitmap_BreathMark_DoesNotThrowInCompactOrDefaultLayout()
{
var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2));
measure.Ornaments = new List<JianpuOrnament>
{
new JianpuOrnament { Type = OrnamentType.BreathMark, NoteIndex = 0 }
};
OrnamentService.NormalizeMeasure(measure);

var score = ScoreTestHelper.CreateScore(measure);
using (var renderer = new JianpuRenderer())
{
Assert.NotNull(renderer.RenderToBitmap(score, 1280, ScoreLayoutOptions.Editor));
Assert.NotNull(renderer.RenderToBitmap(score, 1280, ScoreLayoutOptions.Default));
}
}

[Fact]
public void GetAccidentalMark_ReturnsSharpOrFlat()
{
Expand Down
18 changes: 18 additions & 0 deletions JianpuEditor.Tests/Rendering/NoteTopAnnotationPlannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,24 @@ public void Plan_OctaveOnly_PlacesDotsClosestToNote()
Assert.Equal(layout.HeadCenterX - 3f, layout.OctaveDotCenterX, 1);
}

[Fact]
public void GetOrnamentAnchorX_BreathMark_AnchorsAfterNoteInsteadOfAboveIt()
{
var layout = NoteTopAnnotationPlanner.Plan(
new JianpuNote { Type = NoteType.Note, Pitch = 1 },
100,
28,
new List<JianpuOrnament>(),
compactAccidentals: true);

var breathAnchor = layout.GetOrnamentAnchorX(OrnamentType.BreathMark, 100, 28);
var trillAnchor = layout.GetOrnamentAnchorX(OrnamentType.Trill, 100, 28);

Assert.True(breathAnchor > 100 + 28);
Assert.Equal(layout.HeadCenterX, trillAnchor, 1);
Assert.NotEqual(trillAnchor, breathAnchor);
}

[Fact]
public void GetOrnamentsForNote_ReturnsOnlyMatchingOrnaments()
{
Expand Down
1 change: 1 addition & 0 deletions JianpuEditor.Tests/Services/OrnamentServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public void GetPlaceholderGlyph_ReturnsToolbarLabel()
Assert.Equal("tr", OrnamentService.GetPlaceholderGlyph(OrnamentType.Trill));
Assert.Equal("trn", OrnamentService.GetPlaceholderGlyph(OrnamentType.Turn));
Assert.Equal("ferm", OrnamentService.GetPlaceholderGlyph(OrnamentType.Fermata));
Assert.Equal("br", OrnamentService.GetPlaceholderGlyph(OrnamentType.BreathMark));
}

[Fact]
Expand Down
1 change: 1 addition & 0 deletions JianpuEditor/Controls/RibbonIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum RibbonIcon
Turn,
Mordent,
Fermata,
BreathMark,
Duplicate,
Delete,
Library,
Expand Down
7 changes: 7 additions & 0 deletions JianpuEditor/Controls/RibbonIconRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ private static void DrawIcon(Graphics g, RibbonIcon icon, Pen pen, Brush brush)
case RibbonIcon.Fermata:
g.DrawArc(pen, 3f, 6f, 14f, 12f, 180f, 180f);
g.FillEllipse(brush, 8.8f, 9.4f, 2.4f, 2.4f);
return;
case RibbonIcon.BreathMark:
using (var breathPen = new Pen(pen.Color, pen.Width * 1.4f))
{
g.DrawLine(breathPen, 6f, 4f, 13f, 15f);
}

return;
case RibbonIcon.Duplicate:
g.DrawRectangle(pen, 3f, 6f, 10f, 10f);
Expand Down
8 changes: 7 additions & 1 deletion JianpuEditor/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,10 @@ private void PopulateMenuStrip(MenuStrip menu)
"Fermata",
Keys.None,
(s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata))));
ornamentMenu.DropDownItems.Add(CreateMenuItem(
"Breath Mark",
Keys.None,
(s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark))));
editMenu.DropDownItems.Add(ornamentMenu);
editMenu.DropDownItems.Add(CreateMenuItem("Clear Score", Keys.None, OnClearScore));

Expand Down Expand Up @@ -801,7 +805,8 @@ private FlowLayoutPanel BuildToolbarPanel()
CreateRibbonButton(RibbonIcon.Trill, "Trill", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Trill)), compact: true),
CreateRibbonButton(RibbonIcon.Turn, "Turn", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Turn)), compact: true),
CreateRibbonButton(RibbonIcon.Mordent, "Mordent", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Mordent)), compact: true),
CreateRibbonButton(RibbonIcon.Fermata, "Fermata", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)), compact: true));
CreateRibbonButton(RibbonIcon.Fermata, "Fermata", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)), compact: true),
CreateRibbonButton(RibbonIcon.BreathMark, "Breath mark", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)), compact: true));
panel.Controls.Add(ornaments);

var measures = new RibbonGroup("Measures");
Expand Down Expand Up @@ -1151,6 +1156,7 @@ private void AddNoteContextMenuItems(ContextMenuStrip menu)
ornamentsMenu.DropDownItems.Add("Turn", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Turn)));
ornamentsMenu.DropDownItems.Add("Mordent", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Mordent)));
ornamentsMenu.DropDownItems.Add("Fermata", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)));
ornamentsMenu.DropDownItems.Add("Breath Mark", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)));
menu.Items.Add(ornamentsMenu);

AddPasteItemIfAvailable(menu);
Expand Down
1 change: 1 addition & 0 deletions JianpuEditor/Models/OrnamentType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum OrnamentType
RepeatEnd = 11,
Segno = 12,
Coda = 13,
BreathMark = 14,
Custom = 99
}
}
12 changes: 11 additions & 1 deletion JianpuEditor/Rendering/JianpuRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1363,7 +1363,7 @@ private void DrawOrnamentGlyph(

private static bool UsesLatinOrnamentFont(OrnamentType type)
{
return type == OrnamentType.Trill || type == OrnamentType.Mordent;
return type == OrnamentType.Trill || type == OrnamentType.Mordent || type == OrnamentType.BreathMark;
}

private static float GetOrnamentAnchorX(OrnamentType type, int noteX, int noteWidth)
Expand All @@ -1374,6 +1374,11 @@ private static float GetOrnamentAnchorX(OrnamentType type, int noteX, int noteWi
return noteX + Math.Min(14f, headWidth * 0.25f);
}

if (type == OrnamentType.BreathMark)
{
return noteX + headWidth + BreathMarkGap;
}

return GetNoteHeadCenterX(noteX, noteWidth);
}

Expand Down Expand Up @@ -1940,6 +1945,11 @@ private void DrawTextRowCore(

private const float CompactAccidentalFontSize = 10f;

/// <summary>Gap between a note's right edge and a breath mark anchored just after it --
/// breath marks sit in the space between notes, unlike every other ornament here, which is
/// centered above the note itself.</summary>
private const float BreathMarkGap = 3f;

private void DrawSimultaneousNotes(
Graphics g,
JianpuMeasure measure,
Expand Down
12 changes: 9 additions & 3 deletions JianpuEditor/Rendering/NoteTopAnnotationLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public sealed class NoteTopAnnotationLayout

public const float AccidentalMarkWidth = 8f;

/// <summary>Same gap <see cref="JianpuRenderer"/>'s non-compact layout path uses -- kept as
/// its own constant here since this class has no reference to JianpuRenderer's.</summary>
private const float BreathMarkGap = 3f;

public float HeadCenterX { get; set; }

public float OctaveDotCenterX { get; set; }
Expand Down Expand Up @@ -59,9 +63,11 @@ public sealed class NoteTopAnnotationLayout

public float GetOrnamentAnchorX(OrnamentType type, int noteX, int headWidth)
{
_ = type;
_ = noteX;
_ = headWidth;
if (type == OrnamentType.BreathMark)
{
return noteX + headWidth + BreathMarkGap;
}

return HeadCenterX;
}

Expand Down
2 changes: 2 additions & 0 deletions JianpuEditor/Services/OrnamentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ public static string GetPlaceholderGlyph(OrnamentType type)
return "ferm";
case OrnamentType.Mordent:
return "mor";
case OrnamentType.BreathMark:
return "br";
default:
return type.ToString();
}
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti
- Menu **Edit → Undo** (**Ctrl+Z**) / **Redo** (**Ctrl+Y**) steps backward or forward through score edits
- Covers note editing, deletion, measures, transposition, header/lyric/chord inline editing, ties, ornaments, bulk lyric editing, etc.; the undo/redo stack is cleared after creating, opening, or loading a score
- **Ornaments**
- Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata**; the menu **Edit → Ornaments** provides the same options
- Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata** / **Breath Mark**; the menu **Edit → Ornaments** provides the same options
- Select one or more notes first, then click an ornament button; with multiple selection, ornaments are added in batch
- Clicking the same button again removes that type of ornament from the note (other types are kept)
- Delete / "Delete" removes ornaments on the selected note first
- The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata)
- Score playback and MIDI export expand grace notes, trills, turns, mordents, and fermata durations
- The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata); a breath mark draws just after the note instead, matching where it's placed in notasi angka/jianpu sheet music
- Score playback and MIDI export expand grace notes, trills, turns, mordents, and fermata durations; a breath mark is visual-only and doesn't affect playback/export
- **Ties**
- Click "Tie" → select the start note → select the end note; Esc to cancel
- The end note must be the same pitch as the start note (a tie sustains one pitch); picking a
Expand Down Expand Up @@ -185,7 +185,7 @@ You can also manually specify a version number in GitHub under **Actions → Rel
| Split / Merge | Splits or merges the duration of selected notes |
| Toolbar "From / To" + Copy Measures | Copies measures within the specified range |
| Tie | Click "Tie" → select the start/end note; click the arc to select it, Delete to remove |
| Ornaments | Select a note, then click "Grace Note / Trill / Turn / Mordent / Fermata" in the toolbar; click the same button again to remove it |
| Ornaments | Select a note, then click "Grace Note / Trill / Turn / Mordent / Fermata / Breath Mark" in the toolbar; click the same button again to remove it |
| Undo / Redo | **Edit → Undo / Redo** or **Ctrl+Z** / **Ctrl+Y** |
| Play / Stop | Plays the score according to BPM; drag the blue progress bar to seek |
| Transpose | Menu "Edit → Chord Transpose..."; transposes chord markers only |
Expand Down
28 changes: 22 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ Until now, playback always used whatever General MIDI patch 0 (Acoustic Grand Pi

## Indonesian notasi angka completeness (gap analysis + plan)

**Status: phase 3's bug-fix half and the Mordent half of phase 4 are done. Everything else below
is still not started**, including phases 1-2 (`NotationStyle` + the accidental slash convention)
-- see the note under phase 2 for why that one turned out to be more involved than it looked.
**Status: phase 3's bug-fix half, the Mordent half of phase 4, and phase 6 (breath marks) are
done. Everything else below is still not started**, including phases 1-2 (`NotationStyle` + the
accidental slash convention) -- see the note under phase 2 for why that one turned out to be more
involved than it looked, and the new note there about the natural sign specifically.

Researched Indonesian *notasi angka* (Indonesian numbered/jianpu notation — rules, symbols,
conventions) against what `JianpuEditor` actually implements. Full gap list and phased plan below.
Expand Down Expand Up @@ -102,6 +103,15 @@ here and intentionally excluded.*
doesn't carry accidentals through a measure, so it's just a third independent glyph state) can
land separately and first, since it doesn't touch the suffix-vs-prefix positioning question at
all.
**Checked before starting this, found a bigger prerequisite gap**: `AccidentalKind.Sharp`/`Flat`
have zero manual entry UI anywhere in the app today (`JianpuPitchCodec.SetAccidentalPitch` is
never called outside its own definition and tests) — the only code path that ever produces an
accidental note is `MidiImportService` reading a chromatic pitch out of an imported file. So
before a `Natural` sign is actually reachable by a user hand-notating a score (as opposed to one
that only shows up after a MIDI import), this needs a manual sharp/flat/natural entry command
too — a real, if small, UI feature of its own, not just a third glyph case. Scoping that
alongside the glyph work, rather than shipping a glyph nothing can ever attach, is the right
order once this phase is picked up.
3. **Tie/slur pitch bug: the validation half is done; slur support is not.** Added a same-pitch
check to `TieEditorViewModel.TryCompleteTie` (rejects with a status message and treats the
mismatched note as a new start candidate, mirroring the existing "must come after" rejection
Expand All @@ -124,9 +134,15 @@ here and intentionally excluded.*
plus optionally a hairpin start/end pair), a small "add dynamic here" UI mirroring how chord
markers already attach to a beat, rendering below the melody row, and a playback/MIDI-export
velocity-scaling pass applied to notes until the next marking.
6. **Breath marks.** Simplest of the remaining additive items — fold into the existing
`Ornaments` per-note-index list (`OrnamentType.BreathMark`, new value), visual-only glyph, no
playback effect in v1 (a version that inserts a micro-rest is a possible follow-up, not v1).
6. **Breath marks — done.** Added `OrnamentType.BreathMark`, folded into the existing per-note
ornament list/UI pattern (ribbon + Edit menu + context menu, same as Mordent). Unlike every
other ornament here, it's anchored just *after* the note's right edge instead of centered above
it (`JianpuRenderer.GetOrnamentAnchorX` / `NoteTopAnnotationLayout.GetOrnamentAnchorX`, one new
branch each) since that's where a breath mark actually sits in notasi angka/jianpu sheet music
— but it deliberately reuses the existing ornament stacking band for its Y position and doesn't
participate in `NoteTopAnnotationPlanner`'s accidental/octave-dot collision math at all, so it
carries none of the layout risk flagged under phase 2. Visual-only, no playback/MIDI-export
effect (a version that inserts a micro-rest is a possible follow-up, not v1).
7. **Repeat bar lines, volta brackets, and D.C./D.S./Coda/Segno navigation.** Two sub-parts with
very different risk:
- **Visual-only** (lower risk): a `BarLineType` field per measure boundary (single/double/
Expand Down