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
106 changes: 106 additions & 0 deletions JianpuEditor.Tests/Services/OrnamentPlaybackServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,112 @@ public void ScheduleMelodyNote_Fermata_ExtendsDuration()
Assert.Equal(3, events[0].DurationQuarter, 3);
}

[Fact]
public void ScheduleMelodyNote_Staccato_ShortensDurationWithoutMovingStart()
{
var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1));
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Staccato);

var events = OrnamentPlaybackService.ScheduleMelodyNote(
measure,
measure.MelodyNotes[0],
0,
0,
2,
ScoreMidiSchedule.DefaultTonicMidi,
ScoreMidiSchedule.MelodyChannel,
ScoreMidiSchedule.MelodyVelocity);

Assert.Single(events);
Assert.Equal(0, events[0].StartQuarter, 3);
Assert.Equal(1, events[0].DurationQuarter, 3);
}

[Fact]
public void ScheduleMelodyNote_Accent_BoostsVelocityWithoutChangingDuration()
{
var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1));
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Accent);

var events = OrnamentPlaybackService.ScheduleMelodyNote(
measure,
measure.MelodyNotes[0],
0,
0,
2,
ScoreMidiSchedule.DefaultTonicMidi,
ScoreMidiSchedule.MelodyChannel,
ScoreMidiSchedule.MelodyVelocity);

Assert.Single(events);
Assert.Equal(2, events[0].DurationQuarter, 3);
Assert.Equal(ScoreMidiSchedule.MelodyVelocity + OrnamentPlaybackService.AccentVelocityBoost, events[0].Velocity);
}

[Fact]
public void ScheduleMelodyNote_Tenuto_AppliesSmallerVelocityBoostThanAccent()
{
var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1));
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Tenuto);

var events = OrnamentPlaybackService.ScheduleMelodyNote(
measure,
measure.MelodyNotes[0],
0,
0,
2,
ScoreMidiSchedule.DefaultTonicMidi,
ScoreMidiSchedule.MelodyChannel,
ScoreMidiSchedule.MelodyVelocity);

Assert.Single(events);
Assert.Equal(ScoreMidiSchedule.MelodyVelocity + OrnamentPlaybackService.TenutoVelocityBoost, events[0].Velocity);
Assert.True(OrnamentPlaybackService.TenutoVelocityBoost < OrnamentPlaybackService.AccentVelocityBoost);
}

[Fact]
public void ScheduleMelodyNote_StaccatoAndTrillTogether_ShortensEverySegment()
{
var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(3));
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Trill);
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Staccato);

var events = OrnamentPlaybackService.ScheduleMelodyNote(
measure,
measure.MelodyNotes[0],
0,
0,
1,
ScoreMidiSchedule.DefaultTonicMidi,
ScoreMidiSchedule.MelodyChannel,
ScoreMidiSchedule.MelodyVelocity);

Assert.True(events.Count >= 4);
var expectedSegmentDuration = 1.0 / events.Count * OrnamentPlaybackService.StaccatoDurationMultiplier;
Assert.Equal(expectedSegmentDuration, events[0].DurationQuarter, 3);
}

[Fact]
public void ScheduleMelodyNote_VelocityBoost_NeverExceedsMidiMaximum()
{
var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1));
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Accent);
OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Tenuto);

var events = OrnamentPlaybackService.ScheduleMelodyNote(
measure,
measure.MelodyNotes[0],
0,
0,
1,
ScoreMidiSchedule.DefaultTonicMidi,
ScoreMidiSchedule.MelodyChannel,
120);

Assert.Single(events);
Assert.Equal(127, events[0].Velocity);
}

[Fact]
public void Build_IncludesOrnamentExpandedMelodyNotes()
{
Expand Down
3 changes: 3 additions & 0 deletions JianpuEditor.Tests/Services/OrnamentServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ public void GetPlaceholderGlyph_ReturnsToolbarLabel()
Assert.Equal("trn", OrnamentService.GetPlaceholderGlyph(OrnamentType.Turn));
Assert.Equal("ferm", OrnamentService.GetPlaceholderGlyph(OrnamentType.Fermata));
Assert.Equal("br", OrnamentService.GetPlaceholderGlyph(OrnamentType.BreathMark));
Assert.Equal("stac", OrnamentService.GetPlaceholderGlyph(OrnamentType.Staccato));
Assert.Equal("acc", OrnamentService.GetPlaceholderGlyph(OrnamentType.Accent));
Assert.Equal("ten", OrnamentService.GetPlaceholderGlyph(OrnamentType.Tenuto));
}

[Fact]
Expand Down
3 changes: 3 additions & 0 deletions JianpuEditor/Controls/RibbonIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public enum RibbonIcon
Mordent,
Fermata,
BreathMark,
Staccato,
Accent,
Tenuto,
Duplicate,
Delete,
Library,
Expand Down
14 changes: 14 additions & 0 deletions JianpuEditor/Controls/RibbonIconRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ private static void DrawIcon(Graphics g, RibbonIcon icon, Pen pen, Brush brush)
g.DrawLine(breathPen, 6f, 4f, 13f, 15f);
}

return;
case RibbonIcon.Staccato:
g.FillEllipse(brush, 8f, 8f, 4f, 4f);
return;
case RibbonIcon.Accent:
g.DrawLine(pen, 4f, 5f, 14f, 10f);
g.DrawLine(pen, 14f, 10f, 4f, 15f);
return;
case RibbonIcon.Tenuto:
using (var tenutoPen = new Pen(pen.Color, pen.Width * 1.6f))
{
g.DrawLine(tenutoPen, 4f, 10f, 16f, 10f);
}

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

Expand Down Expand Up @@ -807,6 +819,10 @@ private FlowLayoutPanel BuildToolbarPanel()
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.BreathMark, "Breath mark", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)), compact: true));
ornaments.AddRow(
CreateRibbonButton(RibbonIcon.Staccato, "Staccato", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Staccato)), compact: true),
CreateRibbonButton(RibbonIcon.Accent, "Accent", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Accent)), compact: true),
CreateRibbonButton(RibbonIcon.Tenuto, "Tenuto", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Tenuto)), compact: true));
panel.Controls.Add(ornaments);

var measures = new RibbonGroup("Measures");
Expand Down Expand Up @@ -1157,6 +1173,9 @@ private void AddNoteContextMenuItems(ContextMenuStrip menu)
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)));
ornamentsMenu.DropDownItems.Add("Staccato", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Staccato)));
ornamentsMenu.DropDownItems.Add("Accent", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Accent)));
ornamentsMenu.DropDownItems.Add("Tenuto", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Tenuto)));
menu.Items.Add(ornamentsMenu);

AddPasteItemIfAvailable(menu);
Expand Down
7 changes: 6 additions & 1 deletion JianpuEditor/Rendering/JianpuRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1363,7 +1363,12 @@ private void DrawOrnamentGlyph(

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

private static float GetOrnamentAnchorX(OrnamentType type, int noteX, int noteWidth)
Expand Down
3 changes: 3 additions & 0 deletions JianpuEditor/Rendering/NoteTopAnnotationPlanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ private static void AnalyzeOrnaments(IReadOnlyList<JianpuOrnament> ornaments, No
case OrnamentType.Trill:
case OrnamentType.Turn:
case OrnamentType.Mordent:
case OrnamentType.Staccato:
case OrnamentType.Accent:
case OrnamentType.Tenuto:
layout.HasCenterOrnament = true;
break;
}
Expand Down
43 changes: 43 additions & 0 deletions JianpuEditor/Services/OrnamentPlaybackService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public static class OrnamentPlaybackService
public const double TrillSegmentQuarter = 0.125;
public const double FermataDurationMultiplier = 1.5;
public const double MinNoteDurationQuarter = 0.0625;
public const double StaccatoDurationMultiplier = 0.5;
public const int AccentVelocityBoost = 24;
public const int TenutoVelocityBoost = 8;

public static List<ScheduledMidiNote> ScheduleMelodyNote(
JianpuMeasure measure,
Expand Down Expand Up @@ -79,6 +82,46 @@ public static List<ScheduledMidiNote> ScheduleMelodyNote(
events.Add(MakeEvent(cursor, duration, note, tonicMidi, channel, velocity));
}

return ApplyArticulation(events, ornaments);
}

/// <summary>Staccato/Accent/Tenuto are expressive modifiers on however many events the note
/// already expanded into above (a plain note, or every segment of a trill/turn/mordent/
/// grace note) rather than pitch-decorating ornaments of their own, so they're applied as a
/// uniform post-process over the whole event group instead of their own branch in the
/// if/else-if chain above.</summary>
private static List<ScheduledMidiNote> ApplyArticulation(
List<ScheduledMidiNote> events,
IReadOnlyList<JianpuOrnament> ornaments)
{
var hasStaccato = ornaments.Any(item => item.Type == OrnamentType.Staccato);
var hasAccent = ornaments.Any(item => item.Type == OrnamentType.Accent);
var hasTenuto = ornaments.Any(item => item.Type == OrnamentType.Tenuto);
if (!hasStaccato && !hasAccent && !hasTenuto)
{
return events;
}

foreach (var scheduledEvent in events)
{
if (hasStaccato)
{
scheduledEvent.DurationQuarter = Math.Max(
MinNoteDurationQuarter,
scheduledEvent.DurationQuarter * StaccatoDurationMultiplier);
}

if (hasAccent)
{
scheduledEvent.Velocity = Math.Min(127, scheduledEvent.Velocity + AccentVelocityBoost);
}

if (hasTenuto)
{
scheduledEvent.Velocity = Math.Min(127, scheduledEvent.Velocity + TenutoVelocityBoost);
}
}

return events;
}

Expand Down
6 changes: 6 additions & 0 deletions JianpuEditor/Services/OrnamentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ public static string GetPlaceholderGlyph(OrnamentType type)
return "mor";
case OrnamentType.BreathMark:
return "br";
case OrnamentType.Staccato:
return "stac";
case OrnamentType.Accent:
return "acc";
case OrnamentType.Tenuto:
return "ten";
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** / **Breath Mark**; the menu **Edit → Ornaments** provides the same options
- Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata** / **Breath Mark** / **Staccato** / **Accent** / **Tenuto**; 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); 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
- The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata / stac / acc / ten); 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; staccato shortens the sounding duration, accent and tenuto boost velocity (accent more than tenuto); 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 / Breath Mark" 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 / Staccato / Accent / Tenuto" 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
Loading