diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md
index 7b71440b..0ae21076 100644
--- a/.agent/DECISIONS.md
+++ b/.agent/DECISIONS.md
@@ -12,6 +12,18 @@ 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.
+- 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.
- 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/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/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
new file mode 100644
index 00000000..ac32966c
--- /dev/null
+++ b/OpenUtauMobile/Controls/RenderedWaveformCanvas.cs
@@ -0,0 +1,573 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using OpenUtau.Core;
+using OpenUtau.Core.Render;
+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));
+
+ public static readonly StyledProperty IsRenderStatusModeProperty =
+ AvaloniaProperty.Register(nameof(IsRenderStatusMode));
+
+ 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 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
+ {
+ 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);
+ }
+
+ public bool IsRenderStatusMode
+ {
+ get => GetValue(IsRenderStatusModeProperty);
+ set => SetValue(IsRenderStatusModeProperty, value);
+ }
+
+ static RenderedWaveformCanvas()
+ {
+ AffectsRender(
+ PartProperty,
+ TickWidthProperty,
+ TickOffsetProperty,
+ WaveformBrushProperty,
+ IsRenderStatusModeProperty);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == PartProperty)
+ {
+ SeedCompletedPhraseState();
+ RefreshDisplay();
+ }
+ else if (change.Property == IsRenderStatusModeProperty)
+ {
+ RefreshDisplay();
+ }
+ else if (change.Property == TickWidthProperty)
+ {
+ InvalidateGeometry();
+ RefreshDisplay();
+ }
+ else if (change.Property == TickOffsetProperty &&
+ !IsRenderStatusMode && !EnvelopeContainsViewport())
+ {
+ QueueEnvelopeBuild();
+ }
+ }
+
+ protected override void OnSizeChanged(SizeChangedEventArgs e)
+ {
+ base.OnSizeChanged(e);
+ InvalidateGeometry();
+ RefreshDisplay();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ DocManager.Inst.AddSubscriber(this);
+ SeedCompletedPhraseState();
+ if (!ReferenceEquals(_envelopePart, Part))
+ {
+ RefreshDisplay();
+ }
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ DocManager.Inst.RemoveSubscriber(this);
+ CancelEnvelopeBuild();
+ base.OnDetachedFromVisualTree(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)
+ {
+ return;
+ }
+
+ EnsureGeometry(_envelope);
+ if (_geometry == null)
+ {
+ return;
+ }
+
+ double translateX = (_envelope.StartTick - 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 PreRenderNotification)
+ {
+ _projectRenderInvalidated = true;
+ _partsWithPhraseAudio.Clear();
+ _renderedPhrases.Clear();
+ ClearWaveform();
+ }
+ else if (cmd is PartRenderInvalidatedNotification invalidated &&
+ invalidated.part is UVoicePart invalidatedPart)
+ {
+ _explicitlyInvalidatedParts.Add(invalidatedPart);
+ _partsWithPhraseAudio.Remove(invalidatedPart);
+ _renderedPhrases.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 (!_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();
+ }
+ }
+ else if (cmd is LoadProjectNotification)
+ {
+ _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) &&
+ (!_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()
+ {
+ CancelEnvelopeBuild();
+ if (IsRenderStatusMode)
+ {
+ return;
+ }
+ UVoicePart? part = Part;
+ ISignalSource? mix = part?.Mix;
+ if (part == null || mix == null || !IsWaveformDataAvailable(part))
+ {
+ ClearWaveform();
+ return;
+ }
+
+ if (TickWidth <= 0 || Bounds.Width <= 0)
+ {
+ return;
+ }
+
+ TimeAxis timeAxis = DocManager.Inst.Project.timeAxis;
+ 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, startTick, endTick, 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;
+ _requestedPart = null;
+ }
+ cancellation.Dispose();
+ }
+ }
+
+ private static Envelope BuildEnvelope(
+ ISignalSource mix,
+ double startTick,
+ double endTick,
+ 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,
+ StartTick = startTick,
+ EndTick = endTick,
+ };
+ }
+
+ private void EnsureGeometry(Envelope envelope)
+ {
+ bool cacheContainsViewport = ReferenceEquals(_geometryEnvelope, envelope) &&
+ Math.Abs(_geometryTickWidth - TickWidth) < double.Epsilon &&
+ Math.Abs(_geometryHeight - Bounds.Height) < double.Epsilon;
+ if (cacheContainsViewport)
+ {
+ return;
+ }
+
+ double startTick = envelope.StartTick;
+ double endTick = envelope.EndTick;
+
+ 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,
+ 0,
+ 1.0));
+ geometryContext.LineTo(new Point(column, y));
+ }
+ geometryContext.LineTo(new Point(geometryWidth, Bounds.Height));
+ geometryContext.EndFigure(true);
+ }
+
+ _geometry = geometry;
+ _geometryEnvelope = envelope;
+ _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;
+ _requestedPart = null;
+ }
+
+ private void ClearWaveform()
+ {
+ CancelEnvelopeBuild();
+ _envelope = null;
+ _envelopePart = null;
+ InvalidateGeometry();
+ InvalidateVisual();
+ }
+
+ private void InvalidateGeometry()
+ {
+ _geometry = null;
+ _geometryEnvelope = null;
+ }
+}
diff --git a/OpenUtauMobile/ViewConstants.cs b/OpenUtauMobile/ViewConstants.cs
index 7df56e4c..d217bdce 100644
--- a/OpenUtauMobile/ViewConstants.cs
+++ b/OpenUtauMobile/ViewConstants.cs
@@ -75,6 +75,20 @@ 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 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 6592d332..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}">
+
+
+
+
+
-
+
+
+