zebra-report/web/chat.html
Russell Ballestrini 9e67b05f2f chat.html: share-link + QR for SDP exchange (near-automated handshake)
manual copy-paste of raw SDP was v1. this adds:

1. share-link encoding: SDP description → JSON → deflate-raw (native
   CompressionStream) → base64url → URL hash. typical 2 KB SDP fits
   in ~700 chars after compression. fits in any text channel.
   URL format:  .../zebra-report/#o=<deflated-base64>    (offer)
                .../zebra-report/#a=<deflated-base64>    (answer)

2. QR rendering: same URL rendered as 180x180 QR using inlined
   davidshimjs qrcodejs (MIT, 28 KB). offers a visual scan path
   (phone scanners) without an in-browser decoder library yet.

3. auto-fill on link open: page reads location.hash on load.
     #o=... → autofills remote-offer textarea, prompts user to
              complete identity + audio + click "create answer"
     #a=... → autofills remote-answer textarea, prompts "accept"
   hash is cleared from address bar after parse so a reload doesn't
   double-trigger.

4. parsers tolerate either input form: full URL with #o=/#a= hash,
   or raw JSON SDP. peer A can paste back a URL or a textarea dump,
   same handler.

5. tucked the raw SDP textareas behind a "show raw SDP" toggle so
   the default UI is just the URL + QR. expert users still get the
   raw bytes when needed.

UX flow (cross-internet, two peers):
  A: enter room → start audio → "create offer" → "copy link"
     → send link to B via Signal/SMS/anywhere
  B: open link → page auto-fills offer → enter room → start audio
     → "create answer" → "copy link" → send back to A
  A: paste link into the answer textarea → "accept answer"
  → SRTP candidates pair → chat begins.

deferred: in-browser camera scanning of QR (needs jsQR or similar
~60 KB inlined; not on disk currently). v3.

file: 1509 lines, ~85 KB. JS syntax-clean (qrcode lib + main IIFE),
HTML balanced. CompressionStream + DecompressionStream available
in all current browsers (Chrome 80+, Firefox 113+, Safari 16.4+).
2026-05-27 18:37:18 -04:00

1509 lines
83 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra report — chat</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal; font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace; background: #fff; color: #000;
padding: 2rem; max-width: 820px; margin: 0 auto;
}
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal;
letter-spacing: 0.02em; line-height: 1;
margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem; color: #555; margin-bottom: 2.5rem;
letter-spacing: 0.05em; text-transform: uppercase;
}
section { margin-bottom: 2rem; }
h2 {
font-family: 'chunkfiveregular', serif;
font-size: 1.1rem; font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.25rem; margin-bottom: 0.8rem;
}
button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.4rem 1.2rem; font-family: monospace; font-size: 0.85rem;
cursor: pointer;
}
button:hover:not(:disabled) { background: #f0f0f0; }
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
.row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.dot {
width: 9px; height: 9px; border-radius: 50%;
border: 1px solid #000; background: #fff;
flex-shrink: 0; transition: background 0.15s;
}
.dot.on { background: #000; }
.dot.warn { background: #888; }
.dot.ok { background: #060; border-color: #060; }
label, .note { font-size: 0.75rem; color: #555; }
.note { margin-top: 0.4rem; line-height: 1.5; }
input[type=text], input[type=password], textarea, select {
font-family: monospace; font-size: 0.85rem;
border: 1px solid #000; padding: 0.4rem;
background: #fff; color: #000;
}
input[type=text], input[type=password], select { width: 100%; }
textarea { width: 100%; resize: vertical; min-height: 4.5rem; }
.field-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
.field-row > input, .field-row > textarea { flex: 1; }
.key-display {
border: 1px solid #ccc; padding: 0.5rem;
font-family: monospace; font-size: 0.7rem;
word-break: break-all; cursor: pointer;
user-select: all; background: #f8f8f8;
line-height: 1.4;
}
.key-display:hover { background: #efefef; }
.status-line { font-size: 0.7rem; color: #555; }
.status-line.ok { color: #060; }
.status-line.err { color: #b00; }
.meter {
height: 12px; border: 1px solid #000; background: #fff;
position: relative; overflow: hidden;
}
.meter-fill {
height: 100%; background: #000; width: 0%;
transition: width 0.05s linear;
}
.log {
border: 1px solid #000; height: 280px; overflow-y: auto;
padding: 0.5rem; font-size: 0.78rem; line-height: 1.5;
background: #fafafa;
}
.log-line { margin-bottom: 0.25rem; word-wrap: break-word; }
.log-line .ts { color: #888; }
.log-line .from { font-weight: bold; }
.log-line.sys { color: #555; font-style: italic; }
.log-line.me { color: #000; }
.log-line.peer { color: #050; }
.log-line.err { color: #b00; }
.peer-list {
font-size: 0.75rem; color: #444;
border: 1px solid #ccc; padding: 0.5rem;
background: #f8f8f8;
}
.peer-list .peer { margin-bottom: 0.15rem; }
.peer-list .peer .h { font-weight: bold; }
.peer-list .empty { color: #999; font-style: italic; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 600px) {
body { padding: 1rem; }
.grid-2 { grid-template-columns: 1fr; }
}
code {
background: #f0f0f0; padding: 0 0.2rem;
font-size: 0.78rem; border: 1px solid #ddd;
}
details > summary {
cursor: pointer; font-size: 0.8rem;
padding: 0.3rem 0; user-select: none;
}
details[open] > summary { margin-bottom: 0.5rem; }
.mode-toggle {
display: inline-flex; border: 1px solid #000;
font-size: 0.78rem; user-select: none;
}
.mode-toggle > label {
padding: 0.35rem 0.8rem; cursor: pointer;
color: #000;
}
.mode-toggle > input { display: none; }
.mode-toggle > input:checked + label { background: #000; color: #fff; }
.mode-panel { display: none; }
.mode-panel.active { display: block; }
.rtc-role { margin-top: 0.6rem; padding: 0.5rem; border: 1px dashed #999; }
.rtc-role h3 { font-size: 0.85rem; margin-bottom: 0.4rem; font-weight: normal; font-family: monospace; }
.step { font-size: 0.7rem; color: #777; margin: 0.5rem 0 0.2rem; }
.share-box {
margin: 0.4rem 0;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
align-items: start;
}
.share-box .url-side { min-width: 0; }
.share-box .qr-canvas {
width: 180px; height: 180px;
border: 1px solid #ccc; background: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem; color: #999;
}
.share-box input { width: 100%; font-size: 0.7rem; }
.share-box .copy-row { display: flex; gap: 0.4rem; margin-top: 0.3rem; }
.share-box .copy-row button { font-size: 0.75rem; padding: 0.25rem 0.7rem; }
@media (max-width: 600px) {
.share-box { grid-template-columns: 1fr; }
.share-box .qr-canvas { justify-self: center; }
}
</style>
</head>
<body>
<h1>zebra report</h1>
<p class="sub">volume modem chatroom &nbsp;·&nbsp; e2e encrypted &nbsp;·&nbsp; webrtc carrier &nbsp;·&nbsp;
<a href="/" style="color:#555">unturf</a></p>
<!-- ============================================================ -->
<section>
<h2>identity</h2>
<div class="row">
<label for="handle-in">handle:</label>
<input type="text" id="handle-in" style="max-width:14rem" placeholder="your handle">
<span class="status-line" id="id-status"></span>
</div>
<div class="row" style="margin-top:0.4rem">
<span class="mode-toggle">
<input type="radio" name="mode" value="passphrase" id="m-pp" checked>
<label for="m-pp">passphrase</label>
<input type="radio" name="mode" value="pubkey" id="m-pk">
<label for="m-pk">pubkey</label>
</span>
<span class="note">passphrase mode = group; pubkey mode = 1-to-1 ECDH.</span>
</div>
</section>
<!-- ============================================================ -->
<section id="sec-passphrase" class="mode-panel active">
<h2>room (passphrase mode)</h2>
<div class="row">
<input type="password" id="passphrase-in" placeholder="room passphrase — everyone in the room types the same one"
style="flex:1; min-width:0">
</div>
<div class="row">
<button id="btn-pp-join" class="invert">enter room</button>
<span class="status-line" id="pp-status">no room key</span>
</div>
<p class="note">
PBKDF2-SHA256 (600k iter) of passphrase + literal salt "zebra-report-v1"
→ 256-bit AES-GCM group key.
</p>
</section>
<!-- ============================================================ -->
<section id="sec-pubkey" class="mode-panel">
<h2>room (pubkey mode)</h2>
<p class="note" style="margin-bottom:0.5rem">your public key (click to copy):</p>
<div class="key-display" id="pub-display">generating…</div>
<div class="row" style="margin-top:0.5rem">
<button id="btn-regen">regenerate</button>
<span class="status-line" id="key-status"></span>
</div>
<div class="field-row" style="margin-top:0.5rem">
<input type="text" id="peer-key-in" placeholder="paste a peer's public key…">
<button id="btn-pk-add">add peer</button>
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>carrier</h2>
<div class="row">
<div class="dot" id="dot-audio"></div>
<button id="btn-audio">start audio</button>
<label id="audio-status">offline</label>
<span class="note" style="margin-left:auto">
<label><input type="checkbox" id="monitor-toggle"> hear local carrier</label>
</span>
</div>
<div class="row" style="margin-top:0.4rem">
<span class="note" style="min-width:6rem">tx gain:</span>
<div class="meter" style="flex:1"><div class="meter-fill" id="meter-tx"></div></div>
<span class="note" id="gain-label" style="min-width:3rem; text-align:right">0%</span>
</div>
<div class="row">
<span class="note" style="min-width:6rem">rx energy:</span>
<div class="meter" style="flex:1"><div class="meter-fill" id="meter-rx"></div></div>
<span class="note" id="rx-label" style="min-width:3rem; text-align:right"></span>
</div>
<p class="note">
a 440/441&nbsp;Hz stereo carrier is generated locally. each frame swings the
output gain MARK (0.80) ↔ SPACE (0.20) per bit. the modulated audio is the
outbound WebRTC track for every connected peer. mic stays off.
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>peer connection (WebRTC)</h2>
<div class="row">
<div class="dot warn" id="dot-rtc"></div>
<span id="rtc-status" class="note">disconnected — start audio first, then pick a role below</span>
<button id="btn-reset-rtc" style="margin-left:auto">reset connection</button>
</div>
<details>
<summary>stuck on the same LAN? host candidates as <code>.local</code>?</summary>
<p class="note">
modern browsers anonymize host IPs as <code>.local</code> mDNS names for
privacy. if a router blocks multicast (common on guest Wi-Fi, sometimes
consumer defaults), peers cannot resolve each other's <code>.local</code>
addresses & host candidates fail. workaround: turn off the obfuscation.
</p>
<p class="note" style="margin-top:0.3rem">
Firefox: <code>about:config</code> → set
<code>media.peerconnection.ice.obfuscate_host_addresses</code> to <code>false</code>.<br>
Chromium: <code>chrome://flags/#enable-webrtc-hide-local-ips-with-mdns</code>
→ set to Disabled.<br>
reload the page after toggling, then retry.
</p>
</details>
<div class="rtc-role">
<h3>role A · start a new connection</h3>
<p class="step">step 1: click, then share the link with your peer (Signal, SMS, anywhere). They open it on their device.</p>
<div class="row">
<button id="btn-create-offer">create offer</button>
<span class="status-line" id="offer-status"></span>
</div>
<div class="share-box" id="share-offer-box" style="display:none">
<div class="url-side">
<input type="text" id="share-offer-url" readonly>
<div class="copy-row">
<button id="btn-copy-offer-url">copy link</button>
<button id="btn-toggle-offer-raw" style="background:#fff;color:#555;border:1px dashed #999">show raw SDP</button>
</div>
</div>
<div class="qr-canvas" id="qr-offer">QR appears here</div>
</div>
<textarea id="local-offer" readonly placeholder="(offer SDP appears here once gathered)" style="display:none"></textarea>
<p class="step">step 4: paste the answer link (or raw SDP) your peer sends back:</p>
<textarea id="remote-answer" placeholder="(paste peer's answer link or SDP)"></textarea>
<div class="row" style="margin-top:0.4rem">
<button id="btn-accept-answer">accept answer</button>
<span class="status-line" id="answer-status"></span>
</div>
</div>
<div class="rtc-role" style="margin-top:0.6rem">
<h3>role B · join an existing connection</h3>
<p class="step">step 2: paste the offer link (or raw SDP) you received:</p>
<textarea id="remote-offer" placeholder="(paste peer's offer link or SDP)"></textarea>
<div class="row" style="margin-top:0.4rem">
<button id="btn-create-answer">create answer</button>
<span class="status-line" id="ans-status"></span>
</div>
<p class="step">step 3: share this answer link back to your peer.</p>
<div class="share-box" id="share-answer-box" style="display:none">
<div class="url-side">
<input type="text" id="share-answer-url" readonly>
<div class="copy-row">
<button id="btn-copy-answer-url">copy link</button>
<button id="btn-toggle-answer-raw" style="background:#fff;color:#555;border:1px dashed #999">show raw SDP</button>
</div>
</div>
<div class="qr-canvas" id="qr-answer">QR appears here</div>
</div>
<textarea id="local-answer" readonly placeholder="(your answer SDP appears here once gathered)" style="display:none"></textarea>
</div>
<p class="note">
each browser modulates its own outbound audio track (no mic). incoming peer
audio is decoded in-page via <code>AudioWorklet</code>, no native daemon
required. Wireshark sees only encrypted SRTP &mdash; chat content lives in
audio amplitude transitions inside that stream.
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>handshake</h2>
<div class="row">
<button id="btn-bench">benchmark self</button>
<span class="status-line" id="bench-status">not yet measured</span>
</div>
<div class="row">
<button id="btn-offer" disabled>send OFFER frame</button>
<span class="status-line" id="hs-status">idle</span>
</div>
<div class="row">
<span class="note" style="min-width:8rem">negotiated baud:</span>
<span id="baud-label" style="font-weight:bold"></span>
</div>
<p class="note">
OFFER and READY frames travel at fixed <code>ZEBRA_BAUD_HANDSHAKE = 50</code>.
each peer measures its own scheduling jitter, broadcasts a max baud, room
settles on <code>min(all)</code>.
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>chat</h2>
<div class="grid-2">
<div>
<p class="note" style="margin-bottom:0.3rem">peers in room:</p>
<div class="peer-list" id="peer-list">
<div class="empty">none yet</div>
</div>
</div>
<div>
<p class="note" style="margin-bottom:0.3rem">log:</p>
<div class="log" id="log"></div>
</div>
</div>
<div class="field-row" style="margin-top:0.6rem">
<input type="text" id="msg-in" placeholder="type a message and press enter">
<button id="btn-send" disabled>send</button>
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>about</h2>
<p class="note">
The protocol's data path is audio amplitude inside an encrypted WebRTC
SRTP stream. The signaling channel (SDP / ICE) is exchanged once at
connection setup &mdash; it contains no chat content. Every subsequent
bit travels as a MARK/SPACE swing in the carrier audio.
</p>
<details style="margin-top:0.5rem">
<summary>threat model · what this protects against · what it doesn't</summary>
<p class="note" style="margin-top:0.4rem">
<strong>Protects against:</strong> a network-level observer (Wireshark,
IDS, DPI) reading chat content. The bits are inside the SRTP audio
payload, which is end-to-end encrypted by WebRTC, then additionally
encrypted at the app layer (AES-GCM via passphrase or ECDH).
</p>
<p class="note" style="margin-top:0.4rem">
<strong>Does not protect against:</strong>
(1) other same-UID processes that can poll PulseAudio or record the
monitor source on either peer's machine &mdash; <em>that</em> is the
attack surface this whole project documents;
(2) an endpoint compromise of either peer's machine;
(3) traffic-analysis correlation against the encrypted SRTP flow.
</p>
<p class="note" style="margin-top:0.4rem">
<strong>Mitigation for defenders:</strong> require a capability for
same-UID PulseAudio reads. The current Linux audio IPC trust model
grants every same-UID process unrestricted access &mdash; the defect
<a href="https://foxhop.net/linux-audio-ipc-attack-surface.html"
style="color:#555">whitepaper</a> documents this.
</p>
</details>
</section>
<!-- ============================================================ -->
<!-- inlined qrcode.js (MIT, davidshimjs) — see header below -->
<!-- ============================================================ -->
<script>
/*
The MIT License (MIT)
---------------------
Copyright (c) 2012 davidshimjs
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var QRCode;(function(){function QR8bitByte(data){this.mode=QRMode.MODE_8BIT_BYTE;this.data=data;this.parsedData=[];for(var i=0,l=this.data.length;i<l;i++){var byteArray=[];var code=this.data.charCodeAt(i);if(code>65536){byteArray[0]=240|(code&1835008)>>>18;byteArray[1]=128|(code&258048)>>>12;byteArray[2]=128|(code&4032)>>>6;byteArray[3]=128|code&63}else if(code>2048){byteArray[0]=224|(code&61440)>>>12;byteArray[1]=128|(code&4032)>>>6;byteArray[2]=128|code&63}else if(code>128){byteArray[0]=192|(code&1984)>>>6;byteArray[1]=128|code&63}else{byteArray[0]=code}this.parsedData.push(byteArray)}this.parsedData=Array.prototype.concat.apply([],this.parsedData);if(this.parsedData.length!=this.data.length){this.parsedData.unshift(191);this.parsedData.unshift(187);this.parsedData.unshift(239)}}QR8bitByte.prototype={getLength:function(buffer){return this.parsedData.length},write:function(buffer){for(var i=0,l=this.parsedData.length;i<l;i++){buffer.put(this.parsedData[i],8)}}};function QRCodeModel(typeNumber,errorCorrectLevel){this.typeNumber=typeNumber;this.errorCorrectLevel=errorCorrectLevel;this.modules=null;this.moduleCount=0;this.dataCache=null;this.dataList=[]}QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col)}return this.modules[row][col]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(false,this.getBestMaskPattern())},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row<this.moduleCount;row++){this.modules[row]=new Array(this.moduleCount);for(var col=0;col<this.moduleCount;col++){this.modules[row][col]=null}}this.setupPositionProbePattern(0,0);this.setupPositionProbePattern(this.moduleCount-7,0);this.setupPositionProbePattern(0,this.moduleCount-7);this.setupPositionAdjustPattern();this.setupTimingPattern();this.setupTypeInfo(test,maskPattern);if(this.typeNumber>=7){this.setupTypeNumber(test)}if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)}this.mapData(this.dataCache,maskPattern)},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if(0<=r&&r<=6&&(c==0||c==6)||0<=c&&c<=6&&(r==0||r==6)||2<=r&&r<=4&&2<=c&&c<=4){this.modules[row+r][col+c]=true}else{this.modules[row+r][col+c]=false}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i}}return pattern},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row<this.modules.length;row++){var y=row*cs;for(var col=0;col<this.modules[row].length;col++){var x=col*cs;var dark=this.modules[row][col];if(dark){qr_mc.beginFill(0,100);qr_mc.moveTo(x,y);qr_mc.lineTo(x+cs,y);qr_mc.lineTo(x+cs,y+cs);qr_mc.lineTo(x,y+cs);qr_mc.endFill()}}}return qr_mc},setupTimingPattern:function(){for(var r=8;r<this.moduleCount-8;r++){if(this.modules[r][6]!=null){continue}this.modules[r][6]=r%2==0}for(var c=8;c<this.moduleCount-8;c++){if(this.modules[6][c]!=null){continue}this.modules[6][c]=c%2==0}},setupPositionAdjustPattern:function(){var pos=QRUtil.getPatternPosition(this.typeNumber);for(var i=0;i<pos.length;i++){for(var j=0;j<pos.length;j++){var row=pos[i];var col=pos[j];if(this.modules[row][col]!=null){continue}for(var r=-2;r<=2;r++){for(var c=-2;c<=2;c++){if(r==-2||r==2||c==-2||c==2||r==0&&c==0){this.modules[row+r][col+c]=true}else{this.modules[row+r][col+c]=false}}}}}},setupTypeNumber:function(test){var bits=QRUtil.getBCHTypeNumber(this.typeNumber);for(var i=0;i<18;i++){var mod=!test&&(bits>>i&1)==1;this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod}for(var i=0;i<18;i++){var mod=!test&&(bits>>i&1)==1;this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod}},setupTypeInfo:function(test,maskPattern){var data=this.errorCorrectLevel<<3|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=!test&&(bits>>i&1)==1;if(i<6){this.modules[i][8]=mod}else if(i<8){this.modules[i+1][8]=mod}else{this.modules[this.moduleCount-15+i][8]=mod}}for(var i=0;i<15;i++){var mod=!test&&(bits>>i&1)==1;if(i<8){this.modules[8][this.moduleCount-i-1]=mod}else if(i<9){this.modules[8][15-i-1+1]=mod}else{this.modules[8][15-i-1]=mod}}this.modules[this.moduleCount-8][8]=!test},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex<data.length){dark=(data[byteIndex]>>>bitIndex&1)==1}var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark}this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7}}}row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break}}}}};QRCodeModel.PAD0=236;QRCodeModel.PAD1=17;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer;for(var i=0;i<dataList.length;i++){var data=dataList[i];buffer.put(data.mode,4);buffer.put(data.getLength(),QRUtil.getLengthInBits(data.mode,typeNumber));data.write(buffer)}var totalDataCount=0;for(var i=0;i<rsBlocks.length;i++){totalDataCount+=rsBlocks[i].dataCount}if(buffer.getLengthInBits()>totalDataCount*8){throw new Error("code length overflow. ("+buffer.getLengthInBits()+">"+totalDataCount*8+")")}if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4)}while(buffer.getLengthInBits()%8!=0){buffer.putBit(false)}while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break}buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break}buffer.put(QRCodeModel.PAD1,8)}return QRCodeModel.createBytes(buffer,rsBlocks)};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r<rsBlocks.length;r++){var dcCount=rsBlocks[r].dataCount;var ecCount=rsBlocks[r].totalCount-dcCount;maxDcCount=Math.max(maxDcCount,dcCount);maxEcCount=Math.max(maxEcCount,ecCount);dcdata[r]=new Array(dcCount);for(var i=0;i<dcdata[r].length;i++){dcdata[r][i]=255&buffer.buffer[i+offset]}offset+=dcCount;var rsPoly=QRUtil.getErrorCorrectPolynomial(ecCount);var rawPoly=new QRPolynomial(dcdata[r],rsPoly.getLength()-1);var modPoly=rawPoly.mod(rsPoly);ecdata[r]=new Array(rsPoly.getLength()-1);for(var i=0;i<ecdata[r].length;i++){var modIndex=i+modPoly.getLength()-ecdata[r].length;ecdata[r][i]=modIndex>=0?modPoly.get(modIndex):0}}var totalCodeCount=0;for(var i=0;i<rsBlocks.length;i++){totalCodeCount+=rsBlocks[i].totalCount}var data=new Array(totalCodeCount);var index=0;for(var i=0;i<maxDcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<dcdata[r].length){data[index++]=dcdata[r][i]}}}for(var i=0;i<maxEcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<ecdata[r].length){data[index++]=ecdata[r][i]}}}return data};var QRMode={MODE_NUMBER:1<<0,MODE_ALPHA_NUM:1<<1,MODE_8BIT_BYTE:1<<2,MODE_KANJI:1<<3};var QRErrorCorrectLevel={L:1,M:0,Q:3,H:2};var QRMaskPattern={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var QRUtil={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1<<10|1<<8|1<<5|1<<4|1<<2|1<<1|1<<0,G18:1<<12|1<<11|1<<10|1<<9|1<<8|1<<5|1<<2|1<<0,G15_MASK:1<<14|1<<12|1<<10|1<<4|1<<1,getBCHTypeInfo:function(data){var d=data<<10;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)>=0){d^=QRUtil.G15<<QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)}return(data<<10|d)^QRUtil.G15_MASK},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=QRUtil.G18<<QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)}return data<<12|d},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1}return digit},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1]},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return i*j%2+i*j%3==0;case QRMaskPattern.PATTERN110:return(i*j%2+i*j%3)%2==0;case QRMaskPattern.PATTERN111:return(i*j%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern)}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i<errorCorrectLength;i++){a=a.multiply(new QRPolynomial([1,QRMath.gexp(i)],0))}return a},getLengthInBits:function(mode,type){if(1<=type&&type<10){switch(mode){case QRMode.MODE_NUMBER:return 10;case QRMode.MODE_ALPHA_NUM:return 9;case QRMode.MODE_8BIT_BYTE:return 8;case QRMode.MODE_KANJI:return 8;default:throw new Error("mode:"+mode)}}else if(type<27){switch(mode){case QRMode.MODE_NUMBER:return 12;case QRMode.MODE_ALPHA_NUM:return 11;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 10;default:throw new Error("mode:"+mode)}}else if(type<41){switch(mode){case QRMode.MODE_NUMBER:return 14;case QRMode.MODE_ALPHA_NUM:return 13;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 12;default:throw new Error("mode:"+mode)}}else{throw new Error("type:"+type)}},getLostPoint:function(qrCode){var moduleCount=qrCode.getModuleCount();var lostPoint=0;for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount;col++){var sameCount=0;var dark=qrCode.isDark(row,col);for(var r=-1;r<=1;r++){if(row+r<0||moduleCount<=row+r){continue}for(var c=-1;c<=1;c++){if(col+c<0||moduleCount<=col+c){continue}if(r==0&&c==0){continue}if(dark==qrCode.isDark(row+r,col+c)){sameCount++}}}if(sameCount>5){lostPoint+=3+sameCount-5}}}for(var row=0;row<moduleCount-1;row++){for(var col=0;col<moduleCount-1;col++){var count=0;if(qrCode.isDark(row,col))count++;if(qrCode.isDark(row+1,col))count++;if(qrCode.isDark(row,col+1))count++;if(qrCode.isDark(row+1,col+1))count++;if(count==0||count==4){lostPoint+=3}}}for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount-6;col++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row,col+1)&&qrCode.isDark(row,col+2)&&qrCode.isDark(row,col+3)&&qrCode.isDark(row,col+4)&&!qrCode.isDark(row,col+5)&&qrCode.isDark(row,col+6)){lostPoint+=40}}}for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount-6;row++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row+1,col)&&qrCode.isDark(row+2,col)&&qrCode.isDark(row+3,col)&&qrCode.isDark(row+4,col)&&!qrCode.isDark(row+5,col)&&qrCode.isDark(row+6,col)){lostPoint+=40}}}var darkCount=0;for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount;row++){if(qrCode.isDark(row,col)){darkCount++}}}var ratio=Math.abs(100*darkCount/moduleCount/moduleCount-50)/5;lostPoint+=ratio*10;return lostPoint}};var QRMath={glog:function(n){if(n<1){throw new Error("glog("+n+")")}return QRMath.LOG_TABLE[n]},gexp:function(n){while(n<0){n+=255}while(n>=256){n-=255}return QRMath.EXP_TABLE[n]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<<i}for(var i=8;i<256;i++){QRMath.EXP_TABLE[i]=QRMath.EXP_TABLE[i-4]^QRMath.EXP_TABLE[i-5]^QRMath.EXP_TABLE[i-6]^QRMath.EXP_TABLE[i-8]}for(var i=0;i<255;i++){QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]]=i}function QRPolynomial(num,shift){if(num.length==undefined){throw new Error(num.length+"/"+shift)}var offset=0;while(offset<num.length&&num[offset]==0){offset++}this.num=new Array(num.length-offset+shift);for(var i=0;i<num.length-offset;i++){this.num[i]=num[i+offset]}}QRPolynomial.prototype={get:function(index){return this.num[index]},getLength:function(){return this.num.length},multiply:function(e){var num=new Array(this.getLength()+e.getLength()-1);for(var i=0;i<this.getLength();i++){for(var j=0;j<e.getLength();j++){num[i+j]^=QRMath.gexp(QRMath.glog(this.get(i))+QRMath.glog(e.get(j)))}}return new QRPolynomial(num,0)},mod:function(e){if(this.getLength()-e.getLength()<0){return this}var ratio=QRMath.glog(this.get(0))-QRMath.glog(e.get(0));var num=new Array(this.getLength());for(var i=0;i<this.getLength();i++){num[i]=this.get(i)}for(var i=0;i<e.getLength();i++){num[i]^=QRMath.gexp(QRMath.glog(e.get(i))+ratio)}return new QRPolynomial(num,0).mod(e)}};function QRRSBlock(totalCount,dataCount){this.totalCount=totalCount;this.dataCount=dataCount}QRRSBlock.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]];QRRSBlock.getRSBlocks=function(typeNumber,errorCorrectLevel){var rsBlock=QRRSBlock.getRsBlockTable(typeNumber,errorCorrectLevel);if(rsBlock==undefined){throw new Error("bad rs block @ typeNumber:"+typeNumber+"/errorCorrectLevel:"+errorCorrectLevel)}var length=rsBlock.length/3;var list=[];for(var i=0;i<length;i++){var count=rsBlock[i*3+0];var totalCount=rsBlock[i*3+1];var dataCount=rsBlock[i*3+2];for(var j=0;j<count;j++){list.push(new QRRSBlock(totalCount,dataCount))}}return list};QRRSBlock.getRsBlockTable=function(typeNumber,errorCorrectLevel){switch(errorCorrectLevel){case QRErrorCorrectLevel.L:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+0];case QRErrorCorrectLevel.M:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+1];case QRErrorCorrectLevel.Q:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+2];case QRErrorCorrectLevel.H:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+3];default:return undefined}};function QRBitBuffer(){this.buffer=[];this.length=0}QRBitBuffer.prototype={get:function(index){var bufIndex=Math.floor(index/8);return(this.buffer[bufIndex]>>>7-index%8&1)==1},put:function(num,length){for(var i=0;i<length;i++){this.putBit((num>>>length-i-1&1)==1)}},getLengthInBits:function(){return this.length},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0)}if(bit){this.buffer[bufIndex]|=128>>>this.length%8}this.length++}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];function _isSupportCanvas(){return typeof CanvasRenderingContext2D!="undefined"}function _getAndroid(){var android=false;var sAgent=navigator.userAgent;if(/android/i.test(sAgent)){android=true;var aMat=sAgent.toString().match(/android ([0-9]\.[0-9])/i);if(aMat&&aMat[1]){android=parseFloat(aMat[1])}}return android}var svgDrawer=function(){var Drawing=function(el,htOption){this._el=el;this._htOption=htOption};Drawing.prototype.draw=function(oQRCode){var _htOption=this._htOption;var _el=this._el;var nCount=oQRCode.getModuleCount();var nWidth=Math.floor(_htOption.width/nCount);var nHeight=Math.floor(_htOption.height/nCount);this.clear();function makeSVG(tag,attrs){var el=document.createElementNS("http://www.w3.org/2000/svg",tag);for(var k in attrs)if(attrs.hasOwnProperty(k))el.setAttribute(k,attrs[k]);return el}var svg=makeSVG("svg",{viewBox:"0 0 "+String(nCount)+" "+String(nCount),width:"100%",height:"100%",fill:_htOption.colorLight});svg.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink");_el.appendChild(svg);svg.appendChild(makeSVG("rect",{fill:_htOption.colorLight,width:"100%",height:"100%"}));svg.appendChild(makeSVG("rect",{fill:_htOption.colorDark,width:"1",height:"1",id:"template"}));for(var row=0;row<nCount;row++){for(var col=0;col<nCount;col++){if(oQRCode.isDark(row,col)){var child=makeSVG("use",{x:String(col),y:String(row)});child.setAttributeNS("http://www.w3.org/1999/xlink","href","#template");svg.appendChild(child)}}}};Drawing.prototype.clear=function(){while(this._el.hasChildNodes())this._el.removeChild(this._el.lastChild)};return Drawing}();var useSVG=document.documentElement.tagName.toLowerCase()==="svg";var Drawing=useSVG?svgDrawer:!_isSupportCanvas()?function(){var Drawing=function(el,htOption){this._el=el;this._htOption=htOption};Drawing.prototype.draw=function(oQRCode){var _htOption=this._htOption;var _el=this._el;var nCount=oQRCode.getModuleCount();var nWidth=Math.floor(_htOption.width/nCount);var nHeight=Math.floor(_htOption.height/nCount);var aHTML=['<table style="border:0;border-collapse:collapse;">'];for(var row=0;row<nCount;row++){aHTML.push("<tr>");for(var col=0;col<nCount;col++){aHTML.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+nWidth+"px;height:"+nHeight+"px;background-color:"+(oQRCode.isDark(row,col)?_htOption.colorDark:_htOption.colorLight)+';"></td>')}aHTML.push("</tr>")}aHTML.push("</table>");_el.innerHTML=aHTML.join("");var elTable=_el.childNodes[0];var nLeftMarginTable=(_htOption.width-elTable.offsetWidth)/2;var nTopMarginTable=(_htOption.height-elTable.offsetHeight)/2;if(nLeftMarginTable>0&&nTopMarginTable>0){elTable.style.margin=nTopMarginTable+"px "+nLeftMarginTable+"px"}};Drawing.prototype.clear=function(){this._el.innerHTML=""};return Drawing}():function(){function _onMakeImage(){this._elImage.src=this._elCanvas.toDataURL("image/png");this._elImage.style.display="block";this._elCanvas.style.display="none"}if(this._android&&this._android<=2.1){var factor=1/window.devicePixelRatio;var drawImage=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(image,sx,sy,sw,sh,dx,dy,dw,dh){if("nodeName"in image&&/img/i.test(image.nodeName)){for(var i=arguments.length-1;i>=1;i--){arguments[i]=arguments[i]*factor}}else if(typeof dw=="undefined"){arguments[1]*=factor;arguments[2]*=factor;arguments[3]*=factor;arguments[4]*=factor}drawImage.apply(this,arguments)}}function _safeSetDataURI(fSuccess,fFail){var self=this;self._fFail=fFail;self._fSuccess=fSuccess;if(self._bSupportDataURI===null){var el=document.createElement("img");var fOnError=function(){self._bSupportDataURI=false;if(self._fFail){self._fFail.call(self)}};var fOnSuccess=function(){self._bSupportDataURI=true;if(self._fSuccess){self._fSuccess.call(self)}};el.onabort=fOnError;el.onerror=fOnError;el.onload=fOnSuccess;el.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";return}else if(self._bSupportDataURI===true&&self._fSuccess){self._fSuccess.call(self)}else if(self._bSupportDataURI===false&&self._fFail){self._fFail.call(self)}}var Drawing=function(el,htOption){this._bIsPainted=false;this._android=_getAndroid();this._htOption=htOption;this._elCanvas=document.createElement("canvas");this._elCanvas.width=htOption.width;this._elCanvas.height=htOption.height;el.appendChild(this._elCanvas);this._el=el;this._oContext=this._elCanvas.getContext("2d");this._bIsPainted=false;this._elImage=document.createElement("img");this._elImage.alt="QR Code, scan me!";this._elImage.style.display="none";this._el.appendChild(this._elImage);this._bSupportDataURI=null};Drawing.prototype.draw=function(oQRCode){var _elImage=this._elImage;var _oContext=this._oContext;var _htOption=this._htOption;var nCount=oQRCode.getModuleCount();var nCountWithQuietZone=nCount;if(_htOption.addQuietZone){nCountWithQuietZone+=8}var nWidth=_htOption.width/nCountWithQuietZone;var nHeight=_htOption.height/nCountWithQuietZone;var nRoundedWidth=Math.round(nWidth);var nRoundedHeight=Math.round(nHeight);_elImage.style.display="none";this.clear();var drawBitSquare=function(row,col,bIsDark){var nLeft=col*nWidth;var nTop=row*nHeight;_oContext.strokeStyle=bIsDark?_htOption.colorDark:_htOption.colorLight;_oContext.lineWidth=1;_oContext.fillStyle=bIsDark?_htOption.colorDark:_htOption.colorLight;_oContext.fillRect(nLeft,nTop,nWidth,nHeight);_oContext.strokeRect(Math.floor(nLeft)+.5,Math.floor(nTop)+.5,nRoundedWidth,nRoundedHeight);_oContext.strokeRect(Math.ceil(nLeft)-.5,Math.ceil(nTop)-.5,nRoundedWidth,nRoundedHeight)};if(_htOption.addQuietZone){var last=nCountWithQuietZone-1;for(var i=0;i<nCountWithQuietZone;i++){for(let j=0;j<4;j++){drawBitSquare(j,i,false);drawBitSquare(last-j,i,false);drawBitSquare(i,j,false);drawBitSquare(i,last-j,false)}}}for(var row=0;row<nCount;row++){for(var col=0;col<nCount;col++){let offset=0;if(_htOption.addQuietZone)offset=4;drawBitSquare(row+offset,col+offset,oQRCode.isDark(row,col))}}this._bIsPainted=true};Drawing.prototype.makeImage=function(){if(this._bIsPainted){_safeSetDataURI.call(this,_onMakeImage)}};Drawing.prototype.isPainted=function(){return this._bIsPainted};Drawing.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height);this._bIsPainted=false};Drawing.prototype.round=function(nNumber){if(!nNumber){return nNumber}return Math.floor(nNumber*1e3)/1e3};return Drawing}();function _getTypeNumber(sText,nCorrectLevel){var nType=1;var length=_getUTF8Length(sText);for(var i=0,len=QRCodeLimitLength.length;i<=len;i++){var nLimit=0;switch(nCorrectLevel){case QRErrorCorrectLevel.L:nLimit=QRCodeLimitLength[i][0];break;case QRErrorCorrectLevel.M:nLimit=QRCodeLimitLength[i][1];break;case QRErrorCorrectLevel.Q:nLimit=QRCodeLimitLength[i][2];break;case QRErrorCorrectLevel.H:nLimit=QRCodeLimitLength[i][3];break}if(length<=nLimit){break}else{nType++}}if(nType>QRCodeLimitLength.length){throw new Error("Too long data")}return nType}function _getUTF8Length(sText){var replacedText=encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return replacedText.length+(replacedText.length!=sText?3:0)}QRCode=function(el,vOption){this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRErrorCorrectLevel.H,addQuietZone:false};if(typeof vOption==="string"){vOption={text:vOption}}if(vOption){for(var i in vOption){this._htOption[i]=vOption[i]}}if(typeof el=="string"){el=document.getElementById(el)}if(this._htOption.useSVG){Drawing=svgDrawer}this._android=_getAndroid();this._el=el;this._oQRCode=null;this._oDrawing=new Drawing(this._el,this._htOption);if(this._htOption.text){this.makeCode(this._htOption.text)}};QRCode.prototype.makeCode=function(sText){this._oQRCode=new QRCodeModel(_getTypeNumber(sText,this._htOption.correctLevel),this._htOption.correctLevel);this._oQRCode.addData(sText);this._oQRCode.make();this._el.title=sText;this._oDrawing.draw(this._oQRCode);this.makeImage()};QRCode.prototype.makeImage=function(){if(typeof this._oDrawing.makeImage=="function"&&(!this._android||this._android>=3)){this._oDrawing.makeImage()}};QRCode.prototype.clear=function(){this._oDrawing.clear()};QRCode.CorrectLevel=QRErrorCorrectLevel})();
</script>
<!-- ============================================================ -->
<!-- JS -->
<!-- ============================================================ -->
<script>
(async () => {
/* ============================================================== *
* constants (mirror include/zebra.h) *
* ============================================================== */
const ZEBRA_VOL_MARK = 0.80;
const ZEBRA_VOL_SPACE = 0.20;
const ZEBRA_BAUD_HANDSHAKE = 50;
const ZEBRA_BAUD_MIN = 1;
const ZEBRA_BAUD_MAX = 100000;
const ZEBRA_BAUD_DEFAULT = 10;
const HS_MAGIC = [0x5A, 0x42];
const T_OFFER = 0x01;
const T_READY = 0x02;
const T_DATA = 0x03;
const T_HELLO = 0x04;
const HS_FRAME_LEN = 6;
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
const PBKDF2_ITER = 600000;
const LOOPBACK = new URLSearchParams(location.search).has('loopback');
const RTC_CONFIG = {
iceServers: [
/* our STUN/TURN on proxy.uncloseai.com — coturn deployed via
* git.unturf.com/engineering/unturf/proxy.unturf.com.
* credentials are public by design (baked into a static page);
* rate-limited via coturn quotas, not via secrecy. */
{ urls: ['stun:proxy.uncloseai.com:3478', 'stun:stun.l.google.com:19302'] },
{
urls: 'turn:proxy.uncloseai.com:3478',
username: 'zebra',
credential: '7a4a2b1c8d9e6f5a'
}
]
};
/* ============================================================== *
* tiny utils *
* ============================================================== */
const $ = (id) => document.getElementById(id);
function b64(buf) {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = ''; for (const b of u8) s += String.fromCharCode(b);
return btoa(s);
}
function unb64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)); }
function hex(buf) {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
return Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
const CRC32_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC32_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, ch =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[ch]));
}
/* ============================================================== *
* share-link helpers: deflate-raw + base64-url-safe *
* ============================================================== */
function b64UrlSafe(u8) {
return b64(u8).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
}
function unb64UrlSafe(s) {
s = s.replace(/-/g,'+').replace(/_/g,'/');
while (s.length % 4) s += '=';
return unb64(s);
}
async function compressForUrl(text) {
const bytes = new TextEncoder().encode(text);
const cs = new CompressionStream('deflate-raw');
const w = cs.writable.getWriter();
w.write(bytes); w.close();
const out = new Uint8Array(await new Response(cs.readable).arrayBuffer());
return b64UrlSafe(out);
}
async function decompressFromUrl(b64s) {
const bytes = unb64UrlSafe(b64s);
const ds = new DecompressionStream('deflate-raw');
const w = ds.writable.getWriter();
w.write(bytes); w.close();
const out = await new Response(ds.readable).arrayBuffer();
return new TextDecoder().decode(out);
}
async function makeShareUrl(sdpDesc, kind /* 'o' | 'a' */) {
const c = await compressForUrl(JSON.stringify(sdpDesc));
return `${location.origin}${location.pathname}#${kind}=${c}`;
}
/* Accepts a full URL with #o=/#a= hash, raw SDP JSON, or already-extracted base64.
* Returns { kind, sdpJSON } or throws. */
async function parseShareInput(text) {
const t = (text || '').trim();
if (!t) throw new Error('empty');
const m = t.match(/[#?&]([oa])=([A-Za-z0-9_-]+)/);
if (m) {
const sdpJSON = await decompressFromUrl(m[2]);
return { kind: m[1], sdpJSON };
}
const obj = JSON.parse(t);
if (obj.type === 'offer') return { kind: 'o', sdpJSON: t };
if (obj.type === 'answer') return { kind: 'a', sdpJSON: t };
throw new Error('not an offer or answer');
}
function renderQR(containerId, text) {
const el = $(containerId);
if (!el) return;
el.innerHTML = '';
try {
new QRCode(el, { text, width: 180, height: 180, correctLevel: QRCode.CorrectLevel.L });
} catch (e) {
el.textContent = 'QR too long (' + text.length + ' chars). use the copy link button.';
}
}
/* ============================================================== *
* logging *
* ============================================================== */
const logEl = $('log');
function logLine(kind, html) {
const div = document.createElement('div');
div.className = 'log-line ' + (kind || 'sys');
const ts = new Date().toLocaleTimeString();
div.innerHTML = `<span class="ts">${ts}</span> ${html}`;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
/* ============================================================== *
* crypto *
* ============================================================== */
let groupKey = null; /* passphrase mode */
let myPriv = null; /* pubkey mode */
let myPubB64 = null; /* pubkey mode */
let peers = new Map(); /* sid(hex) → { handle, sharedKey, maxBaud } */
async function deriveGroupKey(phrase) {
const baseKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(phrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' },
baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
const LS_PRIV = 'zebra_chat_privkey';
const LS_PUB = 'zebra_chat_pubkey';
const LS_HANDLE = 'zebra_chat_handle';
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']);
const privJwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey);
localStorage.setItem(LS_PRIV, JSON.stringify(privJwk));
localStorage.setItem(LS_PUB, b64(pubRaw));
return { privateKey: kp.privateKey, pubB64: b64(pubRaw) };
}
async function loadOrGenKeypair() {
const p = localStorage.getItem(LS_PRIV), pub = localStorage.getItem(LS_PUB);
if (p && pub) {
try {
const privateKey = await crypto.subtle.importKey(
'jwk', JSON.parse(p), { name: 'ECDH', namedCurve: 'P-256' },
true, ['deriveKey', 'deriveBits']);
return { privateKey, pubB64: pub };
} catch (_) {}
}
return genKeypair();
}
async function deriveShared(myPrivKey, peerPubB64) {
const peerPub = await crypto.subtle.importKey(
'raw', unb64(peerPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
return crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPub }, myPrivKey,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
async function aesEncrypt(key, str) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(str)));
const out = new Uint8Array(12 + ct.length);
out.set(iv); out.set(ct, 12);
return out;
}
async function aesDecrypt(key, bytes) {
const iv = bytes.slice(0, 12), ct = bytes.slice(12);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(pt);
}
/* ============================================================== *
* sender id *
* ============================================================== */
async function senderIdBytes() {
if (currentMode() === 'pubkey' && myPubB64) {
const h = await crypto.subtle.digest('SHA-256', unb64(myPubB64));
return new Uint8Array(h).slice(0, 4);
}
if (!senderIdBytes._cached) {
senderIdBytes._cached = crypto.getRandomValues(new Uint8Array(4));
}
return senderIdBytes._cached;
}
/* ============================================================== *
* frame codec *
* ============================================================== */
function xorChecksum(bytes) {
let x = 0; for (const b of bytes) x ^= b; return x & 0xFF;
}
function buildHandshakeFrame(type, baudVal) {
const b = new Uint8Array(HS_FRAME_LEN);
b[0] = HS_MAGIC[0]; b[1] = HS_MAGIC[1]; b[2] = type;
b[3] = baudVal & 0xFF; b[4] = (baudVal >> 8) & 0xFF;
b[5] = xorChecksum(b.slice(0, 5));
return b;
}
async function buildDataFrame(payloadBytes) {
const sid = await senderIdBytes();
const len = payloadBytes.length;
const head = new Uint8Array(9);
head[0] = HS_MAGIC[0]; head[1] = HS_MAGIC[1]; head[2] = T_DATA;
head.set(sid, 3);
head[7] = len & 0xFF; head[8] = (len >> 8) & 0xFF;
const body = new Uint8Array(head.length + len);
body.set(head); body.set(payloadBytes, head.length);
const crc = crc32(body);
const out = new Uint8Array(body.length + 4);
out.set(body);
out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF;
out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
async function buildHelloFrame(handle, maxBaud) {
const sid = await senderIdBytes();
const hb = new TextEncoder().encode(handle.slice(0, 40));
const body = new Uint8Array(10 + hb.length);
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_HELLO;
body.set(sid, 3);
body[7] = maxBaud & 0xFF; body[8] = (maxBaud >> 8) & 0xFF;
body[9] = hb.length;
body.set(hb, 10);
const crc = crc32(body);
const out = new Uint8Array(body.length + 4);
out.set(body);
out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF;
out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
function parseFrame(bytes) {
if (bytes.length < 3) return null;
if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null;
const type = bytes[2];
if (type === T_OFFER || type === T_READY) {
if (bytes.length < HS_FRAME_LEN) return null;
if (xorChecksum(bytes.slice(0, 5)) !== bytes[5]) return { type, error: 'xor' };
const baud = bytes[3] | (bytes[4] << 8);
return { type, baud };
}
if (type === T_DATA) {
if (bytes.length < 13) return null;
const sid = bytes.slice(3, 7);
const len = bytes[7] | (bytes[8] << 8);
if (bytes.length < 9 + len + 4) return null;
const payload = bytes.slice(9, 9 + len);
const frameNoCrc = bytes.slice(0, 9 + len);
const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24);
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, payload };
}
if (type === T_HELLO) {
if (bytes.length < 10) return null;
const sid = bytes.slice(3, 7);
const maxBaud = bytes[7] | (bytes[8] << 8);
const hlen = bytes[9];
if (bytes.length < 10 + hlen + 4) return null;
const handle = new TextDecoder().decode(bytes.slice(10, 10 + hlen));
const frameNoCrc = bytes.slice(0, 10 + hlen);
const got = bytes[10+hlen] | (bytes[10+hlen+1] << 8) | (bytes[10+hlen+2] << 16) | (bytes[10+hlen+3] << 24);
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, maxBaud, handle };
}
return null;
}
/* ============================================================== *
* audio carrier + outbound RTC stream *
* ============================================================== */
let audioCtx = null;
let oscL = null, oscR = null;
let gainL = null, gainR = null;
let merger = null;
let monitorGain = null; /* local audible (off by default) */
let txStreamDest = null; /* outbound media track for RTC */
let outboundStream = null;
let carrierOn = false;
let txBusy = false;
let txQueue = [];
const meterFill = $('meter-tx');
const gainLabel = $('gain-label');
function _meterTick() {
if (!carrierOn) return;
const g = gainL ? gainL.gain.value : 0;
meterFill.style.width = Math.round(g * 100) + '%';
gainLabel.textContent = Math.round(g * 100) + '%';
requestAnimationFrame(_meterTick);
}
function _scheduleGain(v, t) {
gainL.gain.setValueAtTime(v, t);
gainR.gain.setValueAtTime(v, t);
}
async function startCarrier() {
if (carrierOn) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
if (audioCtx.state === 'suspended') await audioCtx.resume();
oscL = audioCtx.createOscillator(); oscR = audioCtx.createOscillator();
oscL.type = 'sine'; oscL.frequency.value = 440;
oscR.type = 'sine'; oscR.frequency.value = 441;
gainL = audioCtx.createGain(); gainR = audioCtx.createGain();
gainL.gain.value = ZEBRA_VOL_MARK;
gainR.gain.value = ZEBRA_VOL_MARK;
merger = audioCtx.createChannelMerger(2);
oscL.connect(gainL); gainL.connect(merger, 0, 0);
oscR.connect(gainR); gainR.connect(merger, 0, 1);
/* outbound: route to RTC media stream destination */
txStreamDest = audioCtx.createMediaStreamDestination();
merger.connect(txStreamDest);
outboundStream = txStreamDest.stream;
/* local monitor (muted by default) */
monitorGain = audioCtx.createGain();
monitorGain.gain.value = 0;
merger.connect(monitorGain);
monitorGain.connect(audioCtx.destination);
oscL.start(); oscR.start();
carrierOn = true;
_meterTick();
await setupWorkletDecoder();
}
$('monitor-toggle').addEventListener('change', (e) => {
if (monitorGain) monitorGain.gain.value = e.target.checked ? 0.04 : 0;
});
/* ============================================================== *
* AudioWorklet: per-quantum peak detector *
* ============================================================== */
const WORKLET_CODE = `
class EnergyDetector extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (!input || !input.length || !input[0]) return true;
const ch = input[0];
let peak = 0;
for (let i = 0; i < ch.length; i++) {
const a = Math.abs(ch[i]);
if (a > peak) peak = a;
}
this.port.postMessage(peak);
return true;
}
}
registerProcessor('energy-detector', EnergyDetector);
`;
async function setupWorkletDecoder() {
const url = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
try {
await audioCtx.audioWorklet.addModule(url);
} catch (e) {
logLine('err', 'audio worklet add failed: ' + e.message);
return;
}
}
/* one decoder per inbound RTC track (peer) */
const rxDecoders = new Map(); /* trackId → { uart, asm, node } */
class UartDecoder {
constructor(baud, sampleRate) {
this.sampleRate = sampleRate;
this.setBaud(baud);
this.state = 'hunt';
this.byte = 0; this.bitIdx = 0; this.curSample = 0;
this.prevAbove = true;
this.peak = 0;
this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */
}
setBaud(b) { this.baud = b; this.sps = this.sampleRate / b; }
push(e) {
this.peak *= this.peakDecay;
if (e > this.peak) this.peak = e;
if (this.peak < 0.02) { this.state = 'hunt'; this.prevAbove = true; return null; }
const thr = this.peak * 0.50;
const above = e >= thr;
if (this.state === 'hunt') {
if (this.prevAbove && !above) {
this.state = 'sample'; this.curSample = 1; this.byte = 0; this.bitIdx = 0;
}
} else {
this.curSample++;
const center = (1.5 + this.bitIdx) * this.sps;
if (this.curSample >= center) {
const bit = above ? 1 : 0;
this.byte |= (bit << this.bitIdx);
this.bitIdx++;
if (this.bitIdx === 8) {
const out = this.byte;
this.state = 'hunt'; this.prevAbove = true;
return out;
}
}
}
this.prevAbove = above;
return null;
}
}
class FrameAssembler {
constructor() { this.buf = []; }
push(byte) {
this.buf.push(byte);
while (this.buf.length >= 2 && (this.buf[0] !== HS_MAGIC[0] || this.buf[1] !== HS_MAGIC[1])) {
this.buf.shift();
}
if (this.buf.length < 3) return null;
const t = this.buf[2];
if (t === T_OFFER || t === T_READY) {
if (this.buf.length < 6) return null;
const frame = new Uint8Array(this.buf.slice(0, 6));
this.buf.splice(0, 6);
return frame;
}
if (t === T_DATA) {
if (this.buf.length < 9) return null;
const ln = this.buf[7] | (this.buf[8] << 8);
if (ln > 512) { this.buf.splice(0, 2); return null; }
const need = 9 + ln + 4;
if (this.buf.length < need) return null;
const frame = new Uint8Array(this.buf.slice(0, need));
this.buf.splice(0, need);
return frame;
}
if (t === T_HELLO) {
if (this.buf.length < 10) return null;
const hlen = this.buf[9];
if (hlen > 64) { this.buf.splice(0, 2); return null; }
const need = 10 + hlen + 4;
if (this.buf.length < need) return null;
const frame = new Uint8Array(this.buf.slice(0, need));
this.buf.splice(0, need);
return frame;
}
this.buf.splice(0, 2);
return null;
}
}
const rxMeterFill = $('meter-rx');
const rxLabel = $('rx-label');
let lastRxPeak = 0;
function _rxMeterTick() {
rxMeterFill.style.width = Math.round(lastRxPeak * 100) + '%';
rxLabel.textContent = lastRxPeak > 0.001
? Math.round(lastRxPeak * 100) + '%' : '—';
requestAnimationFrame(_rxMeterTick);
}
requestAnimationFrame(_rxMeterTick);
async function attachInboundTrack(stream, trackId) {
if (!audioCtx) return;
/* must attach to a (muted) audio element for the WebRTC stack to deliver */
const a = new Audio();
a.srcObject = stream;
a.muted = true;
a.autoplay = true;
try { await a.play(); } catch (_) {}
const src = audioCtx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(audioCtx, 'energy-detector');
src.connect(node);
/* effective decoder rate: sampleRate / 128 (one peak per audio quantum) */
const effRate = audioCtx.sampleRate / 128;
const uart = new UartDecoder(ZEBRA_BAUD_HANDSHAKE, effRate);
const asm = new FrameAssembler();
node.port.onmessage = (ev) => {
const peak = ev.data;
lastRxPeak = peak;
const byte = uart.push(peak);
if (byte === null) return;
const frame = asm.push(byte);
if (frame) onRxFrame(frame);
};
rxDecoders.set(trackId, { uart, asm, node, audio: a });
logLine('sys', 'inbound track attached — decoder live');
}
function detachInboundTrack(trackId) {
const d = rxDecoders.get(trackId);
if (!d) return;
try { d.node.disconnect(); } catch (_) {}
try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {}
rxDecoders.delete(trackId);
}
/* ============================================================== *
* transmit *
* ============================================================== */
async function txFrame(bytes, baud) {
if (!carrierOn) throw new Error('carrier not started');
txQueue.push({ bytes, baud });
if (txBusy) return;
txBusy = true;
try {
while (txQueue.length) {
const job = txQueue.shift();
await _txOne(job.bytes, job.baud);
if (loopChan) {
try {
const ab = job.bytes.buffer.slice(job.bytes.byteOffset, job.bytes.byteOffset + job.bytes.byteLength);
loopChan.postMessage(ab);
} catch (_) {}
}
}
} finally {
txBusy = false;
}
}
async function _txOne(bytes, baud) {
const period = 1.0 / baud;
let t = audioCtx.currentTime + 0.02;
for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
for (const byte of bytes) {
_scheduleGain(ZEBRA_VOL_SPACE, t); t += period;
for (let bit = 0; bit < 8; bit++) {
_scheduleGain(((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t);
t += period;
}
_scheduleGain(ZEBRA_VOL_MARK, t); t += period;
}
for (let i = 0; i < 5; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
_scheduleGain(ZEBRA_VOL_MARK, t);
await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000) + 30);
}
async function benchmarkSelf() {
if (!carrierOn) await startCarrier();
const N = 200;
const t0 = performance.now();
let t = audioCtx.currentTime;
for (let i = 0; i < N; i++) { _scheduleGain(i & 1 ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); t += 0.0005; }
await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000));
const t1 = performance.now();
_scheduleGain(ZEBRA_VOL_MARK, audioCtx.currentTime + 0.01);
const avgUs = (t1 - t0) * 1000 / N;
const rawBaud = 1e6 / avgUs;
let baud = Math.floor(rawBaud / 2);
baud = Math.max(ZEBRA_BAUD_MIN, Math.min(ZEBRA_BAUD_MAX, baud));
return { baud, avgUs };
}
/* ============================================================== *
* handshake state *
* ============================================================== */
let myMaxBaud = null;
let negotiatedBaud = null;
const baudLabel = $('baud-label');
function recomputeGroupBaud() {
let m = myMaxBaud || ZEBRA_BAUD_DEFAULT;
for (const p of peers.values()) if (p.maxBaud && p.maxBaud < m) m = p.maxBaud;
negotiatedBaud = m;
baudLabel.textContent = m + ' baud';
}
/* ============================================================== *
* loopback (same-browser dev shortcut, ?loopback=1) *
* ============================================================== */
let loopChan = null;
if (LOOPBACK) {
loopChan = new BroadcastChannel('zebra-report-loopback');
loopChan.onmessage = (ev) => {
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data);
onRxFrame(buf);
};
}
/* ============================================================== *
* frame handler *
* ============================================================== */
async function onRxFrame(bytes) {
const f = parseFrame(bytes);
if (!f) return;
if (f.error) { logLine('err', `rx: malformed (${f.type}, ${f.error})`); return; }
/* drop self-echoes (own sid) */
if (f.sid) {
const mine = await senderIdBytes();
let self = true;
for (let i = 0; i < 4; i++) if (f.sid[i] !== mine[i]) { self = false; break; }
if (self) return;
}
if (f.type === T_OFFER) {
logLine('sys', `rx: OFFER from peer — ${f.baud} baud`);
await txFrame(buildHandshakeFrame(T_READY, myMaxBaud || ZEBRA_BAUD_DEFAULT), ZEBRA_BAUD_HANDSHAKE);
return;
}
if (f.type === T_READY) {
logLine('sys', `rx: READY from peer — ${f.baud} baud`);
return;
}
if (f.type === T_HELLO) {
const sid = hex(f.sid);
const seen = peers.has(sid);
let entry = peers.get(sid) || {};
entry.handle = f.handle; entry.maxBaud = f.maxBaud;
peers.set(sid, entry);
recomputeGroupBaud(); renderPeerList();
logLine('sys', seen
? `peer ${escapeHtml(f.handle)} updated (baud ${f.maxBaud})`
: `peer ${escapeHtml(f.handle)} joined (baud ${f.maxBaud}, sid ${sid.slice(0,8)})`);
if (!seen) await announceHello();
return;
}
if (f.type === T_DATA) {
const sid = hex(f.sid);
let pt = null;
if (currentMode() === 'passphrase') {
if (!groupKey) { logLine('err', 'rx: data but no room key'); return; }
try { pt = await aesDecrypt(groupKey, f.payload); } catch (e) { logLine('err', `rx: decrypt failed (${e.message})`); return; }
} else {
const entry = peers.get(sid);
if (entry && entry.sharedKey) {
try { pt = await aesDecrypt(entry.sharedKey, f.payload); } catch (_) {}
}
if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; }
}
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
logLine('peer', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(pt)}`);
}
}
/* ============================================================== *
* peer list render *
* ============================================================== */
function renderPeerList() {
const el = $('peer-list');
el.innerHTML = '';
if (peers.size === 0) { el.innerHTML = '<div class="empty">none yet</div>'; return; }
for (const [sid, p] of peers) {
const row = document.createElement('div');
row.className = 'peer';
row.innerHTML = `<span class="h">${escapeHtml(p.handle || '?')}</span>
<span style="color:#888"> · sid ${sid.slice(0,8)} · ${p.maxBaud || '?'} baud</span>`;
el.appendChild(row);
}
}
async function announceHello() {
const handle = $('handle-in').value.trim() || 'anon';
if (!myMaxBaud) myMaxBaud = ZEBRA_BAUD_DEFAULT;
const hello = await buildHelloFrame(handle, myMaxBaud);
await txFrame(hello, ZEBRA_BAUD_HANDSHAKE);
logLine('sys', `tx: HELLO as ${escapeHtml(handle)} (${myMaxBaud} baud)`);
}
/* ============================================================== *
* WebRTC *
* ============================================================== */
let pc = null;
const dotRtc = $('dot-rtc');
const rtcStatus = $('rtc-status');
function ensurePC() {
if (pc) return pc;
if (!carrierOn) throw new Error('start carrier first');
pc = new RTCPeerConnection(RTC_CONFIG);
for (const tr of outboundStream.getTracks()) pc.addTrack(tr, outboundStream);
pc.ontrack = (ev) => {
const stream = ev.streams[0] || new MediaStream([ev.track]);
attachInboundTrack(stream, ev.track.id);
};
pc.onicecandidate = (ev) => {
if (!ev.candidate) return;
const c = ev.candidate.candidate;
const tm = c.match(/typ\s+(\S+)/);
const typ = tm ? tm[1] : '?';
const mdns = c.includes('.local') ? ' (mdns-anonymized)' : '';
logLine('sys', `ice candidate: ${typ}${mdns}`);
};
pc.onconnectionstatechange = () => {
const s = pc.connectionState;
rtcStatus.textContent = 'connection: ' + s;
if (s === 'connected') {
dotRtc.className = 'dot ok';
logLine('sys', 'webrtc connected');
announceHello().catch(() => {});
} else if (s === 'failed' || s === 'closed' || s === 'disconnected') {
dotRtc.className = 'dot';
logLine('err', `webrtc ${s} — click "reset connection" then retry`);
}
};
pc.oniceconnectionstatechange = () => {
rtcStatus.textContent = 'ice: ' + pc.iceConnectionState;
};
return pc;
}
function resetConnection() {
if (pc) {
try { pc.close(); } catch (_) {}
pc = null;
}
for (const [, d] of rxDecoders) {
try { d.node.disconnect(); } catch (_) {}
try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {}
}
rxDecoders.clear();
$('local-offer').value = '';
$('local-answer').value = '';
$('remote-offer').value = '';
$('remote-answer').value = '';
for (const id of ['offer-status','answer-status','ans-status']) {
$(id).textContent = ''; $(id).className = 'status-line';
}
rtcStatus.textContent = 'disconnected — pick a role below';
dotRtc.className = 'dot warn';
logLine('sys', 'connection reset — ready to retry');
}
$('btn-reset-rtc').addEventListener('click', resetConnection);
function waitForIceGathering(p) {
return new Promise(resolve => {
if (p.iceGatheringState === 'complete') return resolve();
const h = () => {
if (p.iceGatheringState === 'complete') {
p.removeEventListener('icegatheringstatechange', h);
resolve();
}
};
p.addEventListener('icegatheringstatechange', h);
});
}
$('btn-create-offer').addEventListener('click', async () => {
$('offer-status').textContent = 'gathering ICE…';
try {
ensurePC();
const off = await pc.createOffer();
await pc.setLocalDescription(off);
await waitForIceGathering(pc);
const sdpJSON = JSON.stringify(pc.localDescription);
$('local-offer').value = sdpJSON;
const url = await makeShareUrl(pc.localDescription, 'o');
$('share-offer-url').value = url;
$('share-offer-box').style.display = '';
renderQR('qr-offer', url);
$('offer-status').textContent = 'offer ready — share the link or QR with your peer';
$('offer-status').className = 'status-line ok';
} catch (e) {
$('offer-status').textContent = 'failed: ' + e.message;
$('offer-status').className = 'status-line err';
}
});
$('btn-create-answer').addEventListener('click', async () => {
$('ans-status').textContent = 'parsing offer…';
try {
const parsed = await parseShareInput($('remote-offer').value);
if (parsed.kind !== 'o') throw new Error('expected an offer, got an answer');
const off = JSON.parse(parsed.sdpJSON);
ensurePC();
await pc.setRemoteDescription(off);
const ans = await pc.createAnswer();
await pc.setLocalDescription(ans);
await waitForIceGathering(pc);
const sdpJSON = JSON.stringify(pc.localDescription);
$('local-answer').value = sdpJSON;
const url = await makeShareUrl(pc.localDescription, 'a');
$('share-answer-url').value = url;
$('share-answer-box').style.display = '';
renderQR('qr-answer', url);
$('ans-status').textContent = 'answer ready — share the link or QR back to your peer';
$('ans-status').className = 'status-line ok';
} catch (e) {
$('ans-status').textContent = 'failed: ' + e.message;
$('ans-status').className = 'status-line err';
}
});
$('btn-accept-answer').addEventListener('click', async () => {
$('answer-status').textContent = 'parsing answer…';
try {
const parsed = await parseShareInput($('remote-answer').value);
if (parsed.kind !== 'a') throw new Error('expected an answer, got an offer');
const ans = JSON.parse(parsed.sdpJSON);
if (!pc) throw new Error('no pending offer');
await pc.setRemoteDescription(ans);
$('answer-status').textContent = 'answer accepted — waiting for ICE/connection';
$('answer-status').className = 'status-line ok';
} catch (e) {
$('answer-status').textContent = 'failed: ' + e.message;
$('answer-status').className = 'status-line err';
}
});
/* copy-link buttons */
function attachCopy(btnId, inputId) {
$(btnId).addEventListener('click', () => {
const v = $(inputId).value;
if (!v) return;
navigator.clipboard.writeText(v).then(() => {
const orig = $(btnId).textContent;
$(btnId).textContent = 'copied';
setTimeout(() => { $(btnId).textContent = orig; }, 1500);
});
});
}
attachCopy('btn-copy-offer-url', 'share-offer-url');
attachCopy('btn-copy-answer-url', 'share-answer-url');
/* show/hide raw SDP toggle */
function attachToggleRaw(btnId, taId) {
$(btnId).addEventListener('click', () => {
const ta = $(taId);
const show = ta.style.display === 'none';
ta.style.display = show ? '' : 'none';
$(btnId).textContent = show ? 'hide raw SDP' : 'show raw SDP';
});
}
attachToggleRaw('btn-toggle-offer-raw', 'local-offer');
attachToggleRaw('btn-toggle-answer-raw', 'local-answer');
/* ============================================================== *
* mode toggle *
* ============================================================== */
function currentMode() {
const r = document.querySelector('input[name="mode"]:checked');
return r ? r.value : 'passphrase';
}
function showModePanel() {
const m = currentMode();
$('sec-passphrase').classList.toggle('active', m === 'passphrase');
$('sec-pubkey').classList.toggle('active', m === 'pubkey');
}
for (const r of document.querySelectorAll('input[name="mode"]')) {
r.addEventListener('change', () => { showModePanel(); updateSendButton(); });
}
/* ============================================================== *
* identity / passphrase / pubkey UI *
* ============================================================== */
const handleIn = $('handle-in');
handleIn.value = localStorage.getItem(LS_HANDLE) || '';
handleIn.addEventListener('change', () => {
localStorage.setItem(LS_HANDLE, handleIn.value.trim());
});
$('btn-pp-join').addEventListener('click', async () => {
const p = $('passphrase-in').value;
if (!p) { $('pp-status').textContent = 'enter a passphrase first'; return; }
$('pp-status').textContent = 'deriving (~600k iter)…';
try {
groupKey = await deriveGroupKey(p);
$('pp-status').textContent = 'room key ready';
$('pp-status').className = 'status-line ok';
logLine('sys', 'room key derived');
updateSendButton();
if (carrierOn && pc && pc.connectionState === 'connected') await announceHello();
} catch (e) {
$('pp-status').textContent = 'derive failed: ' + e.message;
$('pp-status').className = 'status-line err';
}
});
async function initPubkey() {
const kp = await loadOrGenKeypair();
myPriv = kp.privateKey; myPubB64 = kp.pubB64;
$('pub-display').textContent = myPubB64;
}
initPubkey();
$('pub-display').addEventListener('click', () => {
navigator.clipboard.writeText(myPubB64 || '').then(() => {
$('key-status').textContent = 'copied'; $('key-status').className = 'status-line ok';
setTimeout(() => { $('key-status').textContent = ''; }, 1500);
});
});
$('btn-regen').addEventListener('click', async () => {
if (!confirm('regenerate keys?')) return;
const kp = await genKeypair();
myPriv = kp.privateKey; myPubB64 = kp.pubB64;
$('pub-display').textContent = myPubB64;
for (const p of peers.values()) delete p.sharedKey;
$('key-status').textContent = 'new keys'; $('key-status').className = 'status-line ok';
setTimeout(() => { $('key-status').textContent = ''; }, 2000);
});
$('btn-pk-add').addEventListener('click', async () => {
const peerB64 = $('peer-key-in').value.trim();
if (!peerB64) return;
try {
const sk = await deriveShared(myPriv, peerB64);
const sidBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', unb64(peerB64)));
const sid = hex(sidBytes.slice(0, 4));
const entry = peers.get(sid) || {};
entry.sharedKey = sk;
entry.handle = entry.handle || ('peer-' + sid.slice(0,4));
peers.set(sid, entry);
renderPeerList();
$('peer-key-in').value = '';
logLine('sys', `pubkey peer added: ${sid.slice(0,8)}`);
updateSendButton();
} catch (e) {
logLine('err', 'add peer failed: ' + e.message);
}
});
/* ============================================================== *
* carrier UI *
* ============================================================== */
$('btn-audio').addEventListener('click', async () => {
if (carrierOn) return;
try {
await startCarrier();
$('dot-audio').className = 'dot on';
$('audio-status').textContent = 'online';
$('btn-audio').disabled = true;
logLine('sys', 'carrier started — modulated stereo at 440/441 Hz');
} catch (e) {
logLine('err', 'carrier failed: ' + e.message);
}
});
/* ============================================================== *
* handshake UI *
* ============================================================== */
$('btn-bench').addEventListener('click', async () => {
$('bench-status').textContent = 'measuring…';
try {
if (!carrierOn) await startCarrier();
const r = await benchmarkSelf();
myMaxBaud = r.baud;
$('bench-status').textContent = `${r.baud} baud (avg ${r.avgUs.toFixed(1)} µs/symbol)`;
$('btn-offer').disabled = false;
recomputeGroupBaud();
logLine('sys', `benchmark: ${r.baud} baud max`);
} catch (e) {
$('bench-status').textContent = 'failed: ' + e.message;
}
});
$('btn-offer').addEventListener('click', async () => {
if (!myMaxBaud) return;
$('hs-status').textContent = 'sending OFFER…';
try {
await txFrame(buildHandshakeFrame(T_OFFER, myMaxBaud), ZEBRA_BAUD_HANDSHAKE);
$('hs-status').textContent = 'OFFER sent — waiting for READY';
await announceHello();
} catch (e) {
$('hs-status').textContent = 'tx failed: ' + e.message;
}
});
/* ============================================================== *
* send UI *
* ============================================================== */
function updateSendButton() {
const mode = currentMode();
const haveKey = mode === 'passphrase' ? !!groupKey
: Array.from(peers.values()).some(p => p.sharedKey);
$('btn-send').disabled = !(carrierOn && haveKey);
}
async function sendCurrentMsg() {
const msg = $('msg-in').value;
if (!msg) return;
if (!carrierOn) { logLine('err', 'start carrier first'); return; }
const mode = currentMode();
const baud = negotiatedBaud || myMaxBaud || ZEBRA_BAUD_DEFAULT;
try {
if (mode === 'passphrase') {
if (!groupKey) { logLine('err', 'no room key'); return; }
const ct = await aesEncrypt(groupKey, msg);
await txFrame(await buildDataFrame(ct), baud);
} else {
let sent = 0;
for (const p of peers.values()) {
if (!p.sharedKey) continue;
const ct = await aesEncrypt(p.sharedKey, msg);
await txFrame(await buildDataFrame(ct), baud);
sent++;
}
if (sent === 0) { logLine('err', 'no pubkey peers ready'); return; }
}
const handle = handleIn.value.trim() || 'me';
logLine('me', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(msg)}`);
$('msg-in').value = '';
} catch (e) {
logLine('err', 'send failed: ' + e.message);
}
}
$('btn-send').addEventListener('click', sendCurrentMsg);
$('msg-in').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendCurrentMsg(); }
});
/* ============================================================== *
* boot *
* ============================================================== */
showModePanel();
updateSendButton();
setInterval(updateSendButton, 1000);
if (LOOPBACK) {
const banner = document.createElement('div');
banner.style.cssText =
'background:#fc0;color:#000;padding:0.6rem 1rem;text-align:center;' +
'font-size:0.8rem;font-weight:bold;border:1px solid #000;' +
'margin-bottom:1.2rem;letter-spacing:0.03em';
banner.textContent =
'dev loopback active — frames cross via BroadcastChannel between ' +
'same-browser tabs, NOT via WebRTC. drop ?loopback=1 for real cross-machine path.';
document.body.insertBefore(banner, document.body.firstChild);
logLine('sys', 'loopback mode: frames will echo to other tabs of this same browser.');
}
logLine('sys', 'ready — start carrier, set up a WebRTC peer connection, then chat.');
logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry plaintext.');
/* ============================================================== *
* page-load hash auto-handle: pre-fill offer when joining via link*
* ============================================================== */
async function handleInboundLink() {
const m = location.hash.match(/^#([oa])=([A-Za-z0-9_-]+)$/);
if (!m) return;
try {
const sdpJSON = await decompressFromUrl(m[2]);
if (m[1] === 'o') {
$('remote-offer').value = sdpJSON;
logLine('sys', 'offer link detected — autofilled. enter passphrase + start audio, then click "create answer".');
$('ans-status').textContent = 'offer pre-filled — complete identity + carrier, then create answer';
$('remote-offer').scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
$('remote-answer').value = sdpJSON;
logLine('sys', 'answer link detected — autofilled. click "accept answer" once your offer is still pending.');
$('answer-status').textContent = 'answer pre-filled — click "accept answer"';
}
/* clean hash so a reload doesn't re-trigger */
history.replaceState(null, '', location.pathname);
} catch (e) {
logLine('err', 'inbound link parse failed: ' + e.message);
}
}
handleInboundLink();
window.addEventListener('hashchange', handleInboundLink);
})();
</script>
</body>
</html>