Done
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user