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