731 lines
27 KiB
Python
731 lines
27 KiB
Python
|
|
#!/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_())
|