commit 386ff588e3e95a33b326f01c8f755bfc5bd8226e Author: David Rice Date: Fri Jun 12 10:47:09 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..46d5859 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Introduction +AXIO TEST - PYTHON SCRIPTS FOR AXIO TESTING diff --git a/arrive_audio_ui.py b/arrive_audio_ui.py new file mode 100644 index 0000000..7b099a8 --- /dev/null +++ b/arrive_audio_ui.py @@ -0,0 +1,654 @@ +#!/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_())