From 656296eb32cf14501439304e6b242fa3b70abb94 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Mon, 24 Aug 2026 14:27:31 +0900 Subject: [PATCH 1/3] feat: add metronome toggle button and add button listener Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- OpenUtau/Strings/Strings.axaml | 2 ++ OpenUtau/Strings/Strings.ja-JP.axaml | 2 ++ OpenUtau/ViewModels/MainWindowViewModel.cs | 31 +++++++++++++--------- OpenUtau/Views/MainWindow.axaml | 17 ++++++++++-- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index fe9830797..b08c86847 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -553,6 +553,8 @@ Warning: this option removes custom presets. Preutter Release time delta + Toggle Metronom + Advanced Beta Lyrics Helper diff --git a/OpenUtau/Strings/Strings.ja-JP.axaml b/OpenUtau/Strings/Strings.ja-JP.axaml index cd56ac000..c1fbedc79 100644 --- a/OpenUtau/Strings/Strings.ja-JP.axaml +++ b/OpenUtau/Strings/Strings.ja-JP.axaml @@ -401,6 +401,8 @@ Ctrl長押しで選択 選択ツール (1) + メトロノーム + 高度な設定 ベータ版 歌詞入力ヘルパー diff --git a/OpenUtau/ViewModels/MainWindowViewModel.cs b/OpenUtau/ViewModels/MainWindowViewModel.cs index d3090673f..e1b4cd0f5 100644 --- a/OpenUtau/ViewModels/MainWindowViewModel.cs +++ b/OpenUtau/ViewModels/MainWindowViewModel.cs @@ -88,6 +88,8 @@ public class MainWindowViewModel : ViewModelBase, ICmdSubscriber { [Reactive] public string UndoText { get; set; } = ThemeManager.GetString("menu.edit.undo"); [Reactive] public string RedoText { get; set; } = ThemeManager.GetString("menu.edit.redo"); + [Reactive] public bool IsMetronomeEnabled { get; set; } = PlaybackManager.Inst.metronomeGenerator.Enabled; + private ObservableCollectionExtended openRecentMenuItems = new ObservableCollectionExtended(); private ObservableCollectionExtended openTemplatesMenuItems @@ -136,6 +138,9 @@ public MainWindowViewModel() { PianoRollMaxHeight = x ? double.PositiveInfinity : 0; PianoRollMinHeight = x ? ViewConstants.PianoRollMinHeight : 0; }); + this.WhenAnyValue(x => x.IsMetronomeEnabled) + .Subscribe(enabled => + PlaybackManager.Inst.metronomeGenerator.Enabled = enabled); } public void Undo() { @@ -187,7 +192,7 @@ public void InitProject(MainWindow window) { HasRecovery = true; return; } - + var args = Environment.GetCommandLineArgs(); if (args.Length == 2 && File.Exists(args[1])) { try { @@ -269,7 +274,7 @@ public void SaveProject(string file = "") { this.RaisePropertyChanged(nameof(Title)); } - public void ImportTracks(UProject[] loadedProjects, bool importTempo){ + public void ImportTracks(UProject[] loadedProjects, bool importTempo) { if (loadedProjects == null || loadedProjects.Length < 1) { return; } @@ -314,7 +319,7 @@ public void ImportMidi(string file) { var track = new UTrack(project); track.TrackNo = project.tracks.Count; part.trackNo = track.TrackNo; - if(part.name != "New Part"){ + if (part.name != "New Part") { track.TrackName = part.name; } part.AfterLoad(project, track); @@ -400,7 +405,7 @@ public void RefreshTimelineContextMenu(int tick) { /// Remap a tick position from the old time axis to the new time axis without changing its absolute position (in ms). /// Note that this can only be used on positions, not durations. /// - private int RemapTickPos(int tickPos, TimeAxis oldTimeAxis, TimeAxis newTimeAxis){ + private int RemapTickPos(int tickPos, TimeAxis oldTimeAxis, TimeAxis newTimeAxis) { double msPos = oldTimeAxis.TickPosToMsPos(tickPos); return newTimeAxis.MsPosToTickPos(msPos); } @@ -409,41 +414,41 @@ private int RemapTickPos(int tickPos, TimeAxis oldTimeAxis, TimeAxis newTimeAxis /// Remap the starting and ending positions of all the notes and parts in the whole project /// from the old time axis to the new time axis, without changing their absolute positions in ms. /// - public void RemapTimeAxis(TimeAxis oldTimeAxis, TimeAxis newTimeAxis){ + public void RemapTimeAxis(TimeAxis oldTimeAxis, TimeAxis newTimeAxis) { var project = DocManager.Inst.Project; - foreach(var part in project.parts){ + foreach (var part in project.parts) { var partOldStartTick = part.position; var partNewStartTick = RemapTickPos(part.position, oldTimeAxis, newTimeAxis); - if(partNewStartTick != partOldStartTick){ + if (partNewStartTick != partOldStartTick) { DocManager.Inst.ExecuteCmd(new MovePartCommand( project, part, partNewStartTick, part.trackNo)); } - if(part is UVoicePart voicePart){ + if (part is UVoicePart voicePart) { var partOldDuration = voicePart.Duration; var partNewDuration = RemapTickPos(partOldStartTick + voicePart.duration, oldTimeAxis, newTimeAxis) - partNewStartTick; - if(partNewDuration != partOldDuration) { + if (partNewDuration != partOldDuration) { DocManager.Inst.ExecuteCmd(new ResizeVoicePartCommand( project, voicePart, partNewDuration - partOldDuration, false)); } var noteCommands = new List(); - foreach(var note in voicePart.notes){ + foreach (var note in voicePart.notes) { var noteOldStartTick = note.position + partOldStartTick; var noteOldEndTick = note.End + partOldStartTick; var noteOldDuration = note.duration; var noteNewStartTick = RemapTickPos(noteOldStartTick, oldTimeAxis, newTimeAxis); var noteNewEndTick = RemapTickPos(noteOldEndTick, oldTimeAxis, newTimeAxis); var deltaPosTickInPart = (noteNewStartTick - partNewStartTick) - (noteOldStartTick - partOldStartTick); - if(deltaPosTickInPart != 0){ + if (deltaPosTickInPart != 0) { noteCommands.Add(new MoveNoteCommand(voicePart, note, deltaPosTickInPart, 0)); } var noteNewDuration = noteNewEndTick - noteNewStartTick; var deltaDur = noteNewDuration - noteOldDuration; - if(deltaDur != 0){ + if (deltaDur != 0) { noteCommands.Add(new ResizeNoteCommand(voicePart, note, deltaDur)); } //TODO: expression curve remapping, phoneme timing remapping } - foreach(var command in noteCommands){ + foreach (var command in noteCommands) { DocManager.Inst.ExecuteCmd(command); } } diff --git a/OpenUtau/Views/MainWindow.axaml b/OpenUtau/Views/MainWindow.axaml index 94523d8b1..e6108680f 100644 --- a/OpenUtau/Views/MainWindow.axaml +++ b/OpenUtau/Views/MainWindow.axaml @@ -220,7 +220,7 @@ - + @@ -231,7 +231,7 @@ - @@ -239,6 +239,19 @@ + + + From 0fb4214cf0bcaea225d847a010f3b24bea0363f2 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Mon, 24 Aug 2026 18:46:10 +0900 Subject: [PATCH 2/3] feat: implement metronome functionality in MetronomeGenerator Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- OpenUtau.Core/PlaybackManager.cs | 146 ++++++++++++++++++++- OpenUtau.Core/Render/RenderEngine.cs | 2 +- OpenUtau.Core/SignalChain/MasterAdapter.cs | 5 +- 3 files changed, 144 insertions(+), 9 deletions(-) diff --git a/OpenUtau.Core/PlaybackManager.cs b/OpenUtau.Core/PlaybackManager.cs index c815c0ad4..8822c142e 100644 --- a/OpenUtau.Core/PlaybackManager.cs +++ b/OpenUtau.Core/PlaybackManager.cs @@ -90,7 +90,7 @@ public class ToneGenerator : ISignalSource { private readonly object _lockObj = new object(); - public ToneGenerator() {} + public ToneGenerator() { } public ToneGenerator(float gain) { this.gain = gain; @@ -166,6 +166,134 @@ private void CleanupTones() { } } + public class MetronomeGenerator : ISignalSource { + private const int SampleRate = 44100; + + private TimeAxis? timeAxis; + private int startTick; + + public bool Enabled { get; set; } + + public MetronomeGenerator() { + } + + public void SetProject(UProject project, int startTick) { + timeAxis = project.timeAxis; + this.startTick = startTick; + } + + public bool IsReady(int position, int count) => true; + + public int Mix(int position, float[] buffer, int offset, int count) { + if (!Enabled || timeAxis == null) { + return position + count; + } + + double startFrame = position / 2.0; + double endFrame = (position + count) / 2.0; + + double startProjectMs = + timeAxis.TickPosToMsPos(startTick) + + startFrame * 1000.0 / SampleRate; + + double endProjectMs = + timeAxis.TickPosToMsPos(startTick) + + endFrame * 1000.0 / SampleRate; + + const double clickLengthMs = 50.0; + double searchStartMs = Math.Max(0, startProjectMs - clickLengthMs); + double searchStartTickExact = timeAxis.MsPosToNonExactTickPos(searchStartMs); + double endTickExact = timeAxis.MsPosToNonExactTickPos(endProjectMs); + + int currentTick = (int)Math.Floor(searchStartTickExact); + + timeAxis.TickPosToBarBeat( + currentTick, + out int bar, + out int beat, + out _); + + int clickTick = timeAxis.BarBeatToTickPos(bar, beat); + + if (clickTick < currentTick) { + timeAxis.NextBarBeat( + bar, + beat, + out bar, + out beat); + + clickTick = timeAxis.BarBeatToTickPos(bar, beat); + } + + int clickLengthSamples = (int)(SampleRate * clickLengthMs / 1000.0); + + while (clickTick < endTickExact) { + double clickMs = timeAxis.TickPosToMsPos(clickTick); + double startMs = timeAxis.TickPosToMsPos(startTick); + + double clickFrame = (clickMs - startMs) * SampleRate / 1000.0; + double relativeFrame = clickFrame - startFrame; + int frame = (int)Math.Round(relativeFrame); + + if (frame + clickLengthSamples > 0 && frame < count / 2) { + AddClick( + buffer, + offset, + frame, + beat == 0, + count); + } + + timeAxis.NextBarBeat( + bar, + beat, + out bar, + out beat); + + clickTick = timeAxis.BarBeatToTickPos(bar, beat); + } + + return position + count; + } + + private static void AddClick( + float[] buffer, + int offset, + int frame, + bool accent, + int count) { + + const double clickLengthMs = 50.0; + const float volume = 0.5f; + + int clickLength = (int)(SampleRate * clickLengthMs / 1000.0); + double frequency = accent ? 1600.0 : 1000.0; + int bufferFrameCount = count / 2; + + int startI = frame < 0 ? -frame : 0; + + for (int i = startI; i < clickLength; ++i) { + int targetFrame = frame + i; + + if (targetFrame >= bufferFrameCount) { + break; + } + + double t = (double)i / SampleRate; + + double envelope = Math.Exp(-t * 80.0); + + float sample = (float)( + Math.Sin(2.0 * Math.PI * frequency * t) + * envelope + * volume); + + buffer[offset + targetFrame * 2] += sample; + buffer[offset + targetFrame * 2 + 1] += sample; + } + } + } + public class PlaybackManager : SingletonBase, ICmdSubscriber { private PlaybackManager() { DocManager.Inst.AddSubscriber(this); @@ -177,14 +305,16 @@ private PlaybackManager() { } toneGenerator = new ToneGenerator(); - editingMix = new MasterAdapter(toneGenerator); + metronomeGenerator = new MetronomeGenerator(); + editingMix = new MasterAdapter(toneGenerator, metronomeGenerator); } public readonly ToneGenerator toneGenerator; + public readonly MetronomeGenerator metronomeGenerator; List faders; MasterAdapter masterMix; MasterAdapter editingMix; - + double startMs; public int StartTick => DocManager.Inst.Project.timeAxis.MsPosToTickPos(startMs); CancellationTokenSource renderCancellation; @@ -230,7 +360,7 @@ public void PlayFile(string file) { if (AudioOutput.PlaybackState == PlaybackState.Playing) { AudioOutput.Stop(); } - try{ + try { var playSound = Wave.OpenFile(file); AudioOutput.Init(playSound.ToSampleProvider()); } catch (Exception ex) { @@ -238,7 +368,7 @@ public void PlayFile(string file) { return; } AudioOutput.Play(); - } + } public void PlayOrPause(int tick = -1, int endTick = -1, int trackNo = -1) { if (PlayingMaster) { @@ -294,8 +424,10 @@ private void StartPlayback(double startMs, MasterAdapter masterAdapter) { toneGenerator.EndAllTones(); this.startMs = startMs; - var start = TimeSpan.FromMilliseconds(startMs); - Log.Information($"StartPlayback at {start}"); + int startTick = DocManager.Inst.Project.timeAxis.MsPosToTickPos(startMs); + Log.Information($"StartPlayback at {startMs}"); + + metronomeGenerator.SetProject(DocManager.Inst.Project, startTick); masterMix = masterAdapter; AudioOutput.Stop(); AudioOutput.Init(masterMix); diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index 75bc3d35b..6eb7e7146 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -140,7 +140,7 @@ public Tuple> RenderMixdown(TaskScheduler uiScheduler, ref public Tuple> RenderProject(TaskScheduler uiScheduler, ref CancellationTokenSource cancellation) { double startMs = project.timeAxis.TickPosToMsPos(startTick); var renderMixdownResult = RenderMixdown(uiScheduler, ref cancellation, wait: false); - var master = new MasterAdapter(renderMixdownResult.Item1); + var master = new MasterAdapter(renderMixdownResult.Item1, PlaybackManager.Inst.metronomeGenerator); master.SetPosition((int)(startMs * 44100 / 1000) * 2); return Tuple.Create(master, renderMixdownResult.Item2); } diff --git a/OpenUtau.Core/SignalChain/MasterAdapter.cs b/OpenUtau.Core/SignalChain/MasterAdapter.cs index 631ff9555..a2d809bcf 100644 --- a/OpenUtau.Core/SignalChain/MasterAdapter.cs +++ b/OpenUtau.Core/SignalChain/MasterAdapter.cs @@ -5,14 +5,16 @@ namespace OpenUtau.Core.SignalChain { class MasterAdapter : ISampleProvider { private readonly WaveFormat waveFormat; private readonly ISignalSource source; + private readonly ISignalSource? metronome; private int position; public WaveFormat WaveFormat => waveFormat; public int Waited { get; private set; } public bool IsWaiting { get; private set; } - public MasterAdapter(ISignalSource source) { + public MasterAdapter(ISignalSource source, ISignalSource? metronome = null) { waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2); this.source = source; + this.metronome = metronome; } public int Read(float[] buffer, int offset, int count) { @@ -25,6 +27,7 @@ public int Read(float[] buffer, int offset, int count) { return count; } else { int pos = source.Mix(position, buffer, offset, count); + metronome?.Mix(position, buffer, offset, count); int n = Math.Max(0, pos - position); position = pos; IsWaiting = false; From eed7e34fa93f5e87d503801e529a7cb6b6c6786a Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Mon, 24 Aug 2026 22:49:06 +0900 Subject: [PATCH 3/3] fix: remove metronome generator from editingMix Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- OpenUtau.Core/PlaybackManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenUtau.Core/PlaybackManager.cs b/OpenUtau.Core/PlaybackManager.cs index 8822c142e..e9123bc81 100644 --- a/OpenUtau.Core/PlaybackManager.cs +++ b/OpenUtau.Core/PlaybackManager.cs @@ -306,7 +306,7 @@ private PlaybackManager() { toneGenerator = new ToneGenerator(); metronomeGenerator = new MetronomeGenerator(); - editingMix = new MasterAdapter(toneGenerator, metronomeGenerator); + editingMix = new MasterAdapter(toneGenerator); } public readonly ToneGenerator toneGenerator;