AnasSarkiz/muse-voice-remote
Muse Voice Remote — bare ESP32-S3R8, 155 TOP JLCPCB parts with real supplier CAD, native 3D assembly and GLB exports. Revised 17 mm case allocation; 0.30 mm via drill /0.45 mm pad. Checked prototype PCB; mechanical, RF and MUSE qualification pending.
- Version
- 1.3.2
- License
- unset
- Stars
- 0
scripts/review.ts
import { assembly as a } from "../design/assembly"
import { parts, reviewBlockers } from "../design/parts"
import { catalogFor } from "../design/catalog"
import { readFileSync, existsSync } from "node:fs"
const escape = (s: string) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """)
export function createAssemblySvg() {
const scale = 16, ox = 670, oy = 590
const x = (v: number) => ox + v*scale, y = (v: number) => oy-v*scale
const rect = (cx: number, cy: number, w: number, h: number, fill: string, stroke: string, dashed=false) =>
`<rect x="${x(cx-w/2)}" y="${y(cy+h/2)}" width="${w*scale}" height="${h*scale}" rx="8" fill="${fill}" stroke="${stroke}" stroke-width="2" ${dashed?'stroke-dasharray="8 6"':''}/>`
const text = (cx: number,cy: number,s: string,size=16,color="#dbe8ef") => `<text x="${x(cx)}" y="${y(cy)}" text-anchor="middle" fill="${color}" font-size="${size}" font-family="sans-serif">${escape(s)}</text>`
let svg=`<svg xmlns="http://www.w3.org/2000/svg" width="1480" height="1230" viewBox="0 0 1480 1230"><rect width="1480" height="1230" fill="#101a24"/><text x="60" y="65" fill="#f0f7fb" font-size="36" font-family="sans-serif">Muse Voice Remote</text><text x="60" y="103" fill="#99b2c5" font-size="19" font-family="sans-serif">TOP SIDE ASSEMBLY · 66 × 48 × 1 mm PCB · all coordinates in mm</text><text x="60" y="137" fill="#f7bb70" font-size="17" font-family="sans-serif">Prototype assembly study — enclosure, RF and hardware behavior need physical verification</text>`
svg+=rect(0,0,72,53.5,"#172837","#536b7d")+rect(0,0,66,48,"#183d39","#6ea795")
for(const hole of a.mountingHoles) svg+=`<circle cx="${x(hole.x)}" cy="${y(hole.y)}" r="${1.6*scale}" fill="#657782"/><circle cx="${x(hole.x)}" cy="${y(hole.y)}" r="${0.9*scale}" fill="#101a24"/>`
// Draw imported land-pattern extents rather than a stale module envelope.
for(const reference of ["U1","U_FLASH","Y1","J_RF"]){
const p=parts.find(p=>p.name===reference)!,selected=catalogFor(p)
const imported=JSON.parse(readFileSync(`vendor/jlcpcb/${selected.lcsc}.circuit.json`,"utf8"))
const bounds=imported.find((e:{type:string})=>e.type==="pcb_component")
const quarterTurn=Math.abs((p.rotation??0)%180)===90
const w=quarterTurn?bounds.height:bounds.width,h=quarterTurn?bounds.width:bounds.height
svg+=rect(p.x,p.y,w,h,"#51606b","#a2b5c2")+text(p.x,p.y+0.6,p.name,14)
svg+=text(p.x,p.y-0.9,reference==="U1"?selected.mpn:reference==="U_FLASH"?"8 MB flash":reference==="Y1"?"40 MHz":"U.FL",11)
}
svg+=rect(a.battery.x,a.battery.y,a.battery.width,a.battery.height,"#d9b96e22","#e5bd6b",true)+text(-2,14,"BATTERY ABOVE TOP PCB",19,"#eec986")+text(-2,11.8,"52.5 × 35 × 5.7 MAX · NTC + protection required",14,"#eec986")
svg+=rect(a.speaker.x,a.speaker.y,a.speaker.width,a.speaker.height,"#3c6b9955","#74b4e3",true)+text(-15,-19.1,"SPEAKER / TBD",17,"#b7dcfa")+text(-15,-21.2,`${a.speaker.width} × ${a.speaker.height} × ${a.speaker.depth}`,14,"#b7dcfa")+text(-15,-23,"Elevated above USB",12,"#b7dcfa")
svg+=`<circle cx="${x(a.motor.x)}" cy="${y(a.motor.y)}" r="${a.motor.diameter/2*scale}" fill="#9465a844" stroke="#c5a0de" stroke-width="2" stroke-dasharray="8 6"/>`+text(25,-18,"ERM",18,"#e4c9f8")+text(25,-20,"Ø10 / TBD",12,"#e4c9f8")
for(let i=0;i<a.microphones.length;i++){const m=a.microphones[i];svg+=rect(m.x,m.y,3.5,2.65,"#68829a","#acc8db")+text(m.x,m.y+3.1,`MIC ${i+1}`,16)+text(m.x,m.y-3.1,"acoustic duct TBD",11)}
svg+=rect(0,20.5,7.5,4.5,"#2e897f","#81dac5")+text(0,26.6,"TALK · actuator Y=23",17)
svg+=rect(-32.5,3,5.2,7.8,"#7b655533","#eac09b",true)+rect(-29.5,3,3.5,8.5,"#7b6555","#eac09b")+text(-32.5,8.3,"PRIVACY · linkage TBD",13)
svg+=rect(32,-6,3.5,4.6,"#7b655533","#eac09b",true)+rect(29.5,-6,3.5,7.7,"#7b6555","#eac09b")+text(32,-1,"POWER · linkage TBD",13)
const usb=parts.find(p=>p.name==="J_USB")!
svg+=rect(usb.x,usb.y-1.26,8.94,7.35,"#788da0","#c0d0dd")+text(0,-27.8,"USB-C / charge + program",17)
const batteryConnector=parts.find(p=>p.name==="J_BAT")!
const batterySelected=catalogFor(batteryConnector)
const batteryImported=JSON.parse(readFileSync(`vendor/jlcpcb/${batterySelected.lcsc}.circuit.json`,"utf8"))
const batteryBounds=batteryImported.find((e:{type:string})=>e.type==="pcb_component")
const batteryQuarterTurn=Math.abs((batteryConnector.rotation??0)%180)===90
svg+=rect(batteryConnector.x,batteryConnector.y,batteryQuarterTurn?batteryBounds.height:batteryBounds.width,batteryQuarterTurn?batteryBounds.width:batteryBounds.height,"#a6a58388","#ff947e")+text(batteryConnector.x,batteryConnector.y,"BAT / NTC",12)+text(batteryConnector.x,batteryConnector.y-2,"JST PA · 6.2 mm mated",10,"#ff947e")
const lcd=parts.find(p=>p.name==="J_LCD")!
svg+=rect(lcd.x,lcd.y,6.2,29.5,"#91493e55","#e49a86")+text(lcd.x,lcd.y+2,"LCD FPC",14)+text(lcd.x,lcd.y,"50 pins",13)+text(lcd.x,lcd.y-2,"top contact",11)
for(const p of parts.filter(p=>p.name.startsWith("LED_")))svg+=`<circle cx="${x(p.x)}" cy="${y(p.y)}" r="7" fill="${p.value==='R'?'#ff786d':p.value==='G'?'#77dfb6':'#79c0fa'}"/>`
svg+=text(12,22.3,"STATUS",13)
for(const [ref,label] of [["U_CHARGE","CHARGER"],["U_REG","3.3 V"],["U_AMP","I2S AMP"]] as const){const p=parts.find(p=>p.name===ref)!;svg+=rect(p.x,p.y,3,3,"#253541","#a9c7d6")+text(p.x,p.y+2.7,label,11)}
svg+=`<path d="M ${x(-33)} ${y(-31)} H ${x(33)} M ${x(-33)} ${y(-30)} V ${y(-32)} M ${x(33)} ${y(-30)} V ${y(-32)}" stroke="#829bab" fill="none"/>`+text(0,-33.2,"66.00 PCB",17)
svg+=`<text x="60" y="1140" fill="#b5c8d6" font-family="sans-serif" font-size="16">MCU/support: imported land-pattern bounds. Dashed: unselected allocation or elevated body.</text><text x="60" y="1170" fill="#f7bb70" font-family="sans-serif" font-size="16">ER-TFT028A2-4: 2.8″ IPS · no touch · 320 × 240 landscape · front-facing, flex folds to top PCB.</text><text x="60" y="1200" fill="#f7bb70" font-family="sans-serif" font-size="16">PDF is an envelope study. Nominal Z clearances, LCD flex fold, RF and harness still require engineering.</text></svg>`
return svg
}
export function createReviewHtml(pcb: string, schematic: string, top: string) {
const stock=JSON.parse(readFileSync("docs/stock-evidence.json","utf8"))
const version=JSON.parse(readFileSync("package.json","utf8")).version
const manufacturingReady=existsSync("manufacturing/verification.json") && JSON.parse(readFileSync("manufacturing/verification.json","utf8")).version===version
const manufacturingStatus=manufacturingReady ? `Checked prototype manufacturing files: <a href="../manufacturing/MuseVoiceRemote-fabrication-v${version}.zip">fabrication ZIP</a>.` : "Manufacturing export is pending final native routing checks."
const routeStatus=process.env.MUSE_ROUTE ? "Routing and independent checks are pending for this placement preview." : "Verified native copper: zero DRC violations and zero unconnected pads."
const rows=parts.map(p=>{
const selected=catalogFor(p)
const record=selected ? stock.parts.find((e:{mpn:string;stock:number|null})=>e.mpn===selected.mpn && e.stock!==null && e.stock>0) : undefined
return `<tr><td>${escape(p.name)}</td><td>${escape(selected?.mpn??p.value)}<br><small>${escape(selected?.library??"Unplaced / unresolved")}</small></td><td>${p.x}, ${p.y}</td><td>${record?`${escape(record.supplier)}: ${record.stock.toLocaleString()} in snapshot`:"Unverified"}</td><td>${escape(p.status??"Electrical/enclosure qualification pending")}</td></tr>`
}).join("")
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Muse Voice Remote — design review</title>
<style>*{box-sizing:border-box}body{margin:0;background:#101a24;color:#e2edf3;font:16px/1.55 system-ui,sans-serif}main{max-width:1500px;margin:auto;padding:30px}h1{font-size:38px;margin:0}p{color:#b0c4d3}nav{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}button{background:#223545;border:1px solid #4b687d;color:#e2edf3;padding:10px 20px;border-radius:8px;cursor:pointer}button[aria-selected=true]{background:#20615b;border-color:#78c9b5}.badge{color:#f1c481}section{display:none}section.active{display:block}.canvas{overflow:auto;border:1px solid #355168;border-radius:12px;background:#101a24}.canvas svg{width:100%;height:auto;display:block}#schematic .canvas{background:white;min-width:900px}table{width:100%;border-collapse:collapse;font-size:14px}td,th{border-bottom:1px solid #355168;text-align:left;padding:12px;vertical-align:top}th{color:#8bcfbd}li{margin:12px 0}a{color:#8bcfbd}.note{border-left:3px solid #e4b768;padding:10px 18px;background:#d9b96e12}input{width:100%;padding:10px;background:#223545;color:#e2edf3;border:1px solid #4b687d;margin:16px 0}small{color:#99b2c5}</style>
<main><small>tscircuit · electrical architecture + top side placement</small><h1>Muse Voice Remote</h1><p>Rechargeable Wi-Fi voice handheld · ESP32-S3 · dual microphones · TALK · display · speaker · vibration · hardware privacy</p><p class="badge">PROTOTYPE PCB — MUSE runs on the connected service; integration unverified.</p>
<nav role="tablist">${[["assembly3d","3D assembly"],["assembly","Top assembly"],["pcb","tscircuit PCB"],["schematic","Schematic"],["parts","Parts & placement"],["review","Open engineering items"]].map(([id,name],i)=>`<button role="tab" aria-selected="${i===0}" data-tab="${id}">${name}</button>`).join("")}</nav>
<section id="assembly3d" class="active"><p><a href="assembly/">Open interactive 3D assembly</a> · <a href="assembly/MuseVoiceRemote-assembly.glb">Assembly GLB</a> · <a href="assembly/MuseVoiceRemote-populated-pcb.glb">Populated PCB GLB</a></p><iframe title="Muse Voice Remote interactive 3D assembly" src="assembly/" style="width:100%;height:850px;border:1px solid #355168;border-radius:12px"></iframe><p>All 155 imported supplier models are loaded. The case proposal is now 17 mm deep, with the battery shifted +1 mm in X and raised 0.5 mm to clear the actual switch CAD. Mechanical envelopes remain labelled and unqualified.</p></section>
<section id="assembly"><div class="canvas">${top}</div><p>Coordinates follow page 2 of the assembly PDF without mirroring. Top means the populated PCB face toward the internal battery/rear cover; the front LCD connects by flex. All PCB components are on that one face.</p></section>
<section id="pcb"><div class="canvas">${pcb}</div><p>tscircuit source and imported library footprints. Native KiCad CAD preserves original pad shapes and is authoritative for routing checks. Populated PCB: ${parts.filter(p=>catalogFor(p)).length} top-side components. ${routeStatus} The LCD interface uses the exact manufacturer pinout and an imported 50-pin top-contact connector.</p><a href="circuit.json">Circuit JSON</a> · <a href="pcb-top.svg">PCB SVG</a> · <a href="MuseVoiceRemote.kicad_pcb">Native CAD</a> · <a href="MuseVoiceRemote.kicad_pro">CAD rules</a> · <a href="native-drc.txt">Retained independent DRC</a> · <a href="diagnostics.json">tscircuit diagnostics</a></section>
<section id="schematic"><div class="canvas">${schematic}</div><p>ER-TFT028A2-4 uses 2.8 V power/logic, five level buffers, IM=1110 serial-II straps and a nominal 40 mA total current backlight. GPIO42 provides separate hard-off enable; GPIO48 provides PWM. Flex fit and hardware behavior need qualification. Privacy switch state is documented in README.md.</p><a href="schematic.svg">Schematic SVG</a></section>
<section id="parts"><input id="filter" aria-label="Filter parts" placeholder="Filter reference, part or qualification"><div style="overflow:auto"><table><thead><tr><th>Reference</th><th>MPN / imported library</th><th>X, Y mm</th><th>Stock snapshot</th><th>Qualification / limitations</th></tr></thead><tbody>${rows}</tbody></table></div><p>Inventory checked ${escape(stock.checked_at)}. Final quantity is subject to distributor checkout.</p><p><a href="bom.csv">Download populated PCB BOM</a> · <a href="placement.json">Placement + net assignment JSON</a></p></section>
<section id="review"><div class="note">${manufacturingStatus} Enclosure fit, RF, acoustic and electrical behavior require physical prototype testing.</div><ul>${reviewBlockers.map(s=>`<li>${escape(s)}</li>`).join("")}</ul><p>Retain the exact ER-TFT028A2-4, 320 × 240 landscape, no touch requirement. No approve/reject buttons or encoder are present.</p></section></main>
<script>document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{document.querySelectorAll('section').forEach(s=>s.classList.toggle('active',s.id===b.dataset.tab));document.querySelectorAll('[data-tab]').forEach(t=>t.setAttribute('aria-selected',t===b))});document.getElementById('filter').oninput=e=>document.querySelectorAll('tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(e.target.value.toLowerCase()));</script></html>`
}