diff --git a/OpenUtau.Core/Commands/ProjectCommands.cs b/OpenUtau.Core/Commands/ProjectCommands.cs index a955de597..ba809216c 100644 --- a/OpenUtau.Core/Commands/ProjectCommands.cs +++ b/OpenUtau.Core/Commands/ProjectCommands.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using OpenUtau.Core.Ustx; +using OpenUtau.Core.Util.MusicTheory; namespace OpenUtau.Core { public abstract class ProjectCommand : UCommand { @@ -141,9 +142,9 @@ public override void Unexecute() { } public class KeyCommand : ProjectCommand{ - public readonly int oldKey; - public readonly int newKey; - public KeyCommand(UProject project, int key) : base(project) { + public readonly Note oldKey; + public readonly Note newKey; + public KeyCommand(UProject project, Note key) : base(project) { oldKey = project.key; newKey = key; } @@ -152,6 +153,18 @@ public KeyCommand(UProject project, int key) : base(project) { public override void Unexecute() => project.key = oldKey; } + public class ModeCommand : ProjectCommand{ + public readonly Mode oldMode; + public readonly Mode newMode; + public ModeCommand(UProject project, Mode key) : base(project) { + oldMode = project.mode; + newMode = key; + } + public override string ToString() => $"Change mode from {oldMode} to {newMode}"; + public override void Execute() => project.mode = newMode; + public override void Unexecute() => project.mode = oldMode; + } + public class ConfigureExpressionsCommand : ProjectCommand { readonly UExpressionDescriptor[] oldProjectDescriptors; readonly UExpressionDescriptor[] newProjectDescriptors; diff --git a/OpenUtau.Core/Ustx/UProject.cs b/OpenUtau.Core/Ustx/UProject.cs index 1ed36edee..ec0681f50 100644 --- a/OpenUtau.Core/Ustx/UProject.cs +++ b/OpenUtau.Core/Ustx/UProject.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using OpenUtau.Core.Util; +using OpenUtau.Core.Util.MusicTheory; using SharpCompress; using YamlDotNet.Serialization; @@ -49,7 +50,8 @@ public class UProject { public string[] expSelectors = new string[] { Format.Ustx.DYN, Format.Ustx.PITD, Format.Ustx.CLR, Format.Ustx.ENG, Format.Ustx.VEL, Format.Ustx.VOL, Format.Ustx.ATK, Format.Ustx.DEC, Format.Ustx.GEN, Format.Ustx.BRE }; public int expPrimary = 0; public int expSecondary = 1; - public int key = 0;//Music key of the project, 0 = C, 1 = C#, 2 = D, ..., 11 = B + public Note key = Scale.Default().Tonic; + public Mode mode = Scale.Default().Mode; public List timeSignatures; public List tempos; public List tracks; diff --git a/OpenUtau.Core/Util/MusicMath.cs b/OpenUtau.Core/Util/MusicMath.cs index e5c0883d3..ff66c2e1a 100644 --- a/OpenUtau.Core/Util/MusicMath.cs +++ b/OpenUtau.Core/Util/MusicMath.cs @@ -5,6 +5,7 @@ namespace OpenUtau.Core { public static class MusicMath { private static readonly double a = Math.Pow(2, 1.0 / 12); + // TODO move all the harmonics to MusicTheory namespace and refactor public enum KeyColor { White, Black } public static readonly Tuple[] KeysInOctave = { @@ -32,36 +33,6 @@ public enum KeyColor { White, Black } { "B", 11 }, }; - public static readonly string[] Solfeges = { - "do", - "", - "re", - "", - "mi", - "fa", - "", - "sol", - "", - "la", - "", - "ti", - }; - - public static readonly string[] NumberedNotations = { - "1", - "", - "2", - "", - "3", - "4", - "", - "5", - "", - "6", - "", - "7", - }; - public static string GetToneName(int noteNum) { return noteNum < 0 ? string.Empty : KeysInOctave[noteNum % 12].Item1 + (noteNum / 12 - 1).ToString(); } @@ -81,14 +52,6 @@ public static int NameToTone(string name) { return 12 * (octave + 1) + inOctave; } - public static bool IsBlackKey(int noteNum) { - return KeysInOctave[noteNum % 12].Item2 == KeyColor.Black; - } - - public static bool IsCenterKey(int noteNum) { - return noteNum % 12 == 0; - } - public static double[] zoomRatios = { 4.0, 2.0, 1.0, 1.0 / 2, 1.0 / 4, 1.0 / 8, 1.0 / 16, 1.0 / 32, 1.0 / 64 }; public static double getZoomRatio(double quarterWidth, int beatPerBar, int beatUnit, double minWidth) { diff --git a/OpenUtau.Core/Util/MusicTheory/Mode.cs b/OpenUtau.Core/Util/MusicTheory/Mode.cs new file mode 100644 index 000000000..7d9a5e455 --- /dev/null +++ b/OpenUtau.Core/Util/MusicTheory/Mode.cs @@ -0,0 +1,13 @@ +namespace OpenUtau.Core.Util.MusicTheory +{ + public enum Mode + { + Lydian, + Ionian, + Mixolydian, + Dorian, + Aeolian, + Phrygian, + Locrian, + } +} diff --git a/OpenUtau.Core/Util/MusicTheory/Note.cs b/OpenUtau.Core/Util/MusicTheory/Note.cs new file mode 100644 index 000000000..00f240771 --- /dev/null +++ b/OpenUtau.Core/Util/MusicTheory/Note.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenUtau.Core.Util.MusicTheory; + +public enum Note +{ + C, + Csharp, + D, + Dsharp, + E, + F, + Fsharp, + G, + Gsharp, + A, + Asharp, + B, +} + + + +public static class NoteHelper +{ + public static Note CastNote(int note) + { + note %= Enum.GetValues().Length; + if (note < 0) + return Note.C; + else + return (Note)note; + } + + public static string StringifyNote(Note note) + { + return note.ToString().Replace("sharp", "#"); + } +} diff --git a/OpenUtau.Core/Util/MusicTheory/Scale.cs b/OpenUtau.Core/Util/MusicTheory/Scale.cs new file mode 100644 index 000000000..7a759d8d0 --- /dev/null +++ b/OpenUtau.Core/Util/MusicTheory/Scale.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenUtau.Core.Util.MusicTheory; + +public class Scale +{ + public readonly Mode Mode; + private readonly List _scale; + public Note Tonic => _scale[0]; + + private Scale(List notes, Mode mode) + { + _scale = notes; + Mode = mode; + } + + public bool IsOutOfScale(Note note) + { + return _scale.Contains(note) == false; + } + + public bool IsTonic(Note note) + { + return Tonic.Equals(note); + } + + public int? Interval(Note note) + { + int index = _scale.IndexOf(note); + return index >= 0 ? index + 1 : null; + } + + public string? SolfegeIntervalName(Note note) + { + if (IsOutOfScale(note)) + return null; + + return note switch + { + Note.C => "do", + Note.Csharp => "do#", + Note.D => "re", + Note.Dsharp => "re#", + Note.E => "mi", + Note.F => "fa", + Note.Fsharp => "fa#", + Note.G => "sol", + Note.Gsharp => "sol#", + Note.A => "la", + Note.Asharp => "la#", + Note.B => "ti", + _ => null, + }; + } + + public static Scale Build(Note tonic, Mode mode) + { + int tonicValue = (int)tonic; + int[] intervals = mode switch + { + Mode.Lydian => [0, 2, 4, 6, 7, 9, 11], + Mode.Ionian => [0, 2, 4, 5, 7, 9, 11], + Mode.Mixolydian => [0, 2, 4, 5, 7, 9, 10], + Mode.Dorian => [0, 2, 3, 5, 7, 9, 10], + Mode.Aeolian => [0, 2, 3, 5, 7, 8, 10], + Mode.Phrygian => [0, 1, 3, 5, 7, 8, 10], + Mode.Locrian => [0, 1, 3, 5, 6, 8, 10], + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null), + }; + + var notes = intervals + .Select(interval => NoteHelper.CastNote(tonicValue + interval)) + .ToList(); + + return new Scale(notes, mode); + } + + public static Scale Default() + { + return Build(Note.C, Mode.Ionian); + } +} diff --git a/OpenUtau.Test/Core/Util/MusicTheory/NoteTest.cs b/OpenUtau.Test/Core/Util/MusicTheory/NoteTest.cs new file mode 100644 index 000000000..4ef77f48b --- /dev/null +++ b/OpenUtau.Test/Core/Util/MusicTheory/NoteTest.cs @@ -0,0 +1,32 @@ +using Xunit; + +namespace OpenUtau.Core.Util.MusicTheory; + +public class NoteTest +{ + public class CastNote + { + [Fact] + public void BelowZeroReturnsC() => Assert.Equal(Note.C, NoteHelper.CastNote(-1)); + + [Fact] + public void ZeroReturnsC() => Assert.Equal(Note.C, NoteHelper.CastNote(0)); + + [Fact] + public void AboveTwelveReturnsModuloNoteIndex() => + Assert.Equal(Note.Csharp, NoteHelper.CastNote(25)); + + [Fact] + public void ElseReturnsNoteIndex() => Assert.Equal(Note.G, NoteHelper.CastNote(7)); + } + + public class StringifyNote + { + [Fact] + public void PlainNoteDoesNotChange() => Assert.Equal("G", NoteHelper.StringifyNote(Note.G)); + + [Fact] + public void SharpIsStringifiedDoesNotChange() => + Assert.Equal("G#", NoteHelper.StringifyNote(Note.Gsharp)); + } +} diff --git a/OpenUtau.Test/Core/Util/MusicTheory/ScaleTest.cs b/OpenUtau.Test/Core/Util/MusicTheory/ScaleTest.cs new file mode 100644 index 000000000..f38419494 --- /dev/null +++ b/OpenUtau.Test/Core/Util/MusicTheory/ScaleTest.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using Xunit.Internal; + +namespace OpenUtau.Core.Util.MusicTheory; + +public class ScaleTest +{ + public class Default + { + [Fact] + public void TonicIsC() + { + Assert.Equal(Note.C, Scale.Default().Tonic); + } + + [Fact] + public void ModeIsIonian() + { + Assert.Equal(Mode.Ionian, Scale.Default().Mode); + } + } + + public class Build + { + private static void AssertScaleIsCorrect(Note tonic, Mode mode, List expectedNotes) + { + var scale = Scale.Build(tonic, mode); + + Assert.Equal(tonic, scale.Tonic); + Assert.Equal(mode, scale.Mode); + foreach (var note in Enum.GetValues()) + { + Assert.Equal(!expectedNotes.Contains(note), scale.IsOutOfScale(note)); + } + Assert.Equal(Note.C, Scale.Default().Tonic); + } + + [Fact] + public void CLydian() => + AssertScaleIsCorrect( + Note.C, + Mode.Lydian, + [Note.C, Note.D, Note.E, Note.Fsharp, Note.G, Note.A, Note.B] + ); + + [Fact] + public void CIonian() => + AssertScaleIsCorrect( + Note.C, + Mode.Ionian, + [Note.C, Note.D, Note.E, Note.F, Note.G, Note.A, Note.B] + ); + + [Fact] + public void CMixolydian() => + AssertScaleIsCorrect( + Note.C, + Mode.Mixolydian, + [Note.C, Note.D, Note.E, Note.F, Note.G, Note.A, Note.Asharp] + ); + + [Fact] + public void CDorian() => + AssertScaleIsCorrect( + Note.C, + Mode.Dorian, + [Note.C, Note.D, Note.Dsharp, Note.F, Note.G, Note.A, Note.Asharp] + ); + + [Fact] + public void CAeolian() => + AssertScaleIsCorrect( + Note.C, + Mode.Aeolian, + [Note.C, Note.D, Note.Dsharp, Note.F, Note.G, Note.Gsharp, Note.Asharp] + ); + + [Fact] + public void CPhrygian() => + AssertScaleIsCorrect( + Note.C, + Mode.Phrygian, + [Note.C, Note.Csharp, Note.Dsharp, Note.F, Note.G, Note.Gsharp, Note.Asharp] + ); + + [Fact] + public void CLocrian() => + AssertScaleIsCorrect( + Note.C, + Mode.Locrian, + [Note.C, Note.Csharp, Note.Dsharp, Note.F, Note.Fsharp, Note.Gsharp, Note.Asharp] + ); + + [Fact] + public void ALydian() => + AssertScaleIsCorrect( + Note.A, + Mode.Lydian, + [Note.A, Note.B, Note.Csharp, Note.Dsharp, Note.E, Note.Fsharp, Note.Gsharp] + ); + + [Fact] + public void AIonian() => + AssertScaleIsCorrect( + Note.A, + Mode.Ionian, + [Note.A, Note.B, Note.Csharp, Note.D, Note.E, Note.Fsharp, Note.Gsharp] + ); + + [Fact] + public void AMixolydian() => + AssertScaleIsCorrect( + Note.A, + Mode.Mixolydian, + [Note.A, Note.B, Note.Csharp, Note.D, Note.E, Note.Fsharp, Note.G] + ); + + [Fact] + public void ADorian() => + AssertScaleIsCorrect( + Note.A, + Mode.Dorian, + [Note.A, Note.B, Note.C, Note.D, Note.E, Note.Fsharp, Note.G] + ); + + [Fact] + public void AAeolian() => + AssertScaleIsCorrect( + Note.A, + Mode.Aeolian, + [Note.A, Note.B, Note.C, Note.D, Note.E, Note.F, Note.G] + ); + + [Fact] + public void APhrygian() => + AssertScaleIsCorrect( + Note.A, + Mode.Phrygian, + [Note.A, Note.Asharp, Note.C, Note.D, Note.E, Note.F, Note.G] + ); + + [Fact] + public void ALocrian() => + AssertScaleIsCorrect( + Note.A, + Mode.Locrian, + [Note.A, Note.Asharp, Note.C, Note.D, Note.Dsharp, Note.F, Note.G] + ); + } + + public class IsTonic + { + [Fact] + public void DIsTheOnlyTonic() + { + var scale = Scale.Build(Note.D, Mode.Ionian); + Assert.True(scale.IsTonic(Note.D)); + Enum.GetValues() + .Where(note => !note.Equals(Note.D)) + .ForEach(note => + { + Assert.False(scale.IsTonic(note)); + }); + } + } + + public class Interval + { + private readonly Scale _cIonian = Scale.Build(Note.C, Mode.Ionian); + + [Fact] + public void CIsFirstInCIonian() => Assert.Equal(1, _cIonian.Interval(Note.C)); + + [Fact] + public void CsharpIsOutOfScaleInCIonian() => Assert.Null(_cIonian.Interval(Note.Csharp)); + + [Fact] + public void DIsSecondInCIonian() => Assert.Equal(2, _cIonian.Interval(Note.D)); + + [Fact] + public void DsharpIsOutOfScaleInCIonian() => Assert.Null(_cIonian.Interval(Note.Dsharp)); + + [Fact] + public void EIsThirdInCIonian() => Assert.Equal(3, _cIonian.Interval(Note.E)); + + [Fact] + public void FIsFourthInCIonian() => Assert.Equal(4, _cIonian.Interval(Note.F)); + + [Fact] + public void FsharpIsOutOfScaleInCIonian() => Assert.Null(_cIonian.Interval(Note.Fsharp)); + + [Fact] + public void GIsFifthInCIonian() => Assert.Equal(5, _cIonian.Interval(Note.G)); + + [Fact] + public void GsharpIsOutOfScaleInCIonian() => Assert.Null(_cIonian.Interval(Note.Gsharp)); + + [Fact] + public void AIsSixthInCIonian() => Assert.Equal(6, _cIonian.Interval(Note.A)); + + [Fact] + public void AsharpIsOutOfScaleInCIonian() => Assert.Null(_cIonian.Interval(Note.Asharp)); + + [Fact] + public void BIsSeventhInCIonian() => Assert.Equal(7, _cIonian.Interval(Note.B)); + + private readonly Scale _aAeolian = Scale.Build(Note.A, Mode.Aeolian); + + [Fact] + public void AIsFirstInAAeolian() => Assert.Equal(1, _aAeolian.Interval(Note.A)); + + [Fact] + public void AsharpIsOutOfScaleInAAeolian() => Assert.Null(_aAeolian.Interval(Note.Asharp)); + + + [Fact] + public void EIsFifthInAAeolian() => Assert.Equal(5, _aAeolian.Interval(Note.E)); + } +} diff --git a/OpenUtau/Controls/PianoRoll.axaml b/OpenUtau/Controls/PianoRoll.axaml index 5e5da6e14..144ef2209 100644 --- a/OpenUtau/Controls/PianoRoll.axaml +++ b/OpenUtau/Controls/PianoRoll.axaml @@ -2,8 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:OpenUtau.App.ViewModels" - xmlns:api="using:OpenUtau.Api" xmlns:c="clr-namespace:OpenUtau.App.Controls" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="650" x:Class="OpenUtau.App.Controls.PianoRoll" Focusable="True" @@ -57,7 +55,7 @@ DataContext="{Binding NotesViewModel}" TrackHeight="{Binding TrackHeight}" TrackOffset="{Binding TrackOffset}" - Key="{Binding Key}" + Scale="{Binding Scale}" PointerWheelChanged="KeyboardPointerWheelChanged" PointerPressed="KeyboardPointerPressed" PointerMoved="KeyboardPointerMoved" @@ -122,6 +120,7 @@ Foreground="{DynamicResource TrackBackgroundAltBrush}" DataContext="{Binding NotesViewModel}" Bounds="{Binding Bounds, Mode=OneWayToSource}" + Scale="{Binding Scale}" TrackHeight="{Binding TrackHeight}" TrackOffset="{Binding TrackOffset}" IsPianoRoll="True"/> + + + + diff --git a/OpenUtau/Controls/PianoRoll.axaml.cs b/OpenUtau/Controls/PianoRoll.axaml.cs index 13dc54157..16c086273 100644 --- a/OpenUtau/Controls/PianoRoll.axaml.cs +++ b/OpenUtau/Controls/PianoRoll.axaml.cs @@ -1417,6 +1417,11 @@ public void OnKeyMenuButton(object sender, RoutedEventArgs args) { KeyMenu.Open(); } + public void OnModeMenuButton(object sender, RoutedEventArgs args) { + ModeMenu.PlacementTarget = sender as Button; + ModeMenu.Open(); + } + bool MoveToNextPart(bool next) { var notesVm = ViewModel.NotesViewModel; var playVm = ViewModel.PlaybackViewModel; @@ -1446,7 +1451,7 @@ bool MoveToNextPart(bool next) { return true; } - void OnKeyKeyDown(object sender, KeyEventArgs e) { + void OnScaleKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None) { if (sender is ContextMenu menu && menu.SelectedItem is MenuItemViewModel item) { item.Command?.Execute(item.CommandParameter); diff --git a/OpenUtau/Controls/TrackBackground.cs b/OpenUtau/Controls/TrackBackground.cs index f9332b18d..98d02e181 100644 --- a/OpenUtau/Controls/TrackBackground.cs +++ b/OpenUtau/Controls/TrackBackground.cs @@ -6,6 +6,7 @@ using Avalonia.Media; using OpenUtau.Core; using OpenUtau.Core.Util; +using OpenUtau.Core.Util.MusicTheory; using ReactiveUI; using ReactiveUI.Primitives; @@ -30,12 +31,15 @@ class TrackBackground : TemplatedControl { AvaloniaProperty.RegisterDirect( nameof(IsKeyboard), o => o.IsKeyboard, - (o, v) => o.IsKeyboard = v); - public static readonly DirectProperty KeyProperty = - AvaloniaProperty.RegisterDirect( - nameof(Key), - o => o.Key, - (o, v) => o.Key = v); + (o, v) => o.IsKeyboard = v + ); + + public static readonly DirectProperty ScaleProperty = + AvaloniaProperty.RegisterDirect( + nameof(Scale), + o => o.Scale, + (o, v) => o.Scale = v + ); public double TrackHeight { get => _trackHeight; @@ -53,16 +57,17 @@ public bool IsKeyboard { get => _isKeyboard; set => SetAndRaise(IsPianoRollProperty, ref _isKeyboard, value); } - public int Key { + public Scale Scale + { get => _key; - set => SetAndRaise(KeyProperty, ref _key, value); + set => SetAndRaise(ScaleProperty, ref _key, value); } private double _trackHeight; private double _trackOffset; private bool _isPianoRoll; private bool _isKeyboard; - private int _key; + private Scale _key = Scale.Default(); public TrackBackground() { MessageBus.Current.Listen() @@ -74,60 +79,76 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == TrackHeightProperty || change.Property == TrackOffsetProperty || change.Property == ForegroundProperty || - change.Property == KeyProperty) { + change.Property == ScaleProperty) { InvalidateVisual(); } } - int mod(int a, int b){ - return (a % b + b) % b; - } - - public override void Render(DrawingContext context) { - if (TrackHeight == 0) { + public override void Render(DrawingContext context) + { + if (TrackHeight == 0) + { return; } int track = (int)TrackOffset; double top = TrackHeight * (track - TrackOffset); - string[] degreeNames; - switch(Preferences.Default.DegreeStyle){ - case 1: - degreeNames = MusicMath.Solfeges; - break; - case 2: - degreeNames = MusicMath.NumberedNotations; - break; - default: - degreeNames = Enumerable.Repeat("", 12).ToArray(); - break; - } - while (top < Bounds.Height) { - bool isAltTrack = IsAltTrack(track) ^ (ThemeManager.IsDarkMode && !IsKeyboard); - bool isCenterKey = IsKeyboard && IsCenterKey(track); - var brush = isCenterKey ? ThemeManager.CenterKeyBrush - : IsKeyboard ? (isAltTrack ? ThemeManager.BlackKeyBrush : ThemeManager.WhiteKeyBrush) - : isAltTrack ? Foreground : Background; + + while (top < Bounds.Height) + { + bool isAltTrack = IsOutOfScale(track) ^ (ThemeManager.IsDarkMode && !IsKeyboard); + bool isCenterKey = IsKeyboard && IsTonic(track); + var brush = + isCenterKey ? ThemeManager.CenterKeyBrush + : IsKeyboard + ? (isAltTrack ? ThemeManager.BlackKeyBrush : ThemeManager.WhiteKeyBrush) + : isAltTrack ? Foreground + : Background; context.DrawRectangle( brush, null, - new Rect(0, (int)top, Bounds.Width, TrackHeight)); - if (IsKeyboard && TrackHeight >= 12) { - brush = isCenterKey ? ThemeManager.CenterKeyNameBrush + new Rect(0, (int)top, Bounds.Width, TrackHeight) + ); + if (IsKeyboard && TrackHeight >= 12) + { + brush = + isCenterKey ? ThemeManager.CenterKeyNameBrush : isAltTrack ? ThemeManager.BlackKeyNameBrush - : ThemeManager.WhiteKeyNameBrush; + : ThemeManager.WhiteKeyNameBrush; int tone = ViewConstants.MaxTone - 1 - track; string toneName = MusicMath.GetToneName(tone); var toneTextLayout = TextLayoutCache.Get(toneName, brush, 12); - var toneTextPosition = new Point(Bounds.Width - 4 - (int)toneTextLayout.Width, (int)(top + (TrackHeight - toneTextLayout.Height) / 2)); - using (var state = context.PushTransform(Matrix.CreateTranslation(toneTextPosition))) { + var toneTextPosition = new Point( + Bounds.Width - 4 - (int)toneTextLayout.Width, + (int)(top + (TrackHeight - toneTextLayout.Height) / 2) + ); + using ( + var state = context.PushTransform( + Matrix.CreateTranslation(toneTextPosition) + ) + ) + { toneTextLayout.Draw(context, new Point()); } - //scale degree display - int degree = mod(tone - Key, 12); - string degreeName = degreeNames[degree]; + + // TODO interval notations has nothing to do with scales, both should coexist : if solfege notation then all the app should turn C into Do etc + string degreeName = Preferences.Default.DegreeStyle switch + { + 1 => Scale.SolfegeIntervalName(NoteHelper.CastNote(tone))?.ToString() ?? "", + 2 => Scale.Interval(NoteHelper.CastNote(tone)).ToString() ?? "", + _ => "" + }; + var degreeTextLayout = TextLayoutCache.Get(degreeName, brush, 12); - var degreeTextPosition = new Point(4, (int)(top + (TrackHeight - degreeTextLayout.Height) / 2)); - using (var state = context.PushTransform(Matrix.CreateTranslation(degreeTextPosition))) { + var degreeTextPosition = new Point( + 4, + (int)(top + (TrackHeight - degreeTextLayout.Height) / 2) + ); + using ( + var state = context.PushTransform( + Matrix.CreateTranslation(degreeTextPosition) + ) + ) + { degreeTextLayout.Draw(context, new Point()); } } @@ -136,20 +157,21 @@ public override void Render(DrawingContext context) { } } - private bool IsAltTrack(int track) { - if (!IsPianoRoll) { + private bool IsOutOfScale(int track) + { + if (!IsPianoRoll) return track % 2 == 1; - } + int tone = ViewConstants.MaxTone - 1 - track; - if (tone < 0) { + if (tone < 0) return false; - } - return MusicMath.IsBlackKey(tone); + return Scale.IsOutOfScale(NoteHelper.CastNote(tone)); } - private bool IsCenterKey(int track) { + private bool IsTonic(int track) + { int tone = ViewConstants.MaxTone - 1 - track; - return MusicMath.IsCenterKey(tone); + return Scale.IsTonic(NoteHelper.CastNote(tone)); } } } diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index 87c369bd8..cd57edd22 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -15,6 +15,7 @@ using OpenUtau.Core; using OpenUtau.Core.Ustx; using OpenUtau.Core.Util; +using OpenUtau.Core.Util.MusicTheory; using OpenUtau.ViewModels; using ReactiveUI; using ReactiveUI.Primitives; @@ -46,7 +47,7 @@ public partial class NotesViewModel : ViewModelBase, ICmdSubscriber { [Reactive] public partial double TickOffset { get; set; } [Reactive] public partial double TrackOffset { get; set; } [Reactive] public partial int SnapDiv { get; set; } - [Reactive] public partial int Key { get; set; } + [Reactive] public partial Scale Scale { get; set; } = Scale.Default(); public ObservableCollectionExtended SnapTicks { get; } = new ObservableCollectionExtended(); [Reactive] public partial double PlayPosX { get; set; } [Reactive] public partial double PlayPosHighlightX { get; set; } @@ -70,7 +71,8 @@ public partial class NotesViewModel : ViewModelBase, ICmdSubscriber { [Reactive] public partial bool ShowExpressions { get; set; } [Reactive] public partial bool IsSnapOn { get; set; } [Reactive] public partial string SnapDivText { get; set; } - [Reactive] public partial string KeyText { get; set; } + [Reactive] public partial string TonicText { get; set; } = string.Empty; + [Reactive] public partial string ModeText { get; set; } = string.Empty; [Reactive] public partial Rect ExpBounds { get; set; } [Reactive] public partial string PrimaryKey { get; set; } [Reactive] public partial bool PrimaryKeyNotSupported { get; set; } @@ -94,10 +96,12 @@ public partial class NotesViewModel : ViewModelBase, ICmdSubscriber { public double VScrollBarMax => Math.Max(0, TrackCount - ViewportTracks); public UProject Project => DocManager.Inst.Project; [Reactive] public partial List SnapDivs { get; set; } - [Reactive] public partial List Keys { get; set; } + [Reactive] public partial List Tonics { get; set; } = new List(); + [Reactive] public partial List Modes { get; set; } = new List(); public ReactiveCommand SetSnapUnitCommand { get; set; } - public ReactiveCommand SetKeyCommand { get; set; } + public ReactiveCommand SetKeyCommand { get; set; } + public ReactiveCommand SetModeCommand { get; set; } // See the comments on TracksViewModel.playPosXToTickOffset private double playPosXToTickOffset => Bounds.Width != 0 ? ViewportTicks / Bounds.Width : 0; @@ -117,7 +121,6 @@ public partial class NotesViewModel : ViewModelBase, ICmdSubscriber { private string? portraitSource; private readonly object portraitLock = new object(); private int userSnapDiv = -2; - private int userKey => Project.key; public NotesViewModel() { SnapDivs = new List(); @@ -126,12 +129,17 @@ public NotesViewModel() { UpdateSnapDiv(); }); - Keys = new List(); - SetKeyCommand = ReactiveCommand.Create(key => { + SetKeyCommand = ReactiveCommand.Create(tonic => { DocManager.Inst.StartUndoGroup("command.project.key"); - DocManager.Inst.ExecuteCmd(new KeyCommand(Project, key)); + DocManager.Inst.ExecuteCmd(new KeyCommand(Project, tonic)); DocManager.Inst.EndUndoGroup(); - UpdateKey(); + UpdateScale(); + }); + SetModeCommand = ReactiveCommand.Create(mode => { + DocManager.Inst.StartUndoGroup("command.project.mode"); + DocManager.Inst.ExecuteCmd(new ModeCommand(Project, mode)); + DocManager.Inst.EndUndoGroup(); + UpdateScale(); }); viewportTicks = this.WhenAnyValue(x => x.Bounds, x => x.TickWidth) @@ -211,19 +219,25 @@ public NotesViewModel() { Command = SetSnapUnitCommand, CommandParameter = div, })); - Keys.Clear(); - Keys.AddRange(MusicMath.KeysInOctave - .Select((key, index) => new MenuItemViewModel { - Header = $"1={key.Item1}", + Tonics.Clear(); + Tonics.AddRange( + Enum.GetValues().Select((tonic) => new MenuItemViewModel { + Header = $"1={NoteHelper.StringifyNote(tonic)}", Command = SetKeyCommand, - CommandParameter = index, + CommandParameter = tonic, + })); + Modes.Clear(); + Modes.AddRange( + Enum.GetValues().Select((mode) => new MenuItemViewModel { + Header = mode.ToString(), + Command = SetModeCommand, + CommandParameter = mode, })); }); ShowTips = Preferences.Default.ShowTips; IsSnapOn = true; SnapDivText = string.Empty; - KeyText = string.Empty; PlayTone = Preferences.Default.PlayTone; this.WhenAnyValue(x => x.PlayTone) @@ -367,9 +381,10 @@ private void UpdateSnapDiv() { SnapDivText = $"(1/{div})"; } - private void UpdateKey() { - Key = userKey; - KeyText = "1=" + MusicMath.KeysInOctave[userKey].Item1; + private void UpdateScale() { + Scale = Scale.Build(Project.key, Project.mode); + TonicText = "1=" + NoteHelper.StringifyNote(Scale.Tonic); + ModeText = Scale.Mode.ToString(); } public void OnXZoomed(Point position, double delta) { @@ -489,7 +504,7 @@ private void LoadPart(UPart part, UProject project) { LoadPortrait(part, project); LoadWindowTitle(part, project); LoadTrackColor(part, project); - UpdateKey(); + UpdateScale(); } //If PortraitHeight is 0, the default behaviour is resizing any image taller than 800px to 800px, diff --git a/OpenUtau/ViewModels/PlaybackViewModel.cs b/OpenUtau/ViewModels/PlaybackViewModel.cs index 164fd8928..63050a794 100644 --- a/OpenUtau/ViewModels/PlaybackViewModel.cs +++ b/OpenUtau/ViewModels/PlaybackViewModel.cs @@ -2,6 +2,7 @@ using OpenUtau.Core; using OpenUtau.Core.Ustx; using OpenUtau.Core.Util; +using OpenUtau.Core.Util.MusicTheory; using ReactiveUI; namespace OpenUtau.App.ViewModels { @@ -11,8 +12,8 @@ public class PlaybackViewModel : ViewModelBase, ICmdSubscriber { public int BeatPerBar => Project.timeSignatures[0].beatPerBar; public int BeatUnit => Project.timeSignatures[0].beatUnit; public double Bpm => Project.tempos[0].bpm; - public int Key => Project.key; - public string KeyName => MusicMath.KeysInOctave[Key].Item1; + public Note Key => Project.key; + public string KeyName => MusicMath.KeysInOctave[(int) Key].Item1; public int Resolution => Project.resolution; public int PlayPosTick => DocManager.Inst.playPosTick; public TimeSpan PlayPosTime => TimeSpan.FromMilliseconds((int)Project.timeAxis.TickPosToMsPos(DocManager.Inst.playPosTick)); @@ -84,7 +85,7 @@ public void SetBpm(double bpm) { DocManager.Inst.EndUndoGroup(); } - public void SetKey(int key) { + public void SetKey(Note key) { if (key == DocManager.Inst.Project.key) { return; }