From 3780fe27d3922ab8d02a906ba19b69a0f8f85cbf Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 21 Jun 2026 14:17:03 -0400 Subject: [PATCH 1/3] Backport v2 tuner speedups --- pistomp/tuner/engine.py | 16 ++- pistomp/tuner/panel.py | 125 +++++++++++----------- pistomp/tuner/yin.py | 211 +++++++++++++++++++++---------------- tests/test_strobe_flush.py | 148 ++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 161 deletions(-) create mode 100644 tests/test_strobe_flush.py diff --git a/pistomp/tuner/engine.py b/pistomp/tuner/engine.py index 64ed4a468..5fe25042d 100644 --- a/pistomp/tuner/engine.py +++ b/pistomp/tuner/engine.py @@ -10,7 +10,7 @@ from pistomp.tuner.ringbuffer import RingBuffer from pistomp.tuner.source import AudioSource -from pistomp.tuner.yin import detect_pitch +from pistomp.tuner.yin import YinDetector _NOTE_NAMES = ["C", "C\u266f", "D", "D\u266f", "E", "F", "F\u266f", "G", "G\u266f", "A", "A\u266f", "B"] _A4_HZ = 440.0 @@ -81,10 +81,16 @@ def __init__( self._freq_history: deque[float] = deque(maxlen=self.MEDIAN_LEN) self._prev_rms: float = 0.0 self._onset_holdoff: int = 0 + self._detector: YinDetector | None = None def start(self) -> None: self._running = True self._source.start(on_samples=self._ring.write) + lo, hi = self._freq_bounds + self._detector = YinDetector( + self.FRAME_SIZE, self._source.sample_rate, + freq_min=lo, freq_max=hi, window=self.YIN_WINDOW, + ) self._worker = threading.Thread(target=self._dsp_loop, daemon=True, name="tuner-dsp") self._worker.start() @@ -94,6 +100,7 @@ def stop(self) -> None: if self._worker is not None: self._worker.join(timeout=2.0) self._worker = None + self._detector = None def _dsp_loop(self) -> None: interval = 1.0 / self.DSP_RATE_HZ @@ -109,7 +116,7 @@ def _process(self) -> None: if not self._ring.read_latest(self.FRAME_SIZE, self._frame): return - rms = float(np.sqrt(np.mean(self._frame.astype(np.float64) ** 2))) + rms = float(np.sqrt(np.mean(self._frame ** 2))) if rms < self.SILENCE_RMS: self._freq_history.clear() @@ -132,9 +139,8 @@ def _process(self) -> None: self._onset_holdoff -= 1 return - sr = self._source.sample_rate - lo, hi = self._freq_bounds - estimate = detect_pitch(self._frame, sr, freq_min=lo, freq_max=hi, window=self.YIN_WINDOW) + assert self._detector is not None + estimate = self._detector.detect(self._frame) if estimate is None: return diff --git a/pistomp/tuner/panel.py b/pistomp/tuner/panel.py index 25a294413..a21f118d5 100644 --- a/pistomp/tuner/panel.py +++ b/pistomp/tuner/panel.py @@ -42,10 +42,6 @@ def _zone(cents: float) -> Zone: return "red" -def _zone_color(cents: float) -> Color: - return _ZONE_COLORS[_zone(cents)] - - # ── TunerHeaderWidget ──────────────────────────────────────────────────────── @@ -213,6 +209,7 @@ def __init__(self, box: Box, **kwargs) -> None: self._stripe_color: Color = _ZONE_COLORS["accent"] self._last_tick = time.monotonic() self._has_reading = False + self._pending: Box | None = None # ── drawing ────────────────────────────────────────────────────────────── @@ -253,29 +250,30 @@ def _paint_overlap(self, draw, sx: int, sw: int, rx0: int, rx1: int, y0: int, y1 if wx0 < wx1: draw.rectangle([wx0, y0, wx1 - 1, y1], fill=self._stripe_color) - # ── partial-column refresh ──────────────────────────────────────────────── + # ── batched partial-column refresh ───────────────────────────────────────── - def _refresh_col(self, x: int, w: int) -> None: - """Refresh a w-pixel-wide column at x (with wrap at _W), full widget height.""" - if w <= 0: - return + def _flush_spans(self, spans: list[tuple[int, int]]) -> None: + """Union (x, w) column spans (wrapping at _W) into self._pending. + tick() issues a single refresh() of the accumulated box each call.""" bx = self.box if bx is None: return - if x + w <= _W: - self.refresh(Box(x, bx.y0, x + w, bx.y1)) - else: - right_w = _W - x - if right_w > 0: - self.refresh(Box(x, bx.y0, _W, bx.y1)) - wrap_w = w - right_w - if wrap_w > 0: - self.refresh(Box(0, bx.y0, wrap_w, bx.y1)) - - def _refresh_stripes_at(self, phase_int: int) -> None: - for i in range(self.N_STRIPES): - sx = (phase_int + i * self.STRIPE_P) % _W - self._refresh_col(sx, self.STRIPE_W) + for x, w in spans: + if w <= 0: + continue + x %= _W + end = x + w + self._pending = ( + Box(x, bx.y0, min(end, _W), bx.y1) + if self._pending is None + else self._pending.union(Box(x, bx.y0, min(end, _W), bx.y1)) + ) + if end > _W: + wrap = Box(0, bx.y0, end - _W, bx.y1) + self._pending = self._pending.union(wrap) + + def _stripe_spans_at(self, phase_int: int) -> list[tuple[int, int]]: + return [((phase_int + i * self.STRIPE_P) % _W, self.STRIPE_W) for i in range(self.N_STRIPES)] # ── tick ───────────────────────────────────────────────────────────────── @@ -283,55 +281,52 @@ def tick(self, cents: float | None) -> None: now = time.monotonic() dt = min(now - self._last_tick, 0.5) self._last_tick = now + self._pending = None if cents is None: if self._has_reading: self._has_reading = False self._zone = "accent" self._stripe_color = _ZONE_COLORS["accent"] - self._refresh_stripes_at(int(self._phase)) - return - - if not self._has_reading: + self._flush_spans(self._stripe_spans_at(int(self._phase))) + elif not self._has_reading: self._has_reading = True - self._refresh_stripes_at(int(self._phase)) - return - - new_zone: Zone = _zone(cents) - if new_zone != self._zone: - self._zone = new_zone - self._stripe_color = _zone_color(cents) - self._refresh_stripes_at(int(self._phase)) - return - - if self._zone == "in_tune": - return # frozen — zero SPI writes - - K = (self.STRIPE_P / 50.0) * self.VELOCITY_SCALE - velocity = max(-50.0, min(50.0, cents)) * K - old_phase_int = int(self._phase) - self._phase = (self._phase + velocity * dt) % float(_W) - k = int(self._phase) - old_phase_int - - if k == 0: - return - - if abs(k) >= self.STRIPE_W: - self._refresh_stripes_at(old_phase_int) - self._refresh_stripes_at(int(self._phase)) - return - - ak = abs(k) - for i in range(self.N_STRIPES): - old_sx = (old_phase_int + i * self.STRIPE_P) % _W - if k > 0: - tail_x = old_sx - lead_x = (old_sx + self.STRIPE_W) % _W - else: - tail_x = (old_sx + self.STRIPE_W - ak) % _W - lead_x = (old_sx - ak) % _W - self._refresh_col(tail_x, ak) - self._refresh_col(lead_x, ak) + self._flush_spans(self._stripe_spans_at(int(self._phase))) + else: + new_zone: Zone = _zone(cents) + if new_zone != self._zone: + self._zone = new_zone + self._stripe_color = _ZONE_COLORS[new_zone] + self._flush_spans(self._stripe_spans_at(int(self._phase))) + elif self._zone != "in_tune": + K = (self.STRIPE_P / 50.0) * self.VELOCITY_SCALE + velocity = max(-50.0, min(50.0, cents)) * K + old_phase_int = int(self._phase) + self._phase = (self._phase + velocity * dt) % float(_W) + k = int(self._phase) - old_phase_int + + if k != 0: + if abs(k) >= self.STRIPE_W: + self._flush_spans( + self._stripe_spans_at(old_phase_int) + self._stripe_spans_at(int(self._phase)) + ) + else: + ak = abs(k) + spans: list[tuple[int, int]] = [] + for i in range(self.N_STRIPES): + old_sx = (old_phase_int + i * self.STRIPE_P) % _W + if k > 0: + tail_x = old_sx + lead_x = (old_sx + self.STRIPE_W) % _W + else: + tail_x = (old_sx + self.STRIPE_W - ak) % _W + lead_x = (old_sx - ak) % _W + spans.append((tail_x, ak)) + spans.append((lead_x, ak)) + self._flush_spans(spans) + + if self._pending is not None: + self.refresh(self._pending) # ── TunerPanel ─────────────────────────────────────────────────────────────── diff --git a/pistomp/tuner/yin.py b/pistomp/tuner/yin.py index 24cfd1658..4ea727ac7 100644 --- a/pistomp/tuner/yin.py +++ b/pistomp/tuner/yin.py @@ -15,6 +15,116 @@ class PitchEstimate: yin_error: float # CMND value at tau_est; 0 = perfect, approaching 1 = unreliable +class YinDetector: + """YIN pitch detector with pre-allocated scratch arrays. + + Implements: de Cheveigné & Kawahara (2002), J. Acoust. Soc. Am. 111(4). + """ + + def __init__( + self, + frame_size: int, + sample_rate: int, + threshold: float = 0.10, + freq_min: float = 30.0, + freq_max: float = 1300.0, + window: int | None = None, + ) -> None: + self._sample_rate = sample_rate + self._threshold = threshold + + N = frame_size + W = window if window is not None else N // 2 + self._W = W + self._N = N + self._tau_min = max(2, int(sample_rate / freq_max)) + self._tau_max = min(W - 1, int(sample_rate / freq_min) + 1) + self._n_fft = 1 << (W + N - 1).bit_length() + + # Pre-allocated scratch + self._x_sq_cs = np.empty(N + 1, dtype=np.float32) + self._a = np.zeros(self._n_fft, dtype=np.float32) # [W:] stays zero + self._tau_range = np.arange(self._tau_max + 1) # constant + self._taus = np.arange(1, self._tau_max + 1, dtype=np.float32) # constant + self._cmnd = np.ones(self._tau_max + 1, dtype=np.float32) + + def detect(self, frame: npt.NDArray[np.float32]) -> PitchEstimate | None: + tau_min = self._tau_min + tau_max = self._tau_max + if tau_min >= tau_max: + return None + + x = frame + W = self._W + x_sq_cs = self._x_sq_cs + a = self._a + tau_range = self._tau_range + taus = self._taus + cmnd = self._cmnd + + # Step 1: difference function via FFT. + # d(τ) = Σⱼ(x[j] - x[j+τ])² = E₀ + trailing(τ) - 2·xcorr(τ) + x_sq_cs[0] = 0.0 + np.cumsum(x * x, out=x_sq_cs[1:]) + E0 = x_sq_cs[W] + trailing = x_sq_cs[tau_range + W] - x_sq_cs[tau_range] + + # xcorr via FFT; n_fft must be >= W + N to avoid circular aliasing + a[:W] = x[:W] + # irfft(rfft(x)*conj(rfft(a)))[τ] = Σⱼ x[j+τ]·a[j] — positive-lag correlation + xcorr = np.fft.irfft(np.fft.rfft(x, n=self._n_fft) * np.fft.rfft(a).conj())[: tau_max + 1] + + diff = E0 + trailing - 2 * xcorr + diff[0] = 0.0 + + # Step 2: cumulative mean normalised difference (CMND), eq. 8 in the paper. + cumsum = np.cumsum(diff[1 : tau_max + 1]) + cmnd[0] = 1.0 + # where=False positions keep their previous value; cumsum==0 only on silence, + # which the engine's RMS gate filters before calling detect(). + np.divide(diff[1 : tau_max + 1] * taus, cumsum, out=cmnd[1 : tau_max + 1], where=cumsum > 0.0) + + # Step 3: absolute threshold — first dip below threshold, walk to its bottom. + # No argmin fallback: a reading that doesn't pass the threshold is not published. + hits = np.nonzero(cmnd[tau_min:tau_max] < self._threshold)[0] + if hits.size == 0: + return None + tau = tau_min + int(hits[0]) + while tau + 1 <= tau_max and cmnd[tau + 1] <= cmnd[tau]: + tau += 1 + tau_est = tau + + # Step 4: sub-sample period = cmnd-weighted centroid of the trough basin. The + # textbook 3-point parabola is degenerate on a flat trough bottom (two ~equal + # adjacent samples, true minimum between them): it snaps ±1 sample rather than + # landing between, a bistable ~12-cent waver at guitar-string frequencies. The + # centroid averages the basin; a sharp single-sample trough falls back to parabola. + cmin = cmnd[tau_est] + band = cmin + _TROUGH_BAND + lo = hi = tau_est + while lo - 1 >= tau_min and cmnd[lo - 1] <= band: + lo -= 1 + while hi + 1 <= tau_max and cmnd[hi + 1] <= band: + hi += 1 + + if hi > lo: + basin = np.arange(lo, hi + 1, dtype=np.float64) + weights = band - cmnd[lo : hi + 1] + tau_refined = float(np.sum(basin * weights) / np.sum(weights)) + elif tau_min < tau_est < tau_max: + s0, s1, s2 = cmnd[tau_est - 1], cmnd[tau_est], cmnd[tau_est + 1] + denom = 2.0 * (2.0 * s1 - s0 - s2) + correction = (s0 - s2) / denom if abs(denom) > 1e-10 else 0.0 + tau_refined = tau_est + (correction if abs(correction) < 1.0 else 0.0) + else: + tau_refined = float(tau_est) + + if tau_refined <= 0.0: + return None + + return PitchEstimate(freq=self._sample_rate / tau_refined, yin_error=float(cmin)) + + def detect_pitch( frame: npt.NDArray[np.float32], sample_rate: int, @@ -23,94 +133,13 @@ def detect_pitch( freq_max: float = 1300.0, window: int | None = None, ) -> PitchEstimate | None: - """YIN pitch detection. Returns PitchEstimate or None if no confident pitch found. - - Implements: de Cheveigné & Kawahara (2002), J. Acoust. Soc. Am. 111(4). - - window: explicit YIN correlation window W; defaults to len(frame)//2. - frame must be at least W + sample_rate/freq_min samples long. - """ - N = len(frame) - half = window if window is not None else N // 2 - - tau_min = max(2, int(sample_rate / freq_max)) - tau_max = min(half - 1, int(sample_rate / freq_min) + 1) - - if tau_min >= tau_max: - return None - - # Step 1: difference function via FFT. - # d(τ) = Σⱼ(x[j] - x[j+τ])² = E₀ + trailing(τ) - 2·xcorr(τ) - # where E₀ = Σx[0:W]², trailing(τ) = Σx[τ:τ+W]², xcorr(τ) = Σx[j]·x[j+τ] for j∈[0,W) - x = frame.astype(np.float64) - W = half - - x_sq_cs = np.empty(N + 1, dtype=np.float64) - x_sq_cs[0] = 0.0 - np.cumsum(x * x, out=x_sq_cs[1:]) - E0 = x_sq_cs[W] - tau_range = np.arange(tau_max + 1) - trailing = x_sq_cs[tau_range + W] - x_sq_cs[tau_range] - - # xcorr via FFT; n_fft must be >= W + N to avoid circular aliasing - n_fft = 1 << (W + N - 1).bit_length() - a = np.zeros(n_fft, dtype=np.float64) - a[:W] = x[:W] - # irfft(rfft(x)*conj(rfft(a)))[τ] = Σⱼ x[j+τ]·a[j] — positive-lag correlation - xcorr = np.fft.irfft(np.fft.rfft(x, n=n_fft) * np.fft.rfft(a).conj())[:tau_max + 1] - - diff = E0 + trailing - 2.0 * xcorr - diff[0] = 0.0 - - # Step 2: cumulative mean normalised difference (CMND), eq. 8 in the paper. - cumsum = np.cumsum(diff[1:tau_max + 1]) - taus = np.arange(1, tau_max + 1, dtype=np.float64) - cmnd = np.ones(tau_max + 1, dtype=np.float64) - cmnd[1:tau_max + 1] = 1.0 - np.divide(diff[1:tau_max + 1] * taus, cumsum, out=cmnd[1:tau_max + 1], where=cumsum > 0.0) - - # Step 3: absolute threshold — first dip below threshold, walk to its bottom. - # No argmin fallback: a reading that doesn't pass the threshold is not published. - tau_est = -1 - tau = tau_min - while tau < tau_max: - if cmnd[tau] < threshold: - while tau + 1 <= tau_max and cmnd[tau + 1] <= cmnd[tau]: - tau += 1 - tau_est = tau - break - tau += 1 - - if tau_est < 1: - return None - - # Step 4: sub-sample period = cmnd-weighted centroid of the trough basin. The - # textbook 3-point parabola is degenerate on a flat trough bottom (two ~equal - # adjacent samples, true minimum between them): it snaps ±1 sample rather than - # landing between, a bistable ~12-cent waver at guitar-string frequencies. The - # centroid averages the basin; a sharp single-sample trough falls back to parabola. - cmin = cmnd[tau_est] - band = cmin + _TROUGH_BAND - lo = hi = tau_est - while lo - 1 >= tau_min and cmnd[lo - 1] <= band: - lo -= 1 - while hi + 1 <= tau_max and cmnd[hi + 1] <= band: - hi += 1 - - if hi > lo: - basin = np.arange(lo, hi + 1, dtype=np.float64) - weights = band - cmnd[lo:hi + 1] # >= 0 by construction; peaks at the minimum - tau_refined = float(np.sum(basin * weights) / np.sum(weights)) - elif tau_min < tau_est < tau_max: - # Single-sample basin (ultra-sharp trough): fall back to the 3-point parabola. - s0, s1, s2 = cmnd[tau_est - 1], cmnd[tau_est], cmnd[tau_est + 1] - denom = 2.0 * (2.0 * s1 - s0 - s2) - correction = (s0 - s2) / denom if abs(denom) > 1e-10 else 0.0 - tau_refined = tau_est + (correction if abs(correction) < 1.0 else 0.0) - else: - tau_refined = float(tau_est) - - if tau_refined <= 0.0: - return None - - return PitchEstimate(freq=sample_rate / tau_refined, yin_error=cmin) + """Convenience wrapper — creates a fresh YinDetector per call. Use YinDetector + directly when calling repeatedly on frames of a fixed size.""" + return YinDetector( + len(frame), + sample_rate, + threshold=threshold, + freq_min=freq_min, + freq_max=freq_max, + window=window, + ).detect(frame) diff --git a/tests/test_strobe_flush.py b/tests/test_strobe_flush.py new file mode 100644 index 000000000..1e6ea7a7a --- /dev/null +++ b/tests/test_strobe_flush.py @@ -0,0 +1,148 @@ +"""Unit tests for StrobeWidget._flush_spans — the per-tick refresh coalescer. + +_flush_spans takes a list of (x, width) column spans (which may wrap past _W), +splits wraps, sorts, merges spans within _MERGE_GAP of each other, and accumulates +the result into self._pending via Box.union(). tick() then issues a single +self.refresh(_pending). These tests exercise _flush_spans in isolation by inspecting +_pending after each call. +""" + +from uilib.box import Box +from pistomp.tuner.panel import StrobeWidget, _W + +STROBE_BOX = Box.xywh(0, 81, 320, 127) # mirrors TunerPanel's strobe geometry + + +def make_widget(): + w = StrobeWidget(box=STROBE_BOX.copy()) + return w + + +def pending_x(w): + """Return _pending as (x0, x1) verifying full height, or None.""" + if w._pending is None: + return None + b = w._pending + assert b.y0 == STROBE_BOX.y0 + assert b.y1 == STROBE_BOX.y1 + return (b.x0, b.x1) + + +# ── degenerate inputs ────────────────────────────────────────────────────────── + + +def test_empty_spans_emit_nothing(): + w = make_widget() + w._flush_spans([]) + assert w._pending is None + + +def test_zero_and_negative_width_skipped(): + w = make_widget() + w._flush_spans([(10, 0), (20, -5)]) + assert w._pending is None + + +def test_box_none_emits_nothing(): + w = make_widget() + w.box = None + w._flush_spans([(10, 4)]) + assert w._pending is None + + +# ── single span ──────────────────────────────────────────────────────────────── + + +def test_single_span_one_full_height_box(): + w = make_widget() + w._flush_spans([(40, 6)]) + assert pending_x(w) == (40, 46) + + +# ── coalescing ───────────────────────────────────────────────────────────────── + + +def test_overlapping_spans_coalesce(): + w = make_widget() + w._flush_spans([(10, 8), (14, 8)]) # [10,18) and [14,22) overlap + assert pending_x(w) == (10, 22) + + +def test_contained_span_absorbed(): + w = make_widget() + w._flush_spans([(10, 20), (14, 2)]) # [14,16) inside [10,30) + assert pending_x(w) == (10, 30) + + +def test_adjacent_spans_union(): + w = make_widget() + w._flush_spans([(10, 2), (16, 2)]) # [10,12) and [16,18) + assert pending_x(w) == (10, 18) + + +def test_separated_spans_union_into_one_pending(): + w = make_widget() + w._flush_spans([(10, 2), (100, 2)]) # far apart + assert pending_x(w) == (10, 102) + + +def test_unsorted_input_covered_by_pending(): + w = make_widget() + w._flush_spans([(200, 4), (10, 2), (12, 2)]) + x0, x1 = pending_x(w) + assert x0 <= 10 and x1 >= 204 + + +# ── wrap handling ────────────────────────────────────────────────────────────── + + +def test_wrap_splits_into_two_runs(): + # [318,323) -> [318,320) + [0,3); pending is their union (full width) + w = make_widget() + w._flush_spans([(_W - 2, 5)]) + assert pending_x(w) == (0, _W) + + +def test_wrap_remainder_coalesces_with_low_span(): + w = make_widget() + w._flush_spans([(_W - 2, 4), (1, 2)]) + assert pending_x(w) == (0, _W) + + +def test_wrap_tail_and_right_edge_in_pending(): + w = make_widget() + w._flush_spans([(_W - 1, 2)]) # [319,321) -> [319,320) + [0,1) + assert pending_x(w) == (0, _W) + + +# ── realistic strobe patterns ────────────────────────────────────────────────── + + +def test_stripe_tail_and_lead_in_pending(): + w = make_widget() + ak = 2 + x = 50 + w._flush_spans([(x, ak), (x + StrobeWidget.STRIPE_W, ak)]) + x0, x1 = pending_x(w) + assert x0 <= x and x1 >= x + StrobeWidget.STRIPE_W + ak + + +def test_six_spaced_stripes_union_into_one_pending(): + # 6 stripes all end up in one _pending box covering all of them + w = make_widget() + spans = w._stripe_spans_at(0) + w._flush_spans(spans) + x0, x1 = pending_x(w) + sw = StrobeWidget.STRIPE_W + assert x0 == 0 + assert x1 == (StrobeWidget.N_STRIPES - 1) * StrobeWidget.STRIPE_P + sw + + +def test_flush_spans_does_not_call_refresh_directly(): + # refresh is called once by tick(), never by _flush_spans + w = make_widget() + calls = [] + w.refresh = lambda b=None: calls.append(b) + w._flush_spans([(40, 6)]) + assert calls == [] + assert w._pending is not None From 1d59e5c0d497b44bb9a04b67174ad7399825513e Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 21 Jun 2026 14:20:19 -0400 Subject: [PATCH 2/3] 50fps --- modalapi/modhandler.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 96554bb41..b65acdd53 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -277,13 +277,10 @@ def poll_lcd_updates(self): @property def lcd_poll_divisor(self) -> int: - # Tick the LCD on every 10 ms main-loop pass (~100 fps) while the - # tuner panel is mounted. Strobe's worst-case redraw at STRIPE_W=4 - # is ~4.3 ms of SPI, well inside the 10 ms budget; typical ticks - # are sub-millisecond. Otherwise fall back to the SPI-clock-derived - # divisor computed by the LCD itself. + # 50 fps (every other 10 ms tick) while the tuner is active — fast enough + # for smooth strobe animation and leaves headroom for SPI transfers. if self._tuner_panel is not None: - return 1 + return 2 return self._lcd.poll_divisor if self._lcd is not None else 8 def universal_encoder_select(self, direction): From fdd5657c7e20ddaee633d065b126ae854fd55daf Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 21 Jun 2026 14:21:54 -0400 Subject: [PATCH 3/3] Revert "Don't show v2 tuner" This reverts commit 0ee91c14b433df277a5ee6c6d218c8376cc7e038. --- modalapi/modhandler.py | 5 +---- pistomp/handlerfactory.py | 1 - pistomp/lcd320x240.py | 7 +++---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 4c42320f9..b65acdd53 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -107,8 +107,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") self.ws_bridge.start() logging.info("WebSocket bridge started") - # Tuner state (may be disabled on slow hardware) - self.tuner_supported: bool = True + # Tuner state self._tuner_engine: TunerEngine | None = None self._tuner_panel: TunerPanel | None = None self._tuner_source_factory: TunerSourceFactory | None = None @@ -1047,8 +1046,6 @@ def _tuner_factory(self, port: str) -> AudioSource: return factory(port, name=f"pistomp-tuner-{port.split('_')[-1]}") def toggle_tuner_enable(self, *argv) -> None: - if not self.tuner_supported: - return if self._tuner_engine is None: muted = bool(self.settings.get_setting(Token.TUNER_MUTE)) input_port = int(self.settings.get_setting(Token.TUNER_INPUT) or 1) diff --git a/pistomp/handlerfactory.py b/pistomp/handlerfactory.py index 1c63a6ba7..c1a75b9e5 100644 --- a/pistomp/handlerfactory.py +++ b/pistomp/handlerfactory.py @@ -38,7 +38,6 @@ def create(self, cfg, audiocard, cwd): handler = Mod.Mod(audiocard, cwd) elif (version >= 2.0) and (version < 3.0): handler = Modhandler.Modhandler(audiocard, cwd) - handler.tuner_supported = False elif (version >= 3.0) and (version < 4.0): handler = Modhandler.Modhandler(audiocard, cwd) else: diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index ef3b1349a..ae9d898e7 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -545,10 +545,9 @@ def get_footswitch_pitch(self): # System Menu # def draw_system_menu(self, event, widget): - items = [("System info", self.draw_system_info_dialog, None)] - if self.handler and self.handler.tuner_supported: - items.append(("Tuner", self._toggle_tuner_from_menu, None)) - items += [("System shutdown", self.handler.system_menu_shutdown, None), + items = [("System info", self.draw_system_info_dialog, None), + ("Tuner", self._toggle_tuner_from_menu, None), + ("System shutdown", self.handler.system_menu_shutdown, None), ("System reboot", self.handler.system_menu_reboot, None), ("Restart sound engine", self.handler.system_menu_restart_sound, None), ("Bank Select >", self.draw_bank_menu, None),