first commit

This commit is contained in:
David Rice
2026-06-12 10:47:09 +02:00
commit 386ff588e3
2 changed files with 656 additions and 0 deletions

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# Introduction
AXIO TEST - PYTHON SCRIPTS FOR AXIO TESTING

654
arrive_audio_ui.py Normal file
View File

@@ -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.01.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 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:
# 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_())