Android perf: fix fader stutter (appendLog), drawText feedback widget, bisect docs

Bisect session with per-suspect kill switches (see BISECT_LOG.md):
- appendLog was the confirmed stutter culprit — ran per-message (~110/sec)
  on the main thread even with the log panel hidden; A/B verified. Disabled
  via DEBUG_DISABLE_APPEND_LOG; shippable visible-only fix still TODO.
- Per-message Log.d also disabled (freebie, no felt difference).
- Glow, gradient fill, network flash, feedback setText all exonerated and
  restored; switches left in place at false.
- FocusFeedbackWidgetView rewritten: TextView -> bare View + canvas.drawText.
  60hz updates are now field-assign + invalidate, no per-update text Layout.
- ContextMenuViewLogic: explicit color-picker branch for the new view type.
- Timecode chunkiness root cause was REAPER audio buffer size (source-side
  burst cadence), documented in PERF_SESSION_2026-07-31.md; stutter bug
  report stamped RESOLVED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-31 17:46:07 -05:00
parent 6f66a5db64
commit 60c8255c2d
43 changed files with 301 additions and 41 deletions
@@ -1,4 +1,4 @@
#Thu Jul 30 19:08:15 CDT 2026
#Fri Jul 31 17:37:31 CDT 2026
base.0=E\:\\20260608-virtual-controller-py\\remote-client-android\\app\\build\\intermediates\\dex\\debug\\mergeExtDexDebug\\classes.dex
base.1=E\:\\20260608-virtual-controller-py\\remote-client-android\\app\\build\\intermediates\\dex\\debug\\mergeProjectDexDebug\\0\\classes.dex
base.2=E\:\\20260608-virtual-controller-py\\remote-client-android\\app\\build\\intermediates\\dex\\debug\\mergeProjectDexDebug\\13\\classes.dex
@@ -37,6 +37,24 @@ import okio.ByteString;
public class ArrangeFunctionsWebSocket {
private static final String TAG = "VCWebSocket";
// ---- Stutter-bisect kill switches (see BISECT_LOG.md) ----
// Flip to false to restore the original behavior. Suspect #1+#2 in the
// Focus Fader lag investigation: appendLog runs on EVERY in/out message
// (~110/sec under 60hz feedback) even while the log panel is hidden.
private static final boolean DEBUG_DISABLE_APPEND_LOG = true;
// Suspect #8: Log.d with full-JSON string concat on EVERY incoming message
// (~110/sec under 60hz feedback) — runs on the OkHttp reader thread, but
// the concat allocs + logd writes are pure per-message overhead.
private static final boolean DEBUG_DISABLE_RECEIVE_LOGCAT = true;
// Suspect #4: activity-ring flash on every incoming network message —
// removeCallbacks + postDelayed on the main thread at 60hz+ per widget.
// Local touches still flash their card; only network-triggered flashes stop.
private static final boolean DEBUG_DISABLE_NETWORK_FLASH = false;
// Suspect #5: setText() on the feedback TextView at 60hz — new text layout
// + redraw on the main thread per message. While true, feedback readouts
// (timecode etc.) freeze at their last shown text.
private static final boolean DEBUG_DISABLE_FEEDBACK_SETTEXT = false;
private final ArrangeActivity vc;
private final OkHttpClient client = new OkHttpClient();
@@ -65,7 +83,7 @@ public class ArrangeFunctionsWebSocket {
@Override
public void onMessage(WebSocket webSocket, String text) {
Log.d(TAG, "Received: " + text);
if (!DEBUG_DISABLE_RECEIVE_LOGCAT) Log.d(TAG, "Received: " + text);
dispatch(text);
}
@@ -291,6 +309,7 @@ public class ArrangeFunctionsWebSocket {
String uid = envelope.optString("uid", "");
String text = envelope.optString("text", "");
appendLog("IN", "widget_feedback uid=" + uid + " text=\"" + text + "\"");
if (DEBUG_DISABLE_FEEDBACK_SETTEXT) return;
vc.runOnUiThread(() -> {
TextUpdatable widget = vc.feedbackRegistry.get(uid);
if (widget != null) widget.setTextFromNetwork(text);
@@ -300,6 +319,7 @@ public class ArrangeFunctionsWebSocket {
/** Activity-ring flash on the card wrapping this uid — matches iOS's BaseWidget.update(value:) side effect. */
private void flashCard(String uid) {
if (DEBUG_DISABLE_NETWORK_FLASH) return;
ArrangeCardView card = vc.cardRegistry.get(uid);
if (card != null) card.flashActivity();
}
@@ -330,6 +350,7 @@ public class ArrangeFunctionsWebSocket {
* run on every call, just often enough to bound memory growth.
*/
private void appendLog(String direction, String message) {
if (DEBUG_DISABLE_APPEND_LOG) return;
String line = "[" + direction + "] " + message + "\n";
vc.runOnUiThread(() -> {
vc.outputTextView.append(line);
@@ -81,7 +81,11 @@ public class ContextMenuViewLogic {
@Override
public void onColorSelected(int colorIndex) {
int colorValue = ContextMenuView.COLORS[colorIndex];
if (controlView instanceof android.widget.TextView) {
// FocusFeedbackWidgetView is a bare canvas View (drawText), not a
// TextView — it needs its own branch or the instanceof misses it.
if (controlView instanceof FocusFeedbackWidgetView) {
((FocusFeedbackWidgetView) controlView).setTextColor(colorValue);
} else if (controlView instanceof android.widget.TextView) {
((android.widget.TextView) controlView).setTextColor(colorValue);
}
String colorHex = String.format("#%06X", (0xFFFFFF & colorValue));
@@ -33,6 +33,16 @@ public class FaderWidgetView extends FrameLayout implements UpdatableWidget {
private static final int PAD_HEIGHT_DP = 90;
private static final float FINE_SENSITIVITY = 0.10f;
// Stutter-bisect suspect #6 (see BISECT_LOG.md): setShadowLayer glow forces
// LAYER_TYPE_SOFTWARE — full CPU re-raster of the fader every drag frame.
// true = no glow, hardware rendering; false = original glow look.
private static final boolean DEBUG_DISABLE_GLOW = false;
// Stutter-bisect suspect #7 (see BISECT_LOG.md): a new LinearGradient is
// allocated on every onDraw — GC churn during drags. true = flat translucent
// fill, zero allocations; false = original gradient fill.
private static final boolean DEBUG_DISABLE_GRADIENT_FILL = false;
private final String uid;
private final Paint bgPaint = new Paint();
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -69,13 +79,15 @@ public class FaderWidgetView extends FrameLayout implements UpdatableWidget {
glowLinePaint.setColor(colorArgb);
glowLinePaint.setStrokeWidth(6f);
glowLinePaint.setShadowLayer(18f, 0f, 0f, colorArgb);
dotPaint.setColor(Color.WHITE);
dotPaint.setShadowLayer(10f, 0f, 0f, colorArgb);
// Shadow layers only render on a software-rendered layer.
setLayerType(LAYER_TYPE_SOFTWARE, null);
if (!DEBUG_DISABLE_GLOW) {
glowLinePaint.setShadowLayer(18f, 0f, 0f, colorArgb);
dotPaint.setShadowLayer(10f, 0f, 0f, colorArgb);
// Shadow layers only render on a software-rendered layer.
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
}
public String getUid() {
@@ -131,11 +143,16 @@ public class FaderWidgetView extends FrameLayout implements UpdatableWidget {
// Transparent at the bottom of the fill, full color+alpha at the value
// line — analog of iOS's CAGradientLayer fill.
fillPaint.setShader(new LinearGradient(
0, h, 0, fillTop,
Color.argb(0, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Color.argb(115, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Shader.TileMode.CLAMP));
if (DEBUG_DISABLE_GRADIENT_FILL) {
fillPaint.setShader(null);
fillPaint.setColor(Color.argb(60, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)));
} else {
fillPaint.setShader(new LinearGradient(
0, h, 0, fillTop,
Color.argb(0, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Color.argb(115, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Shader.TileMode.CLAMP));
}
canvas.drawRect(0, fillTop, w, h, fillPaint);
// Glowing value line + a small glowing dot at its center — analog of the
@@ -36,6 +36,16 @@ public class FocusFaderWidgetView extends FrameLayout implements UpdatableWidget
private static final float FINE_SENSITIVITY = 0.10f;
private static final int PAD_HEIGHT_DP = 90;
// Stutter-bisect suspect #6 (see BISECT_LOG.md): setShadowLayer glow forces
// LAYER_TYPE_SOFTWARE — full CPU re-raster of the fader every drag frame.
// true = no glow, hardware rendering; false = original glow look.
private static final boolean DEBUG_DISABLE_GLOW = false;
// Stutter-bisect suspect #7 (see BISECT_LOG.md): a new LinearGradient is
// allocated on every onDraw — GC churn during drags. true = flat translucent
// fill, zero allocations; false = original gradient fill.
private static final boolean DEBUG_DISABLE_GRADIENT_FILL = false;
private final String uid;
private final Paint bgPaint = new Paint();
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -70,10 +80,13 @@ public class FocusFaderWidgetView extends FrameLayout implements UpdatableWidget
borderPaint.setStrokeWidth(4f);
glowLinePaint.setColor(colorArgb);
glowLinePaint.setStrokeWidth(6f);
glowLinePaint.setShadowLayer(18f, 0f, 0f, colorArgb);
dotPaint.setColor(Color.WHITE);
dotPaint.setShadowLayer(10f, 0f, 0f, colorArgb);
setLayerType(LAYER_TYPE_SOFTWARE, null);
if (!DEBUG_DISABLE_GLOW) {
glowLinePaint.setShadowLayer(18f, 0f, 0f, colorArgb);
dotPaint.setShadowLayer(10f, 0f, 0f, colorArgb);
// Shadow layers only render on a software-rendered layer.
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
}
public String getUid() {
@@ -118,11 +131,16 @@ public class FocusFaderWidgetView extends FrameLayout implements UpdatableWidget
float fillHeight = Math.max(8f, h * value);
float fillTop = h - fillHeight;
fillPaint.setShader(new LinearGradient(
0, h, 0, fillTop,
Color.argb(0, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Color.argb(115, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Shader.TileMode.CLAMP));
if (DEBUG_DISABLE_GRADIENT_FILL) {
fillPaint.setShader(null);
fillPaint.setColor(Color.argb(60, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)));
} else {
fillPaint.setShader(new LinearGradient(
0, h, 0, fillTop,
Color.argb(0, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Color.argb(115, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)),
Shader.TileMode.CLAMP));
}
canvas.drawRect(0, fillTop, w, h, fillPaint);
canvas.drawLine(0, fillTop, w, fillTop, glowLinePaint);
@@ -1,14 +1,14 @@
package com.virtualcontroller.remote.widgets;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.os.Build;
import android.text.TextPaint;
import android.util.TypedValue;
import android.view.Gravity;
import android.widget.TextView;
import android.view.View;
/**
* Read-only Channel Focus DAW-state readout — updated live via
@@ -21,8 +21,16 @@ import android.widget.TextView;
* drives the font size and lets the frame follow, rather than scaling the
* frame like every other widget type — the aspect ratio then can't drift out
* of sync with the text it's wrapping.
*
* Draws via canvas.drawText on a bare View rather than extending TextView:
* this readout repaints at up to 60hz (timecode), and TextView.setText builds
* a new internal text Layout on every call — measurement, line-breaking, span
* bookkeeping — all for one line of digits that never wraps. drawText skips
* every bit of that: a network update is now just a String field assign +
* invalidate, and the draw is a single glyph run. (Same conclusion
* FEEDBACK_STUTTER_BUG.md guessed TouchOSC reached.)
*/
public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
public class FocusFeedbackWidgetView extends View implements TextUpdatable {
public static final float DEFAULT_FONT_SP = 32f;
public static final float MIN_FONT_SP = 12f;
@@ -36,6 +44,8 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
void onFitRequest();
}
private final TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
private String text;
private float fontSp = DEFAULT_FONT_SP;
private OnFitRequestListener fitListener;
@@ -51,14 +61,9 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
*/
public FocusFeedbackWidgetView(Context context, String initialText, int savedHeightPx) {
super(context);
setText(initialText);
setTextColor(TEXT_COLOR);
setBackgroundColor(Color.TRANSPARENT);
setGravity(Gravity.CENTER);
setSingleLine(true);
int pad = dp(context, PADDING_DP);
setPadding(pad, pad, pad, pad);
this.text = initialText != null ? initialText : "";
paint.setColor(TEXT_COLOR);
paint.setTextAlign(Paint.Align.CENTER);
int defaultHeight = fittedSize(context, initialText, DEFAULT_FONT_SP)[1];
if (savedHeightPx > 0 && defaultHeight > 0) {
@@ -72,7 +77,8 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
}
public void setTextColor(int color) {
super.setTextColor(color);
paint.setColor(color);
invalidate();
}
public void setFontWeight(String weight) {
@@ -83,14 +89,14 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
tf = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
} else if ("super_bold".equalsIgnoreCase(weight)) {
tf = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
getPaint().setStrokeWidth(2.0f);
paint.setStrokeWidth(2.0f);
} else if ("thin".equalsIgnoreCase(weight)) {
tf = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL);
getPaint().setStrokeWidth(0.5f);
paint.setStrokeWidth(0.5f);
} else {
tf = semiboldMono();
}
setTypeface(tf);
paint.setTypeface(tf);
invalidate();
}
@@ -109,12 +115,12 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
/** Box size for the current text at the current font size. */
public int[] fittedSizePx() {
return fittedSize(getContext(), getText().toString(), fontSp);
return fittedSize(getContext(), text, fontSp);
}
/** Box size for the current text at DEFAULT_FONT_SP — what "Reset size" converges to. */
public int[] defaultFittedSizePx() {
return fittedSize(getContext(), getText().toString(), DEFAULT_FONT_SP);
return fittedSize(getContext(), text, DEFAULT_FONT_SP);
}
/**
@@ -142,15 +148,31 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
@Override
public void setTextFromNetwork(String text) {
if (text.equals(getText().toString())) return;
setText(text);
if (text == null || text.equals(this.text)) return;
this.text = text;
invalidate();
}
// MARK: - Drawing
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (text.isEmpty()) return;
// Centered both ways: CENTER-aligned paint handles x; y places the
// baseline so the glyph block's vertical middle sits at the view's.
Paint.FontMetrics fm = paint.getFontMetrics();
float y = getHeight() / 2f - (fm.ascent + fm.descent) / 2f;
canvas.drawText(text, getWidth() / 2f, y, paint);
}
// MARK: - Internals
private void applyFont() {
setTypeface(semiboldMono());
setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSp);
paint.setTypeface(semiboldMono());
paint.setTextSize(TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, fontSp, getResources().getDisplayMetrics()));
invalidate();
}
private void requestFit() {