From 567409797ded175cc5953367fea0836450059884 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 07:54:40 +0000 Subject: [PATCH 1/3] Add dynamics markings (pp/p/mp/mf/f/ff) Adds the discrete dynamic levels from ROADMAP.md phase 5. Hairpins (cresc./dim., a gradual ramp) are a separate, harder follow-up -- this covers the step-function levels only. Model: DynamicMarking (Text + NoteIndex/BeatPosition, mirroring JianpuOrnament's note-index resolution) on a new JianpuMeasure.Dynamics list, with "at most one marking per note" toggle/replace semantics via DynamicMarkingService -- a note can't be both piano and forte, unlike ornaments where several types can coexist. OnNoteRemoved is wired alongside OrnamentService's existing call sites in ScoreEditorViewModel so indices stay correct after note deletion. UI: a new DynamicsEditorViewModel ("select a note, click a level") wired through ribbon/Edit-menu/context-menu, using ScoreSnapshotEditCommand for undo (the same generic whole-score-clone mechanism TieEditorViewModel already uses) rather than a bespoke diff command, since this is a low-frequency, deliberate action. Registered in AppBootstrapper/MainViewModel alongside the other per-tab scoped editor viewmodels. Rendering got a real new row directly below the melody row (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), touching every place that assumed exactly three rows. This is the one part of this change that carries the same cross-cutting layout risk flagged for the accidental slash-convention work; done this way because the user explicitly chose the real row over the lower-risk alternative (reusing the ornament band) after being shown the tradeoff. Locked in with a dedicated geometry test asserting every row boundary lines up with no gap/overlap drift. 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. Melody part only in this version, not chord markers. Verified via the same sandbox pipeline as every prior change this session: dotnet build succeeds against the real project/package graph, dotnet format --verify-no-changes passes on both projects, and new xUnit tests cover the service's toggle/replace/index-shifting logic, the velocity mapping, the ViewModel's add/toggle/undo behavior, the playback pass end-to-end (including a marking overriding an earlier one), the new row's geometry, and a render-to-bitmap smoke test. dotnet test itself cannot execute in this sandbox; real confirmation comes from GitHub Actions CI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- .../Rendering/AccidentalRenderingTests.cs | 14 ++ .../Rendering/StaffRowLayoutTests.cs | 32 +++ .../DynamicMarkingPlaybackServiceTests.cs | 37 ++++ .../Services/DynamicMarkingServiceTests.cs | 92 ++++++++ .../Services/ScoreMidiScheduleTests.cs | 53 +++++ .../DynamicsEditorViewModelTests.cs | 91 ++++++++ .../ViewModels/ViewModelTestHelper.cs | 19 ++ JianpuEditor/AppBootstrapper.cs | 1 + JianpuEditor/Controls/RibbonIcon.cs | 6 + JianpuEditor/Controls/RibbonIconRenderer.cs | 27 +++ JianpuEditor/MainForm.cs | 27 +++ JianpuEditor/Models/DynamicMarking.cs | 16 ++ JianpuEditor/Models/JianpuMeasure.cs | 2 + JianpuEditor/Rendering/JianpuRenderer.cs | 60 ++++- JianpuEditor/Services/DynamicMarkingLayout.cs | 14 ++ .../Services/DynamicMarkingPlaybackService.cs | 38 ++++ .../Services/DynamicMarkingService.cs | 207 ++++++++++++++++++ JianpuEditor/Services/ScoreMidiSchedule.cs | 12 +- .../ViewModels/DynamicsEditorViewModel.cs | 85 +++++++ JianpuEditor/ViewModels/MainViewModel.cs | 4 + .../ViewModels/ScoreEditorViewModel.cs | 2 + README.md | 7 + ROADMAP.md | 50 ++++- 23 files changed, 877 insertions(+), 19 deletions(-) create mode 100644 JianpuEditor.Tests/Rendering/StaffRowLayoutTests.cs create mode 100644 JianpuEditor.Tests/Services/DynamicMarkingPlaybackServiceTests.cs create mode 100644 JianpuEditor.Tests/Services/DynamicMarkingServiceTests.cs create mode 100644 JianpuEditor.Tests/ViewModels/DynamicsEditorViewModelTests.cs create mode 100644 JianpuEditor/Models/DynamicMarking.cs create mode 100644 JianpuEditor/Services/DynamicMarkingLayout.cs create mode 100644 JianpuEditor/Services/DynamicMarkingPlaybackService.cs create mode 100644 JianpuEditor/Services/DynamicMarkingService.cs create mode 100644 JianpuEditor/ViewModels/DynamicsEditorViewModel.cs 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/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 From 0ec5e24e0ed6d219854f5fadd45894d3261bfb38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 08:06:07 +0000 Subject: [PATCH 2/3] Fix CI: register DynamicsEditorViewModel in hand-built test containers MainFormMultiTabTests.cs and DocumentTabTests.cs each build their own IServiceCollection mirroring AppBootstrapper's registration list rather than using AppBootstrapper itself, so the new DynamicsEditorViewModel scoped registration from the dynamics-markings change wasn't picked up automatically. This broke MainFormMultiTabTests (resolving MainViewModel throws "Unable to resolve service for type DynamicsEditorViewModel") on GitHub Actions -- the same class of gap this session hit before with OrnamentEditorViewModel's own registration. Added the missing services.AddScoped() line to both files, mirroring the existing OrnamentEditorViewModel registration in each. Verified via the same sandbox pipeline as every prior change this session: dotnet build succeeds against the real project/package graph, dotnet format --verify-no-changes passes. dotnet test itself cannot execute in this sandbox; real confirmation comes from GitHub Actions CI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- JianpuEditor.Tests/Glue/DocumentTabTests.cs | 1 + JianpuEditor.Tests/MainFormMultiTabTests.cs | 1 + 2 files changed, 2 insertions(+) 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(); From 50886a09c5e3c1576aa1410376328e87f3567ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 08:13:54 +0000 Subject: [PATCH 3/3] Fix CI: update stale PDF page-count fixture for the new dynamics row PlanPages_LineCount_MapsToExpectedPageCount(7, 1) expected 7 staff lines to fit on one PDF page. Adding the dynamics row increased StaffBlockHeight from 180 to 210px, so a page that used to fit 7 lines now fits 6 -- this is the correct, intended consequence of adding a real new row (more vertical space per line means fewer lines per page), not a pagination bug. Verified the exact page split against the real compiled PdfPagePlanner.PlanPages (7 lines -> 6+1 across 2 pages; 8 lines still splits 6+2, unchanged) before updating the fixture from (7, 1) to (7, 2). No other test hardcodes a StaffBlockHeight-derived value. Verified via the same sandbox pipeline as every prior change this session: dotnet build succeeds against the real project/package graph, dotnet format --verify-no-changes passes. dotnet test itself cannot execute in this sandbox; real confirmation comes from GitHub Actions CI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- JianpuEditor.Tests/Services/PdfExportServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) {