Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Lipscomb a3e6f6415f Restructure app-desktop as self-contained package, add PyInstaller build
Consolidates everything the desktop app depends on into app-desktop/ so it
packages cleanly as a standalone Windows exe:

- app-desktop-macos/ -> app-desktop/src/ (drop macOS-only naming, app now
  targets Windows via PyInstaller too)
- remote-server/ws_server.py and app-presets/ moved into app-desktop/src/
  (both were exclusive dependencies of main.py/presets.py) — fixes a
  PyInstaller "missing module" error for ws_server that only surfaced once
  packaging was attempted, since its old location wasn't on the analyzer's
  search path
- .venv relocated into app-desktop/src/ alongside the code it serves
- run.py moved into src/, path logic simplified now that it's co-located
  with main.py instead of bridging from the repo root
- Deleted app/, server/ — stale __pycache__-only fossils from prior reorgs
- .gitignore: added missing .venv/ entry (was only covering venv/, a
  different name — the real folder was never actually ignored) and
  app-desktop/build|dist/ for PyInstaller output

presets.py: added a frozen-vs-source branch. Running from source is
unchanged (app-presets/ next to the code). A packaged exe can't write to
Program Files, so it uses %APPDATA%\VirtualController\ instead — standard
Windows convention. First launch on a machine with no AppData presets yet
seeds from presets.json bundled into the exe (PyInstaller --add-data,
baked into VirtualController.spec), so a fresh install starts with real
presets instead of empty; every launch after that only touches AppData.

Verified: exe builds clean (no missing-module warnings), launches with a
real window, and a clean first-launch correctly seeds AppData from the
bundled presets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 18:50:35 -05:00
Paul Lipscomb 60c8255c2d 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>
2026-07-31 17:46:07 -05:00
71 changed files with 380 additions and 55 deletions
+2 -1
View File
@@ -41,7 +41,8 @@
"Bash(awk '/int REAPERAPI_LoadAPI/,0' /Users/p4piwabl0/Desktop/projects/virtual-controller-clean/extension-reaper-macos/vendor/reaper-sdk/sdk/reaper_plugin_functions.h)",
"Bash(grep -n \"{NULL, NULL}\")",
"Bash(ls -la \"/Applications/REAPER.app/Contents/MacOS/\" 2>&1 | head -5 *)",
"Read(//Applications/REAPER.app/Contents/MacOS/**)"
"Read(//Applications/REAPER.app/Contents/MacOS/**)",
"Bash(src/.venv/Scripts/python.exe -m PyInstaller --name VirtualController --onedir --windowed --distpath dist --workpath build --specpath . --add-data \"src/app-presets/presets.json;app-presets\" src/main.py)"
]
}
}
+5
View File
@@ -1,4 +1,5 @@
venv/
.venv/
__pycache__/
@@ -6,6 +7,10 @@ __pycache__/
.DS_Store
# PyInstaller output (app-desktop)
app-desktop/build/
app-desktop/dist/
# Xcode
*.xcuserstate
xcuserdata/
+44
View File
@@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['src\\main.py'],
pathex=[],
binaries=[],
datas=[('src/app-presets/presets.json', 'app-presets')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='VirtualController',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='VirtualController',
)
@@ -9,7 +9,26 @@ from PyQt6.QtWidgets import (
)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
import shutil
import sys
if getattr(sys, "frozen", False):
# Packaged exe: Program Files (or wherever it's installed) is not
# writable by a normal user, so presets live in the per-user AppData
# folder instead — standard Windows convention for app-written data.
PRESETS_DIR = os.path.join(os.environ["APPDATA"], "VirtualController")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
if not os.path.exists(PRESETS_FILE):
# First launch on this machine: seed from the presets.json bundled
# into the exe (sys._MEIPASS), so a fresh install starts with the
# real presets instead of empty. Only happens once — after this,
# AppData is the only copy that's ever read or written.
_bundled = os.path.join(sys._MEIPASS, "app-presets", "presets.json")
if os.path.exists(_bundled):
shutil.copyfile(_bundled, PRESETS_FILE)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PRESETS_DIR = os.path.join(BASE_DIR, "app-presets")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
+5
View File
@@ -0,0 +1,5 @@
import os
import runpy
_here = os.path.dirname(os.path.abspath(__file__))
runpy.run_path(os.path.join(_here, "main.py"), run_name="__main__")
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
> **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
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.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,14 +79,16 @@ 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);
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() {
return uid;
@@ -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.
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,11 +80,14 @@ 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);
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() {
return uid;
@@ -118,11 +131,16 @@ public class FocusFaderWidgetView extends FrameLayout implements UpdatableWidget
float fillHeight = Math.max(8f, h * value);
float fillTop = h - fillHeight;
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() {
View File
-9
View File
@@ -1,9 +0,0 @@
import sys
import os
_root = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_root, "app-desktop-macos"))
sys.path.insert(0, os.path.join(_root, "remote-server"))
import runpy
runpy.run_path(os.path.join(_root, "app-desktop-macos", "main.py"), run_name="__main__")