diff --git a/JianpuEditor.Tests/Glue/DocumentTabTests.cs b/JianpuEditor.Tests/Glue/DocumentTabTests.cs index 83b5e6d..2478c58 100644 --- a/JianpuEditor.Tests/Glue/DocumentTabTests.cs +++ b/JianpuEditor.Tests/Glue/DocumentTabTests.cs @@ -80,6 +80,7 @@ private static ServiceProvider BuildServiceProvider() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/JianpuEditor.Tests/MainFormMultiTabTests.cs b/JianpuEditor.Tests/MainFormMultiTabTests.cs index 2c0bb0e..8f07984 100644 --- a/JianpuEditor.Tests/MainFormMultiTabTests.cs +++ b/JianpuEditor.Tests/MainFormMultiTabTests.cs @@ -289,6 +289,7 @@ private static ServiceProvider BuildServiceProvider() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs b/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs index 6b3e0b6..6ef2edb 100644 --- a/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs +++ b/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs @@ -81,6 +81,20 @@ public void RenderToBitmap_SegnoAndCoda_DoesNotThrowInCompactOrDefaultLayout() } } + [Fact] + public void RenderToBitmap_DynamicMarking_DoesNotThrow() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2)); + DynamicMarkingService.TrySetForNote(measure, 0, "mf"); + + 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() { diff --git a/JianpuEditor.Tests/Rendering/StaffRowLayoutTests.cs b/JianpuEditor.Tests/Rendering/StaffRowLayoutTests.cs new file mode 100644 index 0000000..f290b8d --- /dev/null +++ b/JianpuEditor.Tests/Rendering/StaffRowLayoutTests.cs @@ -0,0 +1,32 @@ +using JianpuEditor.Rendering; +using Xunit; + +namespace JianpuEditor.Tests.Rendering +{ + /// Locks in the row-stacking invariant added for the dynamics row: each row's top must + /// sit exactly one RowGap below the row above it's bottom edge, and the sum of every row plus + /// its gaps must equal StaffBlockHeight -- otherwise rows would overlap or StaffBlockHeight + /// (used everywhere: block bounds, hit-testing, PDF pagination content height, block stacking) + /// would silently drift out of sync with what's actually drawn. + public sealed class StaffRowLayoutTests + { + [Fact] + public void RowTops_StackWithoutOverlapOrGapDrift() + { + var measure = new JianpuRenderer.MeasureLayout { BlockTop = 100, X = 0, Width = 200 }; + + var melodyBottom = measure.BlockTop + JianpuRenderer.MelodyRowHeight; + var dynamicsTop = JianpuRenderer.GetDynamicsRowTop(measure); + var dynamicsBottom = dynamicsTop + JianpuRenderer.DynamicsRowHeight; + var secondaryTop = JianpuRenderer.GetSecondaryRowTop(measure); + var secondaryBottom = secondaryTop + JianpuRenderer.SecondaryRowHeight; + var lyricTop = JianpuRenderer.GetLyricRowTop(measure); + var lyricBottom = lyricTop + JianpuRenderer.TextRowHeight; + + Assert.Equal(melodyBottom + JianpuRenderer.RowGap, dynamicsTop); + Assert.Equal(dynamicsBottom + JianpuRenderer.RowGap, secondaryTop); + Assert.Equal(secondaryBottom + JianpuRenderer.RowGap, lyricTop); + Assert.Equal(measure.BlockTop + JianpuRenderer.StaffBlockHeight, lyricBottom); + } + } +} diff --git a/JianpuEditor.Tests/Services/DynamicMarkingPlaybackServiceTests.cs b/JianpuEditor.Tests/Services/DynamicMarkingPlaybackServiceTests.cs new file mode 100644 index 0000000..8ef93c1 --- /dev/null +++ b/JianpuEditor.Tests/Services/DynamicMarkingPlaybackServiceTests.cs @@ -0,0 +1,37 @@ +using JianpuEditor.Services; +using Xunit; + +namespace JianpuEditor.Tests.Services +{ + public sealed class DynamicMarkingPlaybackServiceTests + { + [Theory] + [InlineData("pp", DynamicMarkingPlaybackService.PianissimoVelocity)] + [InlineData("p", DynamicMarkingPlaybackService.PianoVelocity)] + [InlineData("mp", DynamicMarkingPlaybackService.MezzoPianoVelocity)] + [InlineData("mf", DynamicMarkingPlaybackService.MezzoForteVelocity)] + [InlineData("f", DynamicMarkingPlaybackService.ForteVelocity)] + [InlineData("ff", DynamicMarkingPlaybackService.FortissimoVelocity)] + public void ResolveVelocity_MapsKnownDynamicText(string text, int expected) + { + Assert.Equal(expected, DynamicMarkingPlaybackService.ResolveVelocity(text, 90)); + } + + [Fact] + public void ResolveVelocity_FallsBackForUnknownText() + { + Assert.Equal(90, DynamicMarkingPlaybackService.ResolveVelocity("cresc.", 90)); + Assert.Equal(90, DynamicMarkingPlaybackService.ResolveVelocity(null, 90)); + } + + [Fact] + public void ResolveVelocity_LevelsIncreaseMonotonically() + { + Assert.True(DynamicMarkingPlaybackService.PianissimoVelocity < DynamicMarkingPlaybackService.PianoVelocity); + Assert.True(DynamicMarkingPlaybackService.PianoVelocity < DynamicMarkingPlaybackService.MezzoPianoVelocity); + Assert.True(DynamicMarkingPlaybackService.MezzoPianoVelocity < DynamicMarkingPlaybackService.MezzoForteVelocity); + Assert.True(DynamicMarkingPlaybackService.MezzoForteVelocity < DynamicMarkingPlaybackService.ForteVelocity); + Assert.True(DynamicMarkingPlaybackService.ForteVelocity < DynamicMarkingPlaybackService.FortissimoVelocity); + } + } +} diff --git a/JianpuEditor.Tests/Services/DynamicMarkingServiceTests.cs b/JianpuEditor.Tests/Services/DynamicMarkingServiceTests.cs new file mode 100644 index 0000000..fdeb864 --- /dev/null +++ b/JianpuEditor.Tests/Services/DynamicMarkingServiceTests.cs @@ -0,0 +1,92 @@ +using JianpuEditor.Models; +using JianpuEditor.Services; +using JianpuEditor.Tests.Helpers; +using Xunit; + +namespace JianpuEditor.Tests.Services +{ + public sealed class DynamicMarkingServiceTests + { + [Fact] + public void TrySetForNote_AddsMarking() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2)); + + var added = DynamicMarkingService.TrySetForNote(measure, 1, "mf"); + + Assert.True(added); + Assert.Single(measure.Dynamics); + Assert.Equal("mf", measure.Dynamics[0].Text); + Assert.Equal(1, DynamicMarkingService.ResolveNoteIndex(measure, measure.Dynamics[0])); + } + + [Fact] + public void TrySetForNote_ReplacesExistingMarkingOnSameNote() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + DynamicMarkingService.TrySetForNote(measure, 0, "p"); + + DynamicMarkingService.TrySetForNote(measure, 0, "ff"); + + Assert.Single(measure.Dynamics); + Assert.Equal("ff", measure.Dynamics[0].Text); + } + + [Fact] + public void TryRemoveForNote_RemovesMarking() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + DynamicMarkingService.TrySetForNote(measure, 0, "f"); + + var removed = DynamicMarkingService.TryRemoveForNote(measure, 0); + + Assert.True(removed); + Assert.Empty(measure.Dynamics); + } + + [Fact] + public void GetMarkingForNote_ReturnsNullWhenNoneSet() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + + Assert.Null(DynamicMarkingService.GetMarkingForNote(measure, 0)); + } + + [Fact] + public void OnNoteRemoved_ShiftsLaterMarkingIndices() + { + var measure = ScoreTestHelper.Measure( + ScoreTestHelper.Note(1), + ScoreTestHelper.Note(2), + ScoreTestHelper.Note(3)); + DynamicMarkingService.TrySetForNote(measure, 2, "f"); + + DynamicMarkingService.OnNoteRemoved(measure, 0); + + Assert.Single(measure.Dynamics); + Assert.Equal(1, measure.Dynamics[0].NoteIndex); + } + + [Fact] + public void OnNoteRemoved_DropsMarkingOnTheRemovedNote() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2)); + DynamicMarkingService.TrySetForNote(measure, 0, "f"); + + DynamicMarkingService.OnNoteRemoved(measure, 0); + + Assert.Empty(measure.Dynamics); + } + + [Fact] + public void NormalizeMeasure_DropsMarkingsWithEmptyText() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + measure.Dynamics.Add(new DynamicMarking { Text = string.Empty, NoteIndex = 0 }); + + DynamicMarkingService.NormalizeMeasure(measure); + + Assert.Empty(measure.Dynamics); + } + } +} diff --git a/JianpuEditor.Tests/Services/PdfExportServiceTests.cs b/JianpuEditor.Tests/Services/PdfExportServiceTests.cs index aa2e025..4a544a6 100644 --- a/JianpuEditor.Tests/Services/PdfExportServiceTests.cs +++ b/JianpuEditor.Tests/Services/PdfExportServiceTests.cs @@ -75,7 +75,7 @@ public void PlanPages_EightLines_SplitsAcrossPages() [Theory] [InlineData(1, 1)] - [InlineData(7, 1)] + [InlineData(7, 2)] [InlineData(8, 2)] public void PlanPages_LineCount_MapsToExpectedPageCount(int lineCount, int expectedPages) { diff --git a/JianpuEditor.Tests/Services/ScoreMidiScheduleTests.cs b/JianpuEditor.Tests/Services/ScoreMidiScheduleTests.cs index c43d860..68d77a6 100644 --- a/JianpuEditor.Tests/Services/ScoreMidiScheduleTests.cs +++ b/JianpuEditor.Tests/Services/ScoreMidiScheduleTests.cs @@ -74,6 +74,59 @@ public void Build_PickupMeasure_SecondMeasureStartsRightAfterShortFirstMeasure() Assert.Equal(6, schedule.TotalQuarterLength, 3); } + [Fact] + public void Build_NoDynamicMarkings_UsesDefaultMelodyVelocityForEveryNote() + { + var score = ScoreTestHelper.CreateScore( + ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2))); + + var schedule = ScoreMidiSchedule.Build(score); + var melodyNotes = schedule.Notes.Where(note => note.Channel == ScoreMidiSchedule.MelodyChannel).ToList(); + + Assert.All(melodyNotes, note => Assert.Equal(ScoreMidiSchedule.MelodyVelocity, note.Velocity)); + } + + [Fact] + public void Build_DynamicMarking_AppliesVelocityFromThatNoteOnwardAcrossMeasures() + { + var score = ScoreTestHelper.CreateScore( + ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2)), + ScoreTestHelper.Measure(ScoreTestHelper.Note(3))); + DynamicMarkingService.TrySetForNote(score.Measures[0], 1, "pp"); + + var schedule = ScoreMidiSchedule.Build(score); + var melodyNotes = schedule.Notes + .Where(note => note.Channel == ScoreMidiSchedule.MelodyChannel) + .OrderBy(note => note.StartQuarter) + .ToList(); + + Assert.Equal(ScoreMidiSchedule.MelodyVelocity, melodyNotes[0].Velocity); + Assert.Equal(DynamicMarkingPlaybackService.PianissimoVelocity, melodyNotes[1].Velocity); + Assert.Equal(DynamicMarkingPlaybackService.PianissimoVelocity, melodyNotes[2].Velocity); + } + + [Fact] + public void Build_SecondDynamicMarking_OverridesTheFirstFromItsNoteOnward() + { + var score = ScoreTestHelper.CreateScore( + ScoreTestHelper.Measure( + ScoreTestHelper.Note(1), + ScoreTestHelper.Note(2), + ScoreTestHelper.Note(3))); + DynamicMarkingService.TrySetForNote(score.Measures[0], 0, "ff"); + DynamicMarkingService.TrySetForNote(score.Measures[0], 2, "pp"); + + var schedule = ScoreMidiSchedule.Build(score); + var melodyNotes = schedule.Notes + .Where(note => note.Channel == ScoreMidiSchedule.MelodyChannel) + .OrderBy(note => note.StartQuarter) + .ToList(); + + Assert.Equal(DynamicMarkingPlaybackService.FortissimoVelocity, melodyNotes[0].Velocity); + Assert.Equal(DynamicMarkingPlaybackService.FortissimoVelocity, melodyNotes[1].Velocity); + Assert.Equal(DynamicMarkingPlaybackService.PianissimoVelocity, melodyNotes[2].Velocity); + } + [Fact] public void Build_SuppressesTieEndNotes() { diff --git a/JianpuEditor.Tests/ViewModels/DynamicsEditorViewModelTests.cs b/JianpuEditor.Tests/ViewModels/DynamicsEditorViewModelTests.cs new file mode 100644 index 0000000..2b567e5 --- /dev/null +++ b/JianpuEditor.Tests/ViewModels/DynamicsEditorViewModelTests.cs @@ -0,0 +1,91 @@ +using JianpuEditor.Models; +using JianpuEditor.Tests.Helpers; +using JianpuEditor.ViewModels; +using Xunit; + +namespace JianpuEditor.Tests.ViewModels +{ + public sealed class DynamicsEditorViewModelTests + { + [Fact] + public void SetDynamic_AppliesToSelectedNote() + { + var (document, selection, messenger, history) = ViewModelTestHelper.CreateDocumentWithSelection(); + document.EnsureMeasures(); + document.Score.Measures[0].MelodyNotes.Add(ScoreTestHelper.Note(1)); + selection.UpdateFrom(new ScoreSelectionInfo { MeasureIndex = 0, NoteIndex = 0 }); + var navigation = ViewModelTestHelper.CreateMeasureNavigation(document, selection, messenger, history); + var editor = ViewModelTestHelper.CreateDynamicsEditor(document, navigation, selection, messenger, history); + + var result = editor.SetDynamic("mf"); + + Assert.True(result.Changed); + Assert.Single(document.Score.Measures[0].Dynamics); + Assert.Equal("mf", document.Score.Measures[0].Dynamics[0].Text); + } + + [Fact] + public void SetDynamic_SameLevelTwice_RemovesIt() + { + var (document, selection, messenger, history) = ViewModelTestHelper.CreateDocumentWithSelection(); + document.EnsureMeasures(); + document.Score.Measures[0].MelodyNotes.Add(ScoreTestHelper.Note(1)); + selection.UpdateFrom(new ScoreSelectionInfo { MeasureIndex = 0, NoteIndex = 0 }); + var navigation = ViewModelTestHelper.CreateMeasureNavigation(document, selection, messenger, history); + var editor = ViewModelTestHelper.CreateDynamicsEditor(document, navigation, selection, messenger, history); + editor.SetDynamic("f"); + + var result = editor.SetDynamic("f"); + + Assert.True(result.Changed); + Assert.Empty(document.Score.Measures[0].Dynamics); + } + + [Fact] + public void SetDynamic_DifferentLevel_ReplacesPreviousOne() + { + var (document, selection, messenger, history) = ViewModelTestHelper.CreateDocumentWithSelection(); + document.EnsureMeasures(); + document.Score.Measures[0].MelodyNotes.Add(ScoreTestHelper.Note(1)); + selection.UpdateFrom(new ScoreSelectionInfo { MeasureIndex = 0, NoteIndex = 0 }); + var navigation = ViewModelTestHelper.CreateMeasureNavigation(document, selection, messenger, history); + var editor = ViewModelTestHelper.CreateDynamicsEditor(document, navigation, selection, messenger, history); + editor.SetDynamic("p"); + + var result = editor.SetDynamic("ff"); + + Assert.True(result.Changed); + Assert.Single(document.Score.Measures[0].Dynamics); + Assert.Equal("ff", document.Score.Measures[0].Dynamics[0].Text); + } + + [Fact] + public void SetDynamic_NoSelection_ReturnsUnchanged() + { + var (document, selection, messenger, history) = ViewModelTestHelper.CreateDocumentWithSelection(); + document.EnsureMeasures(); + var navigation = ViewModelTestHelper.CreateMeasureNavigation(document, selection, messenger, history); + var editor = ViewModelTestHelper.CreateDynamicsEditor(document, navigation, selection, messenger, history); + + var result = editor.SetDynamic("mf"); + + Assert.False(result.Changed); + } + + [Fact] + public void SetDynamic_Undo_RemovesAddedMarking() + { + var (document, selection, messenger, history) = ViewModelTestHelper.CreateDocumentWithSelection(); + document.EnsureMeasures(); + document.Score.Measures[0].MelodyNotes.Add(ScoreTestHelper.Note(1)); + selection.UpdateFrom(new ScoreSelectionInfo { MeasureIndex = 0, NoteIndex = 0 }); + var navigation = ViewModelTestHelper.CreateMeasureNavigation(document, selection, messenger, history); + var editor = ViewModelTestHelper.CreateDynamicsEditor(document, navigation, selection, messenger, history); + editor.SetDynamic("mf"); + + history.Undo(); + + Assert.Empty(document.Score.Measures[0].Dynamics); + } + } +} diff --git a/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs b/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs index 5bbe585..2158383 100644 --- a/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs +++ b/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs @@ -113,6 +113,21 @@ public static OrnamentEditorViewModel CreateOrnamentEditor( messenger); } + public static DynamicsEditorViewModel CreateDynamicsEditor( + ScoreDocumentViewModel document, + MeasureNavigationViewModel navigation, + ScoreSelectionViewModel selection, + IAppMessenger messenger, + IEditCommandHistory history = null) + { + return new DynamicsEditorViewModel( + document, + navigation, + selection, + history ?? CreateHistory(messenger), + messenger); + } + public static ScoreEditorViewModel CreateScoreEditor( ScoreDocumentViewModel document, ScoreSelectionViewModel selection, @@ -152,6 +167,7 @@ public static MainViewModel CreateMainViewModel() history, messenger); var ornamentEditor = new OrnamentEditorViewModel(document, selection, history, messenger); + var dynamicsEditor = new DynamicsEditorViewModel(document, measureNavigation, selection, history, messenger); var scoreEditor = new ScoreEditorViewModel( document, selection, @@ -171,6 +187,7 @@ public static MainViewModel CreateMainViewModel() measureContent, chordEditor, ornamentEditor, + dynamicsEditor, scoreEditor, playback, sampleLibrary, @@ -202,6 +219,7 @@ public static MainViewModel CreateMainViewModel( history, messenger); var ornamentEditor = new OrnamentEditorViewModel(document, selection, history, messenger); + var dynamicsEditor = new DynamicsEditorViewModel(document, measureNavigation, selection, history, messenger); var scoreEditor = new ScoreEditorViewModel( document, selection, @@ -221,6 +239,7 @@ public static MainViewModel CreateMainViewModel( measureContent, chordEditor, ornamentEditor, + dynamicsEditor, scoreEditor, playback, sampleLibrary, diff --git a/JianpuEditor/AppBootstrapper.cs b/JianpuEditor/AppBootstrapper.cs index 8c6454e..d80a41b 100644 --- a/JianpuEditor/AppBootstrapper.cs +++ b/JianpuEditor/AppBootstrapper.cs @@ -39,6 +39,7 @@ public static ServiceProvider ConfigureServices() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/JianpuEditor/Controls/RibbonIcon.cs b/JianpuEditor/Controls/RibbonIcon.cs index 2a1c32a..ec9db24 100644 --- a/JianpuEditor/Controls/RibbonIcon.cs +++ b/JianpuEditor/Controls/RibbonIcon.cs @@ -29,6 +29,12 @@ public enum RibbonIcon Tenuto, Segno, Coda, + DynamicPianissimo, + DynamicPiano, + DynamicMezzoPiano, + DynamicMezzoForte, + DynamicForte, + DynamicFortissimo, Duplicate, Delete, Library, diff --git a/JianpuEditor/Controls/RibbonIconRenderer.cs b/JianpuEditor/Controls/RibbonIconRenderer.cs index c88fa16..2e33892 100644 --- a/JianpuEditor/Controls/RibbonIconRenderer.cs +++ b/JianpuEditor/Controls/RibbonIconRenderer.cs @@ -175,6 +175,24 @@ private static void DrawIcon(Graphics g, RibbonIcon icon, Pen pen, Brush brush) g.DrawLine(pen, 10f, 2f, 10f, 18f); g.DrawLine(pen, 2f, 10f, 18f, 10f); return; + case RibbonIcon.DynamicPianissimo: + DrawDynamicLabel(g, brush, "pp"); + return; + case RibbonIcon.DynamicPiano: + DrawDynamicLabel(g, brush, "p"); + return; + case RibbonIcon.DynamicMezzoPiano: + DrawDynamicLabel(g, brush, "mp"); + return; + case RibbonIcon.DynamicMezzoForte: + DrawDynamicLabel(g, brush, "mf"); + return; + case RibbonIcon.DynamicForte: + DrawDynamicLabel(g, brush, "f"); + return; + case RibbonIcon.DynamicFortissimo: + DrawDynamicLabel(g, brush, "ff"); + return; case RibbonIcon.Duplicate: g.DrawRectangle(pen, 3f, 6f, 10f, 10f); g.DrawLine(pen, 7f, 6f, 7f, 4f); @@ -251,6 +269,15 @@ private static void DrawIcon(Graphics g, RibbonIcon icon, Pen pen, Brush brush) } } + private static void DrawDynamicLabel(Graphics g, Brush brush, string text) + { + using (var font = new Font("Times New Roman", 11f, FontStyle.Bold | FontStyle.Italic)) + using (var format = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }) + { + g.DrawString(text, font, brush, new RectangleF(0f, 0f, Space, Space), format); + } + } + private static void DrawChevron(Graphics g, Pen pen, float centerX, float topY, float width, bool up) { var half = width / 2f; diff --git a/JianpuEditor/MainForm.cs b/JianpuEditor/MainForm.cs index 97d6b1f..a0fe022 100644 --- a/JianpuEditor/MainForm.cs +++ b/JianpuEditor/MainForm.cs @@ -738,6 +738,14 @@ private void PopulateMenuStrip(MenuStrip menu) Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Coda)))); editMenu.DropDownItems.Add(ornamentMenu); + var dynamicsMenu = new ToolStripMenuItem("Dynamics"); + dynamicsMenu.DropDownItems.Add(CreateMenuItem("pp", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("pp")))); + dynamicsMenu.DropDownItems.Add(CreateMenuItem("p", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("p")))); + dynamicsMenu.DropDownItems.Add(CreateMenuItem("mp", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("mp")))); + dynamicsMenu.DropDownItems.Add(CreateMenuItem("mf", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("mf")))); + dynamicsMenu.DropDownItems.Add(CreateMenuItem("f", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("f")))); + dynamicsMenu.DropDownItems.Add(CreateMenuItem("ff", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("ff")))); + editMenu.DropDownItems.Add(dynamicsMenu); editMenu.DropDownItems.Add(CreateMenuItem("Clear Score", Keys.None, OnClearScore)); var viewMenu = new ToolStripMenuItem("View"); @@ -835,6 +843,16 @@ private FlowLayoutPanel BuildToolbarPanel() CreateRibbonButton(RibbonIcon.Coda, "Coda", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Coda)), compact: true)); panel.Controls.Add(ornaments); + var dynamics = new RibbonGroup("Dynamics"); + dynamics.AddRow( + CreateRibbonButton(RibbonIcon.DynamicPianissimo, "pp", () => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("pp")), compact: true), + CreateRibbonButton(RibbonIcon.DynamicPiano, "p", () => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("p")), compact: true), + CreateRibbonButton(RibbonIcon.DynamicMezzoPiano, "mp", () => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("mp")), compact: true), + CreateRibbonButton(RibbonIcon.DynamicMezzoForte, "mf", () => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("mf")), compact: true), + CreateRibbonButton(RibbonIcon.DynamicForte, "f", () => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("f")), compact: true), + CreateRibbonButton(RibbonIcon.DynamicFortissimo, "ff", () => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("ff")), compact: true)); + panel.Controls.Add(dynamics); + var measures = new RibbonGroup("Measures"); _measureSelector.Minimum = 1; _measureSelector.Maximum = 1; @@ -1190,6 +1208,15 @@ private void AddNoteContextMenuItems(ContextMenuStrip menu) ornamentsMenu.DropDownItems.Add("Coda", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Coda))); menu.Items.Add(ornamentsMenu); + var dynamicsMenu = new ToolStripMenuItem("Dynamics"); + dynamicsMenu.DropDownItems.Add("pp", null, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("pp"))); + dynamicsMenu.DropDownItems.Add("p", null, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("p"))); + dynamicsMenu.DropDownItems.Add("mp", null, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("mp"))); + dynamicsMenu.DropDownItems.Add("mf", null, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("mf"))); + dynamicsMenu.DropDownItems.Add("f", null, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("f"))); + dynamicsMenu.DropDownItems.Add("ff", null, (s, e) => ExecuteScoreEdit(() => _viewModel.DynamicsEditor.SetDynamic("ff"))); + menu.Items.Add(dynamicsMenu); + AddPasteItemIfAvailable(menu); } diff --git a/JianpuEditor/Models/DynamicMarking.cs b/JianpuEditor/Models/DynamicMarking.cs new file mode 100644 index 0000000..732d852 --- /dev/null +++ b/JianpuEditor/Models/DynamicMarking.cs @@ -0,0 +1,16 @@ +namespace JianpuEditor.Models +{ + /// A dynamics marking (e.g. "mf", "f") anchored to a melody note, rendered in its own + /// row below the melody and applied as a velocity level to that note and every one after it + /// until the next marking or the end of the score. + public sealed class DynamicMarking + { + public string Text { get; set; } = string.Empty; + + /// Index of the melody note this marking is anchored to (0 = first note). -1 means anchored by beat position only. + public int NoteIndex { get; set; } = -1; + + /// Quarter-beat position within the measure (0 = beat 1). Synced by the normalization logic when NoteIndex is valid. + public double BeatPosition { get; set; } + } +} diff --git a/JianpuEditor/Models/JianpuMeasure.cs b/JianpuEditor/Models/JianpuMeasure.cs index f3e5944..efade6c 100644 --- a/JianpuEditor/Models/JianpuMeasure.cs +++ b/JianpuEditor/Models/JianpuMeasure.cs @@ -16,6 +16,8 @@ public class JianpuMeasure public List Ornaments { get; set; } = new List(); + public List Dynamics { get; set; } = new List(); + public string LyricText { get; set; } = string.Empty; } } diff --git a/JianpuEditor/Rendering/JianpuRenderer.cs b/JianpuEditor/Rendering/JianpuRenderer.cs index 30de80a..525592f 100644 --- a/JianpuEditor/Rendering/JianpuRenderer.cs +++ b/JianpuEditor/Rendering/JianpuRenderer.cs @@ -16,10 +16,11 @@ public sealed class JianpuRenderer : IDisposable public const int MarginLeft = 72; public const int MarginTop = 72; public const int MelodyRowHeight = 88; + public const int DynamicsRowHeight = 24; public const int SecondaryRowHeight = 40; public const int TextRowHeight = SecondaryRowHeight; public const int RowGap = 6; - public const int StaffBlockHeight = MelodyRowHeight + RowGap + SecondaryRowHeight + RowGap + TextRowHeight; + public const int StaffBlockHeight = MelodyRowHeight + RowGap + DynamicsRowHeight + RowGap + SecondaryRowHeight + RowGap + TextRowHeight; public const int StaffBlockSpacing = 48; public const int GapEdgeWidth = 10; public const int BarHitWidth = 10; @@ -34,6 +35,7 @@ public sealed class JianpuRenderer : IDisposable private readonly Font _ornamentLatinStackedFont = new Font("Arial", 11f, FontStyle.Italic); private readonly Font _noteFont = new Font("Arial", 26f, FontStyle.Bold); private readonly Font _secondaryFont = new Font("Arial", 20f, FontStyle.Bold); + private readonly Font _dynamicsFont = new Font("Times New Roman", 14f, FontStyle.Bold | FontStyle.Italic); private bool _disposed; private ScoreLayoutOptions _activeLayoutOptions; @@ -73,6 +75,7 @@ public void Dispose() _ornamentLatinStackedFont.Dispose(); _noteFont.Dispose(); _secondaryFont.Dispose(); + _dynamicsFont.Dispose(); _disposed = true; } @@ -234,7 +237,8 @@ public ScoreHitResult HitTest(JianpuScore score, int width, Point point) } var melodyBottom = measure.BlockTop + MelodyRowHeight; - var secondaryTop = melodyBottom + RowGap; + var dynamicsBottom = melodyBottom + RowGap + DynamicsRowHeight; + var secondaryTop = dynamicsBottom + RowGap; var secondaryBottom = secondaryTop + SecondaryRowHeight; var lyricTop = secondaryBottom + RowGap; var lyricBottom = lyricTop + TextRowHeight; @@ -244,6 +248,15 @@ public ScoreHitResult HitTest(JianpuScore score, int width, Point point) return HitTestMelodyRow(score, measure, point); } + if (point.Y < dynamicsBottom) + { + return new ScoreHitResult + { + HitType = ScoreHitType.Measure, + MeasureIndex = measure.MeasureIndex + }; + } + if (point.Y < secondaryBottom) { return HitTestSecondaryRow(score, measure, point, secondaryTop); @@ -538,11 +551,16 @@ public static Rectangle GetTextCellBounds(MeasureLayout measure, int rowTop, int return new Rectangle(measure.X + 4, rowTop + 2, measure.Width - 8, rowHeight - 4); } - public static int GetSecondaryRowTop(MeasureLayout measure) + public static int GetDynamicsRowTop(MeasureLayout measure) { return measure.BlockTop + MelodyRowHeight + RowGap; } + public static int GetSecondaryRowTop(MeasureLayout measure) + { + return GetDynamicsRowTop(measure) + DynamicsRowHeight + RowGap; + } + public static int GetLyricRowTop(MeasureLayout measure) { return GetSecondaryRowTop(measure) + SecondaryRowHeight + RowGap; @@ -562,8 +580,9 @@ private void DrawPdfContinuationHeader(Graphics g, JianpuScore score, int width, private void DrawRowLabelsForBlock(Graphics g, int blockTop) { DrawRowLabel(g, "Melody", blockTop + 28); - DrawRowLabel(g, "Secondary", blockTop + MelodyRowHeight + RowGap + 10); - DrawRowLabel(g, "Lyrics", blockTop + MelodyRowHeight + RowGap + SecondaryRowHeight + RowGap + 8); + DrawRowLabel(g, "Dynamics", blockTop + MelodyRowHeight + RowGap + 6); + DrawRowLabel(g, "Secondary", blockTop + MelodyRowHeight + RowGap + DynamicsRowHeight + RowGap + 10); + DrawRowLabel(g, "Lyrics", blockTop + MelodyRowHeight + RowGap + DynamicsRowHeight + RowGap + SecondaryRowHeight + RowGap + 8); } private void DrawHeader(Graphics g, JianpuScore score, int width, ScoreLayoutOptions options) @@ -765,8 +784,9 @@ private void DrawRowLabels(Graphics g, ScoreLayout layout) var blockTop = layout.Lines[0].BlockTop; DrawRowLabel(g, "Melody", blockTop + 28); - DrawRowLabel(g, "Secondary", blockTop + MelodyRowHeight + RowGap + 10); - DrawRowLabel(g, "Lyrics", blockTop + MelodyRowHeight + RowGap + SecondaryRowHeight + RowGap + 8); + DrawRowLabel(g, "Dynamics", blockTop + MelodyRowHeight + RowGap + 6); + DrawRowLabel(g, "Secondary", blockTop + MelodyRowHeight + RowGap + DynamicsRowHeight + RowGap + 10); + DrawRowLabel(g, "Lyrics", blockTop + MelodyRowHeight + RowGap + DynamicsRowHeight + RowGap + SecondaryRowHeight + RowGap + 8); } private void DrawRowLabel(Graphics g, string text, float y) @@ -876,6 +896,7 @@ private void DrawStaffLineRange( } DrawMelodyRow(g, measureData, measure, selectedMeasureIndex, selectedNoteIndex, selectedInsertIndex, selectedNotes); + DrawDynamicsRow(g, measureData, measure); DrawChordMarkersRow( g, measureData, @@ -1672,6 +1693,31 @@ private ScoreHitResult HitTestSecondaryRow(JianpuScore score, MeasureLayout meas }; } + private void DrawDynamicsRow(Graphics g, JianpuMeasure measureData, MeasureLayout measure) + { + DynamicMarkingService.NormalizeMeasure(measureData); + if (measureData.Dynamics.Count == 0) + { + return; + } + + var rowBounds = DynamicMarkingLayout.GetRowBounds(measure); + using (var ink = CreateInkBrush()) + { + foreach (var marking in measureData.Dynamics) + { + if (string.IsNullOrWhiteSpace(marking.Text)) + { + continue; + } + + var anchorX = ChordMarkerLayout.GetBeatAnchorX(measure, measureData, marking.BeatPosition); + var y = rowBounds.Top + (rowBounds.Height - _dynamicsFont.Height) / 2f; + g.DrawString(marking.Text, _dynamicsFont, ink, anchorX, y); + } + } + } + private void DrawChordMarkersRow( Graphics g, JianpuMeasure measureData, diff --git a/JianpuEditor/Services/DynamicMarkingLayout.cs b/JianpuEditor/Services/DynamicMarkingLayout.cs new file mode 100644 index 0000000..dd6bc49 --- /dev/null +++ b/JianpuEditor/Services/DynamicMarkingLayout.cs @@ -0,0 +1,14 @@ +using System.Drawing; +using JianpuEditor.Rendering; + +namespace JianpuEditor.Services +{ + public static class DynamicMarkingLayout + { + public static Rectangle GetRowBounds(JianpuRenderer.MeasureLayout layout) + { + var top = JianpuRenderer.GetDynamicsRowTop(layout); + return new Rectangle(layout.X, top, layout.Width, JianpuRenderer.DynamicsRowHeight); + } + } +} diff --git a/JianpuEditor/Services/DynamicMarkingPlaybackService.cs b/JianpuEditor/Services/DynamicMarkingPlaybackService.cs new file mode 100644 index 0000000..987daa5 --- /dev/null +++ b/JianpuEditor/Services/DynamicMarkingPlaybackService.cs @@ -0,0 +1,38 @@ +namespace JianpuEditor.Services +{ + /// Maps a dynamics marking's text to a fixed MIDI velocity, evenly spaced across the + /// 0-127 range (roughly matching common notation-software defaults). Unrecognized text (a + /// score with no dynamics at all, or a future marking this doesn't know about yet) falls back + /// to whatever velocity was already in effect, so a score with none of these markings schedules + /// byte-identical output to before this existed. + public static class DynamicMarkingPlaybackService + { + public const int PianissimoVelocity = 33; + public const int PianoVelocity = 49; + public const int MezzoPianoVelocity = 64; + public const int MezzoForteVelocity = 80; + public const int ForteVelocity = 96; + public const int FortissimoVelocity = 112; + + public static int ResolveVelocity(string text, int fallback) + { + switch ((text ?? string.Empty).Trim()) + { + case "pp": + return PianissimoVelocity; + case "p": + return PianoVelocity; + case "mp": + return MezzoPianoVelocity; + case "mf": + return MezzoForteVelocity; + case "f": + return ForteVelocity; + case "ff": + return FortissimoVelocity; + default: + return fallback; + } + } + } +} diff --git a/JianpuEditor/Services/DynamicMarkingService.cs b/JianpuEditor/Services/DynamicMarkingService.cs new file mode 100644 index 0000000..a2e3184 --- /dev/null +++ b/JianpuEditor/Services/DynamicMarkingService.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JianpuEditor.Models; +using JianpuEditor.Rendering; + +namespace JianpuEditor.Services +{ + /// Mirrors the note-index/beat-position resolution pattern + /// uses, but with "at most one marking per note" toggle/replace semantics instead of ornaments' + /// additive "several types can coexist on one note" semantics -- a note can't be both piano and + /// forte at the same instant. + public static class DynamicMarkingService + { + public static void NormalizeMeasure(JianpuMeasure measure) + { + if (measure == null) + { + return; + } + + if (measure.Dynamics == null) + { + measure.Dynamics = new List(); + } + + TrimAndSort(measure); + } + + public static int ResolveNoteIndex(JianpuMeasure measure, DynamicMarking marking) + { + if (measure == null || marking == null) + { + return -1; + } + + if (marking.NoteIndex >= 0 && marking.NoteIndex < (measure.MelodyNotes?.Count ?? 0)) + { + return marking.NoteIndex; + } + + return FindNoteIndexAtBeat(measure, marking.BeatPosition); + } + + public static DynamicMarking GetMarkingForNote(JianpuMeasure measure, int noteIndex) + { + NormalizeMeasure(measure); + return measure.Dynamics.FirstOrDefault(marking => ResolveNoteIndex(measure, marking) == noteIndex); + } + + public static bool TrySetForNote(JianpuMeasure measure, int noteIndex, string text) + { + if (measure == null || string.IsNullOrWhiteSpace(text)) + { + return false; + } + + if (noteIndex < 0 || noteIndex >= (measure.MelodyNotes?.Count ?? 0)) + { + return false; + } + + NormalizeMeasure(measure); + var beatPosition = LyricSyllableService.GetNoteBeatPosition(measure, noteIndex); + var existingIndex = measure.Dynamics.FindIndex(marking => ResolveNoteIndex(measure, marking) == noteIndex); + var updated = new DynamicMarking + { + Text = text, + NoteIndex = noteIndex, + BeatPosition = beatPosition + }; + + if (existingIndex >= 0) + { + measure.Dynamics[existingIndex] = updated; + } + else + { + measure.Dynamics.Add(updated); + } + + TrimAndSort(measure); + return true; + } + + public static bool TryRemoveForNote(JianpuMeasure measure, int noteIndex) + { + if (measure?.Dynamics == null) + { + return false; + } + + var removed = measure.Dynamics.RemoveAll(marking => ResolveNoteIndex(measure, marking) == noteIndex); + if (removed <= 0) + { + return false; + } + + TrimAndSort(measure); + return true; + } + + public static void OnNoteRemoved(JianpuMeasure measure, int removedNoteIndex) + { + if (measure?.Dynamics == null || measure.Dynamics.Count == 0) + { + return; + } + + measure.Dynamics.RemoveAll(marking => marking.NoteIndex == removedNoteIndex); + foreach (var marking in measure.Dynamics) + { + if (marking.NoteIndex > removedNoteIndex) + { + marking.NoteIndex--; + } + } + + TrimAndSort(measure); + } + + public static List CloneMarkings(IReadOnlyList markings) + { + var clone = new List(); + if (markings == null) + { + return clone; + } + + foreach (var marking in markings) + { + if (marking == null) + { + continue; + } + + clone.Add(new DynamicMarking + { + Text = marking.Text, + NoteIndex = marking.NoteIndex, + BeatPosition = marking.BeatPosition + }); + } + + return clone; + } + + private static void TrimAndSort(JianpuMeasure measure) + { + var noteCount = measure.MelodyNotes?.Count ?? 0; + var normalized = new List(); + foreach (var marking in measure.Dynamics) + { + if (marking == null || string.IsNullOrWhiteSpace(marking.Text)) + { + continue; + } + + var clone = new DynamicMarking + { + Text = marking.Text, + NoteIndex = marking.NoteIndex, + BeatPosition = marking.BeatPosition + }; + + if (clone.NoteIndex >= 0 && clone.NoteIndex < noteCount) + { + clone.BeatPosition = LyricSyllableService.GetNoteBeatPosition(measure, clone.NoteIndex); + } + else + { + clone.NoteIndex = -1; + } + + normalized.Add(clone); + } + + measure.Dynamics.Clear(); + foreach (var marking in normalized.OrderBy(marking => marking.BeatPosition)) + { + measure.Dynamics.Add(marking); + } + } + + private static int FindNoteIndexAtBeat(JianpuMeasure measure, double beatPosition) + { + var notes = measure?.MelodyNotes; + if (notes == null || notes.Count == 0) + { + return -1; + } + + var beat = 0.0; + for (var i = 0; i < notes.Count; i++) + { + if (Math.Abs(beat - beatPosition) < 0.001) + { + return i; + } + + beat += JianpuRenderer.GetDurationUnits(notes[i]); + } + + return -1; + } + } +} diff --git a/JianpuEditor/Services/ScoreMidiSchedule.cs b/JianpuEditor/Services/ScoreMidiSchedule.cs index 2ec7061..72721e4 100644 --- a/JianpuEditor/Services/ScoreMidiSchedule.cs +++ b/JianpuEditor/Services/ScoreMidiSchedule.cs @@ -92,12 +92,14 @@ private static List BuildMelodyNotes(JianpuScore score) var tieExtensionCache = new Dictionary(); var events = new List(); var quarterTime = 0.0; + var currentVelocity = MelodyVelocity; var measures = score.Measures ?? new List(); for (var measureIndex = 0; measureIndex < measures.Count; measureIndex++) { var measure = measures[measureIndex]; MelodyChordService.NormalizeMeasure(measure); + DynamicMarkingService.NormalizeMeasure(measure); var notes = measure.MelodyNotes; if (notes == null) { @@ -106,6 +108,12 @@ private static List BuildMelodyNotes(JianpuScore score) for (var noteIndex = 0; noteIndex < notes.Count; noteIndex++) { + var dynamicMarking = DynamicMarkingService.GetMarkingForNote(measure, noteIndex); + if (dynamicMarking != null) + { + currentVelocity = DynamicMarkingPlaybackService.ResolveVelocity(dynamicMarking.Text, currentVelocity); + } + var slotNote = notes[noteIndex]; var duration = JianpuRenderer.GetDurationUnits(slotNote); var position = new NotePosition(measureIndex, noteIndex); @@ -137,7 +145,7 @@ private static List BuildMelodyNotes(JianpuScore score) totalDuration, tonicMidi, MelodyChannel, - MelodyVelocity)); + currentVelocity)); } else { @@ -149,7 +157,7 @@ private static List BuildMelodyNotes(JianpuScore score) DurationQuarter = totalDuration, MidiNote = ToMelodyMidiNote(note, tonicMidi), Channel = MelodyChannel, - Velocity = MelodyVelocity + Velocity = currentVelocity }); } } diff --git a/JianpuEditor/ViewModels/DynamicsEditorViewModel.cs b/JianpuEditor/ViewModels/DynamicsEditorViewModel.cs new file mode 100644 index 0000000..e55b86d --- /dev/null +++ b/JianpuEditor/ViewModels/DynamicsEditorViewModel.cs @@ -0,0 +1,85 @@ +using System; +using JianpuEditor.Core.Abstractions; +using JianpuEditor.Core.Messaging; +using JianpuEditor.Core.Messaging.Messages; +using JianpuEditor.Services; +using JianpuEditor.Services.EditCommands; + +namespace JianpuEditor.ViewModels +{ + public sealed class DynamicsEditorViewModel + { + private readonly ScoreDocumentViewModel _document; + private readonly MeasureNavigationViewModel _navigation; + private readonly ScoreSelectionViewModel _selection; + private readonly IEditCommandHistory _history; + private readonly IAppMessenger _messenger; + + public DynamicsEditorViewModel( + ScoreDocumentViewModel document, + MeasureNavigationViewModel navigation, + ScoreSelectionViewModel selection, + IEditCommandHistory history, + IAppMessenger messenger) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + _navigation = navigation ?? throw new ArgumentNullException(nameof(navigation)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _history = history ?? throw new ArgumentNullException(nameof(history)); + _messenger = messenger ?? throw new ArgumentNullException(nameof(messenger)); + } + + /// Applies a dynamic marking to the currently selected note, or removes it if + /// that note already carries the same marking (mirroring the toggle-on-repeat-click pattern + /// the ornament buttons already use). Only the single selected note is affected -- unlike + /// ornaments, this doesn't batch across a multi-note selection in this first version. + public ScoreEditResult SetDynamic(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return ScoreEditResult.Unchanged; + } + + if (!_selection.HasNoteSelected || _selection.MeasureIndex < 0 || _selection.NoteIndex < 0) + { + _messenger.Send(new StatusChangedMessage("Select a note first")); + return ScoreEditResult.Unchanged; + } + + var measureIndex = _selection.MeasureIndex; + var noteIndex = _selection.NoteIndex; + return EditCommandHelper.Execute( + _history, + new ScoreSnapshotEditCommand( + _document, + _navigation, + _messenger, + () => ApplySetDynamic(measureIndex, noteIndex, text), + "Set dynamic")); + } + + private ScoreEditResult ApplySetDynamic(int measureIndex, int noteIndex, string text) + { + if (measureIndex < 0 || measureIndex >= _document.Score.Measures.Count) + { + return ScoreEditResult.Unchanged; + } + + var measure = _document.Score.Measures[measureIndex]; + DynamicMarkingService.NormalizeMeasure(measure); + var existing = DynamicMarkingService.GetMarkingForNote(measure, noteIndex); + if (existing != null && string.Equals(existing.Text, text, StringComparison.Ordinal)) + { + DynamicMarkingService.TryRemoveForNote(measure, noteIndex); + return ScoreEditResult.WithMessage("Removed dynamic '" + text + "'"); + } + + if (!DynamicMarkingService.TrySetForNote(measure, noteIndex, text)) + { + return ScoreEditResult.Unchanged; + } + + return ScoreEditResult.WithMessage("Set dynamic to '" + text + "'"); + } + } +} diff --git a/JianpuEditor/ViewModels/MainViewModel.cs b/JianpuEditor/ViewModels/MainViewModel.cs index 5eb6e13..83de7e0 100644 --- a/JianpuEditor/ViewModels/MainViewModel.cs +++ b/JianpuEditor/ViewModels/MainViewModel.cs @@ -28,6 +28,7 @@ public MainViewModel( MeasureContentViewModel measureContent, ChordEditorViewModel chordEditor, OrnamentEditorViewModel ornamentEditor, + DynamicsEditorViewModel dynamicsEditor, ScoreEditorViewModel scoreEditor, PlaybackViewModel playback, SampleLibraryViewModel sampleLibrary, @@ -45,6 +46,7 @@ public MainViewModel( MeasureContent = measureContent ?? throw new ArgumentNullException(nameof(measureContent)); ChordEditor = chordEditor ?? throw new ArgumentNullException(nameof(chordEditor)); OrnamentEditor = ornamentEditor ?? throw new ArgumentNullException(nameof(ornamentEditor)); + DynamicsEditor = dynamicsEditor ?? throw new ArgumentNullException(nameof(dynamicsEditor)); ScoreEditor = scoreEditor ?? throw new ArgumentNullException(nameof(scoreEditor)); Playback = playback ?? throw new ArgumentNullException(nameof(playback)); SampleLibrary = sampleLibrary ?? throw new ArgumentNullException(nameof(sampleLibrary)); @@ -88,6 +90,8 @@ public MainViewModel( public OrnamentEditorViewModel OrnamentEditor { get; } + public DynamicsEditorViewModel DynamicsEditor { get; } + public ScoreEditorViewModel ScoreEditor { get; } public PlaybackViewModel Playback { get; } diff --git a/JianpuEditor/ViewModels/ScoreEditorViewModel.cs b/JianpuEditor/ViewModels/ScoreEditorViewModel.cs index 07cf26b..6a1ac6a 100644 --- a/JianpuEditor/ViewModels/ScoreEditorViewModel.cs +++ b/JianpuEditor/ViewModels/ScoreEditorViewModel.cs @@ -115,6 +115,7 @@ private ScoreEditResult ApplyDelete() MelodyChordService.RemoveSlot(targetMeasure, noteIndex); TieMaintenanceService.OnNoteRemoved(_document.Score, group.Key, noteIndex); OrnamentService.OnNoteRemoved(targetMeasure, noteIndex); + DynamicMarkingService.OnNoteRemoved(targetMeasure, noteIndex); removedCount++; } } @@ -141,6 +142,7 @@ private ScoreEditResult ApplyDelete() MelodyChordService.RemoveSlot(measure, noteIndex); TieMaintenanceService.OnNoteRemoved(_document.Score, measureIndex, noteIndex); OrnamentService.OnNoteRemoved(measure, noteIndex); + DynamicMarkingService.OnNoteRemoved(measure, noteIndex); return new ScoreEditResult { Changed = true, diff --git a/README.md b/README.md index 6390fa2..581468c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti - Delete / "Delete" removes ornaments on the selected note first - The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata / stac / acc / ten / segno / coda); 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, Segno, and Coda are visual-only and don't affect playback/export -- Segno/Coda mark where a D.S./D.C. jump would go, but the jump itself isn't performed during playback yet +- **Dynamics** + - Toolbar "Dynamics" section: **pp** / **p** / **mp** / **mf** / **f** / **ff**; the menu **Edit → Dynamics** provides the same options + - Select a note first, then click a dynamic level; clicking the same level again removes it, clicking a different level replaces it (a note can only be at one dynamic level at a time, unlike ornaments) + - Dynamics render in their own row directly below the melody row, in italic bold type + - The dynamic level applies to that note and every note after it -- across measures -- until the next dynamic marking or the end of the score, both during playback and MIDI export (each level maps to a fixed velocity; a score with no dynamics plays exactly as before) + - Currently applies to the melody part only, not chord markers; there's no click-to-add-at-a-beat or drag-to-move interaction yet (select a note, then use the toolbar/menu) - **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 @@ -186,6 +192,7 @@ You can also manually specify a version number in GitHub under **Actions → Rel | 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 / Staccato / Accent / Tenuto / Segno / Coda" in the toolbar; click the same button again to remove it | +| Dynamics | Select a note, then click "pp / p / mp / mf / f / ff" in the toolbar; click the same level 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 | diff --git a/ROADMAP.md b/ROADMAP.md index 4e2a268..6c8c484 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,12 +29,13 @@ 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, phase 4 (all of it except Glissando), phase 6 (breath marks), -the Segno/Coda half of phase 7, and phase 10 (pickup measure verification) 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. Phase 10 also surfaced a -separate, real gap in MIDI import (pickup measures aren't preserved) -- see the note under phase +**Status: phase 3's bug-fix half, phase 4 (all of it except Glissando), the discrete-levels half +of phase 5 (dynamics), phase 6 (breath marks), the Segno/Coda half of phase 7, and phase 10 +(pickup measure verification) 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. Phase 10 also surfaced a separate, real gap in MIDI import (pickup +measures aren't preserved) -- see the note under phase 10. Researched Indonesian *notasi angka* (Indonesian numbered/jianpu notation — rules, symbols, @@ -141,10 +142,39 @@ here and intentionally excluded.* trill/turn/mordent/grace note) instead of a new branch in the ornament-expansion if/else-if chain -- so none of this carries the layout-band risk flagged elsewhere in this phase. Glissando (pitch-bend between notes) is the one genuinely harder case -- left for its own separate step. -5. **Dynamics markings.** New model (a marking anchored to a beat, e.g. `mf`/`cresc.`/`dim.`, - 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. +5. **Dynamics markings — the six discrete levels (pp/p/mp/mf/f/ff) are done; hairpins + (cresc./dim.) are not.** New `DynamicMarking` model (`Text` + `NoteIndex`/`BeatPosition`, + mirroring `JianpuOrnament`'s note-index resolution, but with "at most one marking per note" + toggle/replace semantics instead of ornaments' additive semantics — a note can't be both piano + and forte). UI is a "select a note, click a level" toolbar/menu/context-menu button set + (`DynamicsEditorViewModel`), not chord-marker-style click-to-add-at-a-beat/drag/inline-text-edit + — deliberately simpler than that richer interaction model to avoid new canvas hit-testing code, + since only a fixed vocabulary of levels is supported (no free-text dynamics like "molto + espress." in this version). + **Rendering got its own new row directly below the melody row** (the roadmap's original + ask), not the ornament band above the note — this needed expanding `StaffBlockHeight` (a + constant used everywhere: block bounds, hit-testing, PDF pagination content height, block + stacking) to insert a fourth `DynamicsRowHeight` band between the melody and the existing + "Secondary" (chord marker) row, touching every place that assumed exactly three rows (both + copies of the row-label drawing, the main `HitTest` dispatcher's row-band math, and the + `GetSecondaryRowTop`/`GetLyricRowTop` helper chain). This is the one item in this whole gap + list that carries the same category of cross-cutting layout risk flagged for the accidental + slash-convention work (phase 2) — done here only because the user explicitly asked for the + real row over the lower-risk alternative (reusing the ornament band) after being shown the + tradeoff. Locked in with a dedicated geometry test + (`StaffRowLayoutTests.RowTops_StackWithoutOverlapOrGapDrift`) asserting every row boundary + lines up with no gap/overlap drift, on top of the usual render-to-bitmap smoke test — but the + actual on-screen appearance still hasn't been visually confirmed on a real Windows machine. + **Playback**: a velocity-scaling pass in `ScoreMidiSchedule.BuildMelodyNotes` tracks "the + current dynamic level" across notes and measures (`DynamicMarkingPlaybackService` maps each + level to a fixed velocity 33-112), replacing the constant `MelodyVelocity` from the marked note + onward until the next marking or the end of the score. A score with no dynamic markings + schedules byte-identical output to before this existed. Applies to the melody part only, not + chord markers, in this version. + **Hairpins (cresc./dim., a gradual ramp between two points) are a separate, harder follow-up**: + they need continuous interpolation between two markers rather than this phase's step-function + level changes, plus a rendering shape (an actual `<`/`>` wedge, not text) — scoping that as its + own piece of work rather than folding it in here. 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