You are in a call. There is a fan running next to you, and you wonder whether the others can hear it. Someone's dog barks, and nobody knows whose. The call app shows a little green ring when a microphone is "speaking", and that is all you get.
This bookmarklet draws a real graph of the audio, one per participant, right on top of their video tile. It works in Nextcloud Talk, Jitsi Meet, Google Meet and Microsoft Teams, and it does a best effort on WhatsApp and any other page that plays audio.
That is fifteen seconds of audio, visualized. Two sentences, and under them, the thin line that never stops: a fan. (The audio is synthetic; the picture is generated by the test suite, see below.)
Each overlay has a button that cycles through four views. The spectrogram above is the default; the other three are:
| view | |
|---|---|
| Wave, the live waveform | ![]() |
| Level, 15 seconds of loudness | ![]() |
| Spectrum, live | ![]() |
In a real call there is one of these on every tile. This is the Jitsi test harness with four participants:
GitHub does not let a README carry a javascript: link, so this takes two steps instead of a drag: create a bookmark, then paste the code below as its URL (the copy button on the block copies the whole thing).
Bookmark URL, version 0.5.4 (32 KB, expand to copy)
javascript:(()=>{"use strict";const t="__NCTALK_WAVEFORM__",e="__TALK_WAVEFORMS__",n="nctalk-waveform",a="nctalk-waveform-placement",o=15e3,i=Math.ceil(150),r=128,c=["waveform","amplitude","spectrum","spectrogram"],s={waveform:"Wave",amplitude:"Level",spectrum:"Spectrum",spectrogram:"Spectrogram"},l=window.AudioContext||window.webkitAudioContext;if(!l){const t=document.createElement("div");return t.id=n,t.textContent="This browser cannot visualize call audio.",Object.assign(t.style,{position:"fixed",left:"16px",bottom:"16px",zIndex:"2147483647",padding:"14px 16px",color:"%23f5f7fa",background:"%23161b22",border:"1px solid rgba(255,255,255,.16)",borderRadius:"11px",font:"14px/1.4 system-ui,sans-serif"}),void document.documentElement.append(t)}const d=window[e]||window[t];if("function"==typeof d?.destroy)return void d.destroy();document.getElementById(n)?.remove();const u=new l,p=new Map,m=new Set;let f=1,g=1;const h=new WeakMap,v="spectrogram",y=Boolean(window.APP?.store?.getState&&window.JitsiMeetJS),b="meet.google.com"===location.hostname,w="teams.live.com"===location.hostname,x=/\/call\/[^/]+/.test(location.pathname)&&Boolean(document.querySelector("%23app-content-vue")),S="web.whatsapp.com"===location.hostname||"call.whatsapp.com"===location.hostname,k=y||b||w||x;let M=0,T=0,C=0,A=!1,I=!1;const E=new Map,H=new WeakMap,L=new WeakMap,B=new Set;function R(t,e,n){const a=(1-Math.abs(2*n-1))*e,o=(t%25360+360)%25360/60,i=a*(1-Math.abs(o%252-1));let r=0,c=0,s=0;o<1?[r,c]=[a,i]:o<2?[r,c]=[i,a]:o<3?[c,s]=[a,i]:o<4?[c,s]=[i,a]:[r,s]=o<5?[i,a]:[a,i];const l=n-a/2;return[r,c,s].map(t=>Math.round(255*(t+l)))}const P=new Uint8ClampedArray(1024);for(let t=0;t<256;t++){const e=Math.max(0,(t-18)/237),[n,a,o]=R(230-210*e,.88,.07+.6*e),i=4*t;P[i]=n,P[i+1]=a,P[i+2]=o,P[i+3]=255}const D=document.createElement("div");D.id=n,D.hidden=!1,D.setAttribute("aria-live","polite");const z=D.attachShadow({mode:"open"}),F=new CSSStyleSheet;function _(t,e,n=""){const a=document.createElement(t);return e&&(a.className=e),n&&(a.textContent=n),a}F.replaceSync('\n\t\t\t:host {\n\t\t\t\tall: initial;\n\t\t\t\tposition: fixed;\n\t\t\t\tleft: 16px;\n\t\t\t\tbottom: 16px;\n\t\t\t\tz-index: 2147483647;\n\t\t\t\twidth: min(340px, calc(100vw - 32px));\n\t\t\t\tmax-width: calc(100vw - 16px);\n\t\t\t\toverflow: visible;\n\t\t\t\tcolor-scheme: light dark;\n\t\t\t\tfont: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n\t\t\t}\n\t\t\t* { box-sizing: border-box; }\n\t\t\t[hidden] { display: none !important; }\n\t\t\t.panel {\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-direction: column;\n\t\t\t\tfont: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n\t\t\t\tcolor: %23f5f7fa;\n\t\t\t\tbackground: rgba(22, 27, 34, .96);\n\t\t\t\tborder: 1px solid rgba(255, 255, 255, .16);\n\t\t\t\tborder-radius: 11px;\n\t\t\t\tbox-shadow: 0 12px 40px rgba(0, 0, 0, .38);\n\t\t\t\toverflow: hidden;\n\t\t\t}\n\t\t\t.header {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tgap: 8px;\n\t\t\t\tmin-height: 44px;\n\t\t\t\tpadding: 6px 8px 6px 13px;\n\t\t\t\tbackground: rgba(255, 255, 255, .055);\n\t\t\t\tborder-bottom: 1px solid rgba(255, 255, 255, .1);\n\t\t\t\tcursor: move;\n\t\t\t\tuser-select: none;\n\t\t\t\ttouch-action: none;\n\t\t\t}\n\t\t\t.title { margin-right: auto; font-weight: 650; letter-spacing: .01em; }\n\t\t\t.count {\n\t\t\t\tmin-width: 24px;\n\t\t\t\tpadding: 2px 7px;\n\t\t\t\tborder-radius: 999px;\n\t\t\t\tcolor: %23b9f6ca;\n\t\t\t\tbackground: rgba(46, 160, 67, .24);\n\t\t\t\ttext-align: center;\n\t\t\t\tfont-size: 12px;\n\t\t\t}\n\t\t\tbutton {\n\t\t\t\tappearance: none;\n\t\t\t\tmin-height: 30px;\n\t\t\t\tpadding: 4px 9px;\n\t\t\t\tcolor: inherit;\n\t\t\t\tbackground: rgba(255, 255, 255, .08);\n\t\t\t\tborder: 1px solid rgba(255, 255, 255, .12);\n\t\t\t\tborder-radius: 7px;\n\t\t\t\tfont: inherit;\n\t\t\t\tcursor: pointer;\n\t\t\t}\n\t\t\tbutton:hover, button:focus-visible { background: rgba(255, 255, 255, .16); outline: none; }\n\t\t\tbutton:disabled { cursor: wait; opacity: .55; }\n\t\t\t.body { max-height: min(280px, calc(100vh - 90px)); padding: 8px; overflow: auto; border-top: 1px solid rgba(255, 255, 255, .1); }\n\t\t\t.empty {\n\t\t\t\tpadding: 9px 10px;\n\t\t\t\tcolor: %23aeb6c2;\n\t\t\t\ttext-align: center;\n\t\t\t}\n\t\t\t.lanes { display: grid; gap: 7px; }\n\t\t\t@media (max-width: 420px) {\n\t\t\t\t:host { left: 8px; bottom: 8px; width: min(320px, calc(100vw - 16px)); }\n\t\t\t\t.mic { display: none; }\n\t\t\t}\n\t'),z.adoptedStyleSheets=[F];const W=_("div","panel"),q=_("div","header"),$=_("span","title","Call audio"),j=_("span","count","0");j.setAttribute("aria-label","Audio source count");const N=_("button","mic","Mic test");N.type="button",N.title="Direct microphone fallback",q.append($,j,N);const V=_("div","body"),O=_("div","empty","Looking for audio streams…"),U=_("div","lanes");V.append(O,U),W.append(q,V),z.append(W);const Y=new IntersectionObserver(t=>{for(const e of t){const t=H.get(e.target);t&&(t.renderVisible=e.isIntersecting)}Lt()});try{const t=JSON.parse(localStorage.getItem(a));Number.isFinite(t?.left)&&Number.isFinite(t?.top)&&(D.style.left=`${Math.max(0,Math.min(window.innerWidth-80,t.left))}px`,D.style.top=`${Math.max(0,Math.min(window.innerHeight-46,t.top))}px`,D.style.right="auto",D.style.bottom="auto")}catch{}function J(t){return t.getAudioTracks().map(t=>{let e=h.get(t);return e||(e="track-"+g++,h.set(t,e)),e}).sort().join(":")}function X(t){return String(t||"").replace(/\s+/g," ").trim()}function Q(t){const e=X(t).toLowerCase();return!e||/^participant \d+$/.test(e)?0:["guest","remote audio","audio","video"].includes(e)?1:2}function K(t,e){const n=t?.closest?.("[data-participant-id],[data-self-name],[data-participant-name]");if(n){for(const t of["data-participant-name","data-self-name"]){const e=X(n.getAttribute(t));if(e&&e.length<80)return"data-self-name"===t?"You":e}for(const t of n.querySelectorAll("[data-participant-name],[data-self-name]"))for(const e of["data-participant-name","data-self-name"]){const n=X(t.getAttribute(e));if(n&&n.length<80)return"data-self-name"===e?"You":n}}if(w){if(t?.closest?.('[data-tid="prejoin-v2-video-preview-container"],[data-tid="local-video-tile"],[data-tid*="local-video"]'))return"You";const e=t?.closest?.('[data-tid*="participant"],[data-tid*="video-tile"],[data-cid],[data-participant-id]');for(const t of e?.querySelectorAll?.('[data-tid*="display-name"],[data-tid*="participant-name"],[aria-label]')||[]){const e=X(t.getAttribute("aria-label")||t.textContent);if(e&&e.length<80&&!/^(more options|mute|unmute|video|audio)$/i.test(e))return e}}const a=X(t?.getAttribute?.("aria-label"));if(a&&a.length<80)return a;const o=t?.closest?.([".video-container",'[class*="video-container"]','[class*="participant"]','[class*="call-view"]','[class*="tile"]'].join(","))||t?.parentElement,i=[".video-container__user-name",".video-container__name",".displayname",'[id$="_name"]','[data-testid*="name"]','[class*="participant-name"]','[class*="user-name"]','[class$="__name"]',"figcaption"];for(const t of i){const e=X(o?.querySelector?.(t)?.textContent);if(e&&e.length<80)return e}const r=X(o?.textContent);if(r&&r.length<80)return r;const c=X(e?.getAudioTracks?.()[0]?.label);return c&&c.length<80?c:""}function G(t){return!(!w||!t.closest?.('[data-tid="prejoin-v2-video-preview-container"],[data-tid="local-video-tile"],[data-tid*="local-video"]'))||!!t.closest?.("[data-self-name]")||!!t.closest?.('.localVideoContainer, %23localVideoContainer, [class*="local-video"], [class*="localVideo"]')||!t.closest?.('.video-container, [class*="participant"], [class*="remote-video"], [class*="remoteVideo"]')&&t.muted}function Z(t,e){const n=t.getAudioTracks().filter(t=>"live"===t.readyState);if(1!==n.length)return;const a=n[0],o=h.get(a);if(o&&p.has(o))return;const i=[...p.values()].find(t=>"webrtc"===t.origin&&t.direction===e&&t.trackId===a.id&&0===t.elements.size);i&&h.set(a,i.key)}function tt(t){t&&![...p.values()].some(e=>e.card===t)&&E.has(t)&&(t.style.position=E.get(t),E.delete(t))}function et(t){return x&&t instanceof Element&&t.id.match(/^(?:placeholder-)?container_(.+)_video_incoming$/)?.[1]||null}function nt(t){const e=t.getBoundingClientRect();return e.width<=0||e.height<=0||"hidden"===getComputedStyle(t).visibility?0:e.width*e.height}function at(t){return t&&[...document.querySelectorAll(".video-container[id]")].filter(e=>et(e)===t).sort((t,e)=>nt(e)-nt(t))[0]||null}function ot(t,e=null,n=null){let a=k?n?.isConnected?n:function(t){return w?t?.closest?.(['[data-tid="prejoin-v2-video-preview-container"]','[data-tid*="participant-tile"]','[data-tid*="video-tile"]','[data-cid][role="listitem"]'].join(","))||null:t?.closest?.(["[data-participant-id]","[data-self-name]","[data-participant-name]",".video-container",".localVideoContainer",".videocontainer","%23localVideoContainer",'[id^="participant_"]','[class*="video-container"]','[data-testid*="participant"]','[class*="participant-card"]','[class*="participant-tile"]','[class*="call-view__participant"]','[class*="remote-video"]'].join(","))||null}(e):null;if(x&&"remote"===t.direction&&a){const e=et(a);!t.nextcloudParticipantId&&e&&(t.nextcloudParticipantId=e),t.nextcloudParticipantId&&(a=at(t.nextcloudParticipantId)||(e===t.nextcloudParticipantId?a:null))}const o=t.card;a?(E.has(a)||"static"!==getComputedStyle(a).position||(E.set(a,a.style.position),a.style.position="relative"),t.card=a,t.viewHost.dataset.placement="card",a.append(t.viewHost)):t.card?.isConnected||(t.card=null,t.viewHost.dataset.placement="fallback",U.append(t.viewHost)),o&&o!==a&&tt(o),rt()}function it(t){if(!x||"remote"!==t.direction||!t.nextcloudParticipantId)return;const e=at(t.nextcloudParticipantId);e?t.card===e&&t.viewHost.parentElement===e||ot(t,null,e):t.card&&et(t.card)!==t.nextcloudParticipantId&&function(t){const e=t.card;t.card=null,t.viewHost.dataset.placement="fallback",U.append(t.viewHost),tt(e),rt()}(t)}function rt(){j.textContent=String(p.size),O.hidden=p.size>0;const t=[...p.values()].filter(t=>!t.card?.isConnected).length;O.textContent=S?"No call audio found yet. Join the call or try Mic test.":k?"No call audio found yet.":"No audio streams found on this page.",U.hidden=0===t,V.hidden=p.size>0&&0===t,D.style.display=!k||0===p.size||t>0?"":"none";const e=[...p.values()].some(t=>"capture"===t.origin),n=[...p.values()].some(t=>"local"===t.direction&&"capture"!==t.origin);N.textContent=e?"Stop mic test":"Mic test",N.disabled=I||n&&!e,N.title=e?"Stop microphone test":n?"Outgoing call audio is already detected":"Direct microphone fallback"}function ct(t){const e=p.get(t);if(!e)return;e.owned&&e.stream&&(e.stream.getTracks().forEach(t=>t.stop()),m.delete(e.stream));try{e.node.disconnect()}catch{}const n=e.card;Y.unobserve(e.viewHost),e.viewHost.remove(),p.delete(t),tt(n),rt()}function st(t){t.labelElement.textContent=t.label,t.labelElement.title=t.label,t.modeButton.textContent=s[t.mode],t.modeButton.title=`Change ${t.label} visualization (currently ${s[t.mode]})`,t.modeButton.setAttribute("aria-label",t.modeButton.title),t.reopenButton.title=`Show ${t.label} audio visualization`,t.reopenButton.setAttribute("aria-label",t.reopenButton.title),t.canvas.setAttribute("aria-label",`${t.label} ${s[t.mode]} visualization`)}function lt(t,e){t.collapsed=Boolean(e),t.viewHost.dataset.collapsed=String(t.collapsed),Lt()}function dt(t,e={}){if(!(t instanceof MediaStream))return null;const n=t.getAudioTracks().filter(t=>"live"===t.readyState);if(!n.length)return null;const a=J(t);if(!a)return null;const o=p.get(a);if(o){const t=X(e.label),n=Q(t);return t&&(n>o.labelQuality||e.authoritativeLabel||e.senderTrack)&&(o.label=t,o.labelQuality=n),"webrtc"===e.origin?(o.direction=e.direction||"remote",o.origin="webrtc"):"webrtc"!==o.origin&&(e.direction&&(o.direction=e.direction),"capture"!==e.origin&&o.origin||(o.origin=e.origin)),e.receiverTrack&&(o.receiverTrack=e.receiverTrack),e.senderTrack&&(o.senderTrack=e.senderTrack),e.jitsiParticipantId&&(o.jitsiParticipantId=e.jitsiParticipantId),o.persistent||=Boolean(e.persistent),e.element?(o.elements.add(e.element),ot(o,e.element,e.card)):e.card&&ot(o,null,e.card),st(o),o}let i;try{i=u.createMediaStreamSource(t)}catch(t){return console.warn("Talk waveforms could not analyse a media stream:",t),null}const r=u.createAnalyser();return r.fftSize=1024,r.smoothingTimeConstant=.72,i.connect(r),ut(a,{stream:t,audioTracks:n,node:i,analyser:r},e)}function ut(t,{stream:e,audioTracks:n,node:a,analyser:o},s={}){const l=X(s.label)||"Participant "+f++,d=s.direction||"remote",u=s.origin||"dom",m={key:t,stream:e,trackId:n[0]?.id||null,receiverTrack:s.receiverTrack||null,senderTrack:s.senderTrack||null,elements:new Set(s.element?[s.element]:[]),owned:Boolean(s.owned),persistent:Boolean(s.persistent),direction:d,origin:u,jitsiParticipantId:s.jitsiParticipantId||null,nextcloudParticipantId:null,node:a,analyser:o,card:null,viewHost:null,label:l,labelQuality:Q(l),mode:v,collapsed:!1,timeData:new Float32Array(o.fftSize),frequencyData:new Uint8Array(o.frequencyBinCount),amplitudeHistory:[],lastHistoryAt:0,spectrogramCanvas:document.createElement("canvas"),spectrogramContext:null,spectrogramColumn:null,spectrogramBinMap:new Uint16Array(r),spectrogramWriteIndex:0,spectrogramFrameCount:0,lastSpectrogramAt:0,spectrogramFrames:0,lastLevel:0,renderVisible:!0,renderFrames:0,lastRenderedMode:null,lastRenderedFrequencyFrame:-1};m.spectrogramCanvas.width=i,m.spectrogramCanvas.height=r,m.spectrogramContext=m.spectrogramCanvas.getContext("2d"),m.spectrogramContext.fillStyle="%23071018",m.spectrogramContext.fillRect(0,0,i,r),m.spectrogramColumn=m.spectrogramContext.createImageData(1,r);const g=o.context.sampleRate/2,h=Math.min(m.frequencyData.length-1,Math.floor(8e3/g*m.frequencyData.length));for(let t=0;t<r;t++){const e=1-t/Math.max(1,127);m.spectrogramBinMap[t]=Math.floor(e*e*h)}!function(t){const e=document.createElement("div");e.className="nctalk-waveform-source",e.dataset.placement="fallback",e.dataset.collapsed="false",e.setAttribute("role","group");const n=e.attachShadow({mode:"open"}),a=new CSSStyleSheet;a.replaceSync('\n\t\t\t\t:host {\n\t\t\t\t\tall: initial;\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\theight: 72px;\n\t\t\t\t\tmin-width: 0;\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t\tcolor-scheme: dark;\n\t\t\t\t\tfont: 12px/1.25 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n\t\t\t\t}\n\t\t\t\t:host([hidden]) { display: none; }\n\t\t\t\t:host([data-placement="card"]) {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 8px;\n\t\t\t\t\tright: auto;\n\t\t\t\t\tbottom: 8px;\n\t\t\t\t\twidth: min(640px, calc(100%25 - 16px));\n\t\t\t\t\tz-index: 30;\n\t\t\t\t\tpointer-events: auto;\n\t\t\t\t}\n\t\t\t\t:host([data-placement="card"][data-collapsed="true"]) {\n\t\t\t\t\tleft: 8px;\n\t\t\t\t\twidth: auto;\n\t\t\t\t\tmin-width: 0;\n\t\t\t\t\theight: 30px;\n\t\t\t\t}\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\t.view {\n\t\t\t\t\tposition: relative;\n\t\t\t\t\theight: 100%25;\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\tcolor: %23f5f7fa;\n\t\t\t\t\tbackground: rgba(7, 16, 24, .86);\n\t\t\t\t\tborder: 1px solid rgba(255, 255, 255, .22);\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tbox-shadow: 0 4px 18px rgba(0, 0, 0, .3);\n\t\t\t\t\tbackdrop-filter: blur(5px);\n\t\t\t\t}\n\t\t\t\tcanvas { display: block; width: 100%25; height: 100%25; }\n\t\t\t\t.identity {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 7px;\n\t\t\t\t\ttop: 6px;\n\t\t\t\t\tz-index: 2;\n\t\t\t\t\tmax-width: calc(100%25 - 138px);\n\t\t\t\t\tpadding: 2px 5px;\n\t\t\t\t\tborder-radius: 4px;\n\t\t\t\t\tbackground: rgba(0, 0, 0, .52);\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t}\n\t\t\t\t.label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }\n\t\t\t\t.mode {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tright: 36px;\n\t\t\t\t\ttop: 6px;\n\t\t\t\t\tz-index: 3;\n\t\t\t\t\tmin-height: 25px;\n\t\t\t\t\tpadding: 2px 7px;\n\t\t\t\t\tcolor: %23f5f7fa;\n\t\t\t\t\tbackground: rgba(0, 0, 0, .62);\n\t\t\t\t\tborder: 1px solid rgba(255, 255, 255, .26);\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tfont: 10px/1 system-ui, sans-serif;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\tpointer-events: auto;\n\t\t\t\t}\n\t\t\t\t.mode:hover, .mode:focus-visible { background: rgba(31, 41, 52, .92); outline: none; }\n\t\t\t\t.collapse {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tright: 6px;\n\t\t\t\t\ttop: 6px;\n\t\t\t\t\tz-index: 3;\n\t\t\t\t\twidth: 25px;\n\t\t\t\t\theight: 25px;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tcolor: %23f5f7fa;\n\t\t\t\t\tbackground: rgba(0, 0, 0, .62);\n\t\t\t\t\tborder: 1px solid rgba(255, 255, 255, .26);\n\t\t\t\t\tborder-radius: 6px;\n\t\t\t\t\tfont: 16px/1 system-ui, sans-serif;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t\t.reopen {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t\theight: 30px;\n\t\t\t\t\tpadding: 0 10px;\n\t\t\t\t\tcolor: %23f5f7fa;\n\t\t\t\t\tbackground: rgba(7, 16, 24, .9);\n\t\t\t\t\tborder: 1px solid rgba(255, 255, 255, .22);\n\t\t\t\t\tborder-radius: 999px;\n\t\t\t\t\tbox-shadow: 0 4px 18px rgba(0, 0, 0, .3);\n\t\t\t\t\tfont: 11px/1 system-ui, sans-serif;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t\t.collapse:hover, .collapse:focus-visible, .reopen:hover, .reopen:focus-visible { background: rgba(31, 41, 52, .96); outline: none; }\n\t\t\t\t:host([data-collapsed="true"]) .view { display: none; }\n\t\t\t\t:host([data-collapsed="true"]) .reopen { display: block; }\n\t\t\t\t.level { position: absolute; inset: auto 0 0; height: 3px; background: linear-gradient(90deg, %234ade80, %23facc15, %23fb7185); transform: scaleX(0); transform-origin: left; }\n\t\t'),n.adoptedStyleSheets=[a];const o=_("div","view"),i=document.createElement("canvas"),r=_("div","identity"),s=_("span","label");r.append(s);const l=_("button","mode");l.type="button";const d=_("button","collapse","−");d.type="button",d.title="Collapse audio visualization",d.setAttribute("aria-label","Collapse audio visualization");const u=_("div","level");o.append(i,r,l,d,u);const p=_("button","reopen","Show audio");p.type="button",n.append(o,p),t.viewHost=e,t.viewShadow=n,t.labelElement=s,t.modeButton=l,t.collapseButton=d,t.reopenButton=p,t.canvas=i,t.level=u,t.modeButton.addEventListener("click",async()=>{await Rt(),t.mode=c[(c.indexOf(t.mode)+1)%25c.length],st(t),Lt()}),t.collapseButton.addEventListener("click",()=>lt(t,!0)),t.reopenButton.addEventListener("click",async()=>{await Rt(),lt(t,!1)});for(const t of["pointerdown","pointerup","click","dblclick","contextmenu"])e.addEventListener(t,t=>t.stopPropagation());H.set(e,t),Y.observe(e),st(t)}(m),m.lane=m.viewHost,p.set(t,m),ot(m,s.element,s.card);for(const a of n)a.addEventListener("ended",()=>{e.getAudioTracks().some(t=>"live"===t.readyState)||ct(t)},{once:!0});return rt(),m}const pt=new Map;let mt=1;const ft=window.AudioNode?.prototype,gt=l.prototype,ht=ft?.connect,vt=gt?.createMediaStreamSource;let yt=null,bt=null;if(!k&&ht&&window.AudioDestinationNode){yt=function(t,...e){const n=ht.call(this,t,...e);return!A&&t instanceof AudioDestinationNode&&function(t){const e=t.context;if(!e||e===u||"closed"===e.state)return;let n=pt.get(e);if(!n){let t;try{t=e.createAnalyser()}catch{return}t.fftSize=1024,t.smoothingTimeConstant=.72;const a=ut("graph-"+mt++,{stream:null,audioTracks:[],node:t,analyser:t},{label:"Page audio",direction:"remote",origin:"audio-graph",persistent:!0});n={source:a,analyser:t,tapped:new WeakSet},pt.set(e,n),e.addEventListener?.("statechange",()=>{"closed"===e.state&&(pt.delete(e),ct(a.key))})}if(!n.tapped.has(t)){n.tapped.add(t);try{ht.call(t,n.analyser)}catch{}}}(this),n};try{ft.connect=yt}catch{}}if(!k&&vt){bt=function(t,...e){const n=vt.call(this,t,...e);if(!A&&this!==u&&t instanceof MediaStream){const e=t.getAudioTracks().filter(t=>"live"===t.readyState).some(t=>{const e=t.getSettings?.().deviceId;return Boolean(e&&!e.startsWith("WebAudio"))});dt(t,{label:e?"You":void 0,direction:e?"local":"remote",origin:"audio-graph",persistent:!0})}return n};try{gt.createMediaStreamSource=bt}catch{}}function wt(t){const e=t.srcObject;if(e instanceof MediaStream&&e.getAudioTracks().some(t=>"live"===t.readyState))return e;let n=L.get(t);if(n&&!n.getAudioTracks().some(t=>"live"===t.readyState)&&(B.delete(n),L.delete(t),n=null),!k&&!n&&!t.muted&&t.readyState>=HTMLMediaElement.HAVE_CURRENT_DATA){const e=t.captureStream||t.mozCaptureStream;if("function"==typeof e)try{n=e.call(t),n instanceof MediaStream&&(L.set(t,n),B.add(n))}catch{}}return n instanceof MediaStream&&n.getAudioTracks().some(t=>"live"===t.readyState)?n:null}function xt(t){const e=t.srcObject;return e instanceof MediaStream&&e.getAudioTracks().some(t=>"live"===t.readyState)?e:L.get(t)||null}function St(){if(A)return;!function(){const t=window.APP?.store?.getState?.(),e=t?.["features/base/tracks"],n=t?.["features/base/participants"];if(Array.isArray(e)&&n)for(const t of e){if("audio"!==t?.mediaType)continue;const e=t.jitsiTrack,a=e?.getTrack?.();if(!(a instanceof MediaStreamTrack)||"live"!==a.readyState)continue;const o=Boolean(t.local),i=t.participantId||e?.getParticipantId?.(),r=o?n.local:n.remote?.get?.(i),c=o?document.querySelector("%23localVideoContainer"):document.getElementById(`participant_${i}`),s=dt(new MediaStream([a]),{label:o?"You":r?.name,authoritativeLabel:!0,direction:o?"local":"remote",origin:"webrtc",persistent:!0,card:c,jitsiParticipantId:i,...o?{senderTrack:a}:{receiverTrack:a}});s&&c&&s.card!==c&&ot(s,null,c)}}();const t=new Set;for(const e of document.querySelectorAll("audio, video")){const n=wt(e);if(!n)continue;const a=G(e)?"local":"remote";Z(n,a),t.add(e),dt(n,{element:e,label:K(e,n),direction:a,origin:"dom"})}for(const[e,n]of p){for(const t of n.elements){const a=xt(t);t.isConnected&&a&&J(a)===e||n.elements.delete(t)}n.card?.isConnected||ot(n,[...n.elements].find(t=>t.isConnected)),it(n),(n.stream&&!n.stream.getAudioTracks().some(t=>"live"===t.readyState)||!n.owned&&!n.persistent&&0===n.elements.size&&t.size>0)&&ct(e)}rt()}function kt(t){const e=Math.min(window.devicePixelRatio||1,2),n=Math.max(1,Math.round(t.clientWidth*e)),a=Math.max(1,Math.round(t.clientHeight*e));return t.width===n&&t.height===a||(t.width=n,t.height=a),{width:n,height:a}}function Mt(t,e,n,a){t.strokeStyle="rgba(255, 255, 255, .09)",t.lineWidth=1,t.beginPath(),t.moveTo(0,Math.round(a/2)+.5),t.lineTo(n,Math.round(a/2)+.5),t.stroke(),t.beginPath();for(let o=0;o<e.timeData.length;o++){const i=o*n/(e.timeData.length-1),r=(1-e.timeData[o])*a/2;0===o?t.moveTo(i,r):t.lineTo(i,r)}t.strokeStyle="%235eead4",t.lineWidth=Math.max(1.5,n/360),t.stroke()}function Tt(t,e){let n=0;for(const e of t.timeData)n+=e*e;const a=Math.sqrt(n/t.timeData.length);if(e-t.lastHistoryAt>=50){t.amplitudeHistory.push({time:e,level:a}),t.lastHistoryAt=e;const n=e-o;for(;t.amplitudeHistory[0]?.time<n;)t.amplitudeHistory.shift()}return a}function Ct(t,e,n,a,i){t.strokeStyle="rgba(255, 255, 255, .07)",t.lineWidth=1;for(let e=5;e<15;e+=5){const i=n-1e3*e/o*n;t.beginPath(),t.moveTo(Math.round(i)+.5,0),t.lineTo(Math.round(i)+.5,a),t.stroke()}if(!e.amplitudeHistory.length)return;const r=i-o;t.beginPath();for(let i=0;i<e.amplitudeHistory.length;i++){const c=e.amplitudeHistory[i],s=Math.max(0,(c.time-r)/o*n),l=a-Math.min(1,3.3*c.level)*(a-4)-2;0===i?t.moveTo(s,l):t.lineTo(s,l)}t.strokeStyle="%235eead4",t.lineWidth=Math.max(1.5,n/360),t.stroke();const c=e.amplitudeHistory[e.amplitudeHistory.length-1],s=Math.max(0,(c.time-r)/o*n);t.lineTo(s,a),t.lineTo(Math.max(0,(e.amplitudeHistory[0].time-r)/o*n),a),t.closePath();const l=t.createLinearGradient(0,0,0,a);l.addColorStop(0,"rgba(94, 234, 212, .38)"),l.addColorStop(1,"rgba(94, 234, 212, .02)"),t.fillStyle=l,t.fill()}function At(t,e,n,a){const o=Math.min(56,e.frequencyData.length),i=Math.max(1,Math.floor(.58*e.frequencyData.length/o)),r=Math.max(1,n/o-1);for(let n=0;n<o;n++){const o=e.frequencyData[n*i]/255,c=Math.max(1,o*a);t.fillStyle=`hsl(${168-35*o} 78%25 ${58+8*o}%25)`,t.fillRect(n*(r+1),a-c,r,c)}}function It(t,e){if(e-t.lastSpectrogramAt<100)return;t.analyser.getByteFrequencyData(t.frequencyData);const n=t.spectrogramColumn.data;for(let e=0;e<r;e++){const a=4*t.frequencyData[t.spectrogramBinMap[e]],o=4*e;n[o]=P[a],n[o+1]=P[a+1],n[o+2]=P[a+2],n[o+3]=255}t.spectrogramContext.putImageData(t.spectrogramColumn,t.spectrogramWriteIndex,0),t.spectrogramWriteIndex=(t.spectrogramWriteIndex+1)%25i,t.spectrogramFrameCount=Math.min(i,t.spectrogramFrameCount+1),t.lastSpectrogramAt=e,t.spectrogramFrames++}function Et(t,e,n,a){const o=e.spectrogramCanvas,r=e.spectrogramFrameCount;if(!r)return;if(r<i){const e=n*(i-r)/i;return void t.drawImage(o,0,0,r,o.height,e,0,n-e,a)}const c=e.spectrogramWriteIndex,s=i-c,l=s/i*n;t.drawImage(o,c,0,s,o.height,0,0,l,a),c&&t.drawImage(o,0,0,c,o.height,l,0,n-l,a)}function Ht(t=performance.now()){if(!A){for(const e of p.values()){e.analyser.getFloatTimeDomainData(e.timeData);const n=Tt(e,t);e.lastLevel=Math.max(n,.72*e.lastLevel),It(e,t)}Lt()}}function Lt(){A||document.hidden||M||(M=window.requestAnimationFrame(Bt))}function Bt(t=performance.now()){if(M=0,!A&&!document.hidden)for(const e of p.values()){if(e.collapsed||e.viewHost.hidden||!e.renderVisible||!e.viewHost.isConnected)continue;if(!e.canvas.clientWidth||!e.canvas.clientHeight)continue;if(e.level.style.transform=`scaleX(${Math.min(1,3.3*e.lastLevel)})`,("spectrum"===e.mode||"spectrogram"===e.mode)&&e.lastRenderedMode===e.mode&&e.lastRenderedFrequencyFrame===e.spectrogramFrames)continue;const n=e.canvas.getContext("2d"),{width:a,height:o}=kt(e.canvas);n.fillStyle="%23071018",n.fillRect(0,0,a,o),"waveform"===e.mode?Mt(n,e,a,o):"amplitude"===e.mode?Ct(n,e,a,o,t):"spectrum"===e.mode?At(n,e,a,o):Et(n,e,a,o),e.lastRenderedMode=e.mode,e.lastRenderedFrequencyFrame=e.spectrogramFrames,e.renderFrames++}}async function Rt(){if("suspended"===u.state)try{await u.resume()}catch{}}const Pt=new Set,Dt=new Map,zt=new Map,Ft=window.RTCPeerConnection?.prototype,_t=Ft?.setRemoteDescription,Wt=Ft?.getReceivers,qt=Ft?.getSenders,$t=Ft?.getStats,jt=Ft?.addTrack,Nt=Ft?.addTransceiver,Vt=window.RTCRtpSender?.prototype,Ot=window.RTCRtpReceiver?.prototype,Ut=Vt?.replaceTrack,Yt=Vt?.getStats,Jt=Ot?.getStats;let Xt=null,Qt=null,Kt=null,Gt=null,Zt=null,te=null,ee=null,ne=null,ae=null;function oe(t){if(!(t instanceof MediaStreamTrack)||"audio"!==t.kind||"live"!==t.readyState)return null;zt.get(t)?.();const e=h.get(t);if(!e||!p.has(e)){const e=[...p.values()].find(e=>"dom"===e.origin&&!e.receiverTrack&&e.trackId===t.id);e&&h.set(t,e.key)}return dt(new MediaStream([t]),{direction:"remote",origin:"webrtc",persistent:!0,receiverTrack:t})}function ie(t){if(!(t instanceof MediaStreamTrack)||"audio"!==t.kind||"live"!==t.readyState)return;if(!t.muted)return void oe(t);if(zt.has(t))return;const e=()=>{t.removeEventListener("unmute",n),t.removeEventListener("ended",e),zt.delete(t)},n=()=>{e(),oe(t)};t.addEventListener("unmute",n,{once:!0}),t.addEventListener("ended",e,{once:!0}),zt.set(t,e)}function re(t){if(!(t instanceof MediaStreamTrack)||"audio"!==t.kind||"live"!==t.readyState)return null;const e=h.get(t);if(!e||!p.has(e)){const e=[...p.values()].find(e=>"dom"===e.origin&&"local"===e.direction&&!e.senderTrack&&e.trackId===t.id);e&&h.set(t,e.key)}return dt(new MediaStream([t]),{label:"You",direction:"local",origin:"webrtc",persistent:!0,senderTrack:t})}function ce(t){if(t&&"closed"!==t.connectionState){try{for(const e of Wt.call(t))ie(e.track)}catch{}try{for(const e of qt.call(t))re(e.track)}catch{}}}function se(t){if(!t||Pt.has(t))return;Pt.add(t);const e=t=>oe(t.track),n=()=>{ce(t),"closed"===t.connectionState&&(t.removeEventListener("track",e),t.removeEventListener("connectionstatechange",n),Dt.delete(t),Pt.delete(t))};t.addEventListener("track",e),t.addEventListener("connectionstatechange",n),Dt.set(t,()=>{t.removeEventListener("track",e),t.removeEventListener("connectionstatechange",n)}),ce(t)}if(!y&&_t&&Wt&&qt){Xt=function(...t){se(this);const e=_t.apply(this,t);return Promise.resolve(e).then(()=>ce(this),()=>{}),e},Qt=function(...t){se(this);const e=Wt.apply(this,t);for(const t of e)ie(t.track);return e},Kt=function(...t){se(this);const e=qt.apply(this,t);for(const t of e)re(t.track);return e},Gt=function(...t){return se(this),$t.apply(this,t)},Zt=function(t,...e){se(this);const n=jt.call(this,t,...e);return re(t),n},te=function(t,...e){se(this);const n=Nt.call(this,t,...e);return t instanceof MediaStreamTrack?re(t):re(n?.sender?.track),n},ee=function(t){const e=Ut.call(this,t);return re(t),e},ne=function(...t){return re(this.track),Yt.apply(this,t)},ae=function(...t){return ie(this.track),Jt.apply(this,t)};try{Ft.setRemoteDescription=Xt,Ft.getReceivers=Qt,Ft.getSenders=Kt,$t&&(Ft.getStats=Gt),jt&&(Ft.addTrack=Zt),Nt&&(Ft.addTransceiver=te),Ut&&(Vt.replaceTrack=ee),Yt&&(Vt.getStats=ne),Jt&&(Ot.getStats=ae)}catch{}}const le=navigator.mediaDevices,de=le?.getUserMedia;let ue=null;if(de&&!k){ue=function(...t){const e=de.apply(this,t);return Promise.resolve(e).then(t=>(!A&&t instanceof MediaStream&&t.getAudioTracks().some(t=>"live"===t.readyState)&&dt(t,{label:"You",direction:"local",origin:"get-user-media",persistent:!0}),t))};try{le.getUserMedia=ue}catch{}}N.addEventListener("click",async()=>{if(!de)return;const t=[...p.values()].filter(t=>"capture"===t.origin);if(t.length)for(const e of t)ct(e.key);else{I=!0,rt();try{await Rt();const t=await de.call(le,{audio:!0,video:!1});m.add(t),dt(t,{label:"Microphone test",owned:!0,persistent:!0,direction:"local",origin:"capture"})||(t.getTracks().forEach(t=>t.stop()),m.delete(t))}catch(t){O.textContent=`Microphone unavailable: ${t.message}`,O.hidden=!1,V.hidden=!1,D.style.display=""}finally{I=!1,rt()}}});let pe=null;function me(){if(!D.isConnected)return;const t=D.getBoundingClientRect();try{localStorage.setItem(a,JSON.stringify({left:Math.round(t.left),top:Math.round(t.top)}))}catch{}}q.addEventListener("pointerdown",t=>{if(t.target.closest("button, select"))return;const e=D.getBoundingClientRect();pe={pointerId:t.pointerId,x:t.clientX-e.left,y:t.clientY-e.top},q.setPointerCapture(t.pointerId)}),q.addEventListener("pointermove",t=>{if(!pe||pe.pointerId!==t.pointerId)return;const e=Math.max(0,Math.min(window.innerWidth-D.offsetWidth,t.clientX-pe.x)),n=Math.max(0,Math.min(window.innerHeight-D.offsetHeight,t.clientY-pe.y));D.style.left=`${e}px`,D.style.top=`${n}px`,D.style.right="auto",D.style.bottom="auto"}),q.addEventListener("pointerup",t=>{pe?.pointerId===t.pointerId&&(pe=null,me())});const fe=new ResizeObserver(me);function ge(){document.hidden||Lt()}D.addEventListener("pointerdown",Rt,{once:!0}),document.documentElement.append(D),fe.observe(D),document.addEventListener("visibilitychange",ge),St(),C=window.setInterval(St,750),Ht(),T=window.setInterval(Ht,50);const he={version:"0.5.4",platform:y?"jitsi":b?"google-meet":w?"microsoft-teams":x?"nextcloud-talk":S?"whatsapp":"generic",sources:p,context:u,host:D,peerConnections:Pt,scan:St,addStream:dt,get mode(){return v},destroy:function n(){if(!A){A=!0,window.cancelAnimationFrame(M),window.clearInterval(T),window.clearInterval(C),document.removeEventListener("visibilitychange",ge),fe.disconnect(),Y.disconnect();for(const t of m)t.getTracks().forEach(t=>t.stop());for(const t of B)t.getTracks().forEach(t=>t.stop());for(const t of p.values()){try{t.node.disconnect()}catch{}t.viewHost.remove()}for(const t of Dt.values())t();Dt.clear(),Pt.clear();for(const t of zt.values())t();if(zt.clear(),Ft?.setRemoteDescription===Xt)try{Ft.setRemoteDescription=_t}catch{}if(Ft?.getReceivers===Qt)try{Ft.getReceivers=Wt}catch{}if(Ft?.getSenders===Kt)try{Ft.getSenders=qt}catch{}if(Ft?.getStats===Gt)try{Ft.getStats=$t}catch{}if(Ft?.addTrack===Zt)try{Ft.addTrack=jt}catch{}if(Ft?.addTransceiver===te)try{Ft.addTransceiver=Nt}catch{}if(Vt?.replaceTrack===ee)try{Vt.replaceTrack=Ut}catch{}if(Vt?.getStats===ne)try{Vt.getStats=Yt}catch{}if(Ot?.getStats===ae)try{Ot.getStats=Jt}catch{}if(le?.getUserMedia===ue)try{le.getUserMedia=de}catch{}if(ft?.connect===yt)try{ft.connect=ht}catch{}if(gt?.createMediaStreamSource===bt)try{gt.createMediaStreamSource=vt}catch{}!function(){for(const t of pt.values())try{t.analyser.disconnect()}catch{}pt.clear()}(),p.clear();for(const[t,e]of E)t.style.position=e;E.clear(),u.close(),D.remove(),window[t]?.destroy===n&&delete window[t],window[e]?.destroy===n&&delete window[e]}}};window[t]=he,window[e]=he})();
Then join a call and click the bookmark. Click it again to remove everything it added. The same bookmark is also served from the project homepage, where you can drag it straight to the bookmarks bar.
The rest of this README is about the other half: how it works and how to hack on it.
There is no loader. The bookmark is the entire program, minified by terser into a single javascript: URL (about 32 KB). Nothing is fetched at click time.
It's been done this way because of Content Security Policy. Nextcloud, Meet and Teams ship strict CSP headers, and a classic bookmarklet that injects a <script src=...> tag simply dies there. A javascript: URL, on the other hand, runs as a user action and is not subject to script-src. As long as the code never asks the page to load anything, the policy has nothing to block.
That constraint shapes the rest of the code:
- no
innerHTMLanywhere, onlycreateElement, so pages that enforce Trusted Types are fine with it - styles go through
CSSStyleSheet.replaceSync()andadoptedStyleSheets, never through a<style>tag, sostyle-srcis not a problem either - every piece of UI lives in a shadow root, so the host page's CSS cannot restyle it and the overlay's cannot leak out
The build is scripts/build-bookmarklet.js. Besides minifying, it escapes the only two characters that matter inside a javascript: URL: % (browsers percent-decode the URL before running it, so a literal %36 would turn into a 6) and # (which starts a URL fragment and truncates the program). Everything else is left as readable JavaScript, on purpose, so you can paste the bookmark into an editor and still follow it. CI runs npm run check:bookmarklet and fails if the committed bookmark does not match the source.
The readable source is talk-waveforms.<version>.js. The version is in the file name so the deployed copy can be served with a one year immutable cache (see site/_headers), while the homepage fetches bookmarklet-loader.js with a cache buster and always hands you the current one.
The interesting part is getting hold of the audio streams without opening the microphone again. There are three sources, and the code tries all of them.
The WebRTC connection itself. On load, the bookmarklet wraps a handful of methods on RTCPeerConnection.prototype (setRemoteDescription, getReceivers, getSenders, addTrack, addTransceiver, getStats) plus RTCRtpSender.replaceTrack and the getStats of senders and receivers. Each wrapper calls the original, then has a look at the connection: every receiver track becomes a remote lane, every sender track becomes the "You" lane. That set covers the two moments that matter, the ones where the app is negotiating (setRemoteDescription, addTrack) and the ones the app calls all the time anyway (getStats, which every call UI polls for its connection quality indicator). That second group is what makes it work when you click the bookmark after the call has started: the app will call getStats within a second, and the connection it was made on becomes visible. A receiver track that is still muted (a remote track stays muted until the first packets arrive) is captured on its unmute event instead.
The <audio> and <video> elements on the page. Every 750 ms a scan walks the DOM. Any media element whose srcObject is a live MediaStream becomes a lane too. This is what reveals where a track is playing, which the peer connection does not know. On pages without a participant grid (the generic fallback), a media element that has no srcObject gets captureStream() called on it, so a plain <video> with a file works as well.
Jitsi's Redux store. Jitsi is special. It replaces the raw receiver track with a participant specific playback track, so the peer connection hooks would show every person twice. Instead the bookmarklet reads window.APP.store directly: features/base/tracks has the tracks, features/base/participants has the names, and the two are joined by participant id. The WebRTC hooks are not installed on Jitsi at all.
The page's own audio graph. Some apps never hand the browser a media stream. On WhatsApp Web, as observed on a live call in September 2026, the peer connections carry only data channels, no remote audio track is ever exposed, the incoming audio is decoded in JavaScript and played through an AudioWorklet, and there is no <audio> element to find. On pages without a participant grid the bookmarklet therefore also wraps AudioNode.prototype.connect: whatever a page connects to an AudioDestinationNode gets tapped into one "Page audio" lane per AudioContext (an analyser created in the page's own context, so nothing is re-routed). And createMediaStreamSource() is wrapped too, so a microphone stream the page feeds into Web Audio becomes the "You" lane, deduplicated against the getUserMedia wrapper by track identity. This only works when the bookmark is clicked before the call starts: a context and its nodes that already exist cannot be enumerated afterwards.
Everything funnels into one function, addStream(). Its job is deduplication: a lane is keyed by the identity of its MediaStreamTrack objects (held in a WeakMap, so no track is kept alive), and the same track seen first on a <video> and later on a receiver gets merged into one lane rather than two. Labels have a quality score, so a real name from the DOM beats "Participant 3" from the fallback, and a sender track can always assert "You".
On generic pages there is also a getUserMedia wrapper (to catch the local mic when the app opens it) and a Mic test button that opens the microphone directly. Inside a supported call app neither is needed, and the button is disabled once outgoing audio is detected, because a second getUserMedia on the same device would only confuse people.
Once a lane exists it has to land on the right tile. participantCardFor() is a list of CSS selectors, one group per platform: [data-participant-id] on Meet, [data-tid*="video-tile"] on Teams, #participant_<id> on Jitsi, .video-container on Talk. If a card is found the lane is appended to it (setting position: relative on the card if it was static, and remembering the old value so it can be put back). If no card is found the lane goes to a floating panel in the corner, which you can drag around; its position is remembered in localStorage.
Nextcloud Talk needs one extra step. Talk re-creates participant cards as the call changes (someone joins, the layout switches), and the new cards are new DOM nodes. The card id has the shape container_<participantId>_video_incoming, so each lane remembers its participant id, and every scan checks that the lane is still on a card with that id. If there are several (Talk keeps a placeholder around), it picks the largest visible one. That is what keeps a graph attached to the same person across layout changes, and the integration test joins participants one at a time to verify precisely this.
Each lane gets one AnalyserNode (FFT size 1024, smoothing 0.72) fed by a MediaStreamSource. Nothing is connected to the destination, so the bookmarklet is silent; it only reads.
Analysis runs on a timer at 20 Hz. Each tick reads the time domain data, computes the RMS level for the history graph, and every second tick (10 Hz) reads the frequency data and writes one column into an offscreen 150 × 128 canvas that acts as a ring buffer. That canvas is the 15 second spectrogram: drawing it on screen is two drawImage calls (the part after the write index, then the part before). The frequency axis is quadratic and stops at 8 kHz, which is where speech lives, so the lower octaves get most of the pixels.
Painting is decoupled from analysis. A requestAnimationFrame loop redraws only lanes that are expanded, on screen (an IntersectionObserver tracks that) and whose data actually changed. When the tab is hidden nothing is painted at all, while the sampling timer keeps running (at whatever rate the browser throttles background tabs to), so when you come back the recent history is there. This has not been profiled carefully; the design goal was that four participants should not make the call noticeably heavier, and in practice it does not.
The bookmark is a toggle: clicking it on a page where it is already running calls destroy() and stops there. Destroy puts every wrapped prototype method back (only if it is still the bookmarklet's own wrapper, in case something else wrapped it later), removes the listeners from every peer connection, stops the streams it opened itself (mic test, captureStream), restores the position of every card it touched, closes the AudioContext and removes the shadow host. The smoke test checks that the page's RTCPeerConnection looks the same before and after.
While it runs, window.__TALK_WAVEFORMS__ exposes the lanes, the audio context, the detected platform and destroy(). The tests use it, and it is handy from the console.
You need Node 22 and Git LFS (the screenshots and the fake participant camera feeds are LFS objects).
npm install
npm run build:bookmarklet # regenerate bookmarklet-loader.js from the versioned source
npm run test:fixture # the fast local suite, no external servicesThe fixture suite (tests/smoke.spec.js and friends) serves a fake call page from tests/fixture-server.js with Nextcloud's real CSP header, plus synthetic pages routed to the real meet.google.com, teams.live.com and web.whatsapp.com origins. It runs a real WebRTC loopback inside Chromium (one peer connection sending, one receiving) and checks the things that are easy to get wrong: local track deduplication, remote track separation, card placement, all four views, persistent history, collapse and reopen, cleanup and hook restoration. The Meet, Teams and WhatsApp specs also accept environment variables that point at a live meeting, for a manual check; no meeting links or account state are stored in the repo.
The real integration tests run against real servers:
-
Jitsi uses the official
jitsi/docker-jitsi-meetrelease pinned in CI (stable-11146-2), configured withtests/jitsi.env. Three browsers join a room and the test checks that every audio track lands exactly once on its named tile.JITSI_URL=http://127.0.0.1:18000/TalkWaveformsHarness npm run test:jitsi
-
Nextcloud Talk uses Gocassini, a sibling project that brings up a full Nextcloud with Talk and can create rooms from the command line. It expects the checkout next to this one.
../gocassini/bin/cassini dev stack up --services full --recording-backend none npm run test:harness
One observer and four camera participants join in sequence, and after every join the test verifies each graph is still on the card of the same person. CI runs this three times, against a past, current and future Nextcloud and Talk pair (the future one is a release candidate, built by
tests/nextcloud-future.Dockerfile), so a Talk refactor shows up here before it shows up in a bug report.
This README is generated. The prose lives in README.template.md; npm run build:readme fills in the bookmark URL, the version, its size and the latest showcase screenshot from the rest of the repo, and CI runs npm run check:readme so the two cannot drift. Edit the template, not README.md.
The overlay images in this README come from tests/readme-captures.spec.js. It builds a 17 second clip with ffmpeg (two flite sentences over a 120 Hz sine, the fan), plays it into a fake Talk participant card, waits for the 15 second window to fill and takes an element screenshot of the overlay in each view. Run npm run capture:readme after a design change and commit the four PNGs in docs/readme/; they are LFS objects.
The homepage is translated (English, Italian, German, Spanish, French, Portuguese) from site/translations.<version>.js, picked from the browser's primary language, with ?lang=xx as an override. The homepage test checks that every language defines the same keys as English.
The homepage's demo clip (Tom Lehrer's The Elements, with a synchronized karaoke player from Cassini Format) comes from npm run build:demo-clip, which prepares an Opus file and an MP4 into site/assets/. The homepage test plays it and checks that the bookmarklet picks it up as a source.
The homepage screenshots are produced by the same harnesses (npm run test:jitsi:showcase, test:google:showcase, test:teams:showcase, test:showcase), with four generated participants, synthesized non overlapping speech turns and, for one of them, a steady fan tone. The fan is the whole point of the project, so it seemed fair to give it a seat.
npm run deploy rsyncs the current versioned source, the bookmark, the homepage and the assets to the static host without touching older versions already there.
It does not measure loudness in any calibrated sense. The level bar is RMS of whatever the browser hands over after the app's own processing (echo cancellation, noise suppression, gain control), which is what the other side hears, not what your microphone picks up. That is arguably the more useful of the two, but it is worth knowing.
On pages that decode audio themselves (WhatsApp Web), it has to be running before the call starts; clicked mid-call it finds nothing, because existing audio graphs cannot be enumerated. If you know of a hook that is missing, please open an issue.
Made by Code Myriad.




