#!/usr/bin/env python3 """ Arrive Audio Test Console Framebuffer PyQt5 UI for IMX8 SoM + SGTL5000 Display: 1280x800 RGB landscape, Debian 12 custom kernel """ import sys import os import subprocess import threading import time import math import struct import wave import tempfile import io # ── Force framebuffer QPA before any Qt import ──────────────────────────────── if 'DISPLAY' not in os.environ and 'QT_QPA_PLATFORM' not in os.environ: os.environ['QT_QPA_PLATFORM'] = 'linuxfb' from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QSizePolicy, ) from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject from PyQt5.QtGui import QFont, QFontDatabase, QPalette, QColor, QPixmap, QImage # ── Arrive brand palette (official) ────────────────────────────────────────── C_BG = "#5F016F" # P1 — deep purple C_PANEL = "#720180" # P1 lightened — card / panel C_PINK = "#FF80D4" # P3 — medium pink, inactive text/buttons C_PINK_HOT = "#FF33BB" # P2 — hot pink, active / pressed state C_PINK_LIGHT = "#FFADE4" # P4 — light pink, accents C_GREY = "#F9F5F4" # Off-White — secondary labels C_WHITE = "#FFFFFF" # ── Custom font (set after QApplication is created) ────────────────────────── _FONT_FAMILY = "Sans Serif" # overwritten at startup if OptimismSans loads # ── Audio constants ─────────────────────────────────────────────────────────── SAMPLE_RATE = 44100 FREQUENCY = 1000 # Hz BLOCK_FRAMES = 512 AMPLITUDE = 0.85 # 0.0–1.0 (leave headroom) # ── Audio backend detection ─────────────────────────────────────────────────── try: import sounddevice as sd _BACKEND = 'sounddevice' except (ImportError, OSError): try: import pyaudio _BACKEND = 'pyaudio' except ImportError: _BACKEND = None try: import numpy as np _HAS_NUMPY = True except ImportError: _HAS_NUMPY = False # ── BD2802GU LED via kernel sysfs LED class ─────────────────────────────────── _LED_MULTI_PATH = '/sys/class/leds/display-indicator/multi_intensity' _LED_TRIG_PATH = '/sys/class/leds/display-indicator/trigger' _LED_BR_PATH = '/sys/class/leds/display-indicator/brightness' _HAS_SYSFS_LED = os.path.exists(_LED_MULTI_PATH) # Arrive brand colours for knight-rider pulse _LED_PURPLE = (0x5F, 0x01, 0x6F) # #5F016F deep purple _LED_PINK = (0xFF, 0x33, 0xBB) # #FF33BB hot pink # ───────────────────────────────────────────────────────────────────────────── # Audio Engine # ───────────────────────────────────────────────────────────────────────────── class _Signals(QObject): status = pyqtSignal(str) class AudioEngine: """ Continuous waveform generator. Supports sounddevice (preferred), pyaudio, or aplay fallback. """ def __init__(self): self.signals = _Signals() self._mode = None # 'sine' | 'sawtooth' | None self._phase = 0 self._lock = threading.Lock() self._stream = None # sounddevice / pyaudio stream self._pa = None # pyaudio instance self._volume = 70 # logical 0–100 self._backend = _BACKEND if self._backend == 'pyaudio': self._pa = pyaudio.PyAudio() self._alsa_init() self._alsa_volume(self._volume) # ── Public API ──────────────────────────────────────────────────────────── def play(self, mode: str): with self._lock: if self._mode == mode: return self._mode = mode self._phase = 0 if self._stream is None: self._open_stream() def stop(self): self._close_stream() with self._lock: self._mode = None self._phase = 0 def set_volume(self, level: int): level = max(0, min(100, level)) self._volume = level self._alsa_volume(level) def get_volume(self) -> int: return self._volume def current_mode(self): with self._lock: return self._mode def shutdown(self): self.stop() if self._pa is not None: self._pa.terminate() # ── Stream management ───────────────────────────────────────────────────── def _open_stream(self): if self._backend == 'sounddevice': self._stream = sd.OutputStream( samplerate=SAMPLE_RATE, channels=1, dtype='float32', blocksize=BLOCK_FRAMES, callback=self._sd_callback, ) self._stream.start() elif self._backend == 'pyaudio': self._stream = self._pa.open( rate=SAMPLE_RATE, channels=1, format=pyaudio.paInt16, output=True, frames_per_buffer=BLOCK_FRAMES, stream_callback=self._pa_callback, ) self._stream.start_stream() def _close_stream(self): s = self._stream self._stream = None if s is None: return try: if self._backend == 'sounddevice': s.stop() s.close() elif self._backend == 'pyaudio': s.stop_stream() s.close() except Exception: pass # ── Callbacks ───────────────────────────────────────────────────────────── def _generate(self, frames: int): """Return numpy float32 array of the current waveform.""" with self._lock: mode = self._mode phase = self._phase if _HAS_NUMPY: t = (np.arange(frames, dtype=np.float64) + phase) / SAMPLE_RATE if mode == 'sine': wave = AMPLITUDE * np.sin(2.0 * math.pi * FREQUENCY * t) elif mode == 'sawtooth': wave = AMPLITUDE * (2.0 * ((FREQUENCY * t) % 1.0) - 1.0) else: wave = np.zeros(frames, dtype=np.float64) buf = wave.astype(np.float32) else: buf_list = [] for i in range(frames): tt = (i + phase) / SAMPLE_RATE if mode == 'sine': v = AMPLITUDE * math.sin(2.0 * math.pi * FREQUENCY * tt) elif mode == 'sawtooth': v = AMPLITUDE * (2.0 * ((FREQUENCY * tt) % 1.0) - 1.0) else: v = 0.0 buf_list.append(v) buf = buf_list with self._lock: self._phase = (phase + frames) % (SAMPLE_RATE * 10) return buf def _sd_callback(self, outdata, frames, time_info, status): buf = self._generate(frames) if _HAS_NUMPY: outdata[:, 0] = buf else: for i, v in enumerate(buf): outdata[i, 0] = v def _pa_callback(self, in_data, frame_count, time_info, status): buf = self._generate(frame_count) if _HAS_NUMPY: raw = (buf * 32767).astype(np.int16).tobytes() else: raw = struct.pack(f'{len(buf)}h', *[int(v * 32767) for v in buf]) return (raw, pyaudio.paContinue) # ── ALSA helpers ────────────────────────────────────────────────────────── @staticmethod def _alsa_init(): """Set SGTL5000 routing and unmute outputs once at startup.""" cmds = [ ['amixer', '-c', '0', 'sset', 'Headphone Mux', 'DAC'], ['amixer', '-c', '0', 'sset', 'DAP Mux', 'I2S'], ['amixer', '-c', '0', 'sset', 'Headphone', 'unmute'], ['amixer', '-c', '0', 'sset', 'Lineout', 'unmute'], ] for cmd in cmds: try: subprocess.run(cmd, capture_output=True, timeout=2) except Exception: pass @staticmethod def _alsa_volume(level: int): controls = ['Headphone', 'Lineout', 'PCM', 'Master', 'DAC', 'Speaker', 'HP Analog'] for ctl in controls: try: subprocess.run( ['amixer', '-q', 'sset', ctl, f'{level}%'], capture_output=True, timeout=2 ) except Exception: pass # ───────────────────────────────────────────────────────────────────────────── # BD2802GU LED controller # ───────────────────────────────────────────────────────────────────────────── class LedController: """Knight-rider purple/pink pulse via kernel sysfs LED class (BD2802GU).""" def __init__(self): self._running = False self._thread = None if not _HAS_SYSFS_LED: print('[LED] display-indicator sysfs node not found') return try: with open(_LED_TRIG_PATH, 'w') as f: f.write('none') with open(_LED_BR_PATH, 'w') as f: f.write('255') except Exception as e: print(f'[LED] init error: {e}') def _set_color(self, r, g, b): try: with open(_LED_MULTI_PATH, 'w') as f: f.write(f'{r} {g} {b}') except Exception: pass def start(self): if not _HAS_SYSFS_LED: return self._running = True self._thread = threading.Thread(target=self._knight_rider, daemon=True) self._thread.start() def stop(self): self._running = False if self._thread: self._thread.join(timeout=2.0) self._thread = None try: self._set_color(0, 0, 0) except Exception: pass def _knight_rider(self): STEPS = 30 STEP_S = 0.08 while self._running: for i in range(STEPS): if not self._running: return t = i / STEPS r = int(_LED_PURPLE[0] + (_LED_PINK[0] - _LED_PURPLE[0]) * t) g = int(_LED_PURPLE[1] + (_LED_PINK[1] - _LED_PURPLE[1]) * t) b = int(_LED_PURPLE[2] + (_LED_PINK[2] - _LED_PURPLE[2]) * t) self._set_color(r, g, b) time.sleep(STEP_S) for i in range(STEPS): if not self._running: return t = i / STEPS r = int(_LED_PINK[0] + (_LED_PURPLE[0] - _LED_PINK[0]) * t) g = int(_LED_PINK[1] + (_LED_PURPLE[1] - _LED_PINK[1]) * t) b = int(_LED_PINK[2] + (_LED_PURPLE[2] - _LED_PINK[2]) * t) self._set_color(r, g, b) time.sleep(STEP_S) # ───────────────────────────────────────────────────────────────────────────── # Qt stylesheet helpers # ───────────────────────────────────────────────────────────────────────────── def _wave_btn_style(active: bool = False) -> str: if active: bg, fg, border = C_PINK_HOT, C_BG, C_PINK_HOT else: bg, fg, border = C_PANEL, C_PINK, C_PINK return f""" QPushButton {{ background-color: {bg}; color: {fg}; border: 2px solid {border}; border-radius: 18px; font-size: 28px; font-weight: bold; }} QPushButton:pressed {{ background-color: {C_PINK_HOT}; color: {C_BG}; border: 2px solid {C_PINK_HOT}; }} """ def _vol_btn_style() -> str: return f""" QPushButton {{ background-color: {C_PANEL}; color: {C_PINK}; border: 2px solid {C_PINK}; border-radius: 18px; font-size: 48px; font-weight: bold; }} QPushButton:pressed {{ background-color: {C_PINK_HOT}; color: {C_BG}; border: 2px solid {C_PINK_HOT}; }} """ # ───────────────────────────────────────────────────────────────────────────── # Autocrop helper # ───────────────────────────────────────────────────────────────────────────── def _autocrop(img): """Crop a QImage (ARGB32) to the bounding box of non-transparent pixels.""" if not _HAS_NUMPY: return img w, h = img.width(), img.height() b = img.bits() b.setsize(h * w * 4) arr = np.frombuffer(b, dtype=np.uint8).reshape(h, w, 4) alpha = arr[:, :, 3] rows = np.where(alpha.max(axis=1) > 5)[0] cols = np.where(alpha.max(axis=0) > 5)[0] if len(rows) == 0 or len(cols) == 0: return img pad = 8 r0 = max(0, int(rows[0]) - pad) r1 = min(h, int(rows[-1]) + pad + 1) c0 = max(0, int(cols[0]) - pad) c1 = min(w, int(cols[-1]) + pad + 1) return img.copy(c0, r0, c1 - c0, r1 - r0) # ───────────────────────────────────────────────────────────────────────────── # Volume bar widget (simple segmented indicator) # ───────────────────────────────────────────────────────────────────────────── from PyQt5.QtWidgets import QWidget as _QW from PyQt5.QtGui import QPainter, QColor as _QC class VolumeBar(_QW): SEGMENTS = 20 def __init__(self, parent=None): super().__init__(parent) self._level = 70 self.setMinimumSize(280, 56) def set_level(self, v: int): self._level = max(0, min(100, v)) self.update() def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w, h = self.width(), self.height() seg_w = (w - self.SEGMENTS) // self.SEGMENTS filled = round(self._level / 100 * self.SEGMENTS) for i in range(self.SEGMENTS): x = i * (seg_w + 1) colour = _QC(C_PINK_HOT) if i < filled else _QC(C_PANEL) p.fillRect(x, 6, seg_w, h - 12, colour) p.end() # ───────────────────────────────────────────────────────────────────────────── # Main window # ───────────────────────────────────────────────────────────────────────────── class MainWindow(QMainWindow): SCREEN_W = 1280 SCREEN_H = 800 def __init__(self): super().__init__() self.audio = AudioEngine() self.audio.signals.status.connect(self._on_audio_status) self._led = LedController() self._led.start() self._build_ui() self._set_bg(C_BG) # ── UI construction ─────────────────────────────────────────────────────── def _build_ui(self): self.setWindowTitle("Arrive") self.setFixedSize(self.SCREEN_W, self.SCREEN_H) central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(24, 10, 24, 10) root.setSpacing(6) root.addLayout(self._header()) root.addWidget(self._divider()) root.addSpacing(8) root.addLayout(self._waveform_section()) root.addSpacing(16) root.addLayout(self._volume_section()) root.addStretch() root.addWidget(self._divider()) root.addLayout(self._status_bar()) def _header(self) -> QHBoxLayout: row = QHBoxLayout() row.setSpacing(16) # Logo image logo_lbl = QLabel() logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'arrive_logo.png') if not os.path.exists(logo_path): logo_path = os.path.expanduser('~/arrive_logo.png') pix = QPixmap() if os.path.exists(logo_path): img = QImage(logo_path).convertToFormat(QImage.Format_ARGB32) if not img.isNull(): img = _autocrop(img) pix = QPixmap.fromImage(img) if not pix.isNull(): pix = pix.scaledToHeight(110, Qt.SmoothTransformation) logo_lbl.setPixmap(pix) else: logo_lbl.setText("Arrive") logo_lbl.setFont(QFont(_FONT_FAMILY, 52, QFont.Bold)) logo_lbl.setStyleSheet(f"color: {C_PINK};") sub = QLabel("Audio Test Console") sub.setFont(QFont(_FONT_FAMILY, 18)) sub.setStyleSheet(f"color: {C_GREY};") sub.setAlignment(Qt.AlignBottom | Qt.AlignLeft) btn_quit = QPushButton("X") btn_quit.setFixedSize(64, 64) btn_quit.setFocusPolicy(Qt.NoFocus) btn_quit.setStyleSheet(f""" QPushButton {{ background-color: {C_PANEL}; color: {C_PINK}; border: 2px solid {C_PINK}; border-radius: 18px; font-size: 26px; font-weight: bold; }} QPushButton:pressed {{ background-color: {C_PINK_HOT}; color: {C_BG}; }} """) btn_quit.clicked.connect(self.close) row.addWidget(logo_lbl, 0, Qt.AlignVCenter) row.addStretch(1) row.addWidget(sub, 0, Qt.AlignVCenter) row.addStretch(2) row.addWidget(btn_quit, 0, Qt.AlignVCenter) return row def _waveform_section(self) -> QVBoxLayout: col = QVBoxLayout() col.setSpacing(12) lbl = QLabel("WAVEFORM OUTPUT") lbl.setFont(QFont(_FONT_FAMILY, 14)) lbl.setStyleSheet(f"color: {C_GREY}; letter-spacing: 3px;") col.addWidget(lbl) row = QHBoxLayout() row.setSpacing(20) self.btn_sine = QPushButton("SINE 1 kHz") self.btn_saw = QPushButton("SAWTOOTH 1 kHz") self.btn_stop = QPushButton("STOP") for btn in (self.btn_sine, self.btn_saw, self.btn_stop): btn.setMinimumHeight(110) btn.setStyleSheet(_wave_btn_style(False)) btn.setFocusPolicy(Qt.NoFocus) self.btn_sine.clicked.connect(lambda: self._play('sine')) self.btn_saw.clicked.connect(lambda: self._play('sawtooth')) self.btn_stop.clicked.connect(self._stop) row.addWidget(self.btn_sine) row.addWidget(self.btn_saw) row.addWidget(self.btn_stop) col.addLayout(row) return col def _volume_section(self) -> QVBoxLayout: col = QVBoxLayout() col.setSpacing(12) lbl = QLabel("SPEAKER VOLUME") lbl.setFont(QFont(_FONT_FAMILY, 14)) lbl.setStyleSheet(f"color: {C_GREY}; letter-spacing: 3px;") col.addWidget(lbl) row = QHBoxLayout() row.setSpacing(20) self.btn_dn = QPushButton("-") self.btn_up = QPushButton("+") for btn in (self.btn_dn, self.btn_up): btn.setFixedSize(110, 90) btn.setStyleSheet(_vol_btn_style()) btn.setFocusPolicy(Qt.NoFocus) self.vol_num = QLabel(f"{self.audio.get_volume()}%") self.vol_num.setAlignment(Qt.AlignCenter) self.vol_num.setFont(QFont(_FONT_FAMILY, 48, QFont.Bold)) self.vol_num.setStyleSheet(f"color: {C_PINK};") self.vol_num.setMinimumWidth(140) self.vol_bar = VolumeBar() self.vol_bar.set_level(self.audio.get_volume()) self.vol_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.vol_bar.setFixedHeight(90) centre = QVBoxLayout() centre.setSpacing(8) centre.addWidget(self.vol_num, 0, Qt.AlignCenter) centre.addWidget(self.vol_bar) self.btn_dn.clicked.connect(lambda: self._change_vol(-5)) self.btn_up.clicked.connect(lambda: self._change_vol(+5)) self._vol_timer = QTimer() self._vol_timer.setInterval(150) self.btn_dn.pressed.connect(lambda: self._start_vol_repeat(-5)) self.btn_up.pressed.connect(lambda: self._start_vol_repeat(+5)) self.btn_dn.released.connect(self._stop_vol_repeat) self.btn_up.released.connect(self._stop_vol_repeat) row.addWidget(self.btn_dn) row.addLayout(centre) row.addWidget(self.btn_up) col.addLayout(row) return col def _status_bar(self) -> QHBoxLayout: row = QHBoxLayout() self.status_lbl = QLabel("Ready · SGTL5000 / ALSA") self.status_lbl.setFont(QFont(_FONT_FAMILY, 13)) self.status_lbl.setStyleSheet(f"color: {C_GREY};") backend_lbl = QLabel(f"backend: {_BACKEND or 'none'}") backend_lbl.setFont(QFont("Monospace", 11)) backend_lbl.setStyleSheet(f"color: {C_GREY};") backend_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) row.addWidget(self.status_lbl) row.addStretch() row.addWidget(backend_lbl) return row # ── Helpers ─────────────────────────────────────────────────────────────── def _divider(self) -> QFrame: f = QFrame() f.setFrameShape(QFrame.HLine) f.setFixedHeight(1) f.setStyleSheet(f"background-color: {C_PANEL}; border: none;") return f def _set_bg(self, colour: str): pal = QPalette() pal.setColor(QPalette.Window, QColor(colour)) self.setPalette(pal) self.setAutoFillBackground(True) def _refresh_wave_buttons(self, active_mode): self.btn_sine.setStyleSheet(_wave_btn_style(active_mode == 'sine')) self.btn_saw.setStyleSheet(_wave_btn_style(active_mode == 'sawtooth')) self.btn_stop.setStyleSheet(_wave_btn_style(active_mode is None)) # ── Slots ───────────────────────────────────────────────────────────────── def _play(self, mode: str): if _BACKEND is None: self.status_lbl.setText("No audio backend — install sounddevice or pyaudio") return self.audio.play(mode) label = "Sine 1 kHz" if mode == 'sine' else "Sawtooth 1 kHz" self.status_lbl.setText(f"Playing {label} ▶") self._refresh_wave_buttons(mode) def _stop(self): self.audio.stop() self.status_lbl.setText("Stopped") self._refresh_wave_buttons(None) def _change_vol(self, delta: int): new_v = self.audio.get_volume() + delta self.audio.set_volume(new_v) self.vol_num.setText(f"{self.audio.get_volume()}%") self.vol_bar.set_level(self.audio.get_volume()) self.status_lbl.setText(f"Volume {self.audio.get_volume()}%") def _start_vol_repeat(self, delta: int): self._vol_delta = delta self._change_vol(delta) self._vol_timer.timeout.connect(lambda: self._change_vol(self._vol_delta)) self._vol_timer.start() def _stop_vol_repeat(self): self._vol_timer.stop() try: self._vol_timer.timeout.disconnect() except TypeError: pass def _on_audio_status(self, msg: str): self.status_lbl.setText(msg) def set_fb_snapshot(self, data: bytes): self._fb_snapshot = data def closeEvent(self, event): self._led.stop() self.audio.shutdown() super().closeEvent(event) try: snap = getattr(self, '_fb_snapshot', None) if snap: with open('/dev/fb0', 'wb') as fb: fb.write(snap) except Exception: pass # ───────────────────────────────────────────────────────────────────────────── # Entry point # ───────────────────────────────────────────────────────────────────────────── if __name__ == '__main__': _fb_snapshot = None try: with open('/dev/fb0', 'rb') as fb: _fb_snapshot = fb.read(1280 * 800 * 4) except Exception: pass app = QApplication(sys.argv) app.setStyle('Fusion') for _font_file in ('OptimismSans.ttf', 'OptimismSans.otf'): _font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), _font_file) if not os.path.exists(_font_path): _font_path = os.path.expanduser(f'~/{_font_file}') _fid = QFontDatabase.addApplicationFont(_font_path) if _fid >= 0: _families = QFontDatabase.applicationFontFamilies(_fid) if _families: _FONT_FAMILY = _families[0] break app.setOverrideCursor(Qt.BlankCursor) win = MainWindow() if _fb_snapshot: win.set_fb_snapshot(_fb_snapshot) win.showFullScreen() # fills 1280×800 framebuffer sys.exit(app.exec_())