#!/usr/bin/env python3 """ Arrive Audio Test Console Framebuffer PyQt5 UI for IMX6 SoM (Digi CC-MX-L77C-Z1) + SGTL5000 Display: 800x480 RGB, Debian 12 custom kernel """ import sys import os import subprocess import threading 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: try: import pyaudio _BACKEND = 'pyaudio' except ImportError: _BACKEND = None try: import numpy as np _HAS_NUMPY = True except ImportError: _HAS_NUMPY = False # ───────────────────────────────────────────────────────────────────────────── # 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: # Pure-Python fallback (slow, but works without numpy) 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 # list of floats 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 volume ─────────────────────────────────────────────────────────── @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): """ Try common SGTL5000 / i.MX6 ALSA mixer control names in order. Run `amixer -c 0 scontrols` on the device to find the right name. """ 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 # ───────────────────────────────────────────────────────────────────────────── # 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: 14px; font-size: 20px; 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: 14px; font-size: 36px; font-weight: bold; }} QPushButton:pressed {{ background-color: {C_PINK_HOT}; color: {C_BG}; border: 2px solid {C_PINK_HOT}; }} """ 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 = 6 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(200, 40) 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, 4, seg_w, h - 8, colour) p.end() # ───────────────────────────────────────────────────────────────────────────── # Main window # ───────────────────────────────────────────────────────────────────────────── class MainWindow(QMainWindow): SCREEN_W = 800 SCREEN_H = 480 def __init__(self): super().__init__() self.audio = AudioEngine() self.audio.signals.status.connect(self._on_audio_status) 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(16, 6, 16, 6) root.setSpacing(4) root.addLayout(self._header()) root.addWidget(self._divider()) root.addSpacing(4) root.addLayout(self._waveform_section()) root.addSpacing(10) root.addLayout(self._volume_section()) root.addStretch() root.addWidget(self._divider()) root.addLayout(self._status_bar()) def _header(self) -> QHBoxLayout: row = QHBoxLayout() row.setSpacing(12) # 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(80, Qt.SmoothTransformation) logo_lbl.setPixmap(pix) else: logo_lbl.setText("Arrive") logo_lbl.setFont(QFont(_FONT_FAMILY, 38, QFont.Bold)) logo_lbl.setStyleSheet(f"color: {C_PINK};") sub = QLabel("Audio Test Console") sub.setFont(QFont(_FONT_FAMILY, 13)) sub.setStyleSheet(f"color: {C_GREY};") sub.setAlignment(Qt.AlignBottom | Qt.AlignLeft) btn_quit = QPushButton("X") btn_quit.setFixedSize(48, 48) btn_quit.setFocusPolicy(Qt.NoFocus) btn_quit.setStyleSheet(f""" QPushButton {{ background-color: {C_PANEL}; color: {C_PINK}; border: 2px solid {C_PINK}; border-radius: 14px; font-size: 20px; 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(8) lbl = QLabel("WAVEFORM OUTPUT") lbl.setFont(QFont(_FONT_FAMILY, 10)) lbl.setStyleSheet(f"color: {C_GREY}; letter-spacing: 3px;") col.addWidget(lbl) row = QHBoxLayout() row.setSpacing(14) 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(72) 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(8) lbl = QLabel("SPEAKER VOLUME") lbl.setFont(QFont(_FONT_FAMILY, 10)) lbl.setStyleSheet(f"color: {C_GREY}; letter-spacing: 3px;") col.addWidget(lbl) row = QHBoxLayout() row.setSpacing(14) self.btn_dn = QPushButton("-") self.btn_up = QPushButton("+") for btn in (self.btn_dn, self.btn_up): btn.setFixedSize(80, 64) btn.setStyleSheet(_vol_btn_style()) btn.setFocusPolicy(Qt.NoFocus) # Numeric readout self.vol_num = QLabel(f"{self.audio.get_volume()}%") self.vol_num.setAlignment(Qt.AlignCenter) self.vol_num.setFont(QFont(_FONT_FAMILY, 32, QFont.Bold)) self.vol_num.setStyleSheet(f"color: {C_PINK};") self.vol_num.setMinimumWidth(100) # Segmented bar 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(64) centre = QVBoxLayout() centre.setSpacing(6) 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)) # Hold-to-repeat via QTimer 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, 10)) self.status_lbl.setStyleSheet(f"color: {C_GREY};") backend_lbl = QLabel(f"backend: {_BACKEND or 'none'}") backend_lbl.setFont(QFont("Monospace", 9)) 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.audio.shutdown() super().closeEvent(event) # Restore the framebuffer to what it looked like before Qt launched 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__': # Snapshot the framebuffer before Qt overwrites it _fb_snapshot = None try: with open('/dev/fb0', 'rb') as fb: _fb_snapshot = fb.read(800 * 480 * 4) except Exception: pass app = QApplication(sys.argv) app.setStyle('Fusion') # Load custom font 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 # Hide mouse cursor on embedded framebuffer app.setOverrideCursor(Qt.BlankCursor) win = MainWindow() if _fb_snapshot: win.set_fb_snapshot(_fb_snapshot) win.showFullScreen() # fills 800×480 framebuffer sys.exit(app.exec_())