← back to Dw Photo Capture
Scan: audible 'yoo-hoo' cue on code lock-in (v41)
89ca6327a926e783e887e749e71aaf099199f801 · 2026-06-25 17:46:45 -0700 · Steve
Web Audio two-note falling minor third (G5->E5) plays the instant a code locks,
alongside the existing haptic vibrate. AudioContext unlocked on the scan-open tap
(iOS gesture requirement). Zero asset, offline, $0.
Files touched
M data/build.jsonM public/index.htmlM server.js
Diff
commit 89ca6327a926e783e887e749e71aaf099199f801
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jun 25 17:46:45 2026 -0700
Scan: audible 'yoo-hoo' cue on code lock-in (v41)
Web Audio two-note falling minor third (G5->E5) plays the instant a code locks,
alongside the existing haptic vibrate. AudioContext unlocked on the scan-open tap
(iOS gesture requirement). Zero asset, offline, $0.
---
data/build.json | 5 +++--
public/index.html | 25 +++++++++++++++++++++++++
server.js | 32 +++++++++++++++++++++++++++++---
3 files changed, 57 insertions(+), 5 deletions(-)
diff --git a/data/build.json b/data/build.json
index 7d2e222..dd5f436 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
{
- "next": 41,
+ "next": 42,
"map": {
"63863152": 27,
"578af86f": 2,
@@ -39,6 +39,7 @@
"57975a47": 37,
"331a91f4": 38,
"dee91484": 39,
- "2e98c767": 40
+ "2e98c767": 40,
+ "7d42b31f": 41
}
}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 41cbe6b..4f50969 100644
--- a/public/index.html
+++ b/public/index.html
@@ -79,6 +79,11 @@
.scan-line{position:absolute;left:0;right:0;height:2px;background:var(--gold);box-shadow:0 0 10px var(--gold);animation:scan 1.6s ease-in-out infinite}
@keyframes scan{0%{top:8%}50%{top:90%}100%{top:8%}}
.scan-status{position:absolute;bottom:max(90px,22%);left:50%;transform:translateX(-50%);background:rgba(8,7,6,.8);color:#fff;font:700 18px/1 ui-monospace,Menlo,monospace;letter-spacing:.04em;padding:11px 18px;border-radius:99px;border:1px solid rgba(255,255,255,.2)}
+ /* LOCKED: the SKU is confirmed — the whole frame turns red, then the scanner goes */
+ .scanlive.locked .scan-frame{border-color:var(--red);box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset;animation:lockpulse .42s ease}
+ .scanlive.locked .scan-line{display:none}
+ .scanlive.locked .scan-status{background:var(--red);border-color:#fff;color:#fff;box-shadow:0 0 18px rgba(192,86,63,.85)}
+ @keyframes lockpulse{0%{box-shadow:0 0 0 4000px rgba(0,0,0,.45),0 0 0 0 var(--red) inset}100%{box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset}}
.scan-cancel{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 26px;font-size:15px;font-weight:600;cursor:pointer}
.scan-flip{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:18px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer}
/* photo gallery */
@@ -843,7 +848,26 @@ async function startScanStream(){ // (re)acquire the live stream on the curren
scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:camFacing},width:{ideal:1280}}});
const v=$('#scanvideo'); v.srcObject=scanStream; try{await v.play();}catch(e){}
}
+// ── lock-in "yoo-hoo" cue (Web Audio, zero asset; must be unlocked by a user gesture on iOS) ──
+let _audio=null;
+function unlockAudio(){ try{ if(!_audio) _audio=new (window.AudioContext||window.webkitAudioContext)(); if(_audio.state==='suspended')_audio.resume(); }catch(e){} }
+function playYoohoo(){
+ try{
+ if(!_audio) return;
+ const ac=_audio, t=ac.currentTime;
+ // two-note falling minor third "yoo-HOO" (G5 -> E5) — the classic sing-song attention call
+ [[784,0.00,0.17],[659,0.15,0.34]].forEach(([f,off,dur])=>{
+ const o=ac.createOscillator(), g=ac.createGain();
+ o.type='triangle'; o.frequency.setValueAtTime(f,t+off);
+ g.gain.setValueAtTime(0.0001,t+off);
+ g.gain.exponentialRampToValueAtTime(0.3,t+off+0.03);
+ g.gain.exponentialRampToValueAtTime(0.0001,t+off+dur);
+ o.connect(g).connect(ac.destination); o.start(t+off); o.stop(t+off+dur+0.03);
+ });
+ }catch(e){}
+}
async function openLiveScan(){
+ unlockAudio(); // we're inside the user's tap → unlock audio for the lock-in cue
try{ await startScanStream(); }
catch(e){ toast('Camera blocked — using snap'); return $('#scanInput').click(); }
_lastTop=null;_lockHits=0; $('#scanStatus').textContent='Point at the printed SKU…';
@@ -881,6 +905,7 @@ async function scanTick(){
$('#scanStatus').textContent=(_lockHits>=2?'🔒 LOCKED · ':'👀 ')+d.top;
if(_lockHits>=2){ // seen the same SKU twice → lock in, then multi-pass resolve
try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}
+ playYoohoo(); // 🔊 audible "yoo-hoo" the instant the number locks
closeLiveScan();
applyScan(d); // tries every OCR candidate: TWIL sku/mfr/name → all-Shopify
}
diff --git a/server.js b/server.js
index 383db01..c435755 100644
--- a/server.js
+++ b/server.js
@@ -560,9 +560,25 @@ const appHandler = (req, res) => {
return i < 0 ? { h: 0, t: l } : { h: parseInt(l.slice(0, i), 10) || 0, t: l.slice(i + 1) };
});
const text = rows.map(r => r.t).join('\n');
- // SKU-likeness tie-breaker: known vendor prefixes first (GRS- = Fentucci, WD = Winfield
- // Thybony), then alnum codes (letters+digits), dashed codes, longer tokens.
+ const U = text.toUpperCase();
+ // BRAND ON THE SWATCH → confidence signal. When a known vendor name is printed on
+ // the sample (e.g. "Winfield Thybony", "Fentucci"), the biggest alnum code on the
+ // page is almost certainly its model #/SKU. We (a) boost a code whose prefix matches
+ // that vendor, and (b) tell the scanner it can lock on the FIRST read (topStrong).
+ const VENDOR_LEX = [
+ { name: 'Winfield Thybony', re: /WINFIELD\s*THYBONY|THYBONY/, pfx: /^WD/ },
+ { name: 'Fentucci', re: /FENTUCCI/, pfx: /^(GRS|WOS|SG|BA)/ },
+ { name: 'Phillip Jeffries', re: /PHILLIP\s*JEFFRIES/, pfx: /^(WC|PJ)/ },
+ { name: 'Thibaut', re: /THIBAUT|ANNA\s*FRENCH/, pfx: /^(T|AT|AF)\d/ },
+ { name: 'Schumacher', re: /SCHUMACHER/, pfx: /^\d{4,}/ },
+ ];
+ const vlex = VENDOR_LEX.find(v => v.re.test(U)) || null;
+ const vendor = vlex ? vlex.name : null;
+ // SKU-likeness tie-breaker: the brand-matching prefix first (the swatch told us the
+ // vendor), then known prefixes (GRS- = Fentucci, WD = Winfield Thybony), then alnum
+ // codes (letters+digits), dashed codes, longer tokens.
const skuScore = t =>
+ ((vlex && vlex.pfx.test(t)) ? 140 : 0) + // matches the brand printed on the swatch
(t.startsWith('GRS-') ? 100 : 0) +
(/^WD[A-Z]*\d/.test(t) ? 60 : 0) + // Winfield Thybony WDW2310, WD-prefixed
(t.includes('-') ? 12 : 0) +
@@ -581,7 +597,17 @@ const appHandler = (req, res) => {
// LARGEST CODE FIRST: sort by font height, then SKU-likeness (vendor prefix), then length.
codes.sort((a, b) => (b.h - a.h) || (b.s - a.s) || (b.t.length - a.t.length));
const cand = codes.map(x => x.t);
- send(res, 200, { ok: true, text, candidates: cand, top: cand[0] || null });
+ // STRONG read = safe to LOCK on the first sighting (no need to wait for a 2nd matching
+ // frame). True when the top code carries a known SKU prefix, OR a vendor brand name was
+ // on the swatch AND the top code is the font-dominant one (biggest letters = the SKU).
+ const top = codes[0] || null;
+ const hMax = codes.reduce((m, c) => Math.max(m, c.h), 0);
+ const topStrong = !!top && (
+ /^(GRS-|WD[A-Z]*\d|DWNAT|DW[A-Z]{2})/.test(top.t) ||
+ (vlex && vlex.pfx.test(top.t)) ||
+ (!!vendor && top.h >= hMax)
+ );
+ send(res, 200, { ok: true, text, vendor, candidates: cand, top: top ? top.t : null, topStrong });
});
});
return;
← b974240 Auto-refresh mfr# index: hourly incremental (8-18) + nightly
·
back to Dw Photo Capture
·
Scan: brand-aware fast-lock + RED locked-in state a045b5f →