This commit is contained in:
David Rice
2026-06-15 16:28:26 +02:00
parent c5a71f742c
commit 528c7df10e
2 changed files with 308 additions and 0 deletions

View File

@@ -36,6 +36,8 @@ 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"
C_ACCEPT = "#00C878" # green — ticket accepted
C_REJECT = "#FF3344" # red — ticket rejected
# ── Custom font (set after QApplication is created) ──────────────────────────
_FONT_FAMILY = "Sans Serif" # overwritten at startup if OptimismSans loads
@@ -139,6 +141,55 @@ class AudioEngine:
if self._pa is not None:
self._pa.terminate()
def play_accept(self):
"""Ascending two-tone chime: 880 Hz (120 ms) then 1320 Hz (200 ms)."""
self._play_oneshot([(880, 120, 'sine'), (1320, 200, 'sine')])
def play_reject(self):
"""Low sawtooth buzz: 220 Hz (400 ms)."""
self._play_oneshot([(220, 400, 'sawtooth')])
def _play_oneshot(self, segments):
wav = self._build_wav(segments)
def _run():
try:
proc = subprocess.Popen(
['aplay', '-q', '-'],
stdin=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
proc.communicate(input=wav, timeout=3.0)
except Exception:
pass
threading.Thread(target=_run, daemon=True).start()
def _build_wav(self, segments):
"""Return WAV bytes for a list of (freq, duration_ms, waveform) tuples."""
samples = []
for freq, duration_ms, waveform in segments:
n = int(SAMPLE_RATE * duration_ms / 1000)
if _HAS_NUMPY:
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
if waveform == 'sine':
s = AMPLITUDE * np.sin(2.0 * math.pi * freq * t)
else:
s = AMPLITUDE * (2.0 * ((freq * t) % 1.0) - 1.0)
samples.extend((s * 32767).astype(np.int16).tolist())
else:
for i in range(n):
tt = i / SAMPLE_RATE
if waveform == 'sine':
v = AMPLITUDE * math.sin(2.0 * math.pi * freq * tt)
else:
v = AMPLITUDE * (2.0 * ((freq * tt) % 1.0) - 1.0)
samples.append(int(v * 32767))
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(struct.pack(f'{len(samples)}h', *samples))
return buf.getvalue()
# ── Stream management ─────────────────────────────────────────────────────
def _open_stream(self):
@@ -353,6 +404,24 @@ def _wave_btn_style(active: bool = False) -> str:
}}
"""
def _ticket_btn_style(kind: str) -> str:
c = C_ACCEPT if kind == 'accept' else C_REJECT
return f"""
QPushButton {{
background-color: {C_PANEL};
color: {c};
border: 2px solid {c};
border-radius: 18px;
font-size: 28px;
font-weight: bold;
}}
QPushButton:pressed {{
background-color: {c};
color: {C_WHITE};
border: 2px solid {c};
}}
"""
def _vol_btn_style() -> str:
return f"""
QPushButton {{
@@ -464,6 +533,8 @@ class MainWindow(QMainWindow):
root.addLayout(self._waveform_section())
root.addSpacing(16)
root.addLayout(self._volume_section())
root.addSpacing(12)
root.addLayout(self._ticket_section())
root.addStretch()
root.addWidget(self._divider())
root.addLayout(self._status_bar())
@@ -604,6 +675,33 @@ class MainWindow(QMainWindow):
col.addLayout(row)
return col
def _ticket_section(self) -> QVBoxLayout:
col = QVBoxLayout()
col.setSpacing(12)
lbl = QLabel("TICKET VALIDATOR")
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_accept = QPushButton("✓ ACCEPT")
self.btn_reject = QPushButton("✗ REJECT")
for btn, kind in ((self.btn_accept, 'accept'), (self.btn_reject, 'reject')):
btn.setMinimumHeight(110)
btn.setStyleSheet(_ticket_btn_style(kind))
btn.setFocusPolicy(Qt.NoFocus)
self.btn_accept.clicked.connect(self._play_accept)
self.btn_reject.clicked.connect(self._play_reject)
row.addWidget(self.btn_accept)
row.addWidget(self.btn_reject)
col.addLayout(row)
return col
def _status_bar(self) -> QHBoxLayout:
row = QHBoxLayout()
self.status_lbl = QLabel("Ready · SGTL5000 / ALSA")
@@ -676,6 +774,14 @@ class MainWindow(QMainWindow):
except TypeError:
pass
def _play_accept(self):
self.audio.play_accept()
self.status_lbl.setText("Ticket ACCEPTED ✓")
def _play_reject(self):
self.audio.play_reject()
self.status_lbl.setText("Ticket REJECTED ✗")
def _on_audio_status(self, msg: str):
self.status_lbl.setText(msg)