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
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
# Focus Fader Stutter — Bisect Log
Companion to FEEDBACK_STUTTER_BUG.md. One kill-switch per suspect; flip one,
rebuild, drag a Focus Fader while 60hz feedback streams, record the result here,
then either keep it off or restore it and move to the next.
## Ranked suspect list
| # | Suspect | Where | Switch |
|---|---------|-------|--------|
| 1 | appendLog per message (even when panel hidden) | ArrangeFunctionsWebSocket.appendLog | `DEBUG_DISABLE_APPEND_LOG` |
| 2 | logScrollView.post(fullScroll) per message | inside appendLog | covered by #1 |
| 3 | Runnable flood (~3 posts/msg, 300+/sec) | appendLog + dispatch handlers | mostly covered by #1 |
| 4 | flashCard per message at 60hz | ArrangeFunctionsWebSocket.flashCard → ArrangeCardView.flashActivity | `DEBUG_DISABLE_NETWORK_FLASH` |
| 5 | setText at 60hz on feedback TextView | FocusFeedbackWidgetView.setTextFromNetwork | `DEBUG_DISABLE_FEEDBACK_SETTEXT` |
| 6 | LAYER_TYPE_SOFTWARE + blur glow on fader | FocusFaderWidgetView ctor (also FaderWidgetView) | `DEBUG_DISABLE_GLOW` (one per class) |
| 7 | new LinearGradient every onDraw | FocusFaderWidgetView.onDraw (also FaderWidgetView) | `DEBUG_DISABLE_GRADIENT_FILL` (one per class) |
| 8 | Log.d full-JSON concat per message | ArrangeFunctionsWebSocket.onMessage | `DEBUG_DISABLE_RECEIVE_LOGCAT` |
| 9 | Echo stream (sent values come back, run appendLog+flashCard mid-drag) | handleWidgetUpdateF | partially covered by #1 |
| 10 | vibrator.vibrate at value edges | FocusFaderWidgetView.checkEdgeHaptic | not yet added |
## Verdict (2026-07-31)
**appendLog was the stutter.** A/B confirmed: re-enabling it brings the stutter
back immediately. Receive-logcat kept off as a freebie (no felt difference).
Everything else exonerated and restored to original behavior — including the
glow/software-layer rendering, which Paul tested and cleared.
Final switch states: `DEBUG_DISABLE_APPEND_LOG = true`,
`DEBUG_DISABLE_RECEIVE_LOGCAT = true`, all others `false` (original behavior).
**Timecode fluidity root cause (found by Paul, same day):** REAPER's audio
buffer size. REAPER emits OSC feedback per audio block, so a large buffer
makes updates arrive in chunky bursts — uneven cadence no client-side
rendering can smooth. Lowering the buffer restored fluid feedback. The
drawText rewrite of FocusFeedbackWidgetView (done just before this discovery)
stays: it's the cheapest render path and collapses same-frame updates to one
draw regardless of source pacing.
## Test runs
### Run 1 — DEBUG_DISABLE_APPEND_LOG = true (suspects 1+2, most of 3)
- Date: 2026-07-31
- Switches off: appendLog (all in/out log lines + auto-scroll posts)
- Side effect while off: debug log panel shows nothing new when opened
- Result: **CONFIRMED CULPRIT — "way smoother"** with appendLog disabled.
- A/B re-test same day: re-enabled appendLog, stutter came back immediately;
disabled again. Not a placebo — appendLog is definitively implicated.
Left OFF for now; permanent fix TBD.
### Run 2 — DEBUG_DISABLE_RECEIVE_LOGCAT = true (suspect 8)
- Date: 2026-07-31
- Switches off: appendLog (kept off from Run 1) + per-message Log.d in onMessage
- Side effect while off: incoming messages no longer visible in logcat
- Result: **No perceptible difference.** Kept off anyway (free optimization,
nobody reads logcat during performance).
### Run 3 — DEBUG_DISABLE_GLOW = true (suspect 6)
- Date: 2026-07-31
- Switches off: appendLog + receive-logcat (from Runs 1-2) + shadow-layer glow
and software rendering in FocusFaderWidgetView AND FaderWidgetView
- Side effect while off: faders lose their glow — plain value line + dot
- Result: **Exonerated.** Still smooth with glow off, but no felt difference
attributable to it — Paul: "the glow wasn't an issue." Turned back ON
(original software-layer glow restored) at end of bisect.
### Run 4 — DEBUG_DISABLE_NETWORK_FLASH = true (suspect 4)
- Date: 2026-07-31
- Switches off: appendLog + receive-logcat + glow (Runs 1-3) + network-triggered
activity-ring flashes (local touch flashes still work)
- Side effect while off: cards don't blink green on desktop-driven updates
- Result: **SKIPPED — suspect was already inert.** The tablet's "Activity:
ON/OFF" button (default OFF) was off during all stutter tests, and
flashActivity() early-returns on that flag before any timer work. Kill
switch left in place (harmless, skips a registry lookup) but this suspect
is exonerated for the observed stutter.
### Run 5 — DEBUG_DISABLE_FEEDBACK_SETTEXT = true (suspect 5, plus its share of 3)
- Date: 2026-07-31
- Switches off: appendLog + receive-logcat + glow (Runs 1-3) + the ENTIRE
widget_feedback UI path: the runOnUiThread post, registry lookup, and
setText all skipped (placed before the post, so this also removes 60/sec
of the runnable flood)
- Side effect while off: feedback readouts (timecode etc.) freeze at last text
- Result: **Nothing earth-shaking — no clear felt difference.** Switch turned
back OFF (readouts are needed regardless). 60hz setText is tolerable now
that appendLog is gone; candidate for once-per-frame conflation later, not
a primary culprit.
### Run 6 — DEBUG_DISABLE_GRADIENT_FILL = true (suspect 7)
- Date: 2026-07-31
- Switches off: appendLog + receive-logcat + glow (kept from earlier runs);
gradient fill replaced with flat translucent fill in both fader classes
- Side effect while off: fader fill is flat color, no fade-to-transparent
- Result: **No felt difference — gradient turned back ON** (look kept).
Create-once gradient (setLocalMatrix) stays on the permanent-fix list as
hygiene, not a culprit.
@@ -1,5 +1,13 @@
# Android Feedback Widget Stutter Bug # Android Feedback Widget Stutter Bug
> **RESOLVED 2026-07-31** — two separate root causes: (1) fader stutter was
> `appendLog()` running per-message on the main thread even with the log panel
> hidden (A/B confirmed, disabled via kill switch); (2) chunky timecode was
> REAPER's audio buffer size making OSC feedback leave in bursts. Full
> write-up: `PERF_SESSION_2026-07-31.md`; run-by-run record: `BISECT_LOG.md`.
> The "custom Canvas view" hypothesis below was also acted on —
> FocusFeedbackWidgetView now draws via canvas.drawText.
## Summary ## Summary
When the Android tablet app receives high-frequency network feedback updates (60hz timecode from desktop), the feedback widget AND the fader control stutter and become unresponsive. Works perfectly on iOS and desktop apps at the same 60hz update rate. When the Android tablet app receives high-frequency network feedback updates (60hz timecode from desktop), the feedback widget AND the fader control stutter and become unresponsive. Works perfectly on iOS and desktop apps at the same 60hz update rate.
@@ -0,0 +1,73 @@
# Performance Session — 2026-07-31
Android tablet remote: fader stutter + timecode feedback fluidity. Both root
causes found and fixed. Companion docs: `FEEDBACK_STUTTER_BUG.md` (original
symptom report, now resolved), `BISECT_LOG.md` (run-by-run test record).
## The two problems (they were separate)
### 1. Fader stutter under 60hz feedback → appendLog
**Root cause:** `appendLog()` in `ArrangeFunctionsWebSocket.java` ran on every
in/out WebSocket message (~110/sec during a drag with feedback streaming),
even though the log panel is hidden by default:
- `TextView.append()` on a selectable (editable/spannable) TextView — cost
grows as the text grows
- 2-3 posted main-thread runnables per message (~300/sec), queued ahead of
touch events
- Every ~0.5s, the line-trim: full 350-line copy/split/join/setText — a
multi-millisecond spike that blew the 16ms frame budget. Periodic spikes =
the rhythmic stutter.
**Proof:** A/B tested — kill switch off = smooth, on = stutter returns
immediately. Second time this exact code was convicted (an earlier session
already replaced get+concat+setText with append; the remaining per-message
work was still the culprit).
**Fix (current):** `DEBUG_DISABLE_APPEND_LOG = true` — appendLog fully off.
Side effect: log panel shows nothing. Shippable fix still TODO (see below).
### 2. Chunky timecode readout → REAPER buffer size
**Root cause (found by Paul):** REAPER emits OSC feedback per audio block. A
high audio buffer size makes feedback updates leave REAPER in uneven bursts —
no client-side rendering can smooth a chunky source. Lowering the buffer
restored fluid timecode.
**Rule of thumb:** if timecode/dB readouts ever look chunky again on any
client (Android, iOS, desktop log), check REAPER's audio buffer size FIRST,
before suspecting client code.
## Changes made this session
| Change | File | Status |
|---|---|---|
| Kill switch: appendLog off | `arrange/ArrangeFunctionsWebSocket.java` | **Active** (`DEBUG_DISABLE_APPEND_LOG = true`) |
| Kill switch: per-message `Log.d` off | same | **Active** (`DEBUG_DISABLE_RECEIVE_LOGCAT = true`) — no felt difference, kept as freebie |
| Kill switch: network activity-flash | same | Inactive (`false`) — exonerated; tablet's Activity button (default OFF) already gated it |
| Kill switch: feedback setText | same | Inactive (`false`) — exonerated, 60hz setText was affordable |
| Kill switch: fader glow / software layer | `widgets/FaderWidgetView.java`, `widgets/FocusFaderWidgetView.java` | Inactive (`false`) — exonerated by Paul, glow restored |
| Kill switch: gradient fill alloc | same two files | Inactive (`false`) — exonerated, gradient restored |
| **FocusFeedbackWidgetView rewritten: TextView → bare View + `canvas.drawText`** | `widgets/FocusFeedbackWidgetView.java` | **Permanent.** Update = string compare + field assign + invalidate; no text Layout object per update; same-frame updates collapse to one draw. All sizing/pinch/font-weight/color behavior preserved. |
| Color picker: explicit branch for new view type | `arrange/ContextMenuViewLogic.java` | Permanent — was matching via `instanceof TextView`, which the rewrite would have silently broken |
## The lesson (codebase rule)
Main thread = one queue for touch, draw, and posted runnables. Per-message
work is fine if **small and constant** (setText, a flash, a field assign).
Banned on the message stream: anything **unbounded** (grows with history) or
**spiky** (periodic big jobs). appendLog was both, for an invisible panel.
## Follow-ups (not yet done)
1. **Shippable appendLog fix** — replace the kill switch: log only while the
panel is visible; bounded ring buffer for history so opening the panel
isn't empty; then delete `DEBUG_DISABLE_APPEND_LOG`.
2. Decide fate of the exonerated kill switches (probably delete for cleanliness;
they're documented in BISECT_LOG.md if ever needed again).
3. Optional hygiene: create the fader fill gradient once (setLocalMatrix)
instead of per-onDraw; gate `Log.d` behind `BuildConfig.DEBUG` instead of a
custom flag.
4. Consider documenting the REAPER-buffer/feedback-cadence relationship in
CLAUDE.md's DAW integration section.
@@ -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.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.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 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 { public class ArrangeFunctionsWebSocket {
private static final String TAG = "VCWebSocket"; 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 ArrangeActivity vc;
private final OkHttpClient client = new OkHttpClient(); private final OkHttpClient client = new OkHttpClient();
@@ -65,7 +83,7 @@ public class ArrangeFunctionsWebSocket {
@Override @Override
public void onMessage(WebSocket webSocket, String text) { public void onMessage(WebSocket webSocket, String text) {
Log.d(TAG, "Received: " + text); if (!DEBUG_DISABLE_RECEIVE_LOGCAT) Log.d(TAG, "Received: " + text);
dispatch(text); dispatch(text);
} }
@@ -291,6 +309,7 @@ public class ArrangeFunctionsWebSocket {
String uid = envelope.optString("uid", ""); String uid = envelope.optString("uid", "");
String text = envelope.optString("text", ""); String text = envelope.optString("text", "");
appendLog("IN", "widget_feedback uid=" + uid + " text=\"" + text + "\""); appendLog("IN", "widget_feedback uid=" + uid + " text=\"" + text + "\"");
if (DEBUG_DISABLE_FEEDBACK_SETTEXT) return;
vc.runOnUiThread(() -> { vc.runOnUiThread(() -> {
TextUpdatable widget = vc.feedbackRegistry.get(uid); TextUpdatable widget = vc.feedbackRegistry.get(uid);
if (widget != null) widget.setTextFromNetwork(text); 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. */ /** Activity-ring flash on the card wrapping this uid — matches iOS's BaseWidget.update(value:) side effect. */
private void flashCard(String uid) { private void flashCard(String uid) {
if (DEBUG_DISABLE_NETWORK_FLASH) return;
ArrangeCardView card = vc.cardRegistry.get(uid); ArrangeCardView card = vc.cardRegistry.get(uid);
if (card != null) card.flashActivity(); if (card != null) card.flashActivity();
} }
@@ -330,6 +350,7 @@ public class ArrangeFunctionsWebSocket {
* run on every call, just often enough to bound memory growth. * run on every call, just often enough to bound memory growth.
*/ */
private void appendLog(String direction, String message) { private void appendLog(String direction, String message) {
if (DEBUG_DISABLE_APPEND_LOG) return;
String line = "[" + direction + "] " + message + "\n"; String line = "[" + direction + "] " + message + "\n";
vc.runOnUiThread(() -> { vc.runOnUiThread(() -> {
vc.outputTextView.append(line); vc.outputTextView.append(line);
@@ -81,7 +81,11 @@ public class ContextMenuViewLogic {
@Override @Override
public void onColorSelected(int colorIndex) { public void onColorSelected(int colorIndex) {
int colorValue = ContextMenuView.COLORS[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); ((android.widget.TextView) controlView).setTextColor(colorValue);
} }
String colorHex = String.format("#%06X", (0xFFFFFF & 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 int PAD_HEIGHT_DP = 90;
private static final float FINE_SENSITIVITY = 0.10f; 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 String uid;
private final Paint bgPaint = new Paint(); private final Paint bgPaint = new Paint();
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 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.setColor(colorArgb);
glowLinePaint.setStrokeWidth(6f); glowLinePaint.setStrokeWidth(6f);
glowLinePaint.setShadowLayer(18f, 0f, 0f, colorArgb);
dotPaint.setColor(Color.WHITE); dotPaint.setColor(Color.WHITE);
dotPaint.setShadowLayer(10f, 0f, 0f, colorArgb);
// Shadow layers only render on a software-rendered layer. if (!DEBUG_DISABLE_GLOW) {
setLayerType(LAYER_TYPE_SOFTWARE, null); 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() { 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 // Transparent at the bottom of the fill, full color+alpha at the value
// line — analog of iOS's CAGradientLayer fill. // line — analog of iOS's CAGradientLayer fill.
fillPaint.setShader(new LinearGradient( if (DEBUG_DISABLE_GRADIENT_FILL) {
0, h, 0, fillTop, fillPaint.setShader(null);
Color.argb(0, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)), fillPaint.setColor(Color.argb(60, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)));
Color.argb(115, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)), } else {
Shader.TileMode.CLAMP)); 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.drawRect(0, fillTop, w, h, fillPaint);
// Glowing value line + a small glowing dot at its center — analog of the // 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 float FINE_SENSITIVITY = 0.10f;
private static final int PAD_HEIGHT_DP = 90; 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 String uid;
private final Paint bgPaint = new Paint(); private final Paint bgPaint = new Paint();
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -70,10 +80,13 @@ public class FocusFaderWidgetView extends FrameLayout implements UpdatableWidget
borderPaint.setStrokeWidth(4f); borderPaint.setStrokeWidth(4f);
glowLinePaint.setColor(colorArgb); glowLinePaint.setColor(colorArgb);
glowLinePaint.setStrokeWidth(6f); glowLinePaint.setStrokeWidth(6f);
glowLinePaint.setShadowLayer(18f, 0f, 0f, colorArgb);
dotPaint.setColor(Color.WHITE); dotPaint.setColor(Color.WHITE);
dotPaint.setShadowLayer(10f, 0f, 0f, colorArgb); if (!DEBUG_DISABLE_GLOW) {
setLayerType(LAYER_TYPE_SOFTWARE, null); 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() { public String getUid() {
@@ -118,11 +131,16 @@ public class FocusFaderWidgetView extends FrameLayout implements UpdatableWidget
float fillHeight = Math.max(8f, h * value); float fillHeight = Math.max(8f, h * value);
float fillTop = h - fillHeight; float fillTop = h - fillHeight;
fillPaint.setShader(new LinearGradient( if (DEBUG_DISABLE_GRADIENT_FILL) {
0, h, 0, fillTop, fillPaint.setShader(null);
Color.argb(0, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)), fillPaint.setColor(Color.argb(60, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)));
Color.argb(115, Color.red(fillColor), Color.green(fillColor), Color.blue(fillColor)), } else {
Shader.TileMode.CLAMP)); 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.drawRect(0, fillTop, w, h, fillPaint);
canvas.drawLine(0, fillTop, w, fillTop, glowLinePaint); canvas.drawLine(0, fillTop, w, fillTop, glowLinePaint);
@@ -1,14 +1,14 @@
package com.virtualcontroller.remote.widgets; package com.virtualcontroller.remote.widgets;
import android.content.Context; import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Paint; import android.graphics.Paint;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.os.Build; import android.os.Build;
import android.text.TextPaint; import android.text.TextPaint;
import android.util.TypedValue; import android.util.TypedValue;
import android.view.Gravity; import android.view.View;
import android.widget.TextView;
/** /**
* Read-only Channel Focus DAW-state readout — updated live via * 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 * 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 * frame like every other widget type — the aspect ratio then can't drift out
* of sync with the text it's wrapping. * 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 DEFAULT_FONT_SP = 32f;
public static final float MIN_FONT_SP = 12f; public static final float MIN_FONT_SP = 12f;
@@ -36,6 +44,8 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
void onFitRequest(); void onFitRequest();
} }
private final TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
private String text;
private float fontSp = DEFAULT_FONT_SP; private float fontSp = DEFAULT_FONT_SP;
private OnFitRequestListener fitListener; private OnFitRequestListener fitListener;
@@ -51,14 +61,9 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
*/ */
public FocusFeedbackWidgetView(Context context, String initialText, int savedHeightPx) { public FocusFeedbackWidgetView(Context context, String initialText, int savedHeightPx) {
super(context); super(context);
setText(initialText); this.text = initialText != null ? initialText : "";
setTextColor(TEXT_COLOR); paint.setColor(TEXT_COLOR);
setBackgroundColor(Color.TRANSPARENT); paint.setTextAlign(Paint.Align.CENTER);
setGravity(Gravity.CENTER);
setSingleLine(true);
int pad = dp(context, PADDING_DP);
setPadding(pad, pad, pad, pad);
int defaultHeight = fittedSize(context, initialText, DEFAULT_FONT_SP)[1]; int defaultHeight = fittedSize(context, initialText, DEFAULT_FONT_SP)[1];
if (savedHeightPx > 0 && defaultHeight > 0) { if (savedHeightPx > 0 && defaultHeight > 0) {
@@ -72,7 +77,8 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
} }
public void setTextColor(int color) { public void setTextColor(int color) {
super.setTextColor(color); paint.setColor(color);
invalidate();
} }
public void setFontWeight(String weight) { public void setFontWeight(String weight) {
@@ -83,14 +89,14 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
tf = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD); tf = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
} else if ("super_bold".equalsIgnoreCase(weight)) { } else if ("super_bold".equalsIgnoreCase(weight)) {
tf = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD); tf = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
getPaint().setStrokeWidth(2.0f); paint.setStrokeWidth(2.0f);
} else if ("thin".equalsIgnoreCase(weight)) { } else if ("thin".equalsIgnoreCase(weight)) {
tf = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL); tf = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL);
getPaint().setStrokeWidth(0.5f); paint.setStrokeWidth(0.5f);
} else { } else {
tf = semiboldMono(); tf = semiboldMono();
} }
setTypeface(tf); paint.setTypeface(tf);
invalidate(); invalidate();
} }
@@ -109,12 +115,12 @@ public class FocusFeedbackWidgetView extends TextView implements TextUpdatable {
/** Box size for the current text at the current font size. */ /** Box size for the current text at the current font size. */
public int[] fittedSizePx() { 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. */ /** Box size for the current text at DEFAULT_FONT_SP — what "Reset size" converges to. */
public int[] defaultFittedSizePx() { 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 @Override
public void setTextFromNetwork(String text) { public void setTextFromNetwork(String text) {
if (text.equals(getText().toString())) return; if (text == null || text.equals(this.text)) return;
setText(text); 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 // MARK: - Internals
private void applyFont() { private void applyFont() {
setTypeface(semiboldMono()); paint.setTypeface(semiboldMono());
setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSp); paint.setTextSize(TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, fontSp, getResources().getDisplayMetrics()));
invalidate();
} }
private void requestFit() { private void requestFit() {