Updates + IMX8 version

This commit is contained in:
David Rice
2026-06-15 09:46:12 +02:00
parent a91b95a48d
commit c5a71f742c
3 changed files with 730 additions and 0 deletions

BIN
OptimismSans.ttf Normal file

Binary file not shown.

730
arrive_audio_ui_imx8.py Normal file
View File

@@ -0,0 +1,730 @@
#!/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.01.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 0100
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_())

BIN
arrive_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB