mellon 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,8 +81,8 @@ Refs are fetched automatically during `start()`. You can enroll your own words
81
81
  By default, the WASM runtime and model load from the jsDelivr CDN — no setup needed. For air-gapped or private-network deployments, copy the assets locally and tell the library where to find them:
82
82
 
83
83
  ```bash
84
- cp -r node_modules/mellon/dist/assets/wasm public/mellon-assets/wasm
85
- cp node_modules/mellon/dist/assets/model.onnx public/mellon-assets/model.onnx
84
+ cp -r node_modules/mellon/dist/wasm public/mellon-assets/wasm
85
+ cp node_modules/mellon/dist/models/model.onnx public/mellon-assets/model.onnx
86
86
  ```
87
87
 
88
88
  Then pass the paths to the constructor:
@@ -104,8 +104,8 @@ export default {
104
104
  plugins: [
105
105
  viteStaticCopy({
106
106
  targets: [
107
- { src: 'node_modules/mellon/dist/assets/wasm/*', dest: 'mellon-assets/wasm' },
108
- { src: 'node_modules/mellon/dist/assets/model.onnx', dest: 'mellon-assets' },
107
+ { src: 'node_modules/mellon/dist/wasm/*', dest: 'mellon-assets/wasm' },
108
+ { src: 'node_modules/mellon/dist/models/model.onnx', dest: 'mellon-assets' },
109
109
  ],
110
110
  }),
111
111
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellon",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Offline, in-browser hotword detection powered by EfficientWord-Net (ResNet-50 ArcFace). Works as a standalone app or npm library.",
5
5
  "type": "module",
6
6
  "main": "./dist/mellon.cjs",
@@ -14,7 +14,11 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist",
17
+ "dist/mellon.mjs",
18
+ "dist/mellon.cjs",
19
+ "dist/index.d.ts",
20
+ "dist/wasm",
21
+ "dist/models",
18
22
  "README.md"
19
23
  ],
20
24
  "keywords": [
@@ -1 +0,0 @@
1
- :root{--bg: #080810;--surface: #11111c;--surface-2: #191925;--surface-3: #21212f;--border: rgba(255 255 255 / .08);--border-2: rgba(255 255 255 / .14);--accent: #4f72f5;--accent-glow: rgba(79 114 245 / .35);--accent-dim: rgba(79 114 245 / .18);--green: #22c55e;--green-glow: rgba(34 197 94 / .4);--red: #ef4444;--yellow: #f59e0b;--text: #eeeeff;--text-dim: rgba(238 238 255 / .55);--text-muted: rgba(238 238 255 / .3);--radius: 12px;--radius-sm: 8px;--radius-lg: 18px;--font: system-ui, -apple-system, "Segoe UI", sans-serif;--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:15px;line-height:1.6;min-height:100dvh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}b,strong{font-weight:600}code{font-family:var(--font-mono);font-size:.88em;background:var(--surface-3);padding:1px 5px;border-radius:4px}header{background:var(--surface);border-bottom:1px solid var(--border);padding:18px 24px 14px}.header-inner{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.header-title{display:flex;align-items:center;gap:10px}.header-icon{font-size:1.5rem}h1{font-size:1.5rem;font-weight:700;letter-spacing:-.5px}.header-badges{display:flex;gap:8px;flex-wrap:wrap}.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:999px;font-size:.75rem;font-weight:600;letter-spacing:.3px}.badge--offline{background:#22c55e1f;border:1px solid rgba(34 197 94 / .3);color:var(--green)}.badge--link{background:var(--accent-dim);border:1px solid rgba(79 114 245 / .3);color:var(--accent)}.badge--link:hover{text-decoration:none;filter:brightness(1.15)}.header-sub{margin-top:6px;font-size:.78rem;color:var(--text-muted);letter-spacing:.2px}.loader-bar{background:var(--surface-2);border-bottom:1px solid var(--border);padding:10px 24px}.loader-text{display:flex;align-items:baseline;gap:10px;margin-bottom:6px;font-size:.85rem;color:var(--text-dim)}.loader-note{font-size:.75rem;color:var(--text-muted)}main{max-width:1080px;margin:0 auto;padding:24px 20px 64px}.tab-nav{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:0}.tab-btn{background:none;border:none;color:var(--text-dim);font:inherit;font-size:.9rem;font-weight:500;padding:9px 18px;cursor:pointer;border-radius:var(--radius-sm) var(--radius-sm) 0 0;border-bottom:2px solid transparent;transition:color .15s,border-color .15s,background .15s;position:relative;bottom:-1px}.tab-btn:hover{color:var(--text);background:var(--surface-2)}.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}.tab-panel[hidden]{display:none}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px}.card h2{font-size:1rem;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;margin-bottom:18px}.card h3{font-size:.95rem;font-weight:600;margin:18px 0 8px;color:var(--text)}.progress-track{width:100%;height:6px;background:var(--surface-3);border-radius:999px;overflow:hidden}.progress-fill{height:100%;width:0;background:linear-gradient(90deg,var(--accent),#7c9cff);border-radius:999px;transition:width .3s ease}.progress-fill--record{background:linear-gradient(90deg,var(--red),#ff7eb6);transition:width .1s linear}.field-group{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}.field-label{font-size:.78rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted)}.field-label-row{display:flex;justify-content:space-between;align-items:baseline}.field-value{font-size:.85rem;font-weight:600;font-family:var(--font-mono);color:var(--accent)}.field-hint{font-size:.74rem;color:var(--text-muted);text-align:center}.field-select,.field-input{width:100%;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);color:var(--text);font:inherit;font-size:.9rem;padding:9px 12px;outline:none;transition:border-color .15s}.field-select:focus,.field-input:focus{border-color:var(--accent)}.field-select option{background:var(--surface-2)}.word-checkbox-list{display:flex;flex-direction:column;gap:8px}.word-checkbox-item{display:flex;align-items:center;gap:8px;font-size:.9rem;cursor:pointer;padding:7px 10px;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);transition:border-color .15s}.word-checkbox-item:hover{border-color:var(--accent)}.word-checkbox-item input[type=checkbox]{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;flex-shrink:0}.word-checkbox-item.item--disabled{opacity:.5;pointer-events:none}.field-input::placeholder{color:var(--text-muted)}.range{width:100%;accent-color:var(--accent);cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:var(--radius-sm);font:inherit;font-size:.9rem;font-weight:600;padding:10px 20px;cursor:pointer;transition:filter .15s,opacity .15s,transform .1s;white-space:nowrap}.btn:active{transform:scale(.97)}.btn:disabled{opacity:.45;cursor:not-allowed;transform:none}.btn--primary{background:var(--accent);color:#fff;box-shadow:0 0 20px var(--accent-glow)}.btn--primary:not(:disabled):hover{filter:brightness(1.12)}.btn--ghost{background:var(--surface-2);color:var(--text);border:1px solid var(--border-2)}.btn--ghost:not(:disabled):hover{background:var(--surface-3)}.btn--danger{background:#ef444426;color:var(--red);border:1px solid rgba(239 68 68 / .3)}.btn--danger:hover{background:#ef444440}.btn--wide{width:100%;margin-bottom:18px;padding:13px;font-size:1rem}.btn--sm{padding:5px 10px;font-size:.78rem}.btn--listening{background:#ef444426;color:var(--red);border:1px solid rgba(239 68 68 / .3);box-shadow:0 0 18px #ef444433}.btn--record{width:100%;padding:18px;background:var(--surface-2);color:var(--text);border:2px dashed var(--border-2);border-radius:var(--radius);font-size:.95rem;margin-bottom:8px}.btn--record:not(:disabled):hover{border-color:var(--red);color:var(--red)}.btn--record.recording{background:#ef44441a;border-color:var(--red);border-style:solid;color:var(--red)}.record-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--red)}.btn--record.recording .record-dot{animation:pulse-rec .8s ease-in-out infinite}#orb-wrap{display:flex;flex-direction:column;align-items:center;gap:14px;margin-bottom:24px}.orb{position:relative;width:140px;height:140px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--surface-2);border:2px solid var(--border);transition:background .4s,border-color .4s,box-shadow .4s}.orb-icon{font-size:2.6rem;position:relative;z-index:1;-webkit-user-select:none;user-select:none}.orb-ring{position:absolute;top:-6px;right:-6px;bottom:-6px;left:-6px;border-radius:50%;border:2px solid transparent;pointer-events:none}.orb--idle{box-shadow:none}.orb--listening{background:#4f72f51f;border-color:var(--accent);box-shadow:0 0 40px var(--accent-glow)}.orb--listening .r1{border-color:#4f72f580;animation:ring-out 2s ease-out infinite}.orb--listening .r2{border-color:#4f72f54d;animation:ring-out 2s ease-out infinite .6s}.orb--listening .r3{border-color:#4f72f526;animation:ring-out 2s ease-out infinite 1.2s}.orb--detected{background:#22c55e24;border-color:var(--green);box-shadow:0 0 60px var(--green-glow),0 0 120px #22c55e26}.orb--detected .r1{border-color:#22c55eb3;animation:ring-out .7s ease-out 3}.orb--detected .r2{border-color:#22c55e66;animation:ring-out .7s ease-out .2s 3}.orb--detected .r3{border-color:#22c55e33;animation:ring-out .7s ease-out .4s 3}.orb-label{font-size:.85rem;color:var(--text-dim);font-weight:500;text-align:center;min-height:1.2em}.confidence-wrap{display:flex;flex-direction:column;gap:6px}.confidence-wrap .field-label-row{margin-bottom:4px}.detect-layout{display:grid;grid-template-columns:340px 1fr;gap:20px;align-items:start}@media(max-width:768px){.detect-layout{grid-template-columns:1fr}}.detect-log-card{display:flex;flex-direction:column;min-height:480px}.log-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.log-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;max-height:520px;padding-right:2px}.log-empty{color:var(--text-muted);font-size:.85rem;text-align:center;padding:32px 0;font-style:italic}.log-item{display:flex;justify-content:space-between;align-items:center;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 14px;animation:fade-in-up .25s ease}.log-item-left{display:flex;flex-direction:column;gap:2px}.log-item-word{font-weight:700;color:var(--green);font-size:.95rem}.log-item-time{font-size:.73rem;color:var(--text-muted);font-family:var(--font-mono)}.log-item-score{font-family:var(--font-mono);font-size:.8rem;background:#22c55e1f;color:var(--green);padding:3px 9px;border-radius:999px;border:1px solid rgba(34 197 94 / .2)}.enroll-layout{display:grid;grid-template-columns:1fr 1fr;gap:20px;align-items:start}@media(max-width:768px){.enroll-layout{grid-template-columns:1fr}}.sample-status-row{display:flex;align-items:baseline;gap:8px;margin-bottom:12px}.sample-count{font-size:.85rem;font-weight:600;color:var(--text);font-family:var(--font-mono)}.sample-min{font-size:.78rem;color:var(--text-muted)}.record-progress-wrap{display:flex;align-items:center;gap:10px;margin-bottom:8px}.record-progress-wrap.hidden{display:none}.record-timer{font-family:var(--font-mono);font-size:.8rem;color:var(--red);min-width:32px}.or-divider{display:flex;align-items:center;gap:10px;color:var(--text-muted);font-size:.78rem;text-transform:uppercase;letter-spacing:1px;margin:12px 0}.or-divider:before,.or-divider:after{content:"";flex:1;height:1px;background:var(--border)}.upload-area{display:flex;align-items:center;justify-content:center;width:100%;padding:14px;background:var(--surface-2);border:2px dashed var(--border-2);border-radius:var(--radius);color:var(--text-dim);font-size:.85rem;cursor:pointer;transition:border-color .15s,color .15s;margin-bottom:10px;text-align:center}.upload-area:hover{border-color:var(--accent);color:var(--text)}.upload-area input{display:none}.upload-area--secondary{padding:10px;font-size:.8rem}.samples-list{display:flex;flex-direction:column;gap:6px;margin:10px 0;min-height:20px}.sample-item{display:flex;align-items:center;gap:10px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 12px}.sample-item-name{flex:1;font-size:.85rem;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sample-item-del{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:.85rem;padding:2px 6px;border-radius:4px}.sample-item-del:hover{color:var(--red);background:#ef44441a}.enroll-actions{display:flex;gap:10px;margin-top:14px}.enroll-actions .btn{flex:1}.enroll-status{margin-top:10px;font-size:.83rem;color:var(--text-dim);text-align:center;min-height:1.4em}.custom-words-list{display:flex;flex-direction:column;gap:8px}.custom-word-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 14px}.custom-word-name{font-weight:600;font-size:.9rem}.custom-word-count{font-size:.75rem;color:var(--text-muted)}.custom-word-actions{display:flex;gap:6px}.custom-word-actions .btn{padding:4px 10px;font-size:.75rem}.about-wrap{max-width:680px;margin:0 auto}.about-card p{color:var(--text-dim);font-size:.9rem;margin-bottom:10px}.about-card ol,.about-card ul{color:var(--text-dim);font-size:.9rem;padding-left:20px;margin-bottom:10px}.about-card li{margin-bottom:6px}blockquote{background:var(--surface-2);border-left:3px solid var(--accent);padding:12px 16px;border-radius:0 var(--radius-sm) var(--radius-sm) 0;margin:14px 0;font-size:.85rem;color:var(--text-dim);line-height:1.7}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--surface-3);border-radius:999px}::-webkit-scrollbar-thumb:hover{background:var(--border-2)}@keyframes ring-out{0%{transform:scale(1);opacity:1}to{transform:scale(1.65);opacity:0}}@keyframes pulse-rec{0%,to{opacity:1}50%{opacity:.3}}@keyframes fade-in-up{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes spin{to{transform:rotate(360deg)}}.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255 255 255 / .2);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:6px}.hidden{display:none!important}
@@ -1,19 +0,0 @@
1
- (function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const o of s)if(o.type==="childList")for(const i of o.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&r(i)}).observe(document,{childList:!0,subtree:!0});function e(s){const o={};return s.integrity&&(o.integrity=s.integrity),s.referrerPolicy&&(o.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?o.credentials="include":s.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(s){if(s.ep)return;s.ep=!0;const o=e(s);fetch(s.href,o)}})();const mt="0.0.1",ut=[1,1,149,64],Ne=`https://cdn.jsdelivr.net/npm/mellon@${mt}/dist/assets`,ce={wasmBasePath:`${Ne}/wasm/`,modelUrl:`${Ne}/model.onnx`};let J=null,le=null,de=null;async function ht(t){return J?(t==null||t(1),J):le||(le=(async()=>{const n=ce.wasmBasePath.endsWith("/")?ce.wasmBasePath:ce.wasmBasePath+"/",e=n+"ort.all.min.mjs",r=ce.modelUrl;de=await new Function("url","return import(url)")(e),de.env.wasm.wasmPaths=n;const o=await fetch(r);if(!o.ok)throw new Error(`Failed to fetch model: ${o.status}`);const i=parseInt(o.headers.get("content-length")||"0",10),a=o.body.getReader(),d=[];let m=0;for(;;){const{done:v,value:w}=await a.read();if(v)break;d.push(w),m+=w.byteLength,i>0&&(t==null||t(m/i))}const u=new Uint8Array(m);let f=0;for(const v of d)u.set(v,f),f+=v.byteLength;return J=await de.InferenceSession.create(u.buffer,{executionProviders:["wasm"],graphOptimizationLevel:"all"}),t==null||t(1),J})(),le)}async function Je(t){if(!J)throw new Error("Model not loaded — call loadModel() first");const n=new de.Tensor("float32",t,ut),e=await J.run({input:n}),r=Object.keys(e)[0];return e[r].data}function ft(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var Se,Ie;function pt(){if(Ie)return Se;Ie=1;function t(n){if(this.size=n|0,this.size<=1||(this.size&this.size-1)!==0)throw new Error("FFT size must be a power of two and bigger than 1");this._csize=n<<1;for(var e=new Array(this.size*2),r=0;r<e.length;r+=2){const m=Math.PI*r/this.size;e[r]=Math.cos(m),e[r+1]=-Math.sin(m)}this.table=e;for(var s=0,o=1;this.size>o;o<<=1)s++;this._width=s%2===0?s-1:s,this._bitrev=new Array(1<<this._width);for(var i=0;i<this._bitrev.length;i++){this._bitrev[i]=0;for(var a=0;a<this._width;a+=2){var d=this._width-a-2;this._bitrev[i]|=(i>>>a&3)<<d}}this._out=null,this._data=null,this._inv=0}return Se=t,t.prototype.fromComplexArray=function(e,r){for(var s=r||new Array(e.length>>>1),o=0;o<e.length;o+=2)s[o>>>1]=e[o];return s},t.prototype.createComplexArray=function(){const e=new Array(this._csize);for(var r=0;r<e.length;r++)e[r]=0;return e},t.prototype.toComplexArray=function(e,r){for(var s=r||this.createComplexArray(),o=0;o<s.length;o+=2)s[o]=e[o>>>1],s[o+1]=0;return s},t.prototype.completeSpectrum=function(e){for(var r=this._csize,s=r>>>1,o=2;o<s;o+=2)e[r-o]=e[o],e[r-o+1]=-e[o+1]},t.prototype.transform=function(e,r){if(e===r)throw new Error("Input and output buffers must be different");this._out=e,this._data=r,this._inv=0,this._transform4(),this._out=null,this._data=null},t.prototype.realTransform=function(e,r){if(e===r)throw new Error("Input and output buffers must be different");this._out=e,this._data=r,this._inv=0,this._realTransform4(),this._out=null,this._data=null},t.prototype.inverseTransform=function(e,r){if(e===r)throw new Error("Input and output buffers must be different");this._out=e,this._data=r,this._inv=1,this._transform4();for(var s=0;s<e.length;s++)e[s]/=this.size;this._out=null,this._data=null},t.prototype._transform4=function(){var e=this._out,r=this._csize,s=this._width,o=1<<s,i=r/o<<1,a,d,m=this._bitrev;if(i===4)for(a=0,d=0;a<r;a+=i,d++){const p=m[d];this._singleTransform2(a,p,o)}else for(a=0,d=0;a<r;a+=i,d++){const p=m[d];this._singleTransform4(a,p,o)}var u=this._inv?-1:1,f=this.table;for(o>>=2;o>=2;o>>=2){i=r/o<<1;var v=i>>>2;for(a=0;a<r;a+=i)for(var w=a+v,L=a,g=0;L<w;L+=2,g+=o){const p=L,b=p+v,y=b+v,_=y+v,C=e[p],A=e[p+1],T=e[b],E=e[b+1],F=e[y],R=e[y+1],M=e[_],$=e[_+1],N=C,I=A,D=f[g],G=u*f[g+1],z=T*D-E*G,B=T*G+E*D,V=f[2*g],K=u*f[2*g+1],Y=F*V-R*K,Q=F*K+R*V,X=f[3*g],Z=u*f[3*g+1],ee=M*X-$*Z,te=M*Z+$*X,ne=N+Y,P=I+Q,j=N-Y,re=I-Q,oe=z+ee,U=B+te,H=u*(z-ee),se=u*(B-te),ie=ne+oe,ve=P+U,ge=ne-oe,we=P-U,be=j+se,ye=re-H,_e=j-se,Le=re+H;e[p]=ie,e[p+1]=ve,e[b]=be,e[b+1]=ye,e[y]=ge,e[y+1]=we,e[_]=_e,e[_+1]=Le}}},t.prototype._singleTransform2=function(e,r,s){const o=this._out,i=this._data,a=i[r],d=i[r+1],m=i[r+s],u=i[r+s+1],f=a+m,v=d+u,w=a-m,L=d-u;o[e]=f,o[e+1]=v,o[e+2]=w,o[e+3]=L},t.prototype._singleTransform4=function(e,r,s){const o=this._out,i=this._data,a=this._inv?-1:1,d=s*2,m=s*3,u=i[r],f=i[r+1],v=i[r+s],w=i[r+s+1],L=i[r+d],g=i[r+d+1],p=i[r+m],b=i[r+m+1],y=u+L,_=f+g,C=u-L,A=f-g,T=v+p,E=w+b,F=a*(v-p),R=a*(w-b),M=y+T,$=_+E,N=C+R,I=A-F,D=y-T,G=_-E,z=C-R,B=A+F;o[e]=M,o[e+1]=$,o[e+2]=N,o[e+3]=I,o[e+4]=D,o[e+5]=G,o[e+6]=z,o[e+7]=B},t.prototype._realTransform4=function(){var e=this._out,r=this._csize,s=this._width,o=1<<s,i=r/o<<1,a,d,m=this._bitrev;if(i===4)for(a=0,d=0;a<r;a+=i,d++){const Ee=m[d];this._singleRealTransform2(a,Ee>>>1,o>>>1)}else for(a=0,d=0;a<r;a+=i,d++){const Ee=m[d];this._singleRealTransform4(a,Ee>>>1,o>>>1)}var u=this._inv?-1:1,f=this.table;for(o>>=2;o>=2;o>>=2){i=r/o<<1;var v=i>>>1,w=v>>>1,L=w>>>1;for(a=0;a<r;a+=i)for(var g=0,p=0;g<=L;g+=2,p+=o){var b=a+g,y=b+w,_=y+w,C=_+w,A=e[b],T=e[b+1],E=e[y],F=e[y+1],R=e[_],M=e[_+1],$=e[C],N=e[C+1],I=A,D=T,G=f[p],z=u*f[p+1],B=E*G-F*z,V=E*z+F*G,K=f[2*p],Y=u*f[2*p+1],Q=R*K-M*Y,X=R*Y+M*K,Z=f[3*p],ee=u*f[3*p+1],te=$*Z-N*ee,ne=$*ee+N*Z,P=I+Q,j=D+X,re=I-Q,oe=D-X,U=B+te,H=V+ne,se=u*(B-te),ie=u*(V-ne),ve=P+U,ge=j+H,we=re+ie,be=oe-se;if(e[b]=ve,e[b+1]=ge,e[y]=we,e[y+1]=be,g===0){var ye=P-U,_e=j-H;e[_]=ye,e[_+1]=_e;continue}if(g!==L){var Le=re,et=-oe,tt=P,nt=-j,rt=-u*ie,ot=-u*se,st=-u*H,at=-u*U,it=Le+rt,ct=et+ot,lt=tt+at,dt=nt-st,Me=a+w-g,$e=a+v-g;e[Me]=it,e[Me+1]=ct,e[$e]=lt,e[$e+1]=dt}}}},t.prototype._singleRealTransform2=function(e,r,s){const o=this._out,i=this._data,a=i[r],d=i[r+s],m=a+d,u=a-d;o[e]=m,o[e+1]=0,o[e+2]=u,o[e+3]=0},t.prototype._singleRealTransform4=function(e,r,s){const o=this._out,i=this._data,a=this._inv?-1:1,d=s*2,m=s*3,u=i[r],f=i[r+s],v=i[r+d],w=i[r+m],L=u+v,g=u-v,p=f+w,b=a*(f-w),y=L+p,_=g,C=-b,A=L-p,T=g,E=b;o[e]=y,o[e+1]=0,o[e+2]=_,o[e+3]=C,o[e+4]=A,o[e+5]=0,o[e+6]=T,o[e+7]=E},Se}var vt=pt();const gt=ft(vt),me=16e3,W=512,k=64,De=Math.floor(.025*me),Ge=Math.floor(.01*me);function ze(t){return 2595*Math.log10(1+t/700)}function wt(t){return 700*(10**(t/2595)-1)}function bt(){const t=ze(0),n=ze(me/2),e=new Float64Array(k+2);for(let a=0;a<k+2;a++)e[a]=t+a*(n-t)/(k+1);const s=e.map(a=>wt(a)).map(a=>Math.floor((W+1)*a/me)),o=[],i=Math.floor(W/2)+1;for(let a=0;a<k;a++){const d=new Float32Array(i);for(let m=s[a];m<s[a+1];m++)d[m]=(m-s[a])/(s[a+1]-s[a]);for(let m=s[a+1];m<s[a+2];m++)d[m]=(s[a+2]-m)/(s[a+2]-s[a+1]);o.push(d)}return o}const yt=bt(),ue=new gt(W),Ce=new Float32Array(W),Be=ue.createComplexArray(),Te=ue.createComplexArray(),ke=new Float32Array(Math.floor(W/2)+1);function Oe(t){const n=1+Math.ceil((t.length-De)/Ge),e=new Float32Array(n*k),r=Math.floor(W/2)+1;for(let s=0;s<n;s++){const o=s*Ge;Ce.fill(0);for(let i=0;i<De&&o+i<t.length;i++)Ce[i]=t[o+i];ue.toComplexArray(Ce,Be),ue.transform(Te,Be);for(let i=0;i<r;i++){const a=Te[2*i],d=Te[2*i+1],m=(a*a+d*d)/W;ke[i]=m===0?1e-30:m}for(let i=0;i<k;i++){const a=yt[i];let d=0;for(let m=0;m<r;m++)d+=ke[m]*a[m];e[s*k+i]=Math.log(d===0?1e-30:d)}}return e}function _t(t,n){let e=0;for(let r=0;r<t.length;r++)e+=t[r]*n[r];return(e+1)/2}function Lt(t,n){let e=0;for(const r of n){const s=_t(t,r);s>e&&(e=s)}return e}class Et extends EventTarget{constructor({name:n,refEmbeddings:e,threshold:r=.65,relaxationMs:s=2e3,inferenceGapMs:o=300}){super(),this.name=n,this.refEmbeddings=e,this.threshold=r,this.relaxationMs=s,this.inferenceGapMs=o,this._lastDetectionAt=0,this._lastInferenceAt=0,this._lastScore=0}get lastScore(){return this._lastScore}async scoreFrame(n){const e=Date.now();if(e-this._lastInferenceAt<this.inferenceGapMs)return null;this._lastInferenceAt=e;const r=Oe(n),s=await Je(r),o=Lt(s,this.refEmbeddings);return this._lastScore=o,o>=this.threshold&&e-this._lastDetectionAt>=this.relaxationMs&&(this._lastDetectionAt=e,this.dispatchEvent(new CustomEvent("match",{detail:{name:this.name,confidence:o,timestamp:e}}))),o}}const We=16e3,St=1500,xe=24e3;function Pe(t){if(t.length===xe)return t;const n=new Float32Array(xe);return n.set(t.subarray(0,xe)),n}class Ct extends EventTarget{constructor(n){super(),this.wordName=n.trim().toLowerCase(),this.samples=[]}get sampleCount(){return this.samples.length}async recordSample(){const n=await navigator.mediaDevices.getUserMedia({audio:!0});return new Promise((e,r)=>{const s=new AudioContext({sampleRate:We}),o=new MediaRecorder(n),i=[];this.dispatchEvent(new CustomEvent("recording-start")),o.ondataavailable=a=>{a.data.size>0&&i.push(a.data)},o.onstop=async()=>{n.getTracks().forEach(a=>a.stop());try{const d=await new Blob(i,{type:"audio/webm"}).arrayBuffer(),m=await s.decodeAudioData(d);await s.close();const u=m.getChannelData(0),f=Pe(new Float32Array(u)),v=this._push(f,`Recorded #${this.samples.length}`);e(v)}catch(a){await s.close().catch(()=>{}),r(a)}},o.start(),setTimeout(()=>o.stop(),St)})}async addAudioFile(n){const e=await n.arrayBuffer(),r=new AudioContext({sampleRate:We}),s=await r.decodeAudioData(e);await r.close();const o=s.getChannelData(0),i=Pe(new Float32Array(o));return this._push(i,n.name)}removeSample(n){this.samples.splice(n,1),this.dispatchEvent(new CustomEvent("samples-changed",{detail:{count:this.samples.length}}))}clearSamples(){this.samples=[],this.dispatchEvent(new CustomEvent("samples-changed",{detail:{count:0}}))}async generateRef(){if(this.samples.length<3)throw new Error(`Need at least 3 samples (currently have ${this.samples.length})`);this.dispatchEvent(new CustomEvent("generating",{detail:{total:this.samples.length}}));const n=[];for(let e=0;e<this.samples.length;e++){const r=Oe(this.samples[e].audioBuffer),s=await Je(r);n.push(Array.from(s)),this.dispatchEvent(new CustomEvent("progress",{detail:{done:e+1,total:this.samples.length}}))}return{word_name:this.wordName,model_type:"resnet_50_arc",embeddings:n}}_push(n,e){this.samples.push({audioBuffer:n,name:e});const r=this.samples.length;return this.dispatchEvent(new CustomEvent("sample-added",{detail:{count:r,name:e}})),r}}const Re="mellon_custom_refs";function q(){try{const t=localStorage.getItem(Re);return t?JSON.parse(t):[]}catch{return[]}}function qe(t){const n=q().filter(e=>e.word_name!==t.word_name);n.push(t),localStorage.setItem(Re,JSON.stringify(n))}function Tt(t){const n=q().filter(e=>e.word_name!==t);localStorage.setItem(Re,JSON.stringify(n))}function Ve(t){const n=JSON.stringify(t,null,2),e=new Blob([n],{type:"application/json"}),r=URL.createObjectURL(e),s=Object.assign(document.createElement("a"),{href:r,download:`${t.word_name}_ref.json`});document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(r)}async function xt(t){const n=await t.text();let e;try{e=JSON.parse(n)}catch{throw new Error("Invalid JSON")}if(!e.embeddings||!Array.isArray(e.embeddings)||!e.embeddings.length)throw new Error('Missing or empty "embeddings" array');if(!Array.isArray(e.embeddings[0]))throw new Error('"embeddings" must be a 2D array');return e.word_name||(e.word_name=t.name.replace(/_ref\.json$/i,"").replace(/\.json$/i,"")),e}const c={isListening:!1,audioCtx:null,processorNode:null,silentGain:null,micStream:null,detectors:new Map,selectedWords:new Set,threshold:.65,enrollSession:null,lastGeneratedRef:null,recordTimerInterval:null},h=t=>document.getElementById(t),l={loaderBar:h("model-loader"),loaderPct:h("loader-pct"),loaderFill:h("loader-fill"),orb:h("orb"),orbLabel:h("orb-label"),btnToggle:h("btn-toggle-detect"),wordSelect:h("word-select"),threshRange:h("threshold-range"),threshVal:h("threshold-val"),confPct:h("conf-pct"),confFill:h("conf-fill"),detectLog:h("detection-log"),btnClearLog:h("btn-clear-log"),enrollName:h("enroll-name"),sampleCount:h("sample-count"),btnRecord:h("btn-record"),recordLbl:h("record-lbl"),recordProgress:h("record-progress-wrap"),recordFill:h("record-progress-fill"),recordTimer:h("record-timer"),uploadSamples:h("upload-samples"),uploadRefJson:h("upload-ref-json"),samplesList:h("samples-list"),btnGenerate:h("btn-generate"),btnExport:h("btn-export"),enrollStatus:h("enroll-status"),customList:h("custom-words-list")};async function At(){Ft(),$t(),Nt(),pe(),Ae(!0);try{await ht(t=>Rt(t)),Ae(!1),l.btnToggle.disabled=!1,x("Model ready — press Start Listening")}catch(t){Ae(!1),x("⚠️ Model failed to load — check console"),console.error("[mellon] Model load error:",t)}}function Ft(){document.querySelectorAll(".tab-btn").forEach(t=>{t.addEventListener("click",()=>{const n=t.dataset.tab;document.querySelectorAll(".tab-btn").forEach(e=>{e.classList.toggle("active",e===t),e.setAttribute("aria-selected",e===t?"true":"false")}),document.querySelectorAll(".tab-panel").forEach(e=>{const r=e.id===`tab-${n}`;e.hidden=!r})})})}function Ae(t){l.loaderBar.hidden=!t}function Rt(t){const n=Math.round(t*100);l.loaderPct.textContent=`${n}%`,l.loaderFill.style.width=`${n}%`}function ae(t){l.orb.className=`orb orb--${t}`}function x(t){l.orbLabel.textContent=t}function Ke(t){const n=(t*100).toFixed(1);l.confPct.textContent=`${n}%`,l.confFill.style.width=`${n}%`}function Mt(t,n){l.detectLog.querySelectorAll(".log-empty").forEach(s=>s.remove());const e=new Date().toLocaleTimeString(),r=document.createElement("div");r.className="log-item",r.innerHTML=`
2
- <div class="log-item-left">
3
- <span class="log-item-word">${O(t.toUpperCase())}</span>
4
- <span class="log-item-time">${O(e)}</span>
5
- </div>
6
- <span class="log-item-score">${(n*100).toFixed(1)}%</span>
7
- `,l.detectLog.insertBefore(r,l.detectLog.firstChild)}function $t(){l.btnToggle.addEventListener("click",()=>{c.isListening?Ue():je()}),l.wordSelect.addEventListener("change",t=>{if(t.target.type!=="checkbox")return;const n=t.target.value;t.target.checked?c.selectedWords.add(n):c.selectedWords.delete(n),c.isListening&&(Ue(),je())}),l.threshRange.addEventListener("input",t=>{c.threshold=parseInt(t.target.value,10)/100,l.threshVal.textContent=`${t.target.value}%`;for(const n of c.detectors.values())n.threshold=c.threshold}),l.btnClearLog.addEventListener("click",()=>{l.detectLog.innerHTML='<p class="log-empty">No detections yet — press <em>Start Listening</em></p>'})}async function je(){if(!c.isListening){if(c.selectedWords.size===0){x("⚠️ Select at least one wake word");return}try{const t=[],n=q();for(const r of c.selectedWords){const s=n.find(o=>o.word_name===r);if(!s){x(`⚠️ Reference not found for "${r}"`);return}t.push({word:r,refData:s})}c.micStream=await navigator.mediaDevices.getUserMedia({audio:!0,video:!1}),c.audioCtx=new AudioContext({sampleRate:16e3}),await c.audioCtx.audioWorklet.addModule("./audio-processor.js");const e=c.audioCtx.createMediaStreamSource(c.micStream);c.processorNode=new AudioWorkletNode(c.audioCtx,"audio-processor"),c.silentGain=c.audioCtx.createGain(),c.silentGain.gain.setValueAtTime(0,c.audioCtx.currentTime),c.processorNode.connect(c.silentGain),c.silentGain.connect(c.audioCtx.destination),e.connect(c.processorNode),c.detectors=new Map;for(const{word:r,refData:s}of t){const o=new Et({name:r,refEmbeddings:s.embeddings,threshold:c.threshold,relaxationMs:2e3});o.addEventListener("match",i=>{const{name:a,confidence:d}=i.detail;ae("detected"),x(`✅ ${a.toUpperCase()} — ${(d*100).toFixed(1)}%`),Mt(a,d),setTimeout(()=>{c.isListening&&(ae("listening"),x("Listening…"))},1800)}),c.detectors.set(r,o)}c.processorNode.port.onmessage=async r=>{const s=await Promise.all([...c.detectors.values()].map(i=>i.scoreFrame(r.data))),o=Math.max(...s.filter(i=>i!==null),0);o>0&&Ke(o)},c.isListening=!0,ae("listening"),x("Listening…"),l.btnToggle.textContent="Stop Listening",l.btnToggle.classList.add("btn--listening"),l.btnToggle.classList.remove("btn--primary"),Ye(!0)}catch(t){console.error("[mellon] startListening error:",t);const n=t.name==="NotAllowedError"?"Microphone access denied":`Error: ${t.message}`;x(n),ae("idle"),Qe()}}}function Ue(){Qe(),c.isListening=!1,c.detectors=new Map,ae("idle"),x("Stopped"),Ke(0),l.btnToggle.textContent="Start Listening",l.btnToggle.classList.remove("btn--listening"),l.btnToggle.classList.add("btn--primary"),Ye(!1)}function Ye(t){l.wordSelect.querySelectorAll(".word-checkbox-item").forEach(n=>{n.classList.toggle("item--disabled",t),n.querySelector('input[type="checkbox"]').disabled=t})}function Qe(){var t,n,e,r;(t=c.micStream)==null||t.getTracks().forEach(s=>s.stop()),(n=c.processorNode)==null||n.disconnect(),(e=c.silentGain)==null||e.disconnect(),(r=c.audioCtx)==null||r.close().catch(()=>{}),c.micStream=null,c.processorNode=null,c.silentGain=null,c.audioCtx=null}function fe(){l.wordSelect.innerHTML="";const t=q().map(e=>({word:e.word_name,label:e.word_name})),n=new Set(t.map(e=>e.word));for(const e of c.selectedWords)n.has(e)||c.selectedWords.delete(e);c.selectedWords.size===0&&t.forEach(e=>c.selectedWords.add(e.word));for(const{word:e,label:r}of t){const s=document.createElement("label");s.className="word-checkbox-item";const o=document.createElement("input");o.type="checkbox",o.value=e,o.checked=c.selectedWords.has(e),s.append(o,document.createTextNode(r)),l.wordSelect.appendChild(s)}}function Nt(){l.enrollName.addEventListener("input",he),l.btnRecord.addEventListener("mousedown",()=>He()),l.btnRecord.addEventListener("touchstart",()=>He(),{passive:!0}),l.uploadSamples.addEventListener("change",async t=>{const n=Array.from(t.target.files||[]);t.target.value="";for(const e of n)try{await c.enrollSession.addAudioFile(e)}catch(r){S(`⚠️ Could not decode ${e.name}: ${r.message}`,!0)}}),l.uploadRefJson.addEventListener("change",async t=>{const n=t.target.files[0];if(t.target.value="",!!n)try{const e=await xt(n);qe(e),pe(),fe(),S(`✅ Imported "${e.word_name}" successfully`)}catch(e){S(`⚠️ Import failed: ${e.message}`,!0)}}),l.btnGenerate.addEventListener("click",Dt),l.btnExport.addEventListener("click",()=>{c.lastGeneratedRef&&Ve(c.lastGeneratedRef)}),Xe()}function Xe(){c.enrollSession=new Ct(""),c.lastGeneratedRef=null,c.enrollSession.addEventListener("recording-start",()=>{l.btnRecord.classList.add("recording"),l.recordLbl.textContent="Recording…",l.recordProgress.classList.remove("hidden"),It()}),c.enrollSession.addEventListener("sample-added",t=>{l.btnRecord.classList.remove("recording"),l.recordLbl.textContent="Hold to record 1.5 s",l.recordProgress.classList.add("hidden"),l.recordTimer.textContent="0.0 s",Ze(),Fe(),he(),S(`✅ Sample #${t.detail.count} added`)}),c.enrollSession.addEventListener("samples-changed",()=>{Fe(),he()}),c.enrollSession.addEventListener("generating",t=>{S(`⏳ Computing embeddings (0 / ${t.detail.total})…`)}),c.enrollSession.addEventListener("progress",t=>{S(`⏳ Computing embeddings (${t.detail.done} / ${t.detail.total})…`)})}async function He(){if(!c.enrollSession)return;const t=l.enrollName.value.trim().toLowerCase();if(!t){S("⚠️ Enter a wake word name first",!0);return}c.enrollSession.wordName=t;try{await c.enrollSession.recordSample()}catch(n){l.btnRecord.classList.remove("recording"),l.recordLbl.textContent="Hold to record 1.5 s",l.recordProgress.classList.add("hidden"),Ze();const e=n.name==="NotAllowedError"?"Microphone access denied":`Recording failed: ${n.message}`;S(`⚠️ ${e}`,!0)}}function It(){const t=Date.now();c.recordTimerInterval=setInterval(()=>{const n=(Date.now()-t)/1e3,e=Math.min(n/1.5*100,100);l.recordFill.style.width=`${e}%`,l.recordTimer.textContent=`${n.toFixed(1)} s`},50)}function Ze(){clearInterval(c.recordTimerInterval),c.recordTimerInterval=null,l.recordFill.style.width="0%"}function Fe(){const t=c.enrollSession.sampleCount;l.sampleCount.textContent=`${t} sample${t!==1?"s":""}`,l.samplesList.innerHTML="",c.enrollSession.samples.forEach((n,e)=>{const r=document.createElement("div");r.className="sample-item",r.innerHTML=`
8
- <span class="sample-item-name">${O(n.name)}</span>
9
- <button class="sample-item-del" data-idx="${e}" title="Remove">✕</button>
10
- `,l.samplesList.appendChild(r)}),l.samplesList.querySelectorAll(".sample-item-del").forEach(n=>{n.addEventListener("click",()=>{c.enrollSession.removeSample(parseInt(n.dataset.idx,10))})})}function he(){const t=l.enrollName.value.trim().length>0,n=c.enrollSession.sampleCount>=3;l.btnRecord.disabled=!t,l.btnGenerate.disabled=!t||!n}async function Dt(){const t=l.enrollName.value.trim().toLowerCase();if(t){c.enrollSession.wordName=t,l.btnGenerate.disabled=!0,l.btnGenerate.innerHTML='<span class="spinner"></span> Generating…';try{const n=await c.enrollSession.generateRef();qe(n),c.lastGeneratedRef=n,S(`✅ "${t}" saved! It is now available in the Detect tab.`),l.btnExport.style.display="",l.btnExport.disabled=!1,pe(),fe(),l.enrollName.value="",Xe(),Fe(),he()}catch(n){console.error("[mellon] generateRef error:",n),S(`⚠️ ${n.message}`,!0)}finally{l.btnGenerate.disabled=!1,l.btnGenerate.textContent="Generate & Save"}}}function S(t,n=!1){l.enrollStatus.textContent=t,l.enrollStatus.style.color=n?"var(--red)":"var(--green)"}function pe(){const t=q();if(!t.length){l.customList.innerHTML='<p class="log-empty">No custom words saved yet</p>';return}l.customList.innerHTML="";for(const n of t){const e=document.createElement("div");e.className="custom-word-item";const r=n.embeddings.length;e.innerHTML=`
11
- <div>
12
- <div class="custom-word-name">${O(n.word_name)}</div>
13
- <div class="custom-word-count">${r} embedding${r!==1?"s":""}</div>
14
- </div>
15
- <div class="custom-word-actions">
16
- <button class="btn btn--ghost btn--sm" data-action="export" data-word="${O(n.word_name)}">Export</button>
17
- <button class="btn btn--danger btn--sm" data-action="delete" data-word="${O(n.word_name)}">Delete</button>
18
- </div>
19
- `,l.customList.appendChild(e)}l.customList.querySelectorAll("[data-action]").forEach(n=>{n.addEventListener("click",()=>{const e=n.dataset.word;if(n.dataset.action==="export"){const r=q().find(s=>s.word_name===e);r&&Ve(r)}else if(n.dataset.action==="delete"){if(!confirm(`Delete custom hotword "${e}"?`))return;Tt(e),pe(),fe()}})})}function O(t){return String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}fe();At().catch(t=>console.error("[mellon] init error:",t));"serviceWorker"in navigator&&window.addEventListener("load",()=>{navigator.serviceWorker.register("./sw.js",{scope:"./"}).catch(t=>{console.warn("[mellon] Service worker registration failed:",t)})});
@@ -1,37 +0,0 @@
1
- /**
2
- * public/audio-processor.js
3
- * AudioWorklet that runs at 16 kHz and continuously emits the last
4
- * 1.5-second window (24 000 samples) via a circular buffer.
5
- *
6
- * The main thread receives a fresh Float32Array on every
7
- * AudioWorklet quantum (128 samples ≈ every 8 ms at 16 kHz).
8
- * The inference loop in engine.js rate-limits to avoid excessive work.
9
- */
10
- class AudioProcessor extends AudioWorkletProcessor {
11
- constructor() {
12
- super()
13
- this._size = 24000 // 1.5 s × 16 000 Hz
14
- this._buf = new Float32Array(this._size)
15
- this._ptr = 0
16
- }
17
-
18
- process(inputs) {
19
- const ch = inputs[0]?.[0]
20
- if (!ch) return true
21
-
22
- for (let i = 0; i < ch.length; i++) {
23
- this._buf[this._ptr] = ch[i]
24
- this._ptr = (this._ptr + 1) % this._size
25
- }
26
-
27
- // Send an ordered copy of the ring buffer
28
- const out = new Float32Array(this._size)
29
- for (let i = 0; i < this._size; i++) {
30
- out[i] = this._buf[(this._ptr + i) % this._size]
31
- }
32
- this.port.postMessage(out)
33
- return true
34
- }
35
- }
36
-
37
- registerProcessor('audio-processor', AudioProcessor)
package/dist/index.html DELETED
@@ -1,251 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
-
4
- <head>
5
- <meta charset="UTF-8" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <meta name="theme-color" content="#080810" />
8
- <meta name="description" content="EfficientWord-Net offline hotword detection — runs 100% in your browser" />
9
- <link rel="manifest" href="./manifest.json" />
10
- <title>Mellon — Offline Hotword Detection</title>
11
- <script type="module" crossorigin src="/assets/index-CyhUtQlr.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-B3ZBo_ZU.css">
13
- </head>
14
-
15
- <body>
16
-
17
- <!-- ═══════════════════════════ HEADER ════════════════════════════════════ -->
18
- <header>
19
- <div class="header-inner">
20
- <div class="header-title">
21
- <span class="header-icon">🎙</span>
22
- <h1>Mellon</h1>
23
- </div>
24
- <div class="header-badges">
25
- <span class="badge badge--offline">🔒 Fully Offline</span>
26
- <a class="badge badge--link" href="https://arxiv.org/abs/2111.00379" target="_blank" rel="noopener">📄
27
- Paper</a>
28
- </div>
29
- </div>
30
- <p class="header-sub">EfficientWord-Net · One-Shot Hotword Detection · ResNet-50 ArcFace · ONNX Runtime Web</p>
31
- </header>
32
-
33
- <!-- ══════════════════════════ MODEL LOADER ══════════════════════════════ -->
34
- <div id="model-loader" class="loader-bar" hidden>
35
- <div class="loader-text">
36
- Loading model <span id="loader-pct">0%</span>
37
- <span class="loader-note">~88 MB — cached after first load</span>
38
- </div>
39
- <div class="progress-track">
40
- <div id="loader-fill" class="progress-fill"></div>
41
- </div>
42
- </div>
43
-
44
- <!-- ═══════════════════════════ MAIN ══════════════════════════════════════ -->
45
- <main>
46
-
47
- <!-- Tabs nav -->
48
- <nav class="tab-nav" role="tablist">
49
- <button class="tab-btn active" data-tab="detect" role="tab" aria-selected="true">Detect</button>
50
- <button class="tab-btn" data-tab="enroll" role="tab" aria-selected="false">+ Enroll</button>
51
- <button class="tab-btn" data-tab="about" role="tab" aria-selected="false">About</button>
52
- </nav>
53
-
54
- <!-- ───────────────────────── DETECT TAB ─────────────────────────────── -->
55
- <section id="tab-detect" class="tab-panel" role="tabpanel">
56
- <div class="detect-layout">
57
-
58
- <!-- Left column: orb + controls -->
59
- <div class="card detect-controls-card">
60
-
61
- <!-- Status orb -->
62
- <div id="orb-wrap">
63
- <div id="orb" class="orb orb--idle">
64
- <div class="orb-ring r1"></div>
65
- <div class="orb-ring r2"></div>
66
- <div class="orb-ring r3"></div>
67
- <span id="orb-icon" class="orb-icon">🎙</span>
68
- </div>
69
- <div id="orb-label" class="orb-label">Ready</div>
70
- </div>
71
-
72
- <!-- Start / Stop button -->
73
- <button id="btn-toggle-detect" class="btn btn--primary btn--wide" disabled>
74
- Start Listening
75
- </button>
76
-
77
- <!-- Wake word selector -->
78
- <div class="field-group">
79
- <span class="field-label">Wake words</span>
80
- <div id="word-select" class="word-checkbox-list" role="group" aria-label="Wake words"></div>
81
- </div>
82
-
83
- <!-- Threshold slider -->
84
- <div class="field-group">
85
- <div class="field-label-row">
86
- <label for="threshold-range" class="field-label">Threshold</label>
87
- <span id="threshold-val" class="field-value">65%</span>
88
- </div>
89
- <input id="threshold-range" class="range" type="range" min="50" max="99" value="65" />
90
- <p class="field-hint">Higher → fewer false triggers, requires clearer speech</p>
91
- </div>
92
-
93
- <!-- Confidence meter -->
94
- <div class="confidence-wrap">
95
- <div class="field-label-row">
96
- <span class="field-label">Confidence</span>
97
- <span id="conf-pct" class="field-value">0.0%</span>
98
- </div>
99
- <div class="progress-track">
100
- <div id="conf-fill" class="progress-fill"></div>
101
- </div>
102
- </div>
103
-
104
- </div><!-- /detect-controls-card -->
105
-
106
- <!-- Right column: detection log -->
107
- <div class="card detect-log-card">
108
- <div class="log-header">
109
- <h2>Detection Log</h2>
110
- <button id="btn-clear-log" class="btn btn--ghost btn--sm">Clear</button>
111
- </div>
112
- <div id="detection-log" class="log-list">
113
- <p class="log-empty">No detections yet — press <em>Start Listening</em></p>
114
- </div>
115
- </div>
116
-
117
- </div><!-- /detect-layout -->
118
- </section>
119
-
120
- <!-- ───────────────────────── ENROLL TAB ─────────────────────────────── -->
121
- <section id="tab-enroll" class="tab-panel" hidden role="tabpanel">
122
- <div class="enroll-layout">
123
-
124
- <!-- Left: enrollment form -->
125
- <div class="card enroll-form-card">
126
- <h2>Enroll a Custom Hotword</h2>
127
-
128
- <div class="field-group">
129
- <label for="enroll-name" class="field-label">Wake word name</label>
130
- <input id="enroll-name" class="field-input" type="text"
131
- placeholder="e.g. jarvis (single word works best)" spellcheck="false" />
132
- </div>
133
-
134
- <div class="sample-status-row">
135
- <span id="sample-count" class="sample-count">0 samples</span>
136
- <span class="sample-min">(min. 3 required)</span>
137
- </div>
138
-
139
- <!-- Record button -->
140
- <button id="btn-record" class="btn btn--record" disabled>
141
- <span class="record-dot"></span>
142
- <span id="record-lbl">Hold to record 1.5 s</span>
143
- </button>
144
- <div id="record-progress-wrap" class="record-progress-wrap hidden">
145
- <div class="progress-track">
146
- <div id="record-progress-fill" class="progress-fill progress-fill--record"></div>
147
- </div>
148
- <span id="record-timer" class="record-timer">0.0 s</span>
149
- </div>
150
-
151
- <!-- Divider -->
152
- <div class="or-divider"><span>or</span></div>
153
-
154
- <!-- Upload -->
155
- <label class="upload-area">
156
- <span>📁 Upload audio files (WAV, MP3, webm…)</span>
157
- <input id="upload-samples" type="file" accept="audio/*" multiple />
158
- </label>
159
-
160
- <!-- Upload ref JSON -->
161
- <label class="upload-area upload-area--secondary">
162
- <span>📤 Import existing _ref.json</span>
163
- <input id="upload-ref-json" type="file" accept=".json" />
164
- </label>
165
-
166
- <!-- Samples list -->
167
- <div id="samples-list" class="samples-list"></div>
168
-
169
- <!-- Actions -->
170
- <div class="enroll-actions">
171
- <button id="btn-generate" class="btn btn--primary" disabled>Generate &amp; Save</button>
172
- <button id="btn-export" class="btn btn--ghost" disabled style="display:none">Export
173
- JSON</button>
174
- </div>
175
- <div id="enroll-status" class="enroll-status"></div>
176
- </div>
177
-
178
- <!-- Right: saved custom words -->
179
- <div class="card custom-words-card">
180
- <h2>Saved Custom Words</h2>
181
- <div id="custom-words-list" class="custom-words-list">
182
- <p class="log-empty">No custom words saved yet</p>
183
- </div>
184
- </div>
185
-
186
- </div><!-- /enroll-layout -->
187
- </section>
188
-
189
- <!-- ───────────────────────── ABOUT TAB ──────────────────────────────── -->
190
- <section id="tab-about" class="tab-panel" hidden role="tabpanel">
191
- <div class="about-wrap">
192
- <div class="card about-card">
193
-
194
- <h2>How It Works</h2>
195
- <p>
196
- This demo implements <strong>EfficientWord-Net</strong>, a one-shot hotword (wake-word)
197
- detection system presented in:
198
- </p>
199
- <blockquote>
200
- Chidhambararajan R, Aman Rangapur, Dr. Sibi Chakkaravarthy — VIT-AP University, India.<br />
201
- <em>"EfficientWord-Net: An Open Source Hotword Detection Engine Based on One-Shot Learning"</em>
202
- (2021).
203
- <a href="https://arxiv.org/abs/2111.00379" target="_blank" rel="noopener">arXiv:2111.00379</a>
204
- </blockquote>
205
-
206
- <h3>Pipeline</h3>
207
- <ol>
208
- <li><strong>Capture</strong> — Microphone at 16 kHz via <code>AudioWorklet</code>, 1.5-second
209
- sliding window.</li>
210
- <li><strong>Features</strong> — Log-Mel Spectrogram: 64 mel bands, 512-point FFT, 25 ms frames /
211
- 10 ms hop → <code>[149 × 64]</code> matrix.</li>
212
- <li><strong>Embedding</strong> — ResNet-50 with ArcFace loss → 256-dim L2-normalised vector, run
213
- via <em>ONNX Runtime Web</em> (WASM, no GPU required).</li>
214
- <li><strong>Matching</strong> — Cosine similarity between the live embedding and 3–10 stored
215
- reference embeddings. Detection fires when <code>score ≥ threshold</code>.</li>
216
- </ol>
217
-
218
- <h3>One-Shot Learning</h3>
219
- <p>
220
- Unlike traditional keyword spotters that need thousands of labelled samples per word,
221
- EfficientWord-Net needs only a few (≥3) short recordings. The model learns a
222
- <em>similarity metric</em> (inspired by FaceNet) — not a word classifier — so new words
223
- can be added without retraining.
224
- </p>
225
-
226
- <h3>Privacy</h3>
227
- <p>
228
- <strong>No audio ever leaves your device.</strong> The ONNX model, mel computation, and
229
- comparison all run locally. Custom enrollments are stored in <code>localStorage</code>
230
- and can be exported as portable <code>.json</code> files.
231
- </p>
232
-
233
- <h3>Stack</h3>
234
- <ul>
235
- <li>Vanilla JS + Vite (no framework)</li>
236
- <li><a href="https://onnxruntime.ai/docs/tutorials/web/" target="_blank" rel="noopener">ONNX
237
- Runtime Web</a> — in-browser inference</li>
238
- <li><a href="https://github.com/indutny/fft.js" target="_blank" rel="noopener">fft.js</a> — FFT
239
- for the mel spectrogram</li>
240
- <li>Service Worker — offline caching</li>
241
- </ul>
242
-
243
- </div>
244
- </div>
245
- </section>
246
-
247
- </main>
248
-
249
- </body>
250
-
251
- </html>
@@ -1,16 +0,0 @@
1
- {
2
- "name": "Mellon STT",
3
- "short_name": "Mellon",
4
- "description": "Offline hotword detection demo — EfficientWord-Net in the browser",
5
- "start_url": "./",
6
- "display": "standalone",
7
- "background_color": "#080810",
8
- "theme_color": "#080810",
9
- "icons": [
10
- {
11
- "src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙</text></svg>",
12
- "sizes": "any",
13
- "type": "image/svg+xml"
14
- }
15
- ]
16
- }
package/dist/sw.js DELETED
@@ -1,76 +0,0 @@
1
- /**
2
- * public/sw.js — Service Worker for offline-first caching.
3
- *
4
- * Strategy:
5
- * • model.onnx, *.wasm, *_ref.json → Cache-first (immutable large assets)
6
- * • JS / CSS / HTML → Stale-while-revalidate
7
- * • Everything else → Network-first with cache fallback
8
- */
9
-
10
- const STATIC_CACHE = 'mellon-static-v1'
11
- const MODEL_CACHE = 'mellon-model-v1'
12
-
13
- // ─── Lifecycle ───────────────────────────────────────────────────────────────
14
-
15
- self.addEventListener('install', () => self.skipWaiting())
16
-
17
- self.addEventListener('activate', e => {
18
- e.waitUntil(
19
- caches.keys().then(keys =>
20
- Promise.all(
21
- keys
22
- .filter(k => k !== STATIC_CACHE && k !== MODEL_CACHE)
23
- .map(k => caches.delete(k))
24
- )
25
- ).then(() => self.clients.claim())
26
- )
27
- })
28
-
29
- // ─── Fetch ───────────────────────────────────────────────────────────────────
30
-
31
- self.addEventListener('fetch', e => {
32
- const { request } = e
33
- const url = new URL(request.url)
34
- const p = url.pathname
35
-
36
- // Large immutable assets: model, wasm files, reference JSONs
37
- if (p.endsWith('.onnx') || p.endsWith('.wasm') || p.includes('_ref.json') || p.endsWith('.mjs')) {
38
- e.respondWith(cacheFirst(request, MODEL_CACHE))
39
- return
40
- }
41
-
42
- // App shell: stale-while-revalidate
43
- if (request.mode === 'navigate' || p.endsWith('.js') || p.endsWith('.css')) {
44
- e.respondWith(staleWhileRevalidate(request, STATIC_CACHE))
45
- return
46
- }
47
-
48
- // Default: network first
49
- e.respondWith(
50
- fetch(request).catch(() => caches.match(request))
51
- )
52
- })
53
-
54
- // ─── Helpers ─────────────────────────────────────────────────────────────────
55
-
56
- async function cacheFirst(request, cacheName) {
57
- const cache = await caches.open(cacheName)
58
- const cached = await cache.match(request)
59
- if (cached) return cached
60
-
61
- const response = await fetch(request)
62
- if (response.ok) cache.put(request, response.clone())
63
- return response
64
- }
65
-
66
- async function staleWhileRevalidate(request, cacheName) {
67
- const cache = await caches.open(cacheName)
68
- const cached = await cache.match(request)
69
-
70
- const fetchPromise = fetch(request).then(r => {
71
- if (r.ok) cache.put(request, r.clone())
72
- return r
73
- }).catch(() => null)
74
-
75
- return cached || fetchPromise
76
- }