From ac8b511959111a59fdad36328754228535ab687b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 04:34:47 +0000 Subject: [PATCH] Add Staccato, Accent, and Tenuto ornaments Wires up three more of the dead OrnamentType articulation values (Glissando remains, left for its own step since pitch-bend is genuinely harder). Each gets the full ribbon/Edit-menu/context-menu pattern plus a placeholder glyph (stac/acc/ten, matching this codebase's existing text-abbreviation convention rather than real notation symbols). Playback effects live in OrnamentPlaybackService.ApplyArticulation, a single post-process pass over whatever events a note already expanded into (a plain note, or every segment of a trill/turn/ mordent/grace note) rather than a new branch in the ornament expansion chain: staccato halves the sounding duration (the next note's start time comes from the nominal duration, so this just leaves a gap, not a timing shift), accent and tenuto boost velocity (accent more than tenuto), both clamped to the MIDI max. For layout, all three are registered into NoteTopAnnotationPlanner's existing HasCenterOrnament stacking switch (same as Trill/Turn/ Mordent) so they correctly clear any accidental/octave-dot glyph on the same note, instead of any new anchor-position math -- carries none of the layout-band risk flagged elsewhere in ROADMAP.md for the accidental slash-convention work. 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 glyph mapping and each playback effect (including composed with a trill and clamped at the MIDI velocity max). 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 --- .../Services/OrnamentPlaybackServiceTests.cs | 106 ++++++++++++++++++ .../Services/OrnamentServiceTests.cs | 3 + JianpuEditor/Controls/RibbonIcon.cs | 3 + JianpuEditor/Controls/RibbonIconRenderer.cs | 14 +++ JianpuEditor/MainForm.cs | 19 ++++ JianpuEditor/Rendering/JianpuRenderer.cs | 7 +- .../Rendering/NoteTopAnnotationPlanner.cs | 3 + .../Services/OrnamentPlaybackService.cs | 43 +++++++ JianpuEditor/Services/OrnamentService.cs | 6 + README.md | 8 +- ROADMAP.md | 44 +++++--- 11 files changed, 233 insertions(+), 23 deletions(-) diff --git a/JianpuEditor.Tests/Services/OrnamentPlaybackServiceTests.cs b/JianpuEditor.Tests/Services/OrnamentPlaybackServiceTests.cs index e082fa8..f3479f8 100644 --- a/JianpuEditor.Tests/Services/OrnamentPlaybackServiceTests.cs +++ b/JianpuEditor.Tests/Services/OrnamentPlaybackServiceTests.cs @@ -76,6 +76,112 @@ public void ScheduleMelodyNote_Fermata_ExtendsDuration() Assert.Equal(3, events[0].DurationQuarter, 3); } + [Fact] + public void ScheduleMelodyNote_Staccato_ShortensDurationWithoutMovingStart() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Staccato); + + var events = OrnamentPlaybackService.ScheduleMelodyNote( + measure, + measure.MelodyNotes[0], + 0, + 0, + 2, + ScoreMidiSchedule.DefaultTonicMidi, + ScoreMidiSchedule.MelodyChannel, + ScoreMidiSchedule.MelodyVelocity); + + Assert.Single(events); + Assert.Equal(0, events[0].StartQuarter, 3); + Assert.Equal(1, events[0].DurationQuarter, 3); + } + + [Fact] + public void ScheduleMelodyNote_Accent_BoostsVelocityWithoutChangingDuration() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Accent); + + var events = OrnamentPlaybackService.ScheduleMelodyNote( + measure, + measure.MelodyNotes[0], + 0, + 0, + 2, + ScoreMidiSchedule.DefaultTonicMidi, + ScoreMidiSchedule.MelodyChannel, + ScoreMidiSchedule.MelodyVelocity); + + Assert.Single(events); + Assert.Equal(2, events[0].DurationQuarter, 3); + Assert.Equal(ScoreMidiSchedule.MelodyVelocity + OrnamentPlaybackService.AccentVelocityBoost, events[0].Velocity); + } + + [Fact] + public void ScheduleMelodyNote_Tenuto_AppliesSmallerVelocityBoostThanAccent() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Tenuto); + + var events = OrnamentPlaybackService.ScheduleMelodyNote( + measure, + measure.MelodyNotes[0], + 0, + 0, + 2, + ScoreMidiSchedule.DefaultTonicMidi, + ScoreMidiSchedule.MelodyChannel, + ScoreMidiSchedule.MelodyVelocity); + + Assert.Single(events); + Assert.Equal(ScoreMidiSchedule.MelodyVelocity + OrnamentPlaybackService.TenutoVelocityBoost, events[0].Velocity); + Assert.True(OrnamentPlaybackService.TenutoVelocityBoost < OrnamentPlaybackService.AccentVelocityBoost); + } + + [Fact] + public void ScheduleMelodyNote_StaccatoAndTrillTogether_ShortensEverySegment() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(3)); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Trill); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Staccato); + + var events = OrnamentPlaybackService.ScheduleMelodyNote( + measure, + measure.MelodyNotes[0], + 0, + 0, + 1, + ScoreMidiSchedule.DefaultTonicMidi, + ScoreMidiSchedule.MelodyChannel, + ScoreMidiSchedule.MelodyVelocity); + + Assert.True(events.Count >= 4); + var expectedSegmentDuration = 1.0 / events.Count * OrnamentPlaybackService.StaccatoDurationMultiplier; + Assert.Equal(expectedSegmentDuration, events[0].DurationQuarter, 3); + } + + [Fact] + public void ScheduleMelodyNote_VelocityBoost_NeverExceedsMidiMaximum() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1)); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Accent); + OrnamentService.TryAddOrnament(measure, 0, OrnamentType.Tenuto); + + var events = OrnamentPlaybackService.ScheduleMelodyNote( + measure, + measure.MelodyNotes[0], + 0, + 0, + 1, + ScoreMidiSchedule.DefaultTonicMidi, + ScoreMidiSchedule.MelodyChannel, + 120); + + Assert.Single(events); + Assert.Equal(127, events[0].Velocity); + } + [Fact] public void Build_IncludesOrnamentExpandedMelodyNotes() { diff --git a/JianpuEditor.Tests/Services/OrnamentServiceTests.cs b/JianpuEditor.Tests/Services/OrnamentServiceTests.cs index e9273e7..f6a639d 100644 --- a/JianpuEditor.Tests/Services/OrnamentServiceTests.cs +++ b/JianpuEditor.Tests/Services/OrnamentServiceTests.cs @@ -98,6 +98,9 @@ public void GetPlaceholderGlyph_ReturnsToolbarLabel() Assert.Equal("trn", OrnamentService.GetPlaceholderGlyph(OrnamentType.Turn)); Assert.Equal("ferm", OrnamentService.GetPlaceholderGlyph(OrnamentType.Fermata)); Assert.Equal("br", OrnamentService.GetPlaceholderGlyph(OrnamentType.BreathMark)); + Assert.Equal("stac", OrnamentService.GetPlaceholderGlyph(OrnamentType.Staccato)); + Assert.Equal("acc", OrnamentService.GetPlaceholderGlyph(OrnamentType.Accent)); + Assert.Equal("ten", OrnamentService.GetPlaceholderGlyph(OrnamentType.Tenuto)); } [Fact] diff --git a/JianpuEditor/Controls/RibbonIcon.cs b/JianpuEditor/Controls/RibbonIcon.cs index c789107..800bdfb 100644 --- a/JianpuEditor/Controls/RibbonIcon.cs +++ b/JianpuEditor/Controls/RibbonIcon.cs @@ -24,6 +24,9 @@ public enum RibbonIcon Mordent, Fermata, BreathMark, + Staccato, + Accent, + Tenuto, Duplicate, Delete, Library, diff --git a/JianpuEditor/Controls/RibbonIconRenderer.cs b/JianpuEditor/Controls/RibbonIconRenderer.cs index 4232de9..6f8f5d2 100644 --- a/JianpuEditor/Controls/RibbonIconRenderer.cs +++ b/JianpuEditor/Controls/RibbonIconRenderer.cs @@ -150,6 +150,20 @@ private static void DrawIcon(Graphics g, RibbonIcon icon, Pen pen, Brush brush) g.DrawLine(breathPen, 6f, 4f, 13f, 15f); } + return; + case RibbonIcon.Staccato: + g.FillEllipse(brush, 8f, 8f, 4f, 4f); + return; + case RibbonIcon.Accent: + g.DrawLine(pen, 4f, 5f, 14f, 10f); + g.DrawLine(pen, 14f, 10f, 4f, 15f); + return; + case RibbonIcon.Tenuto: + using (var tenutoPen = new Pen(pen.Color, pen.Width * 1.6f)) + { + g.DrawLine(tenutoPen, 4f, 10f, 16f, 10f); + } + return; case RibbonIcon.Duplicate: g.DrawRectangle(pen, 3f, 6f, 10f, 10f); diff --git a/JianpuEditor/MainForm.cs b/JianpuEditor/MainForm.cs index 409f346..c8a312c 100644 --- a/JianpuEditor/MainForm.cs +++ b/JianpuEditor/MainForm.cs @@ -717,6 +717,18 @@ private void PopulateMenuStrip(MenuStrip menu) "Breath Mark", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)))); + ornamentMenu.DropDownItems.Add(CreateMenuItem( + "Staccato", + Keys.None, + (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Staccato)))); + ornamentMenu.DropDownItems.Add(CreateMenuItem( + "Accent", + Keys.None, + (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Accent)))); + ornamentMenu.DropDownItems.Add(CreateMenuItem( + "Tenuto", + Keys.None, + (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Tenuto)))); editMenu.DropDownItems.Add(ornamentMenu); editMenu.DropDownItems.Add(CreateMenuItem("Clear Score", Keys.None, OnClearScore)); @@ -807,6 +819,10 @@ private FlowLayoutPanel BuildToolbarPanel() CreateRibbonButton(RibbonIcon.Mordent, "Mordent", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Mordent)), compact: true), CreateRibbonButton(RibbonIcon.Fermata, "Fermata", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)), compact: true), CreateRibbonButton(RibbonIcon.BreathMark, "Breath mark", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)), compact: true)); + ornaments.AddRow( + CreateRibbonButton(RibbonIcon.Staccato, "Staccato", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Staccato)), compact: true), + CreateRibbonButton(RibbonIcon.Accent, "Accent", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Accent)), compact: true), + CreateRibbonButton(RibbonIcon.Tenuto, "Tenuto", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Tenuto)), compact: true)); panel.Controls.Add(ornaments); var measures = new RibbonGroup("Measures"); @@ -1157,6 +1173,9 @@ private void AddNoteContextMenuItems(ContextMenuStrip menu) ornamentsMenu.DropDownItems.Add("Mordent", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Mordent))); ornamentsMenu.DropDownItems.Add("Fermata", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata))); ornamentsMenu.DropDownItems.Add("Breath Mark", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark))); + ornamentsMenu.DropDownItems.Add("Staccato", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Staccato))); + ornamentsMenu.DropDownItems.Add("Accent", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Accent))); + ornamentsMenu.DropDownItems.Add("Tenuto", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Tenuto))); menu.Items.Add(ornamentsMenu); AddPasteItemIfAvailable(menu); diff --git a/JianpuEditor/Rendering/JianpuRenderer.cs b/JianpuEditor/Rendering/JianpuRenderer.cs index 186e84c..ca1f405 100644 --- a/JianpuEditor/Rendering/JianpuRenderer.cs +++ b/JianpuEditor/Rendering/JianpuRenderer.cs @@ -1363,7 +1363,12 @@ private void DrawOrnamentGlyph( private static bool UsesLatinOrnamentFont(OrnamentType type) { - return type == OrnamentType.Trill || type == OrnamentType.Mordent || type == OrnamentType.BreathMark; + return type == OrnamentType.Trill + || type == OrnamentType.Mordent + || type == OrnamentType.BreathMark + || type == OrnamentType.Staccato + || type == OrnamentType.Accent + || type == OrnamentType.Tenuto; } private static float GetOrnamentAnchorX(OrnamentType type, int noteX, int noteWidth) diff --git a/JianpuEditor/Rendering/NoteTopAnnotationPlanner.cs b/JianpuEditor/Rendering/NoteTopAnnotationPlanner.cs index f103fb4..98ede3e 100644 --- a/JianpuEditor/Rendering/NoteTopAnnotationPlanner.cs +++ b/JianpuEditor/Rendering/NoteTopAnnotationPlanner.cs @@ -89,6 +89,9 @@ private static void AnalyzeOrnaments(IReadOnlyList ornaments, No case OrnamentType.Trill: case OrnamentType.Turn: case OrnamentType.Mordent: + case OrnamentType.Staccato: + case OrnamentType.Accent: + case OrnamentType.Tenuto: layout.HasCenterOrnament = true; break; } diff --git a/JianpuEditor/Services/OrnamentPlaybackService.cs b/JianpuEditor/Services/OrnamentPlaybackService.cs index f8aa273..b0c4fa1 100644 --- a/JianpuEditor/Services/OrnamentPlaybackService.cs +++ b/JianpuEditor/Services/OrnamentPlaybackService.cs @@ -11,6 +11,9 @@ public static class OrnamentPlaybackService public const double TrillSegmentQuarter = 0.125; public const double FermataDurationMultiplier = 1.5; public const double MinNoteDurationQuarter = 0.0625; + public const double StaccatoDurationMultiplier = 0.5; + public const int AccentVelocityBoost = 24; + public const int TenutoVelocityBoost = 8; public static List ScheduleMelodyNote( JianpuMeasure measure, @@ -79,6 +82,46 @@ public static List ScheduleMelodyNote( events.Add(MakeEvent(cursor, duration, note, tonicMidi, channel, velocity)); } + return ApplyArticulation(events, ornaments); + } + + /// Staccato/Accent/Tenuto are expressive modifiers on however many events the note + /// already expanded into above (a plain note, or every segment of a trill/turn/mordent/ + /// grace note) rather than pitch-decorating ornaments of their own, so they're applied as a + /// uniform post-process over the whole event group instead of their own branch in the + /// if/else-if chain above. + private static List ApplyArticulation( + List events, + IReadOnlyList ornaments) + { + var hasStaccato = ornaments.Any(item => item.Type == OrnamentType.Staccato); + var hasAccent = ornaments.Any(item => item.Type == OrnamentType.Accent); + var hasTenuto = ornaments.Any(item => item.Type == OrnamentType.Tenuto); + if (!hasStaccato && !hasAccent && !hasTenuto) + { + return events; + } + + foreach (var scheduledEvent in events) + { + if (hasStaccato) + { + scheduledEvent.DurationQuarter = Math.Max( + MinNoteDurationQuarter, + scheduledEvent.DurationQuarter * StaccatoDurationMultiplier); + } + + if (hasAccent) + { + scheduledEvent.Velocity = Math.Min(127, scheduledEvent.Velocity + AccentVelocityBoost); + } + + if (hasTenuto) + { + scheduledEvent.Velocity = Math.Min(127, scheduledEvent.Velocity + TenutoVelocityBoost); + } + } + return events; } diff --git a/JianpuEditor/Services/OrnamentService.cs b/JianpuEditor/Services/OrnamentService.cs index 9780f09..82314c3 100644 --- a/JianpuEditor/Services/OrnamentService.cs +++ b/JianpuEditor/Services/OrnamentService.cs @@ -227,6 +227,12 @@ public static string GetPlaceholderGlyph(OrnamentType type) return "mor"; case OrnamentType.BreathMark: return "br"; + case OrnamentType.Staccato: + return "stac"; + case OrnamentType.Accent: + return "acc"; + case OrnamentType.Tenuto: + return "ten"; default: return type.ToString(); } diff --git a/README.md b/README.md index 2086873..d41ea65 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,12 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti - Menu **Edit → Undo** (**Ctrl+Z**) / **Redo** (**Ctrl+Y**) steps backward or forward through score edits - Covers note editing, deletion, measures, transposition, header/lyric/chord inline editing, ties, ornaments, bulk lyric editing, etc.; the undo/redo stack is cleared after creating, opening, or loading a score - **Ornaments** - - Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata** / **Breath Mark**; the menu **Edit → Ornaments** provides the same options + - Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata** / **Breath Mark** / **Staccato** / **Accent** / **Tenuto**; the menu **Edit → Ornaments** provides the same options - Select one or more notes first, then click an ornament button; with multiple selection, ornaments are added in batch - Clicking the same button again removes that type of ornament from the note (other types are kept) - Delete / "Delete" removes ornaments on the selected note first - - The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata); a breath mark draws just after the note instead, matching where it's placed in notasi angka/jianpu sheet music - - Score playback and MIDI export expand grace notes, trills, turns, mordents, and fermata durations; a breath mark is visual-only and doesn't affect playback/export + - The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata / stac / acc / ten); a breath mark draws just after the note instead, matching where it's placed in notasi angka/jianpu sheet music + - Score playback and MIDI export expand grace notes, trills, turns, mordents, and fermata durations; staccato shortens the sounding duration, accent and tenuto boost velocity (accent more than tenuto); a breath mark is visual-only and doesn't affect playback/export - **Ties** - Click "Tie" → select the start note → select the end note; Esc to cancel - The end note must be the same pitch as the start note (a tie sustains one pitch); picking a @@ -185,7 +185,7 @@ You can also manually specify a version number in GitHub under **Actions → Rel | Split / Merge | Splits or merges the duration of selected notes | | Toolbar "From / To" + Copy Measures | Copies measures within the specified range | | Tie | Click "Tie" → select the start/end note; click the arc to select it, Delete to remove | -| Ornaments | Select a note, then click "Grace Note / Trill / Turn / Mordent / Fermata / Breath Mark" in the toolbar; click the same button again to remove it | +| Ornaments | Select a note, then click "Grace Note / Trill / Turn / Mordent / Fermata / Breath Mark / Staccato / Accent / Tenuto" in the toolbar; click the same button again to remove it | | Undo / Redo | **Edit → Undo / Redo** or **Ctrl+Z** / **Ctrl+Y** | | Play / Stop | Plays the score according to BPM; drag the blue progress bar to seek | | Transpose | Menu "Edit → Chord Transpose..."; transposes chord markers only | diff --git a/ROADMAP.md b/ROADMAP.md index 06a0efd..1d0700f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,12 +29,12 @@ 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, the Mordent half of phase 4, phase 6 (breath marks), 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. +**Status: phase 3's bug-fix half, phase 4 (all of it except Glissando), phase 6 (breath marks), 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, conventions) against what `JianpuEditor` actually implements. Full gap list and phased plan below. @@ -120,18 +120,26 @@ here and intentionally excluded.* flow) — the tie tool can no longer silently drop a note's pitch during playback. Covered by two new regression tests. Real slur support (a `JianpuSlur` list shaped like `JianpuTie`, no pitch constraint, rendering-only) is still future work. -4. **Wire up the dead `OrnamentType` articulation values — Mordent done, Staccato/Accent/Tenuto/ - Glissando not started.** Added the missing Mordent button (ribbon + Edit menu + context menu, - plus a new `RibbonIcon.Mordent` glyph) — its backend (glyph layout, playback expansion) already - existed from earlier work, so this was pure UI wiring, unlike the four below: - Staccato/Accent/Tenuto/Glissando need a ribbon button + Edit-menu item + context-menu item each - (matching the now five-strong Grace/Trill/Turn/Mordent/Fermata pattern), plus a *new* glyph and - a *new* playback effect each (staccato = shorten sounding duration; accent = velocity boost; - tenuto = slight duration/emphasis; glissando = pitch-bend between notes, the one genuinely - harder one — may want to split it into its own step). Worth doing carefully rather than - bundled: new note-annotation glyphs share layout code with every existing ornament, and this - sandbox can't visually verify GDI+ output, so a mistake here risks every ornament's - positioning, not just the new ones. +4. **Wire up the dead `OrnamentType` articulation values — Mordent, Staccato, Accent, and Tenuto + done; Glissando not started.** Added the missing Mordent button (ribbon + Edit menu + context + menu, plus a new `RibbonIcon.Mordent` glyph) — its backend (glyph layout, playback expansion) + already existed from earlier work, so this was pure UI wiring. Staccato/Accent/Tenuto needed + the full pattern: a ribbon button + Edit-menu item + context-menu item each (matching the now + eight-strong Grace/Trill/Turn/Mordent/Fermata/BreathMark/Staccato/Accent/Tenuto set), a new + placeholder glyph each (`stac`/`acc`/`ten`, following this codebase's existing text-abbreviation + convention rather than real notation symbols -- see the class doc comment on + `OrnamentService.GetPlaceholderGlyph`), and a new playback effect each in + `OrnamentPlaybackService`: staccato shortens the sounding duration by half (leaving a gap before + the next note, since the next note's start time comes from the *nominal* duration, not the + shortened one), accent and tenuto boost velocity (accent more than tenuto), each clamped to the + MIDI max. All three reuse the exact same generic ornament-band positioning Trill/Turn/Mordent + already use (registered into `NoteTopAnnotationPlanner`'s existing `HasCenterOrnament` stacking + switch so they correctly clear any accidental/octave-dot glyph on the same note) rather than any + new anchor-position math, and the three playback effects are applied as one uniform post-process + over whatever events the note already expanded into (a plain note, or every segment of a + 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