From 19bcf7368f7f15609b61b5c2592ef7b163127a68 Mon Sep 17 00:00:00 2001
From: vocoder712 <100213316+vocoder712@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:12:08 +0800
Subject: [PATCH 1/3] =?UTF-8?q?feature:=20=E5=9F=BA=E6=9C=AC=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E5=B1=95=E7=A4=BA=E5=B7=B2=E6=B8=B2=E6=9F=93=E7=9A=84?=
=?UTF-8?q?=E6=AD=8C=E5=A3=B0=E6=B3=A2=E5=BD=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent/DECISIONS.md | 6 +
.../Controls/RenderedWaveformCanvas.cs | 350 ++++++++++++++++++
OpenUtauMobile/ViewConstants.cs | 12 +
OpenUtauMobile/Views/EditorView.axaml | 13 +-
4 files changed, 380 insertions(+), 1 deletion(-)
create mode 100644 OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md
index 7b71440b..53316c88 100644
--- a/.agent/DECISIONS.md
+++ b/.agent/DECISIONS.md
@@ -12,6 +12,12 @@ Record meaningful technical decisions here. Use one entry per decision.
## Entries
+- Date: 2026-08-29
+- Decision: Display the active voice part's rendered mix as a one-sided peak envelope in the piano-roll ruler placeholder. Build a bounded 4 kHz envelope asynchronously once per rendered result, then cache an overscanned `StreamGeometry` so ordinary `NotesCanvas` panning only changes a translation and clip instead of remixing audio or uploading a bitmap.
+- Rationale: The upstream source of truth is `UVoicePart.Mix`, while a fixed-rate peak envelope preserves transients at the editor's supported zoom range. Reused pooled mix buffers, a bounded envelope, and compositor-friendly retained geometry minimize UI-thread work, allocations, and mobile GPU uploads during high-frequency viewport redraws.
+- Alternatives considered: Port upstream's per-frame full-view `WriteableBitmap` renderer; remix raw samples on every pan; keep a full-resolution sample copy; draw both positive and negative waveform halves.
+- Impacted areas: Mobile piano-roll ruler rendering and shared view constants. OpenUtau.Core and renderer output remain unchanged.
+
- Date: 2026-08-29
- Decision: Introduce an asynchronous Mobile-layer external URL launcher that accepts only absolute HTTP/HTTPS URLs and is injected through `ServiceHub`; implement native launchers for Android, Windows, Linux, macOS, iOS, and browser WASM.
- Rationale: A typed service keeps MVVM callers independent of platform APIs, reports launch failure without exceptions reaching commands, and fits the existing ServiceHub host-injection pattern. Restricting schemes to web URLs prevents About-page links from unexpectedly opening mail, telephone, or custom-scheme handlers.
diff --git a/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
new file mode 100644
index 00000000..c140271e
--- /dev/null
+++ b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
@@ -0,0 +1,350 @@
+using System;
+using System.Buffers;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using OpenUtau.Core;
+using OpenUtau.Core.SignalChain;
+using OpenUtau.Core.Ustx;
+using Serilog;
+
+namespace OpenUtauMobile.Controls;
+
+///
+/// 在钢琴卷帘标尺空白区显示当前歌声分片已经渲染出的单侧波形。
+///
+public sealed class RenderedWaveformCanvas : Control, ICmdSubscriber
+{
+ public static readonly StyledProperty PartProperty =
+ AvaloniaProperty.Register(nameof(Part));
+
+ public static readonly StyledProperty TickWidthProperty =
+ AvaloniaProperty.Register(nameof(TickWidth));
+
+ public static readonly StyledProperty TickOffsetProperty =
+ AvaloniaProperty.Register(nameof(TickOffset));
+
+ public static readonly StyledProperty WaveformBrushProperty =
+ AvaloniaProperty.Register(nameof(WaveformBrush));
+
+ private sealed class Envelope
+ {
+ public required float[] Peaks { get; init; }
+ public required double StartMs { get; init; }
+ public required double PeakRate { get; init; }
+ }
+
+ private CancellationTokenSource? _envelopeCancellation;
+ private Envelope? _envelope;
+ private UVoicePart? _envelopePart;
+ private StreamGeometry? _geometry;
+ private Envelope? _geometryEnvelope;
+ private double _geometryStartTick;
+ private double _geometryEndTick;
+ private double _geometryTickWidth;
+ private double _geometryHeight;
+
+ public UVoicePart? Part
+ {
+ get => GetValue(PartProperty);
+ set => SetValue(PartProperty, value);
+ }
+
+ public double TickWidth
+ {
+ get => GetValue(TickWidthProperty);
+ set => SetValue(TickWidthProperty, value);
+ }
+
+ public double TickOffset
+ {
+ get => GetValue(TickOffsetProperty);
+ set => SetValue(TickOffsetProperty, value);
+ }
+
+ public IBrush? WaveformBrush
+ {
+ get => GetValue(WaveformBrushProperty);
+ set => SetValue(WaveformBrushProperty, value);
+ }
+
+ static RenderedWaveformCanvas()
+ {
+ AffectsRender(
+ PartProperty,
+ TickWidthProperty,
+ TickOffsetProperty,
+ WaveformBrushProperty);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == PartProperty)
+ {
+ QueueEnvelopeBuild();
+ }
+ else if (change.Property == TickWidthProperty)
+ {
+ InvalidateGeometry();
+ }
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ DocManager.Inst.AddSubscriber(this);
+ if (!ReferenceEquals(_envelopePart, Part))
+ {
+ QueueEnvelopeBuild();
+ }
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ DocManager.Inst.RemoveSubscriber(this);
+ CancelEnvelopeBuild();
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+ if (_envelope == null || Part == null || WaveformBrush == null ||
+ TickWidth <= 0 || Bounds.Width <= 0 || Bounds.Height <= 0)
+ {
+ return;
+ }
+
+ EnsureGeometry(_envelope, Part);
+ if (_geometry == null)
+ {
+ return;
+ }
+
+ double translateX = (_geometryStartTick - TickOffset) * TickWidth;
+ using (context.PushClip(new Rect(Bounds.Size)))
+ using (context.PushTransform(Matrix.CreateTranslation(translateX, 0)))
+ {
+ context.DrawGeometry(WaveformBrush, null, _geometry);
+ }
+ }
+
+ public void OnNext(UCommand cmd, bool isUndo)
+ {
+ if (cmd is PartRenderedNotification notification &&
+ ReferenceEquals(notification.part, Part))
+ {
+ QueueEnvelopeBuild();
+ }
+ }
+
+ private async void QueueEnvelopeBuild()
+ {
+ CancelEnvelopeBuild();
+ UVoicePart? part = Part;
+ ISignalSource? mix = part?.Mix;
+ if (part == null || mix == null)
+ {
+ _envelope = null;
+ _envelopePart = part;
+ InvalidateGeometry();
+ InvalidateVisual();
+ return;
+ }
+
+ TimeAxis timeAxis = DocManager.Inst.Project.timeAxis;
+ double startMs = timeAxis.TickPosToMsPos(part.position);
+ double endMs = timeAxis.TickPosToMsPos(part.End);
+ CancellationTokenSource cancellation = new();
+ _envelopeCancellation = cancellation;
+
+ try
+ {
+ Envelope envelope = await Task.Run(
+ () => BuildEnvelope(mix, startMs, endMs, cancellation.Token),
+ cancellation.Token);
+ if (!cancellation.IsCancellationRequested && ReferenceEquals(Part, part))
+ {
+ _envelope = envelope;
+ _envelopePart = part;
+ InvalidateGeometry();
+ InvalidateVisual();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // 快速切换分片或重复渲染时,旧包络任务按预期终止。
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "构建已渲染歌声波形包络失败");
+ }
+ finally
+ {
+ if (ReferenceEquals(_envelopeCancellation, cancellation))
+ {
+ _envelopeCancellation = null;
+ }
+ cancellation.Dispose();
+ }
+ }
+
+ private static Envelope BuildEnvelope(
+ ISignalSource mix,
+ double startMs,
+ double endMs,
+ CancellationToken cancellationToken)
+ {
+ double durationSeconds = Math.Max(0, endMs - startMs) / 1000.0;
+ double peakRate = ViewConstants.RenderedWaveformPeakRate;
+ if (durationSeconds * peakRate > ViewConstants.RenderedWaveformMaxEnvelopePointCount)
+ {
+ peakRate = ViewConstants.RenderedWaveformMaxEnvelopePointCount / durationSeconds;
+ }
+
+ int peakCount = Math.Max(1, (int)Math.Ceiling(durationSeconds * peakRate));
+ float[] peaks = new float[peakCount];
+ long totalFrames = Math.Max(0,
+ (long)Math.Ceiling(durationSeconds * ViewConstants.RenderedWaveformAudioSampleRate));
+ int bufferLength = ViewConstants.RenderedWaveformMixBufferFrames *
+ ViewConstants.RenderedWaveformChannelCount;
+ float[] buffer = ArrayPool.Shared.Rent(bufferLength);
+ long startFrame = (long)Math.Floor(
+ startMs * ViewConstants.RenderedWaveformAudioSampleRate / 1000.0);
+
+ try
+ {
+ long frameOffset = 0;
+ while (frameOffset < totalFrames)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ int frameCount = (int)Math.Min(
+ ViewConstants.RenderedWaveformMixBufferFrames,
+ totalFrames - frameOffset);
+ int sampleCount = frameCount * ViewConstants.RenderedWaveformChannelCount;
+ Array.Clear(buffer, 0, sampleCount);
+ long samplePosition = (startFrame + frameOffset) *
+ ViewConstants.RenderedWaveformChannelCount;
+ mix.Mix((int)Math.Clamp(samplePosition, int.MinValue, int.MaxValue), buffer, 0, sampleCount);
+
+ for (int frame = 0; frame < frameCount; frame++)
+ {
+ int sampleIndex = frame * ViewConstants.RenderedWaveformChannelCount;
+ float amplitude = Math.Max(
+ Math.Abs(buffer[sampleIndex]),
+ Math.Abs(buffer[sampleIndex + 1]));
+ int peakIndex = Math.Min(
+ peakCount - 1,
+ (int)((frameOffset + frame) * peakRate /
+ ViewConstants.RenderedWaveformAudioSampleRate));
+ if (amplitude > peaks[peakIndex])
+ {
+ peaks[peakIndex] = amplitude;
+ }
+ }
+ frameOffset += frameCount;
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
+ return new Envelope
+ {
+ Peaks = peaks,
+ StartMs = startMs,
+ PeakRate = peakRate,
+ };
+ }
+
+ private void EnsureGeometry(Envelope envelope, UVoicePart part)
+ {
+ double visibleEndTick = TickOffset + Bounds.Width / TickWidth;
+ bool cacheContainsViewport = ReferenceEquals(_geometryEnvelope, envelope) &&
+ Math.Abs(_geometryTickWidth - TickWidth) < double.Epsilon &&
+ Math.Abs(_geometryHeight - Bounds.Height) < double.Epsilon &&
+ TickOffset >= _geometryStartTick && visibleEndTick <= _geometryEndTick;
+ if (cacheContainsViewport)
+ {
+ return;
+ }
+
+ double cacheWidth = Math.Clamp(
+ Bounds.Width * ViewConstants.RenderedWaveformCacheViewportFactor,
+ ViewConstants.RenderedWaveformMinCacheWidth,
+ ViewConstants.RenderedWaveformMaxCacheWidth);
+ double overscanTicks = Math.Max(0, cacheWidth - Bounds.Width) / (2.0 * TickWidth);
+ double startTick = Math.Max(part.position, TickOffset - overscanTicks);
+ double endTick = Math.Min(part.End, visibleEndTick + overscanTicks);
+ if (endTick <= startTick)
+ {
+ InvalidateGeometry();
+ return;
+ }
+
+ double geometryWidth = (endTick - startTick) * TickWidth;
+ int columnCount = Math.Max(1, (int)Math.Ceiling(geometryWidth));
+ TimeAxis timeAxis = DocManager.Inst.Project.timeAxis;
+ StreamGeometry geometry = new();
+ using (StreamGeometryContext geometryContext = geometry.Open())
+ {
+ geometryContext.BeginFigure(new Point(0, Bounds.Height), true);
+ for (int column = 0; column <= columnCount; column++)
+ {
+ double tick = Math.Min(endTick, startTick + column / TickWidth);
+ double nextTick = Math.Min(endTick, startTick + (column + 1.0) / TickWidth);
+ double startMs = timeAxis.TickPosToMsPos(tick);
+ double endMs = timeAxis.TickPosToMsPos(nextTick);
+ float peak = GetPeak(envelope, startMs, endMs);
+ double y = Bounds.Height * (1.0 - Math.Clamp(
+ peak,
+ ViewConstants.RenderedWaveformMinimumPeak,
+ 1.0));
+ geometryContext.LineTo(new Point(column, y));
+ }
+ geometryContext.LineTo(new Point(geometryWidth, Bounds.Height));
+ geometryContext.EndFigure(true);
+ }
+
+ _geometry = geometry;
+ _geometryEnvelope = envelope;
+ _geometryStartTick = startTick;
+ _geometryEndTick = endTick;
+ _geometryTickWidth = TickWidth;
+ _geometryHeight = Bounds.Height;
+ }
+
+ private static float GetPeak(Envelope envelope, double startMs, double endMs)
+ {
+ int startIndex = Math.Clamp(
+ (int)Math.Floor((startMs - envelope.StartMs) * envelope.PeakRate / 1000.0),
+ 0,
+ envelope.Peaks.Length - 1);
+ int endIndex = Math.Clamp(
+ (int)Math.Ceiling((endMs - envelope.StartMs) * envelope.PeakRate / 1000.0),
+ startIndex + 1,
+ envelope.Peaks.Length);
+ float peak = 0;
+ for (int index = startIndex; index < endIndex; index++)
+ {
+ peak = Math.Max(peak, envelope.Peaks[index]);
+ }
+ return peak;
+ }
+
+ private void CancelEnvelopeBuild()
+ {
+ _envelopeCancellation?.Cancel();
+ _envelopeCancellation = null;
+ }
+
+ private void InvalidateGeometry()
+ {
+ _geometry = null;
+ _geometryEnvelope = null;
+ }
+}
diff --git a/OpenUtauMobile/ViewConstants.cs b/OpenUtauMobile/ViewConstants.cs
index 7df56e4c..6c5fd4fb 100644
--- a/OpenUtauMobile/ViewConstants.cs
+++ b/OpenUtauMobile/ViewConstants.cs
@@ -75,6 +75,18 @@ static class ViewConstants
public const double PianoRollTickWidthMin = 8.0 / 480.0;
public const double PianoRollTickWidthDefault = 48.0 / 480.0;
+ // 已渲染歌声波形:以固定峰值率建立一次轻量包络,拖动画布时只平移缓存几何。
+ public const int RenderedWaveformAudioSampleRate = 44100;
+ public const int RenderedWaveformChannelCount = 2;
+ public const int RenderedWaveformPeakRate = 4000;
+ public const int RenderedWaveformMixBufferFrames = 16384;
+ public const int RenderedWaveformMaxEnvelopePointCount = 2000000;
+ public const double RenderedWaveformCacheViewportFactor = 2.0;
+ public const double RenderedWaveformMinCacheWidth = 256.0;
+ public const double RenderedWaveformMaxCacheWidth = 4096.0;
+ public const double RenderedWaveformMinimumPeak = 0.01;
+ public const double RenderedWaveformOpacity = 0.5;
+
public const double NoteHeightMax = 128;
public const double NoteHeightMin = 16;
diff --git a/OpenUtauMobile/Views/EditorView.axaml b/OpenUtauMobile/Views/EditorView.axaml
index 6592d332..9a61acb9 100644
--- a/OpenUtauMobile/Views/EditorView.axaml
+++ b/OpenUtauMobile/Views/EditorView.axaml
@@ -375,7 +375,18 @@
VerticalAlignment="Bottom"
Opacity="{Binding PortraitOpacity}"
Source="{Binding PortraitBitmap}" />
-
+
+
+
From bd70c11a40463607ca479592ae3dd45e1f1ed38b Mon Sep 17 00:00:00 2001
From: vocoder712 <100213316+vocoder712@users.noreply.github.com>
Date: Sun, 30 Aug 2026 08:35:38 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feature:=20=E5=AE=9E=E7=8E=B0=E6=8C=89phras?=
=?UTF-8?q?e=E6=B8=90=E8=BF=9B=E7=BB=98=E5=88=B6=E6=B3=A2=E5=BD=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent/DECISIONS.md | 10 +-
OpenUtau.Core/Commands/Notifications.cs | 21 +++
OpenUtau.Core/Render/RenderEngine.cs | 7 +-
.../Controls/RenderedWaveformCanvas.cs | 170 ++++++++++++++----
OpenUtauMobile/ViewConstants.cs | 1 -
5 files changed, 168 insertions(+), 41 deletions(-)
diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md
index 53316c88..c7815813 100644
--- a/.agent/DECISIONS.md
+++ b/.agent/DECISIONS.md
@@ -12,11 +12,11 @@ Record meaningful technical decisions here. Use one entry per decision.
## Entries
-- Date: 2026-08-29
-- Decision: Display the active voice part's rendered mix as a one-sided peak envelope in the piano-roll ruler placeholder. Build a bounded 4 kHz envelope asynchronously once per rendered result, then cache an overscanned `StreamGeometry` so ordinary `NotesCanvas` panning only changes a translation and clip instead of remixing audio or uploading a bitmap.
-- Rationale: The upstream source of truth is `UVoicePart.Mix`, while a fixed-rate peak envelope preserves transients at the editor's supported zoom range. Reused pooled mix buffers, a bounded envelope, and compositor-friendly retained geometry minimize UI-thread work, allocations, and mobile GPU uploads during high-frequency viewport redraws.
-- Alternatives considered: Port upstream's per-frame full-view `WriteableBitmap` renderer; remix raw samples on every pan; keep a full-resolution sample copy; draw both positive and negative waveform halves.
-- Impacted areas: Mobile piano-roll ruler rendering and shared view constants. OpenUtau.Core and renderer output remain unchanged.
+- Date: 2026-08-30
+- Decision: Display the active voice part's available rendered phrases as a one-sided peak envelope in the piano-roll ruler placeholder. Publish the request's initially empty mix when rendering starts, publish each completed phrase's audio interval, clear immediately on invalidation, and rebuild only the visible overscanned range into cached `StreamGeometry`.
+- Rationale: Upstream `UVoicePart.Mix` is the waveform source, but upstream keeps the in-progress `WaveMix` private and assigns it to the part only after every phrase completes. Exposing lifecycle notifications without changing synthesis makes the UI match actual sample availability while pooled visible-range envelope generation and retained geometry keep high-frequency panning inexpensive.
+- Alternatives considered: Port upstream's per-frame full-view `WriteableBitmap` renderer; wait for `PartRenderedNotification`; infer completion from renderer-specific cache files; poll private render state; draw both waveform halves.
+- Impacted areas: Core render lifecycle notifications and mix publication, Mobile piano-roll ruler rendering, and shared view constants. Renderer algorithms and rendered samples are unchanged.
- Date: 2026-08-29
- Decision: Introduce an asynchronous Mobile-layer external URL launcher that accepts only absolute HTTP/HTTPS URLs and is injected through `ServiceHub`; implement native launchers for Android, Windows, Linux, macOS, iOS, and browser WASM.
diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs
index 95741549..8b39413f 100644
--- a/OpenUtau.Core/Commands/Notifications.cs
+++ b/OpenUtau.Core/Commands/Notifications.cs
@@ -1,4 +1,5 @@
using System;
+using OpenUtau.Core.Render;
using OpenUtau.Core.Ustx;
namespace OpenUtau.Core {
@@ -240,6 +241,26 @@ public class PreRenderNotification : UNotification {
public override string ToString() => $"Pre-render notification.";
}
+ public class PartRenderInvalidatedNotification : UNotification {
+ public PartRenderInvalidatedNotification(UVoicePart part) {
+ this.part = part;
+ }
+ public override string ToString() => "Part render invalidated.";
+ }
+
+ public class PhraseRenderedNotification : UNotification {
+ public readonly RenderPhrase phrase;
+ public readonly double audioStartMs;
+ public readonly double audioEndMs;
+ public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, double audioStartMs, double audioEndMs) {
+ this.part = part;
+ this.phrase = phrase;
+ this.audioStartMs = audioStartMs;
+ this.audioEndMs = audioEndMs;
+ }
+ public override string ToString() => "Phrase rendered.";
+ }
+
public class PartRenderedNotification : UNotification {
public PartRenderedNotification(UVoicePart part) {
this.part = part;
diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs
index 75bc3d35..e9260df3 100644
--- a/OpenUtau.Core/Render/RenderEngine.cs
+++ b/OpenUtau.Core/Render/RenderEngine.cs
@@ -236,6 +236,10 @@ private void RenderRequests(
if (requests.Length == 0 || cancellation.IsCancellationRequested) {
return;
}
+ foreach (RenderPartRequest request in requests) {
+ request.part.SetMix(request.mix);
+ DocManager.Inst.ExecuteCmd(new PartRenderInvalidatedNotification(request.part));
+ }
var tuples = requests
.SelectMany(req => req.phrases
.Zip(req.sources, (phrase, source) => Tuple.Create(phrase, source, req)))
@@ -259,8 +263,9 @@ private void RenderRequests(
break;
}
source.SetSamples(task.Result.samples);
+ DocManager.Inst.ExecuteCmd(new PhraseRenderedNotification(
+ request.part, phrase, source.offsetMs, source.EndMs));
if (request.sources.All(s => s.HasSamples)) {
- request.part.SetMix(request.mix);
DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part));
}
}
diff --git a/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
index c140271e..6b4120bf 100644
--- a/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
+++ b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
@@ -1,5 +1,6 @@
using System;
using System.Buffers;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
@@ -34,15 +35,21 @@ private sealed class Envelope
public required float[] Peaks { get; init; }
public required double StartMs { get; init; }
public required double PeakRate { get; init; }
+ public required double StartTick { get; init; }
+ public required double EndTick { get; init; }
}
private CancellationTokenSource? _envelopeCancellation;
private Envelope? _envelope;
private UVoicePart? _envelopePart;
+ private UVoicePart? _requestedPart;
+ private double _requestedStartTick;
+ private double _requestedEndTick;
+ private readonly HashSet _partsWithPhraseAudio = [];
+ private readonly HashSet _explicitlyInvalidatedParts = [];
+ private bool _projectRenderInvalidated;
private StreamGeometry? _geometry;
private Envelope? _geometryEnvelope;
- private double _geometryStartTick;
- private double _geometryEndTick;
private double _geometryTickWidth;
private double _geometryHeight;
@@ -89,9 +96,21 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
else if (change.Property == TickWidthProperty)
{
InvalidateGeometry();
+ QueueEnvelopeBuild();
+ }
+ else if (change.Property == TickOffsetProperty && !EnvelopeContainsViewport())
+ {
+ QueueEnvelopeBuild();
}
}
+ protected override void OnSizeChanged(SizeChangedEventArgs e)
+ {
+ base.OnSizeChanged(e);
+ InvalidateGeometry();
+ QueueEnvelopeBuild();
+ }
+
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
@@ -118,13 +137,13 @@ public override void Render(DrawingContext context)
return;
}
- EnsureGeometry(_envelope, Part);
+ EnsureGeometry(_envelope);
if (_geometry == null)
{
return;
}
- double translateX = (_geometryStartTick - TickOffset) * TickWidth;
+ double translateX = (_envelope.StartTick - TickOffset) * TickWidth;
using (context.PushClip(new Rect(Bounds.Size)))
using (context.PushTransform(Matrix.CreateTranslation(translateX, 0)))
{
@@ -134,11 +153,75 @@ public override void Render(DrawingContext context)
public void OnNext(UCommand cmd, bool isUndo)
{
- if (cmd is PartRenderedNotification notification &&
- ReferenceEquals(notification.part, Part))
+ if (cmd is PreRenderNotification)
{
- QueueEnvelopeBuild();
+ _projectRenderInvalidated = true;
+ _partsWithPhraseAudio.Clear();
+ ClearWaveform();
}
+ else if (cmd is PartRenderInvalidatedNotification invalidated &&
+ invalidated.part is UVoicePart invalidatedPart)
+ {
+ _explicitlyInvalidatedParts.Add(invalidatedPart);
+ _partsWithPhraseAudio.Remove(invalidatedPart);
+ if (ReferenceEquals(invalidatedPart, Part))
+ {
+ ClearWaveform();
+ }
+ }
+ else if (cmd is PhraseRenderedNotification rendered && rendered.part is UVoicePart renderedPart)
+ {
+ _explicitlyInvalidatedParts.Remove(renderedPart);
+ _partsWithPhraseAudio.Add(renderedPart);
+ if (ReferenceEquals(renderedPart, Part) &&
+ IsAudioRangeVisible(rendered.audioStartMs, rendered.audioEndMs))
+ {
+ QueueEnvelopeBuild();
+ }
+ }
+ else if (cmd is LoadProjectNotification)
+ {
+ _projectRenderInvalidated = false;
+ _partsWithPhraseAudio.Clear();
+ _explicitlyInvalidatedParts.Clear();
+ ClearWaveform();
+ }
+ }
+
+ private bool IsWaveformDataAvailable(UVoicePart part)
+ {
+ return !_explicitlyInvalidatedParts.Contains(part) &&
+ (!_projectRenderInvalidated || _partsWithPhraseAudio.Contains(part));
+ }
+
+ private bool IsAudioRangeVisible(double audioStartMs, double audioEndMs)
+ {
+ if (TickWidth <= 0 || Bounds.Width <= 0)
+ {
+ return false;
+ }
+ TimeAxis timeAxis = DocManager.Inst.Project.timeAxis;
+ double visibleStartMs = timeAxis.TickPosToMsPos(TickOffset);
+ double visibleEndMs = timeAxis.TickPosToMsPos(TickOffset + Bounds.Width / TickWidth);
+ return audioEndMs > visibleStartMs && audioStartMs < visibleEndMs;
+ }
+
+ private bool EnvelopeContainsViewport()
+ {
+ if (TickWidth <= 0 || Bounds.Width <= 0 || Part == null)
+ {
+ return false;
+ }
+ double visibleStartTick = Math.Max(Part.position, TickOffset);
+ double visibleEndTick = Math.Min(Part.End, TickOffset + Bounds.Width / TickWidth);
+ bool envelopeContainsViewport = _envelope != null &&
+ ReferenceEquals(_envelopePart, Part) &&
+ visibleStartTick >= _envelope.StartTick && visibleEndTick <= _envelope.EndTick;
+ bool requestContainsViewport = _envelopeCancellation != null &&
+ ReferenceEquals(_requestedPart, Part) &&
+ visibleStartTick >= _requestedStartTick && visibleEndTick <= _requestedEndTick;
+ return visibleEndTick <= visibleStartTick ||
+ envelopeContainsViewport || requestContainsViewport;
}
private async void QueueEnvelopeBuild()
@@ -146,25 +229,43 @@ private async void QueueEnvelopeBuild()
CancelEnvelopeBuild();
UVoicePart? part = Part;
ISignalSource? mix = part?.Mix;
- if (part == null || mix == null)
+ if (part == null || mix == null || !IsWaveformDataAvailable(part))
+ {
+ ClearWaveform();
+ return;
+ }
+
+ if (TickWidth <= 0 || Bounds.Width <= 0)
{
- _envelope = null;
- _envelopePart = part;
- InvalidateGeometry();
- InvalidateVisual();
return;
}
TimeAxis timeAxis = DocManager.Inst.Project.timeAxis;
- double startMs = timeAxis.TickPosToMsPos(part.position);
- double endMs = timeAxis.TickPosToMsPos(part.End);
+ double visibleEndTick = TickOffset + Bounds.Width / TickWidth;
+ double cacheWidth = Math.Clamp(
+ Bounds.Width * ViewConstants.RenderedWaveformCacheViewportFactor,
+ ViewConstants.RenderedWaveformMinCacheWidth,
+ ViewConstants.RenderedWaveformMaxCacheWidth);
+ double overscanTicks = Math.Max(0, cacheWidth - Bounds.Width) / (2.0 * TickWidth);
+ double startTick = Math.Max(part.position, TickOffset - overscanTicks);
+ double endTick = Math.Min(part.End, visibleEndTick + overscanTicks);
+ if (endTick <= startTick)
+ {
+ return;
+ }
+ double startMs = timeAxis.TickPosToMsPos(startTick);
+ double endMs = timeAxis.TickPosToMsPos(endTick);
CancellationTokenSource cancellation = new();
_envelopeCancellation = cancellation;
+ _requestedPart = part;
+ _requestedStartTick = startTick;
+ _requestedEndTick = endTick;
try
{
Envelope envelope = await Task.Run(
- () => BuildEnvelope(mix, startMs, endMs, cancellation.Token),
+ () => BuildEnvelope(
+ mix, startTick, endTick, startMs, endMs, cancellation.Token),
cancellation.Token);
if (!cancellation.IsCancellationRequested && ReferenceEquals(Part, part))
{
@@ -187,6 +288,7 @@ private async void QueueEnvelopeBuild()
if (ReferenceEquals(_envelopeCancellation, cancellation))
{
_envelopeCancellation = null;
+ _requestedPart = null;
}
cancellation.Dispose();
}
@@ -194,6 +296,8 @@ private async void QueueEnvelopeBuild()
private static Envelope BuildEnvelope(
ISignalSource mix,
+ double startTick,
+ double endTick,
double startMs,
double endMs,
CancellationToken cancellationToken)
@@ -258,33 +362,23 @@ private static Envelope BuildEnvelope(
Peaks = peaks,
StartMs = startMs,
PeakRate = peakRate,
+ StartTick = startTick,
+ EndTick = endTick,
};
}
- private void EnsureGeometry(Envelope envelope, UVoicePart part)
+ private void EnsureGeometry(Envelope envelope)
{
- double visibleEndTick = TickOffset + Bounds.Width / TickWidth;
bool cacheContainsViewport = ReferenceEquals(_geometryEnvelope, envelope) &&
Math.Abs(_geometryTickWidth - TickWidth) < double.Epsilon &&
- Math.Abs(_geometryHeight - Bounds.Height) < double.Epsilon &&
- TickOffset >= _geometryStartTick && visibleEndTick <= _geometryEndTick;
+ Math.Abs(_geometryHeight - Bounds.Height) < double.Epsilon;
if (cacheContainsViewport)
{
return;
}
- double cacheWidth = Math.Clamp(
- Bounds.Width * ViewConstants.RenderedWaveformCacheViewportFactor,
- ViewConstants.RenderedWaveformMinCacheWidth,
- ViewConstants.RenderedWaveformMaxCacheWidth);
- double overscanTicks = Math.Max(0, cacheWidth - Bounds.Width) / (2.0 * TickWidth);
- double startTick = Math.Max(part.position, TickOffset - overscanTicks);
- double endTick = Math.Min(part.End, visibleEndTick + overscanTicks);
- if (endTick <= startTick)
- {
- InvalidateGeometry();
- return;
- }
+ double startTick = envelope.StartTick;
+ double endTick = envelope.EndTick;
double geometryWidth = (endTick - startTick) * TickWidth;
int columnCount = Math.Max(1, (int)Math.Ceiling(geometryWidth));
@@ -302,7 +396,7 @@ private void EnsureGeometry(Envelope envelope, UVoicePart part)
float peak = GetPeak(envelope, startMs, endMs);
double y = Bounds.Height * (1.0 - Math.Clamp(
peak,
- ViewConstants.RenderedWaveformMinimumPeak,
+ 0,
1.0));
geometryContext.LineTo(new Point(column, y));
}
@@ -312,8 +406,6 @@ private void EnsureGeometry(Envelope envelope, UVoicePart part)
_geometry = geometry;
_geometryEnvelope = envelope;
- _geometryStartTick = startTick;
- _geometryEndTick = endTick;
_geometryTickWidth = TickWidth;
_geometryHeight = Bounds.Height;
}
@@ -340,6 +432,16 @@ private void CancelEnvelopeBuild()
{
_envelopeCancellation?.Cancel();
_envelopeCancellation = null;
+ _requestedPart = null;
+ }
+
+ private void ClearWaveform()
+ {
+ CancelEnvelopeBuild();
+ _envelope = null;
+ _envelopePart = null;
+ InvalidateGeometry();
+ InvalidateVisual();
}
private void InvalidateGeometry()
diff --git a/OpenUtauMobile/ViewConstants.cs b/OpenUtauMobile/ViewConstants.cs
index 6c5fd4fb..d66b6c3b 100644
--- a/OpenUtauMobile/ViewConstants.cs
+++ b/OpenUtauMobile/ViewConstants.cs
@@ -84,7 +84,6 @@ static class ViewConstants
public const double RenderedWaveformCacheViewportFactor = 2.0;
public const double RenderedWaveformMinCacheWidth = 256.0;
public const double RenderedWaveformMaxCacheWidth = 4096.0;
- public const double RenderedWaveformMinimumPeak = 0.01;
public const double RenderedWaveformOpacity = 0.5;
public const double NoteHeightMax = 128;
From 2c756db810909b73af5fe072acbca4e52abaef56 Mon Sep 17 00:00:00 2001
From: vocoder712 <100213316+vocoder712@users.noreply.github.com>
Date: Sun, 30 Aug 2026 08:49:20 +0800
Subject: [PATCH 3/3] =?UTF-8?q?feature:=20=E5=8F=AF=E5=88=87=E6=8D=A2?=
=?UTF-8?q?=E6=80=A7=E8=83=BD=E6=9B=B4=E9=AB=98=E7=9A=84=E6=B8=B2=E6=9F=93?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8C=87=E7=A4=BA=E5=9B=BE=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent/DECISIONS.md | 6 +
OpenUtau.Core/Util/Preferences.cs | 5 +
.../Controls/RenderedWaveformCanvas.cs | 135 +++++++++++++++++-
OpenUtauMobile/ViewConstants.cs | 3 +
.../ViewModels/PianoRollViewModel.cs | 11 ++
OpenUtauMobile/Views/EditorView.axaml | 29 +++-
6 files changed, 179 insertions(+), 10 deletions(-)
diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md
index c7815813..0ae21076 100644
--- a/.agent/DECISIONS.md
+++ b/.agent/DECISIONS.md
@@ -12,6 +12,12 @@ Record meaningful technical decisions here. Use one entry per decision.
## Entries
+- Date: 2026-08-30
+- Decision: Add a persisted ruler display switch in the piano-key/ruler intersection. Waveform mode retains the cached envelope; render-status mode cancels envelope work and draws each visible phrase as an outlined pending block or filled completed block.
+- Rationale: Phrase blocks expose the same progressive render lifecycle at substantially lower CPU and allocation cost during frequent mobile piano-roll movement, while the explicit button and changing icon keep the performance choice discoverable.
+- Alternatives considered: Always draw both layers; replace the waveform permanently; infer completion from nonzero samples; rebuild a retained geometry for simple rectangles.
+- Impacted areas: Mobile piano-roll view model, ruler canvas and layout, OPUM-specific preferences, and shared view constants. Synthesis and render lifecycle are unchanged.
+
- Date: 2026-08-30
- Decision: Display the active voice part's available rendered phrases as a one-sided peak envelope in the piano-roll ruler placeholder. Publish the request's initially empty mix when rendering starts, publish each completed phrase's audio interval, clear immediately on invalidation, and rebuild only the visible overscanned range into cached `StreamGeometry`.
- Rationale: Upstream `UVoicePart.Mix` is the waveform source, but upstream keeps the in-progress `WaveMix` private and assigns it to the part only after every phrase completes. Exposing lifecycle notifications without changing synthesis makes the UI match actual sample availability while pooled visible-range envelope generation and retained geometry keep high-frequency panning inexpensive.
diff --git a/OpenUtau.Core/Util/Preferences.cs b/OpenUtau.Core/Util/Preferences.cs
index 48e512e4..a48a2b9a 100644
--- a/OpenUtau.Core/Util/Preferences.cs
+++ b/OpenUtau.Core/Util/Preferences.cs
@@ -258,6 +258,11 @@ public class SerializablePreferences {
#region OpenUtau Mobile 特定选项
public double PlaybackRefreshRate = 20.0;
+ ///
+ /// 钢琴卷帘标尺是否以轻量矩形块显示分片渲染状态,而不是绘制波形。
+ ///
+ public bool RenderedPhraseStatusMode = false;
+
///
/// Piano key behavior: 0=Silent, 1=SineWave, 2=SoundFont
///
diff --git a/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
index 6b4120bf..ac32966c 100644
--- a/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
+++ b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
@@ -7,6 +7,7 @@
using Avalonia.Controls;
using Avalonia.Media;
using OpenUtau.Core;
+using OpenUtau.Core.Render;
using OpenUtau.Core.SignalChain;
using OpenUtau.Core.Ustx;
using Serilog;
@@ -30,6 +31,9 @@ public sealed class RenderedWaveformCanvas : Control, ICmdSubscriber
public static readonly StyledProperty WaveformBrushProperty =
AvaloniaProperty.Register(nameof(WaveformBrush));
+ public static readonly StyledProperty IsRenderStatusModeProperty =
+ AvaloniaProperty.Register(nameof(IsRenderStatusMode));
+
private sealed class Envelope
{
public required float[] Peaks { get; init; }
@@ -47,11 +51,14 @@ private sealed class Envelope
private double _requestedEndTick;
private readonly HashSet _partsWithPhraseAudio = [];
private readonly HashSet _explicitlyInvalidatedParts = [];
+ private readonly Dictionary> _renderedPhrases = [];
private bool _projectRenderInvalidated;
private StreamGeometry? _geometry;
private Envelope? _geometryEnvelope;
private double _geometryTickWidth;
private double _geometryHeight;
+ private IBrush? _statusPenBrush;
+ private IPen? _statusPen;
public UVoicePart? Part
{
@@ -77,13 +84,20 @@ public IBrush? WaveformBrush
set => SetValue(WaveformBrushProperty, value);
}
+ public bool IsRenderStatusMode
+ {
+ get => GetValue(IsRenderStatusModeProperty);
+ set => SetValue(IsRenderStatusModeProperty, value);
+ }
+
static RenderedWaveformCanvas()
{
AffectsRender(
PartProperty,
TickWidthProperty,
TickOffsetProperty,
- WaveformBrushProperty);
+ WaveformBrushProperty,
+ IsRenderStatusModeProperty);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
@@ -91,14 +105,20 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
base.OnPropertyChanged(change);
if (change.Property == PartProperty)
{
- QueueEnvelopeBuild();
+ SeedCompletedPhraseState();
+ RefreshDisplay();
+ }
+ else if (change.Property == IsRenderStatusModeProperty)
+ {
+ RefreshDisplay();
}
else if (change.Property == TickWidthProperty)
{
InvalidateGeometry();
- QueueEnvelopeBuild();
+ RefreshDisplay();
}
- else if (change.Property == TickOffsetProperty && !EnvelopeContainsViewport())
+ else if (change.Property == TickOffsetProperty &&
+ !IsRenderStatusMode && !EnvelopeContainsViewport())
{
QueueEnvelopeBuild();
}
@@ -108,16 +128,17 @@ protected override void OnSizeChanged(SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
InvalidateGeometry();
- QueueEnvelopeBuild();
+ RefreshDisplay();
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
DocManager.Inst.AddSubscriber(this);
+ SeedCompletedPhraseState();
if (!ReferenceEquals(_envelopePart, Part))
{
- QueueEnvelopeBuild();
+ RefreshDisplay();
}
}
@@ -131,6 +152,11 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
public override void Render(DrawingContext context)
{
base.Render(context);
+ if (IsRenderStatusMode)
+ {
+ RenderPhraseStatus(context);
+ return;
+ }
if (_envelope == null || Part == null || WaveformBrush == null ||
TickWidth <= 0 || Bounds.Width <= 0 || Bounds.Height <= 0)
{
@@ -157,6 +183,7 @@ public void OnNext(UCommand cmd, bool isUndo)
{
_projectRenderInvalidated = true;
_partsWithPhraseAudio.Clear();
+ _renderedPhrases.Clear();
ClearWaveform();
}
else if (cmd is PartRenderInvalidatedNotification invalidated &&
@@ -164,6 +191,7 @@ public void OnNext(UCommand cmd, bool isUndo)
{
_explicitlyInvalidatedParts.Add(invalidatedPart);
_partsWithPhraseAudio.Remove(invalidatedPart);
+ _renderedPhrases.Remove(invalidatedPart);
if (ReferenceEquals(invalidatedPart, Part))
{
ClearWaveform();
@@ -173,7 +201,17 @@ public void OnNext(UCommand cmd, bool isUndo)
{
_explicitlyInvalidatedParts.Remove(renderedPart);
_partsWithPhraseAudio.Add(renderedPart);
- if (ReferenceEquals(renderedPart, Part) &&
+ if (!_renderedPhrases.TryGetValue(renderedPart, out HashSet? phrases))
+ {
+ phrases = [];
+ _renderedPhrases.Add(renderedPart, phrases);
+ }
+ phrases.Add(rendered.phrase);
+ if (ReferenceEquals(renderedPart, Part) && IsRenderStatusMode)
+ {
+ InvalidateVisual();
+ }
+ else if (ReferenceEquals(renderedPart, Part) &&
IsAudioRangeVisible(rendered.audioStartMs, rendered.audioEndMs))
{
QueueEnvelopeBuild();
@@ -184,10 +222,89 @@ public void OnNext(UCommand cmd, bool isUndo)
_projectRenderInvalidated = false;
_partsWithPhraseAudio.Clear();
_explicitlyInvalidatedParts.Clear();
+ _renderedPhrases.Clear();
ClearWaveform();
}
}
+ private void RenderPhraseStatus(DrawingContext context)
+ {
+ UVoicePart? part = Part;
+ IBrush? brush = WaveformBrush;
+ if (part == null || brush == null || TickWidth <= 0 ||
+ Bounds.Width <= 0 || Bounds.Height <= 0)
+ {
+ return;
+ }
+
+ double leftTick = TickOffset;
+ double rightTick = TickOffset + Bounds.Width / TickWidth;
+ double top = ViewConstants.RenderedPhraseStatusVerticalInset;
+ double height = Math.Max(1, Bounds.Height - top * 2.0);
+ IPen outline = GetStatusOutline(brush);
+ _renderedPhrases.TryGetValue(part, out HashSet? renderedPhrases);
+
+ using (context.PushClip(new Rect(Bounds.Size)))
+ {
+ lock (part)
+ {
+ foreach (RenderPhrase phrase in part.renderPhrases)
+ {
+ if (phrase.position >= rightTick || phrase.end <= leftTick)
+ {
+ continue;
+ }
+
+ double left = (phrase.position - TickOffset) * TickWidth +
+ ViewConstants.RenderedPhraseStatusHorizontalGap;
+ double right = (phrase.end - TickOffset) * TickWidth -
+ ViewConstants.RenderedPhraseStatusHorizontalGap;
+ Rect rect = new(left, top, Math.Max(1, right - left), height);
+ IBrush? fill = renderedPhrases?.Contains(phrase) == true ? brush : null;
+ context.DrawRectangle(fill, outline, rect);
+ }
+ }
+ }
+ }
+
+ private IPen GetStatusOutline(IBrush brush)
+ {
+ if (_statusPen == null || !ReferenceEquals(_statusPenBrush, brush))
+ {
+ _statusPenBrush = brush;
+ _statusPen = new Pen(brush, ViewConstants.RenderedPhraseStatusBorderThickness);
+ }
+ return _statusPen;
+ }
+
+ private void SeedCompletedPhraseState()
+ {
+ UVoicePart? part = Part;
+ if (part?.Mix == null || _projectRenderInvalidated ||
+ _explicitlyInvalidatedParts.Contains(part) || _renderedPhrases.ContainsKey(part))
+ {
+ return;
+ }
+
+ lock (part)
+ {
+ _renderedPhrases[part] = new HashSet(part.renderPhrases);
+ }
+ }
+
+ private void RefreshDisplay()
+ {
+ if (IsRenderStatusMode)
+ {
+ CancelEnvelopeBuild();
+ InvalidateVisual();
+ }
+ else
+ {
+ QueueEnvelopeBuild();
+ }
+ }
+
private bool IsWaveformDataAvailable(UVoicePart part)
{
return !_explicitlyInvalidatedParts.Contains(part) &&
@@ -227,6 +344,10 @@ private bool EnvelopeContainsViewport()
private async void QueueEnvelopeBuild()
{
CancelEnvelopeBuild();
+ if (IsRenderStatusMode)
+ {
+ return;
+ }
UVoicePart? part = Part;
ISignalSource? mix = part?.Mix;
if (part == null || mix == null || !IsWaveformDataAvailable(part))
diff --git a/OpenUtauMobile/ViewConstants.cs b/OpenUtauMobile/ViewConstants.cs
index d66b6c3b..d217bdce 100644
--- a/OpenUtauMobile/ViewConstants.cs
+++ b/OpenUtauMobile/ViewConstants.cs
@@ -85,6 +85,9 @@ static class ViewConstants
public const double RenderedWaveformMinCacheWidth = 256.0;
public const double RenderedWaveformMaxCacheWidth = 4096.0;
public const double RenderedWaveformOpacity = 0.5;
+ public const double RenderedPhraseStatusVerticalInset = 4.0;
+ public const double RenderedPhraseStatusHorizontalGap = 1.0;
+ public const double RenderedPhraseStatusBorderThickness = 1.0;
public const double NoteHeightMax = 128;
public const double NoteHeightMin = 16;
diff --git a/OpenUtauMobile/ViewModels/PianoRollViewModel.cs b/OpenUtauMobile/ViewModels/PianoRollViewModel.cs
index 47f0abd5..895c1555 100644
--- a/OpenUtauMobile/ViewModels/PianoRollViewModel.cs
+++ b/OpenUtauMobile/ViewModels/PianoRollViewModel.cs
@@ -144,6 +144,9 @@ public class PianoRollViewModel : ViewModelBase, IDisposable, ICmdSubscriber
[Reactive] public double TickOffset { get; set; } // X 滚动
[Reactive] public double KeyOffset { get; set; } = 56; // Y 滚动
+ /// 是否以轻量矩形块显示各 phrase 的渲染状态。
+ [Reactive] public bool IsRenderedPhraseStatusMode { get; private set; }
+
// ── 播放状态(直接由权威源驱动)─────
///
/// 当前工程全局播放标记位置(Tick,绝对坐标,与走带编曲区共享同一命令流)。
@@ -322,6 +325,7 @@ public string SecondaryExpressionDisplayName
public System.Windows.Input.ICommand SwapExpressionsCommand { get; }
public System.Windows.Input.ICommand SelectPrimaryExpressionCommand { get; }
public System.Windows.Input.ICommand SelectSecondaryExpressionCommand { get; }
+ public System.Windows.Input.ICommand ToggleRenderedWaveformDisplayModeCommand { get; }
#endregion
@@ -710,6 +714,7 @@ public void ValidateSelectedAnchors()
public PianoRollViewModel()
{
+ IsRenderedPhraseStatusMode = Preferences.Default.RenderedPhraseStatusMode;
IsPitchPenCanvasDragEnabled = Preferences.Default.PitchPenCanvasDragEnabled;
PitchPenNoteHitTickExtension = Math.Clamp(
Preferences.Default.PitchPenNoteHitTickExtension,
@@ -739,6 +744,12 @@ public PianoRollViewModel()
SecondaryExpressionKey = opt.Key;
}
});
+ ToggleRenderedWaveformDisplayModeCommand = ReactiveCommand.Create(() =>
+ {
+ IsRenderedPhraseStatusMode = !IsRenderedPhraseStatusMode;
+ Preferences.Default.RenderedPhraseStatusMode = IsRenderedPhraseStatusMode;
+ Preferences.Save();
+ });
DocManager.Inst.AddSubscriber(this);
diff --git a/OpenUtauMobile/Views/EditorView.axaml b/OpenUtauMobile/Views/EditorView.axaml
index 9a61acb9..bb80246d 100644
--- a/OpenUtauMobile/Views/EditorView.axaml
+++ b/OpenUtauMobile/Views/EditorView.axaml
@@ -38,6 +38,15 @@
+
@@ -344,10 +353,23 @@
-
-
+
+ Classes="RenderedWaveformModeButton"
+ IsVisible="{Binding IsVoiceMode}"
+ Command="{Binding ToggleRenderedWaveformDisplayModeCommand}">
+
+
+
+
+