Jari 3 mesiacov pred
rodič
commit
6bf37f58d9

+ 4 - 0
OpenPuck/OpenPuck.ino

@@ -28,6 +28,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "radio.h"
 #include "controllers.h"
 #include "haptics.h"
+#include "audio.h"
 #include "rf_link.h"
 #include "puck_hid.h" // puckCmdLogDrain()
 #include "rf_diag.h"
@@ -263,6 +264,7 @@ void setup()
 	usbTxBegin();
 	webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
 	hapticInit();
+	audioInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
 		"SWITCH(horipad)",     "LIZARD(puck kb/mouse)",
@@ -373,6 +375,7 @@ void loop()
 	t = micros();
 	faultDiagSetStage(5);
 	hapticTask();
+	audioTask();
 	acc[5] += (uint32_t)(micros() - t);
 	t = micros();
 	faultDiagSetStage(6);
@@ -416,6 +419,7 @@ void loop()
 	rfLinkTask();
 	faultDiagSetStage(5);
 	hapticTask();
+	audioTask();
 	faultDiagSetStage(6);
 	ledTask();
 	faultDiagSetStage(7);

+ 176 - 0
OpenPuck/audio.cpp

@@ -0,0 +1,176 @@
+#include "audio.h"
+#include "haptics.h" // relayEnqueue / hapticLinkUp
+#include "bonds.h" // NSLOT
+#include "config.h" // g_usbMode / g_announceEnabled / MODE_*
+#include <Arduino.h>
+
+// 0x83 LFO_TONE report id + fixed frame fields (see audio.h / reference SteamHapticsPlayer).
+#define RID_LFO_TONE 0x83
+#define AUD_TONE_CH \
+	0 // haptic channel to sing on (0 = the channel the tone test proved clean)
+#define AUD_TONE_DUR \
+	0x64 // hold byte -- a note rings until the next note/stop is sent
+
+// ---- jingle sequencer ----
+static const AudNote *g_jNotes = nullptr;
+static uint8_t g_jN = 0, g_jIdx = 0, g_jSlot = 0;
+static bool g_jOn = false;
+static uint32_t g_jNextMs = 0;
+
+// ---- on-boot announce ----
+static bool g_wasLink[NSLOT] = { false };
+static bool g_annBootDone = false; // fire once per boot
+static uint32_t g_annPendingAtMs = 0; // 0 = none; else scheduled start
+
+// ---- per-mode jingles ------------------------------------------------------------------------------------
+// Steam: a simple monotone. Switch: a crest then a valley. PS3/PS4/PS5: three notes, the first two identical
+// across all three, the third distinct and ascending with the console number. Lizard: two tones back and forth
+// a couple of times. Xbox: a short rising pair. Frequencies kept in the ~440-1050 Hz range that reproduces
+// cleanly on the actuator.
+static const AudNote J_STEAM[] = { { 659, 360 } };
+static const AudNote J_XBOX[] = { { 392, 150 }, { 523, 220 } };
+static const AudNote J_SWITCH[] = { { 660, 150 },
+				    { 990, 170 },
+				    { 440,
+				      240 } }; // crest (990) then valley (440)
+static const AudNote J_LIZARD[] = { { 700, 120 },
+				    { 440, 120 },
+				    { 700, 120 },
+				    { 440, 150 } }; // back and forth x2
+// PlayStation: shared first two notes, distinct ascending third (PS3 < PS4 < PS5).
+static const AudNote J_PS3[] = { { 660, 160 }, { 660, 160 }, { 523, 240 } };
+static const AudNote J_PS4[] = { { 660, 160 }, { 660, 160 }, { 784, 240 } };
+static const AudNote J_PS5[] = { { 660, 160 }, { 660, 160 }, { 1047, 240 } };
+
+static void jingleForMode(uint8_t mode, const AudNote **notes, uint8_t *n)
+{
+#define JSET(arr)                                    \
+	do {                                         \
+		*notes = (arr);                      \
+		*n = sizeof(arr) / sizeof((arr)[0]); \
+	} while (0)
+	switch (mode) {
+	case MODE_XBOX:
+		JSET(J_XBOX);
+		break;
+	case MODE_SW_HORI:
+	case MODE_SW_PRO:
+		JSET(J_SWITCH);
+		break;
+	case MODE_LIZARD:
+		JSET(J_LIZARD);
+		break;
+	case MODE_PS3:
+		JSET(J_PS3);
+		break;
+	case MODE_HIDGYRO: // DS4 layout = "PS4"
+	case MODE_DS4_GAME:
+		JSET(J_PS4);
+		break;
+	case MODE_PS5:
+	case MODE_PS5_GAME:
+		JSET(J_PS5);
+		break;
+	case MODE_STEAM:
+	default:
+		JSET(J_STEAM);
+		break;
+	}
+#undef JSET
+}
+
+int audioFirstLinkedSlot()
+{
+	for (int s = 0; s < NSLOT; s++)
+		if (hapticLinkUp(s))
+			return s;
+	return -1;
+}
+
+static void toneSend(uint8_t slot, uint16_t freq)
+{
+	// [ch][gain][freqLo][freqHi][0xFF][dur][0][0][0]; freq 0 => the all-zero form that silences the channel.
+	uint8_t d[9] = { AUD_TONE_CH,
+			 (uint8_t)(freq ? AUD_TONE_GAIN : 0),
+			 (uint8_t)(freq & 0xFF),
+			 (uint8_t)(freq >> 8),
+			 (uint8_t)(freq ? 0xFF : 0),
+			 (uint8_t)(freq ? AUD_TONE_DUR : 0),
+			 0,
+			 0,
+			 0 };
+	relayEnqueue(RID_LFO_TONE, d, sizeof d, slot);
+}
+
+void audioInit()
+{
+	g_jOn = false;
+	g_annBootDone = false;
+	g_annPendingAtMs = 0;
+	for (int s = 0; s < NSLOT; s++)
+		g_wasLink[s] = false;
+}
+
+void audioPlayJingle(const AudNote *notes, uint8_t n, uint8_t slot)
+{
+	if (!notes || !n)
+		return;
+	if (slot == 0xFF) {
+		int s = audioFirstLinkedSlot();
+		if (s < 0)
+			return;
+		slot = (uint8_t)s;
+	} else if (slot >= NSLOT || !hapticLinkUp(slot)) {
+		return;
+	}
+	g_jNotes = notes;
+	g_jN = n;
+	g_jIdx = 0;
+	g_jSlot = slot;
+	g_jNextMs = millis(); // fire the first note immediately
+	g_jOn = true;
+}
+
+void audioAnnounceNow(uint8_t mode, uint8_t slot)
+{
+	const AudNote *notes;
+	uint8_t n;
+	jingleForMode(mode, &notes, &n);
+	audioPlayJingle(notes, n, slot);
+}
+
+void audioTask()
+{
+	// One-shot on-boot announce: arm on the first controller to come up, then play the active mode's jingle
+	// after a short settle (once its haptic engine is ready).
+	for (int s = 0; s < NSLOT; s++) {
+		bool up = hapticLinkUp(s);
+		if (up && !g_wasLink[s] && !g_annBootDone &&
+		    g_announceEnabled) {
+			g_annBootDone = true;
+			g_annPendingAtMs = millis() + ANN_SETTLE_MS;
+		}
+		g_wasLink[s] = up;
+	}
+	if (g_annPendingAtMs && (int32_t)(millis() - g_annPendingAtMs) >= 0 &&
+	    !g_jOn) {
+		g_annPendingAtMs = 0;
+		audioAnnounceNow(g_usbMode); // first connected controller
+	}
+
+	// Jingle sequencer: emit the next note when the current one's duration elapses; final tick silences.
+	if (g_jOn) {
+		if (!hapticLinkUp(g_jSlot)) {
+			g_jOn = false;
+		} else if ((int32_t)(millis() - g_jNextMs) >= 0) {
+			if (g_jIdx >= g_jN) {
+				toneSend(g_jSlot, 0);
+				g_jOn = false;
+			} else {
+				const AudNote &nt = g_jNotes[g_jIdx++];
+				toneSend(g_jSlot, nt.freq);
+				g_jNextMs = millis() + nt.durMs;
+			}
+		}
+	}
+}

+ 36 - 0
OpenPuck/audio.h

@@ -0,0 +1,36 @@
+// audio.h -- mode-announcement jingles played through the controller's haptic actuators.
+//
+// Streamed PCM audio was abandoned: over the wireless puck<->controller link the controller drops most PCM
+// frames while driving its actuator (noRx spikes, playback breaks up). Single 0x83 LFO_TONE *commands*, by
+// contrast, are cheap and rock-solid over RF -- one packet tells the controller to synthesize a tone locally.
+// So mode announcements are short, distinct tone jingles: reliable and instantly recognizable.
+//
+//   0x83 LFO_TONE  [ch][gain][freqLo][freqHi][0xFF][dur][0][0][0]  -- play a tone until the next tone/stop.
+//
+// A jingle is a list of notes; audioTask() paces one 0x83 per note into the haptic relay ring (haptics.cpp).
+// On the first controller connect after boot (i.e. after a mode switch, which reboots the puck) the active
+// mode's jingle plays once, so the puck "announces" its mode with no host/panel involvement.
+#pragma once
+#include <stdint.h>
+
+// 0x83 tone loudness (gain byte) and the settle delay between a controller (re)connecting and the on-boot
+// announce (lets its haptic engine come up first).
+#define AUD_TONE_GAIN 0x7Fu
+#define ANN_SETTLE_MS 900u
+
+struct AudNote {
+	uint16_t freq; // Hz; 0 = rest (silence)
+	uint16_t durMs;
+};
+
+void audioInit();
+// per-loop upkeep: advances the jingle sequencer and fires the one-shot on-boot mode announce.
+void audioTask();
+
+// Play an explicit jingle. slot = bond slot, or 0xFF for the first connected controller.
+void audioPlayJingle(const AudNote *notes, uint8_t n, uint8_t slot = 0xFF);
+// Play a mode's announcement jingle now (used by the on-boot announce and the panel test button).
+void audioAnnounceNow(uint8_t mode, uint8_t slot = 0xFF);
+
+// first connected controller slot, or -1 if none.
+int audioFirstLinkedSlot();

+ 9 - 2
OpenPuck/config.cpp

@@ -68,6 +68,8 @@ void applyActiveType()
 }
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
+// on-boot mode-announce (jingle + spoken clip via haptics) enable; persisted, panel-toggleable (see audio.cpp)
+uint8_t g_announceEnabled = 1;
 
 // poll rate defaults to POLL_US_DEFAULT (250 Hz), matching the real Valve puck (see config.h). The
 // delivered report rate equals the poll rate (fresh IMU in every reply). Live-adjustable via console
@@ -76,15 +78,16 @@ uint8_t g_rumbleScale = 200;
 uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// Struct layout/semantics changed (TypeCfg gained rumble byte); bump so old flash format is discarded ->
+// Struct layout/semantics changed (added `announce`); bump so old flash format is discarded ->
 // clean defaults once.
-#define CFG_MAGIC 0xCF
+#define CFG_MAGIC 0xD0
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
 	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
 	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
 	uint8_t rxWin10, lizKeep, landAll87;
+	uint8_t announce; // on-boot mode-announce (jingle + spoken clip) enable, see audio.cpp
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
@@ -103,6 +106,7 @@ void saveCfg()
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
 		  g_landAll87,
+		  g_announceEnabled,
 		  {} };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
@@ -164,6 +168,9 @@ void loadCfg()
 			// verbatim-0x87-relay experiment toggle (0/1; default off)
 			if (c.landAll87 <= 1)
 				g_landAll87 = c.landAll87;
+			// on-boot mode-announce enable (0/1; default on)
+			if (c.announce <= 1)
+				g_announceEnabled = c.announce;
 			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();

+ 2 - 0
OpenPuck/config.h

@@ -150,6 +150,8 @@ extern uint8_t g_ledBright;
 void applyActiveType();
 // rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
 extern uint8_t g_rumbleScale;
+// on-boot mode-announce enable (jingle + spoken clip through the controller's haptics); default on, persisted.
+extern uint8_t g_announceEnabled;
 // Switch Pro motion settings. Persisted in their OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
 // them never resets the rest of the config. Set from the WebUSB panel.
 // Switch Pro report cadence: 0 = 66Hz (15ms, compat), 1 = 120Hz (8ms, DEFAULT), 2 = full (~250Hz)

+ 20 - 4
OpenPuck/webusb_config.cpp

@@ -11,6 +11,7 @@
 #include "fw_update.h"
 #include "usb_tx.h"
 #include "usb_mount.h" // modeSwitchReboot()
+#include "audio.h" // PCM/tone playback + mode-announce clips
 #include <Arduino.h>
 #include <string.h>
 
@@ -55,7 +56,8 @@ static void fwupAckPost(uint8_t status)
 //                [v13: per-slot link stats, 4x9B from p[145]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
 //                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
-#define WB_PAYLEN 180
+//                [v18: p[182] announceEnabled (on-boot mode-announce jingle+speech)]
+#define WB_PAYLEN 181
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -86,7 +88,7 @@ static void webusbSendBlob()
 	// unknown op; 15 = +staged firmware-update ops 0x20..0x24; 14 = +landAll87 toggle; 13 = +per-slot link
 	// stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type
 	// cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 17;
+	p[2] = 18;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -255,6 +257,8 @@ static void webusbSendBlob()
 	}
 	// v14/v17: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
 	p[181] = g_landAll87;
+	// v18: on-boot mode-announce enable (panel reflects + toggles it)
+	p[182] = g_announceEnabled;
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -590,7 +594,7 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if ((op < 0x01 || op > 0x15) &&
+			if ((op < 0x01 || op > 0x1D) &&
 			    (op < 0x20 || op > 0x25)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
@@ -608,12 +612,14 @@ void webusbPoll()
 			// binding (18 B); 0x05/0x0E/0x0F/0x10/0x13 carry one value byte; 0x02 a field+value; 0x0A a
 			// 3-byte magic. Firmware update: 0x20 begin [size u32][crc32 u32], 0x21 data (6 B header +
 			// payload), 0x22/0x23/0x24 bare. (0x11/0x14/0x15 are bare lizard opcodes -> default 1.)
+			// 0x1D [mode] = test a mode's announce jingle.
 			uint8_t need =
 				(op == 0x0D) ? 27 :
 				(op == 0x12) ? 18 :
 				(op == 0x02) ? 3 :
 				(op == 0x03 || op == 0x05 || op == 0x0E ||
-				 op == 0x0F || op == 0x10 || op == 0x13) ?
+				 op == 0x0F || op == 0x10 || op == 0x13 ||
+				 op == 0x1D) ?
 					       2 :
 				(op == 0x0A) ? 4 :
 				(op == 0x25) ? 5 :
@@ -819,6 +825,11 @@ void webusbPoll()
 				fwupAbort();
 				fwupAckPost(FWUP_OK);
 
+				// 0x1D [mode]: test a mode's announcement jingle now (audio.cpp).
+			} else if (op == 0x1D) {
+				audioAnnounceNow(buf[1]);
+				g_blobRequest = true;
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 
@@ -968,6 +979,11 @@ void webusbPoll()
 				case 29:
 					g_landAll87 = v ? 1 : 0;
 					break;
+
+				// on-boot mode-announce (jingle) enable
+				case 30:
+					g_announceEnabled = v ? 1 : 0;
+					break;
 				}
 				if (persist)
 					saveCfg();

+ 26 - 0
docs/index.html

@@ -165,6 +165,20 @@
       </div>
     </div>
 
+    <div class="card wide" id="audioCard">
+      <h2>Mode announcements</h2>
+      <p class="note" style="margin-top:0">When the puck boots into a mode (which happens on every mode switch), it plays that mode's short tone jingle through the connected controller's haptics, so you can tell which mode you're in without looking. (Streamed audio/speech isn't carried reliably over the wireless link — single tone commands are, so the announcements are distinct jingles.)</p>
+      <div class="row" style="align-items:center;gap:10px">
+        <label>Announce on boot</label>
+        <button id="audAnnounceEn">off</button>
+        <span class="note" style="flex:1">Play the mode jingle when a controller connects after boot. Persisted.</span>
+      </div>
+      <div class="row" style="gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px;border-top:1px solid #272d3d;padding-top:12px">
+        <label style="min-width:90px">Test jingle</label>
+        <div id="annTestRow" style="display:flex;gap:6px;flex-wrap:wrap"></div>
+      </div>
+    </div>
+
     <div class="card">
       <h2>Back4 + face button chords</h2>
       <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button to switch mode without the WebUI. <strong>A is always Steam.</strong> B / X / Y are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch HORIPAD).</p>
@@ -1026,6 +1040,8 @@ function applyBlob(p){
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   // land-all-0x87 experiment toggle (blob v14/v17, firmware p[181] -> JS p[179])
   { const la=(p.length>179?p[179]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
+  // on-boot mode-announce toggle (blob v18, firmware p[182] -> JS p[180])
+  if(p.length>180 && p[0]>=18){ const an=p[180]; $("#audAnnounceEn").textContent=an?"on":"off"; $("#audAnnounceEn").classList.toggle("active",!!an); }
   // per-type button config (blob v10/v17): 4 types x 9 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
   // the firmware's buffer index 75 lands at JS index 73 here.
   if(p.length>=73+TYPE_DEFS.length*9){
@@ -2155,6 +2171,16 @@ for(const b of document.querySelectorAll(".modebtn")){
     const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold back-paddle 4 + A to return to Steam mode.":"");
     if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
 }
+// ===== Mode announcements (tone jingles) =====
+const ANN_MODES=[
+  {m:0,label:"Steam"},{m:1,label:"Xbox"},{m:2,label:"Switch"},{m:3,label:"Lizard"},
+  {m:4,label:"Switch Pro"},{m:5,label:"PS5"},{m:6,label:"PS4 (DS4)"},{m:9,label:"PS3"},
+];
+$("#audAnnounceEn").onclick=()=>{ const on=$("#audAnnounceEn").classList.contains("active"); setField(30, on?0:1); };
+(function(){ const host=$("#annTestRow"); if(!host) return;
+  for(const it of ANN_MODES){ const b=document.createElement("button"); b.textContent=it.label;
+    b.onclick=()=>{ if(dev) send([0x1D, it.m]); else log("connect first"); }; host.appendChild(b); }
+})();
 updateUf2UI();
 updateFwGate(); // start gated: stays inert until the first status blob proves the firmware speaks v15+
 if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';