@seams/voice-capture 0.67.1 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- const e={assetBase:null,microphonePreference:null,styleNonce:null,label:`Voice capture`,place:`this page`,site:`this site`,recoveryHint:`Reload the page and try again.`,cacheName:`seams-voice-capture-models-v1`};let t=e;function n(n){let r=new URL(n.assetBase,globalThis.document?.baseURI);r.pathname.endsWith(`/`)||(r.pathname+=`/`),t={assetBase:r,microphonePreference:n.microphonePreference??null,styleNonce:n.styleNonce??null,label:n.label??e.label,place:n.place??e.place,site:n.site??e.site,recoveryHint:n.recoveryHint??e.recoveryHint,cacheName:n.cacheName??e.cacheName}}function r(){return t}function i(){let e=t.styleNonce;return(typeof e==`function`?e():e)??void 0}function a(e){if(!t.assetBase)throw Error(`Call configureVoiceCapture({ assetBase }) before you load a voice asset.`);return new URL(e,t.assetBase)}const o=3e4;var s=class extends Error{constructor(e,t,n){super(t,n),this.kind=e,this.name=`VoiceAssetLoadError`}};async function c(){try{return await caches.open(r().cacheName)}catch{return null}}var l=class{pending=null;progress={kind:`checking_cache`};listeners=new Set;constructor(e){this.asset=e}get size(){return this.asset.bytes}async download(e){e&&(this.listeners.add(e),e(this.progress));try{this.pending||=this.bytes().catch(this.failed);let e=await this.pending;return this.publish({kind:`ready`}),e}finally{e&&this.listeners.delete(e)}}failed=e=>{throw this.pending=null,this.progress={kind:`checking_cache`},e};publish(e){this.progress=e;for(let t of this.listeners)t(e)}async valid(e){if(e.byteLength!==this.asset.bytes)return!1;let t=new Uint8Array(await crypto.subtle.digest(`SHA-256`,e)),n=``;for(let e of t)n+=e.toString(16).padStart(2,`0`);return n===this.asset.sha256}async bytes(){let{fileName:e,sha256:t}=this.asset;this.publish({kind:`checking_cache`});let n=await c(),r=new URL(`${e}?sha256=${t}`,a(``)).href;try{let e=await n?.match(r);if(e){let t=await e.arrayBuffer();if(await this.valid(t))return t;await n?.delete(r)}}catch{}let i;try{i=await this.fetchBytes()}catch(e){if(!(e instanceof s)||e.kind!==`connection`)throw e;i=await this.fetchBytes()}if(n)try{await n.put(r,new Response(i,{headers:{"Content-Type":`application/octet-stream`}}))}catch{}return i}async fetchBytes(){let{fileName:e}=this.asset,t=typeof this.asset.label==`function`?this.asset.label():this.asset.label,n=new AbortController,i=setTimeout(u.bind(null,n),o);this.publish({kind:`downloading`,received:0});try{let c=await fetch(a(e),{credentials:`omit`,cache:`reload`,signal:n.signal});if(!c.ok||!c.body){let e=[408,429,500,502,503,504].includes(c.status);throw new s(e?`connection`:`unavailable`,e?`${t} download was interrupted. Check your connection and retry.`:`${t} is unavailable from ${r().site}. ${r().recoveryHint}`)}let l=c.body.getReader(),f=new Uint8Array(this.asset.bytes),p=0;try{for(;;){let e=await l.read();if(e.done)break;if(clearTimeout(i),i=setTimeout(u.bind(null,n),o),p+e.value.length>f.length)throw new s(`integrity`,`${t} size is invalid. Retry loading.`);f.set(e.value,p),p+=e.value.length,this.publish({kind:`downloading`,received:p})}}finally{await l.cancel().catch(d),l.releaseLock()}if(p!==f.length||!await this.valid(f.buffer))throw new s(`integrity`,`${t} integrity check failed. Retry loading.`);return f.buffer}catch(e){throw e instanceof s?e:new s(`connection`,`${t} download was interrupted. Check your connection and retry.`,{cause:e})}finally{clearTimeout(i),n.abort()}}};function u(e){e.abort()}function d(){}const f=new l({fileName:`ort-wasm-simd-threaded.wasm`,sha256:`3398c10d07d229bd91b364548e130e0e51a8e5704b88c7c083ebbeb78842dee2`,bytes:14239897,label:()=>`${r().label} engine`});var p=class{asset;session=null;constructor(e){this.asset=new l(e)}prepare(){return this.session||=this.load().catch(this.failed),this.session}async run(e){let t=await this.prepare(),{Tensor:n}=await import(`./ort.wasm.bundle.min-CWqlff_l.js`),r=new n(`float32`,e,[1,e.length]);try{return await t.run({samples:r})}finally{r.dispose()}}failed=e=>{throw this.session=null,e};async load(){try{let e=await import(`./ort.wasm.bundle.min-CWqlff_l.js`);e.env.wasm.numThreads=1,e.env.wasm.wasmPaths=a(``).href,e.env.wasm.wasmBinary=await f.download();let t=await this.asset.download();return await e.InferenceSession.create(t,{executionProviders:[`wasm`]})}catch(e){if(e instanceof s)throw e;let{label:t,recoveryHint:n}=r();throw console.error(`[${t}] inference engine could not start`,e),new s(`initialization`,`${t} could not start. Retry loading. If it still fails, ${n.charAt(0).toLowerCase()}${n.slice(1)}`,{cause:e})}}};const m=new p({fileName:`silero-vad.onnx`,sha256:`2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f`,bytes:2327524,label:`Speech detection`});async function h(){return new _(await m.prepare(),await import(`./ort.wasm.bundle.min-CWqlff_l.js`))}const g={probability:async()=>1,clear:()=>{}};var _=class{state=new Float32Array(256);context=new Float32Array(64);constructor(e,t){this.session=e,this.ort=t}async probability(e){if(e.length!==512)throw Error(`Speech detection requires a 512-sample frame.`);let t=new Float32Array(576);t.set(this.context),t.set(e,64);let n=new this.ort.Tensor(`float32`,t,[1,576]),r=new this.ort.Tensor(`float32`,this.state,[2,1,128]),i=new this.ort.Tensor(`int64`,BigInt64Array.of(16000n),[]);try{let t=await this.session.run({input:n,state:r,sr:i});try{let n=t.output?.data,r=t.stateN?.data;if(!(n instanceof Float32Array)||n.length!==1||!Number.isFinite(n[0])||n[0]<0||n[0]>1||!(r instanceof Float32Array)||r.length!==256)throw Error(`Speech detection returned an invalid result.`);return this.state.set(r),this.context.set(e.subarray(448)),n[0]}finally{for(let e of Object.values(t))e.dispose()}}finally{n.dispose(),r.dispose(),i.dispose(),t.fill(0)}}clear(){this.state.fill(0),this.context.fill(0)}};function v(e,t){return t!==null&&e>=t?800:1500}function y(e){return e.trim().replace(/[.!?,;:]+$/,``).split(/\s+/).filter(Boolean).reduce((e,t)=>e+b(t),0)*200}function b(e){let t=e.match(/[A-Za-z]+/g)?.length??0,n=e.match(/[0-9]/g)?.length??0,r=e.match(/[.\-_@]/g)?.length??0;return Math.max(1,t+n+r)}const x=16e3,S=8*x;function C(e){let t=Math.min(e.length,S),n=new Uint8Array(44+t*2),r=new DataView(n.buffer);w(n,0,`RIFF`),r.setUint32(4,36+t*2,!0),w(n,8,`WAVEfmt `),r.setUint32(16,16,!0),r.setUint16(20,1,!0),r.setUint16(22,1,!0),r.setUint32(24,x,!0),r.setUint32(28,x*2,!0),r.setUint16(32,2,!0),r.setUint16(34,16,!0),w(n,36,`data`),r.setUint32(40,t*2,!0);for(let n=0;n<t;n+=1){let t=Math.max(-1,Math.min(1,e[n]??0));r.setInt16(44+n*2,Math.round(t<0?t*32768:t*32767),!0)}return n}function w(e,t,n){for(let r=0;r<n.length;r+=1)e[t+r]=n.charCodeAt(r)}let T={kind:`unread`};function E(e){return typeof e==`string`&&e.length>0&&e!==`default`&&e!==`communications`}async function D(){if(T.kind!==`unread`)return T;let e={kind:`system_default`};try{let t=await r().microphonePreference?.read();if(typeof t==`object`&&t&&`kind`in t&&t.kind===`device`){let n=`deviceId`in t?t.deviceId:null;E(n)&&(e={kind:`device`,deviceId:n})}}catch{}return T.kind===`unread`&&(T=e),T}function O(e){let t={channelCount:1,echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0};return e.kind===`device`&&(t.deviceId={exact:e.deviceId}),{audio:t,video:!1}}async function k(e){if(E(e.deviceId))return{kind:`device`,deviceId:e.deviceId};if(e.groupId)try{let t=await navigator.mediaDevices.enumerateDevices();for(let n of t)if(n.kind===`audioinput`&&n.groupId===e.groupId&&E(n.deviceId))return{kind:`device`,deviceId:n.deviceId}}catch{}return{kind:`system_default`}}async function A(e){e.throwIfAborted();let t=await D();e.throwIfAborted();let n,r=t.kind===`device`?`preferred`:`default`;try{n=await navigator.mediaDevices.getUserMedia(O(t))}catch(i){if(e.throwIfAborted(),t.kind!==`device`||!(i instanceof DOMException)||i.name!==`OverconstrainedError`&&i.name!==`NotFoundError`)throw i;n=await navigator.mediaDevices.getUserMedia(O({kind:`system_default`})),r=`replacement`}let i=j.bind(null,n);e.addEventListener(`abort`,i,{once:!0});try{e.throwIfAborted();let t=n.getAudioTracks()[0];if(!t||t.readyState!==`live`)throw new F;let i=t.getSettings(),a=await k(i);if(e.throwIfAborted(),t.readyState!==`live`)throw new F;return{stream:n,preference:a,selection:r,label:t.label||`Default microphone`,processing:{noiseSuppression:i.noiseSuppression??null,echoCancellation:i.echoCancellation??null,autoGainControl:i.autoGainControl??null,sampleRate:i.sampleRate??null}}}catch(e){throw i(),e}finally{e.removeEventListener(`abort`,i)}}function j(e){for(let t of e.getTracks())t.stop()}async function M(e){e.preference.kind!==`device`||e.selection===`replacement`||await P(e.preference)}async function N(){await P({kind:`system_default`})}async function P(e){T=e;try{await r().microphonePreference?.save(e)}catch{}}var F=class extends Error{constructor(){super(`The microphone disconnected or stopped. Check your microphone and try again.`),this.name=`VoiceMicrophoneInterruptedError`}};function I(e){return e.noise===`high`?`Background noise is high. Move closer to the microphone and try again.`:`Audio too brief or quiet: ${(e.voicedMs/1e3).toFixed(2)} of ${(e.requiredVoicedMs/1e3).toFixed(2)} seconds detected.`}var L=class extends Error{constructor(e){super(I(e)),this.quality=e,this.name=`InsufficientVoiceAudioError`}};const R=.006,z=8e3;function B(){if(V(),!navigator.userActivation.isActive){let{label:e,place:t}=r();throw Error(`Open ${t} and select Record to use ${e}.`)}}function V(){if(document.visibilityState!==`visible`||!document.hasFocus()){let{label:e,place:t}=r();throw Error(`Keep ${t} open and focused to use ${e}.`)}}function H(){return Error(`${r().label} recording was cancelled.`)}function U(e){return e.detector===`level`?async()=>g:h}async function W(e,t,n={}){let r=n.expectedSpeechMs??null,i=U(n);if(e.aborted)throw H();let a=crypto.randomUUID();t({kind:`opening`});let o=await A(e),{stream:s}=o;try{if(e.aborted)throw H();let n=await new G(s,e,t,r,i).result.finally(t.bind(null,{kind:`processing`}));if(e.aborted)throw H();let c=await new OfflineAudioContext(1,x,x).decodeAudioData(n.bytes),l=new OfflineAudioContext(1,Math.ceil(c.duration*x),x),u=l.createBufferSource();u.buffer=c,u.connect(l.destination),u.start();try{let r=await l.startRendering();try{if(e.aborted)throw H();let s=r.getChannelData(0),c=await K(s,e,await i()),l={attemptId:a,capturedAtMs:Date.now(),durationMs:r.duration*1e3,recordingMs:n.recordingMs,endedBy:n.endedBy,voicedMs:c.voicedMs,peakFrameRms:c.peakFrameRms,levelThreshold:R,requiredVoicedMs:600,microphone:o.label,microphoneSelection:o.selection,processing:o.processing,backgroundRms:c.backgroundRms,speechToBackgroundDb:c.speechToBackgroundDb,noise:c.noise};if(t({kind:`measured`,quality:l}),c.kind===`insufficient_audio`)return{kind:`insufficient_audio`,quality:l};let u=s.slice(Math.max(0,c.first-3200),Math.min(s.length,c.last+3200));return await M(o),e.aborted&&(u.fill(0),e.throwIfAborted()),{kind:`captured`,samples:u,quality:l,lastSoundAtMs:n.lastSoundAtMs}}finally{q(r)}}finally{q(c)}}finally{for(let e of s.getTracks())e.stop()}}var G=class{recorder;chunks=[];timer=null;completion=Y();startedAtMs=performance.now();ended=null;activity={kind:`waiting`,activeMs:0};audioContext=null;worklet=null;detector=null;pending=Promise.resolve();queuedFrames=0;closed=!1;result=this.completion.promise;constructor(e,t,n,r,i){this.stream=e,this.signal=t,this.onCapture=n,this.expectedSpeechMs=r,this.createDetector=i,this.recorder=new MediaRecorder(e),this.recorder.addEventListener(`dataavailable`,this.data),this.recorder.addEventListener(`stop`,this.stopped),this.recorder.addEventListener(`error`,this.failed);for(let t of e.getAudioTracks())t.addEventListener(`ended`,this.interrupted),t.addEventListener(`mute`,this.interrupted);t.addEventListener(`abort`,this.cancelled,{once:!0}),this.timer=setTimeout(this.startupFailed,z),this.start()}async start(){try{if(await this.startPauseDetection(),this.signal.throwIfAborted(),this.startedAtMs=performance.now(),this.closed)return;this.recorder.start(),this.timer!==null&&clearTimeout(this.timer),this.timer=setTimeout(this.stop.bind(this,`limit`),z),this.onCapture({kind:`listening`})}catch(e){this.cleanup(),this.completion.reject(e instanceof Error?e:Error(`Speech detection could not start.`))}}data=e=>{this.chunks.push(e.data)};stop=e=>{this.recorder.state!==`inactive`&&(this.ended={endedBy:e,recordingMs:performance.now()-this.startedAtMs,lastSoundAtMs:this.activity.kind===`speaking`?this.activity.lastSoundAtMs:null},this.recorder.stop(),this.stopTracks())};cancelled=()=>{if(this.recorder.state===`inactive`){this.cleanup(),this.completion.reject(H());return}this.stop(`stream_ended`)};startupFailed=()=>{this.cleanup(),this.completion.reject(Error(`Speech detection startup timed out. Please try again.`))};detectorFailed=e=>{this.cleanup(),this.completion.reject(e instanceof Error?e:Error(`Speech detection failed.`))};failed=()=>{this.cleanup(),this.completion.reject(Error(`${r().label} could not record this microphone.`))};interrupted=()=>{this.cleanup(),this.completion.reject(new F)};stopped=()=>{let e=this.ended;if(this.cleanup(),this.signal.aborted){this.completion.reject(H());return}if(e===null){this.completion.reject(new F);return}new Blob(this.chunks,{type:this.recorder.mimeType}).arrayBuffer().then(this.complete.bind(this,e),this.completion.reject.bind(this.completion))};complete=(e,t)=>{if(this.signal.aborted){this.completion.reject(H());return}this.completion.resolve({bytes:t,...e})};async startPauseDetection(){let e=new AudioContext({sampleRate:x});this.audioContext=e;let t=e.resume();if(this.detector=await this.createDetector(),this.signal.throwIfAborted(),e.sampleRate!==16e3)throw Error(`Speech detection requires 16 kHz audio.`);await e.audioWorklet.addModule(a(`voice-activity-worklet.js`)),this.signal.throwIfAborted();let n=new AudioWorkletNode(e,`voice-activity-frames`);this.worklet=n,n.port.onmessage=this.receiveFrame,e.createMediaStreamSource(this.stream).connect(n),n.connect(e.destination),await t}receiveFrame=e=>{if(!(e.data instanceof Float32Array)||e.data.length!==512){this.failed();return}if(this.closed||this.recorder.state!==`recording`){e.data.fill(0);return}if(++this.queuedFrames>32){e.data.fill(0),this.failed();return}this.pending=this.pending.then(this.processFrame.bind(this,e.data)).catch(this.detectorFailed)};async processFrame(e){let t=this.detector;try{if(this.closed||!t)return;let n=await t.probability(e);if(this.closed){t.clear();return}let r=0;for(let t of e)r+=t*t;let i=Math.sqrt(r/e.length),a=n>=.5&&i>=R;this.updateActivity(a),this.onCapture({kind:`activity`,rms:i,speech:a})}finally{e.fill(0),--this.queuedFrames}}updateActivity(e){let t=performance.now();switch(this.activity.kind){case`waiting`:{let n=e?this.activity.activeMs+32:0;this.activity=n>=100?{kind:`speaking`,lastSoundAtMs:t,voicedMs:n}:{kind:`waiting`,activeMs:n};return}case`speaking`:{let{lastSoundAtMs:n,voicedMs:r}=this.activity;if(e){this.activity={kind:`speaking`,lastSoundAtMs:t,voicedMs:r+32};return}t-n>=v(r,this.expectedSpeechMs)&&this.stop(`pause`);return}default:return X(this.activity)}}stopPauseDetection=()=>{this.closed=!0,this.worklet&&(this.worklet.port.onmessage=null,this.worklet.port.close(),this.worklet.disconnect()),this.worklet=null,this.detector?.clear(),this.detector=null;let e=this.audioContext;this.audioContext=null,e&&e.close().catch(J)};stopTracks(){for(let e of this.stream.getTracks())e.stop()}cleanup(){for(let e of this.stream.getAudioTracks())e.removeEventListener(`ended`,this.interrupted),e.removeEventListener(`mute`,this.interrupted);this.stopPauseDetection(),this.stopTracks(),this.timer!==null&&clearTimeout(this.timer),this.signal.removeEventListener(`abort`,this.cancelled),this.recorder.removeEventListener(`dataavailable`,this.data),this.recorder.removeEventListener(`stop`,this.stopped),this.recorder.removeEventListener(`error`,this.failed)}};async function K(e,t,n){let r=-1,i=0,a=0,o=0,s=0,c=0,l=0,u=new Float32Array(512);try{for(let d=0;d<e.length;d+=u.length){t.throwIfAborted(),u.fill(0);let f=Math.min(u.length,e.length-d);u.set(e.subarray(d,d+f));let p=0;for(let e of u)p+=e*e;let m=Math.sqrt(p/f);if(o=Math.max(o,m),!(await n.probability(u)>=.5&&m>=R)){s+=p,c+=f;continue}r<0&&(r=d),i=d+f,a+=f,l+=p}}finally{u.fill(0),n.clear()}let d=Math.sqrt(s/Math.max(1,c)),f=Math.sqrt(l/Math.max(1,a)),p=a&&c?20*Math.log10(Math.max(f,1e-8)/Math.max(d,1e-8)):null,m={backgroundRms:d,speechToBackgroundDb:p,noise:d>=R&&(p===null||p<10)?`high`:c?`normal`:`unknown`},h=a/x*1e3;return r<0||h<600?{kind:`insufficient_audio`,voicedMs:h,peakFrameRms:o,...m}:{kind:`captured`,first:r,last:i,voicedMs:h,peakFrameRms:o,...m}}function q(e){for(let t=0;t<e.numberOfChannels;t+=1)e.getChannelData(t).fill(0)}function J(){}function Y(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}function X(e){throw Error(`Unexpected speech activity: ${String(e)}`)}export{i as C,n as S,m as _,I as a,f as b,M as c,x as d,C as f,g,h,B as i,N as l,y as m,W as n,F as o,v as p,V as r,A as s,L as t,S as u,p as v,r as x,s as y};
1
+ const e={assetBase:null,microphonePreference:null,styleNonce:null,label:`Voice capture`,place:`this page`,site:`this site`,recoveryHint:`Reload the page and try again.`,cacheName:`seams-voice-capture-models-v1`};let t=e;function n(n){let r=new URL(n.assetBase,globalThis.document?.baseURI);r.pathname.endsWith(`/`)||(r.pathname+=`/`),t={assetBase:r,microphonePreference:n.microphonePreference??null,styleNonce:n.styleNonce??null,label:n.label??e.label,place:n.place??e.place,site:n.site??e.site,recoveryHint:n.recoveryHint??e.recoveryHint,cacheName:n.cacheName??e.cacheName}}function r(){return t}function i(){let e=t.styleNonce;return(typeof e==`function`?e():e)??void 0}function a(e){if(!t.assetBase)throw Error(`Call configureVoiceCapture({ assetBase }) before you load a voice asset.`);return new URL(e,t.assetBase)}const o=3e4;var s=class extends Error{constructor(e,t,n){super(t,n),this.kind=e,this.name=`VoiceAssetLoadError`}};async function c(){try{return await caches.open(r().cacheName)}catch{return null}}var l=class{pending=null;progress={kind:`checking_cache`};listeners=new Set;constructor(e){this.asset=e}get size(){return this.asset.bytes}async download(e){e&&(this.listeners.add(e),e(this.progress));try{this.pending||=this.bytes().catch(this.failed);let e=await this.pending;return this.publish({kind:`ready`}),e}finally{e&&this.listeners.delete(e)}}failed=e=>{throw this.pending=null,this.progress={kind:`checking_cache`},e};publish(e){this.progress=e;for(let t of this.listeners)t(e)}async valid(e){if(e.byteLength!==this.asset.bytes)return!1;let t=new Uint8Array(await crypto.subtle.digest(`SHA-256`,e)),n=``;for(let e of t)n+=e.toString(16).padStart(2,`0`);return n===this.asset.sha256}async bytes(){let{fileName:e,sha256:t}=this.asset;this.publish({kind:`checking_cache`});let n=await c(),r=new URL(`${e}?sha256=${t}`,a(``)).href;try{let e=await n?.match(r);if(e){let t=await e.arrayBuffer();if(await this.valid(t))return t;await n?.delete(r)}}catch{}let i;try{i=await this.fetchBytes()}catch(e){if(!(e instanceof s)||e.kind!==`connection`)throw e;i=await this.fetchBytes()}if(n)try{await n.put(r,new Response(i,{headers:{"Content-Type":`application/octet-stream`}}))}catch{}return i}async fetchBytes(){let{fileName:e}=this.asset,t=typeof this.asset.label==`function`?this.asset.label():this.asset.label,n=new AbortController,i=setTimeout(u.bind(null,n),o);this.publish({kind:`downloading`,received:0});try{let c=await fetch(a(e),{credentials:`omit`,cache:`reload`,signal:n.signal});if(!c.ok||!c.body){let e=[408,429,500,502,503,504].includes(c.status);throw new s(e?`connection`:`unavailable`,e?`${t} download was interrupted. Check your connection and retry.`:`${t} is unavailable from ${r().site}. ${r().recoveryHint}`)}let l=c.body.getReader(),f=new Uint8Array(this.asset.bytes),p=0;try{for(;;){let e=await l.read();if(e.done)break;if(clearTimeout(i),i=setTimeout(u.bind(null,n),o),p+e.value.length>f.length)throw new s(`integrity`,`${t} size is invalid. Retry loading.`);f.set(e.value,p),p+=e.value.length,this.publish({kind:`downloading`,received:p})}}finally{await l.cancel().catch(d),l.releaseLock()}if(p!==f.length||!await this.valid(f.buffer))throw new s(`integrity`,`${t} integrity check failed. Retry loading.`);return f.buffer}catch(e){throw e instanceof s?e:new s(`connection`,`${t} download was interrupted. Check your connection and retry.`,{cause:e})}finally{clearTimeout(i),n.abort()}}};function u(e){e.abort()}function d(){}const f=new l({fileName:`ort-wasm-simd-threaded.wasm`,sha256:`3398c10d07d229bd91b364548e130e0e51a8e5704b88c7c083ebbeb78842dee2`,bytes:14239897,label:()=>`${r().label} engine`});var p=class{asset;session=null;constructor(e){this.asset=new l(e)}prepare(){return this.session||=this.load().catch(this.failed),this.session}async run(e){let t=await this.prepare(),{Tensor:n}=await import(`./ort.wasm.bundle.min-CWqlff_l.js`),r=new n(`float32`,e,[1,e.length]);try{return await t.run({samples:r})}finally{r.dispose()}}failed=e=>{throw this.session=null,e};async load(){try{let e=await import(`./ort.wasm.bundle.min-CWqlff_l.js`);e.env.wasm.numThreads=1,e.env.wasm.wasmPaths=a(``).href,e.env.wasm.wasmBinary=await f.download();let t=await this.asset.download();return await e.InferenceSession.create(t,{executionProviders:[`wasm`]})}catch(e){if(e instanceof s)throw e;let{label:t,recoveryHint:n}=r();throw console.error(`[${t}] inference engine could not start`,e),new s(`initialization`,`${t} could not start. Retry loading. If it still fails, ${n.charAt(0).toLowerCase()}${n.slice(1)}`,{cause:e})}}};const m=new p({fileName:`silero-vad.onnx`,sha256:`2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f`,bytes:2327524,label:`Speech detection`});async function h(){return new _(await m.prepare(),await import(`./ort.wasm.bundle.min-CWqlff_l.js`))}const g={probability:async()=>1,clear:()=>{}};var _=class{state=new Float32Array(256);context=new Float32Array(64);constructor(e,t){this.session=e,this.ort=t}async probability(e){if(e.length!==512)throw Error(`Speech detection requires a 512-sample frame.`);let t=new Float32Array(576);t.set(this.context),t.set(e,64);let n=new this.ort.Tensor(`float32`,t,[1,576]),r=new this.ort.Tensor(`float32`,this.state,[2,1,128]),i=new this.ort.Tensor(`int64`,BigInt64Array.of(16000n),[]);try{let t=await this.session.run({input:n,state:r,sr:i});try{let n=t.output?.data,r=t.stateN?.data;if(!(n instanceof Float32Array)||n.length!==1||!Number.isFinite(n[0])||n[0]<0||n[0]>1||!(r instanceof Float32Array)||r.length!==256)throw Error(`Speech detection returned an invalid result.`);return this.state.set(r),this.context.set(e.subarray(448)),n[0]}finally{for(let e of Object.values(t))e.dispose()}}finally{n.dispose(),r.dispose(),i.dispose(),t.fill(0)}}clear(){this.state.fill(0),this.context.fill(0)}};function v(e,t){return t!==null&&e>=t?800:1500}function y(e){return e.trim().replace(/[.!?,;:]+$/,``).split(/\s+/).filter(Boolean).reduce((e,t)=>e+b(t),0)*200}function b(e){let t=e.match(/[A-Za-z]+/g)?.length??0,n=e.match(/[0-9]/g)?.length??0,r=e.match(/[.\-_@]/g)?.length??0;return Math.max(1,t+n+r)}const x=16e3,S=8*x;function C(e){let t=Math.min(e.length,S),n=new Uint8Array(44+t*2),r=new DataView(n.buffer);w(n,0,`RIFF`),r.setUint32(4,36+t*2,!0),w(n,8,`WAVEfmt `),r.setUint32(16,16,!0),r.setUint16(20,1,!0),r.setUint16(22,1,!0),r.setUint32(24,x,!0),r.setUint32(28,x*2,!0),r.setUint16(32,2,!0),r.setUint16(34,16,!0),w(n,36,`data`),r.setUint32(40,t*2,!0);for(let n=0;n<t;n+=1){let t=Math.max(-1,Math.min(1,e[n]??0));r.setInt16(44+n*2,Math.round(t<0?t*32768:t*32767),!0)}return n}function w(e,t,n){for(let r=0;r<n.length;r+=1)e[t+r]=n.charCodeAt(r)}let T={kind:`unread`};function E(e){return typeof e==`string`&&e.length>0&&e!==`default`&&e!==`communications`}async function D(){if(T.kind!==`unread`)return T;let e={kind:`system_default`};try{let t=await r().microphonePreference?.read();if(typeof t==`object`&&t&&`kind`in t&&t.kind===`device`){let n=`deviceId`in t?t.deviceId:null;E(n)&&(e={kind:`device`,deviceId:n})}}catch{}return T.kind===`unread`&&(T=e),T}function O(e){let t={channelCount:1,echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0};return e.kind===`device`&&(t.deviceId={exact:e.deviceId}),{audio:t,video:!1}}async function k(e){if(E(e.deviceId))return{kind:`device`,deviceId:e.deviceId};if(e.groupId)try{let t=await navigator.mediaDevices.enumerateDevices();for(let n of t)if(n.kind===`audioinput`&&n.groupId===e.groupId&&E(n.deviceId))return{kind:`device`,deviceId:n.deviceId}}catch{}return{kind:`system_default`}}async function A(e){e.throwIfAborted();let t=await D();e.throwIfAborted();let n,r=t.kind===`device`?`preferred`:`default`;try{n=await navigator.mediaDevices.getUserMedia(O(t))}catch(i){if(e.throwIfAborted(),t.kind!==`device`||!(i instanceof DOMException)||i.name!==`OverconstrainedError`&&i.name!==`NotFoundError`)throw i;n=await navigator.mediaDevices.getUserMedia(O({kind:`system_default`})),r=`replacement`}let i=j.bind(null,n);e.addEventListener(`abort`,i,{once:!0});try{e.throwIfAborted();let t=n.getAudioTracks()[0];if(!t||t.readyState!==`live`)throw new F;let i=t.getSettings(),a=await k(i);if(e.throwIfAborted(),t.readyState!==`live`)throw new F;return{stream:n,preference:a,selection:r,label:t.label||`Default microphone`,processing:{noiseSuppression:i.noiseSuppression??null,echoCancellation:i.echoCancellation??null,autoGainControl:i.autoGainControl??null,sampleRate:i.sampleRate??null}}}catch(e){throw i(),e}finally{e.removeEventListener(`abort`,i)}}function j(e){for(let t of e.getTracks())t.stop()}async function M(e){e.preference.kind!==`device`||e.selection===`replacement`||await P(e.preference)}async function N(){await P({kind:`system_default`})}async function P(e){T=e;try{await r().microphonePreference?.save(e)}catch{}}var F=class extends Error{constructor(){super(`The microphone disconnected or stopped. Check your microphone and try again.`),this.name=`VoiceMicrophoneInterruptedError`}};function I(e){return e.noise===`high`?`Background noise is high. Move closer to the microphone and try again.`:`Audio too brief or quiet: ${(e.voicedMs/1e3).toFixed(2)} of ${(e.requiredVoicedMs/1e3).toFixed(2)} seconds detected.`}var L=class extends Error{constructor(e){super(I(e)),this.quality=e,this.name=`InsufficientVoiceAudioError`}};const R=.006,z=8e3;function B(){if(V(),!navigator.userActivation.isActive){let{label:e,place:t}=r();throw Error(`Open ${t} and select Record to use ${e}.`)}}function V(){if(document.visibilityState!==`visible`||!document.hasFocus()){let{label:e,place:t}=r();throw Error(`Keep ${t} open and focused to use ${e}.`)}}var H=class extends Error{constructor(){super(`Select Record to use ${r().label}.`),this.name=`VoiceUserActionNeededError`}};function U(e){return new Promise(t=>setTimeout(t,e))}function W(){return Error(`${r().label} recording was cancelled.`)}function G(e){return e.detector===`level`?async()=>g:h}async function K(e,t,n={}){let r=n.expectedSpeechMs??null,i=G(n);if(e.aborted)throw W();let a=crypto.randomUUID();t({kind:`opening`});let o=await A(e),{stream:s}=o;try{if(e.aborted)throw W();let n=await new q(s,e,t,r,i).result.finally(t.bind(null,{kind:`processing`}));if(e.aborted)throw W();let c=await new OfflineAudioContext(1,x,x).decodeAudioData(n.bytes),l=new OfflineAudioContext(1,Math.ceil(c.duration*x),x),u=l.createBufferSource();u.buffer=c,u.connect(l.destination),u.start();try{let r=await l.startRendering();try{if(e.aborted)throw W();let s=r.getChannelData(0),c=await J(s,e,await i()),l={attemptId:a,capturedAtMs:Date.now(),durationMs:r.duration*1e3,recordingMs:n.recordingMs,endedBy:n.endedBy,voicedMs:c.voicedMs,peakFrameRms:c.peakFrameRms,levelThreshold:R,requiredVoicedMs:600,microphone:o.label,microphoneSelection:o.selection,processing:o.processing,backgroundRms:c.backgroundRms,speechToBackgroundDb:c.speechToBackgroundDb,noise:c.noise};if(t({kind:`measured`,quality:l}),c.kind===`insufficient_audio`)return{kind:`insufficient_audio`,quality:l};let u=s.slice(Math.max(0,c.first-3200),Math.min(s.length,c.last+3200));return await M(o),e.aborted&&(u.fill(0),e.throwIfAborted()),{kind:`captured`,samples:u,quality:l,lastSoundAtMs:n.lastSoundAtMs}}finally{Y(r)}}finally{Y(c)}}finally{for(let e of s.getTracks())e.stop()}}var q=class{recorder;chunks=[];timer=null;completion=Z();startedAtMs=performance.now();ended=null;activity={kind:`waiting`,activeMs:0};audioContext=null;worklet=null;detector=null;pending=Promise.resolve();queuedFrames=0;closed=!1;result=this.completion.promise;constructor(e,t,n,r,i){this.stream=e,this.signal=t,this.onCapture=n,this.expectedSpeechMs=r,this.createDetector=i,this.recorder=new MediaRecorder(e),this.recorder.addEventListener(`dataavailable`,this.data),this.recorder.addEventListener(`stop`,this.stopped),this.recorder.addEventListener(`error`,this.failed);for(let t of e.getAudioTracks())t.addEventListener(`ended`,this.interrupted),t.addEventListener(`mute`,this.interrupted);t.addEventListener(`abort`,this.cancelled,{once:!0}),this.timer=setTimeout(this.startupFailed,z),this.start()}async start(){try{if(await this.startPauseDetection(),this.signal.throwIfAborted(),this.startedAtMs=performance.now(),this.closed)return;this.recorder.start(),this.timer!==null&&clearTimeout(this.timer),this.timer=setTimeout(this.stop.bind(this,`limit`),z),this.onCapture({kind:`listening`})}catch(e){this.cleanup(),this.completion.reject(e instanceof Error?e:Error(`Speech detection could not start.`))}}data=e=>{this.chunks.push(e.data)};stop=e=>{this.recorder.state!==`inactive`&&(this.ended={endedBy:e,recordingMs:performance.now()-this.startedAtMs,lastSoundAtMs:this.activity.kind===`speaking`?this.activity.lastSoundAtMs:null},this.recorder.stop(),this.stopTracks())};cancelled=()=>{if(this.recorder.state===`inactive`){this.cleanup(),this.completion.reject(W());return}this.stop(`stream_ended`)};startupFailed=()=>{this.cleanup(),this.completion.reject(Error(`Speech detection startup timed out. Please try again.`))};detectorFailed=e=>{this.cleanup(),this.completion.reject(e instanceof Error?e:Error(`Speech detection failed.`))};failed=()=>{this.cleanup(),this.completion.reject(Error(`${r().label} could not record this microphone.`))};interrupted=()=>{this.cleanup(),this.completion.reject(new F)};stopped=()=>{let e=this.ended;if(this.cleanup(),this.signal.aborted){this.completion.reject(W());return}if(e===null){this.completion.reject(new F);return}new Blob(this.chunks,{type:this.recorder.mimeType}).arrayBuffer().then(this.complete.bind(this,e),this.completion.reject.bind(this.completion))};complete=(e,t)=>{if(this.signal.aborted){this.completion.reject(W());return}this.completion.resolve({bytes:t,...e})};async startPauseDetection(){let e=new AudioContext({sampleRate:x});this.audioContext=e;let t=e.resume();if(this.detector=await this.createDetector(),this.signal.throwIfAborted(),e.sampleRate!==16e3)throw Error(`Speech detection requires 16 kHz audio.`);await e.audioWorklet.addModule(a(`voice-activity-worklet.js`)),this.signal.throwIfAborted();let n=new AudioWorkletNode(e,`voice-activity-frames`);if(this.worklet=n,n.port.onmessage=this.receiveFrame,e.createMediaStreamSource(this.stream).connect(n),n.connect(e.destination),await Promise.race([t.catch(()=>{}),U(1e3)]),e.state!==`running`)throw new H}receiveFrame=e=>{if(!(e.data instanceof Float32Array)||e.data.length!==512){this.failed();return}if(this.closed||this.recorder.state!==`recording`){e.data.fill(0);return}if(++this.queuedFrames>32){e.data.fill(0),this.failed();return}this.pending=this.pending.then(this.processFrame.bind(this,e.data)).catch(this.detectorFailed)};async processFrame(e){let t=this.detector;try{if(this.closed||!t)return;let n=await t.probability(e);if(this.closed){t.clear();return}let r=0;for(let t of e)r+=t*t;let i=Math.sqrt(r/e.length),a=n>=.5&&i>=R;this.updateActivity(a),this.onCapture({kind:`activity`,rms:i,speech:a})}finally{e.fill(0),--this.queuedFrames}}updateActivity(e){let t=performance.now();switch(this.activity.kind){case`waiting`:{let n=e?this.activity.activeMs+32:0;this.activity=n>=100?{kind:`speaking`,lastSoundAtMs:t,voicedMs:n}:{kind:`waiting`,activeMs:n};return}case`speaking`:{let{lastSoundAtMs:n,voicedMs:r}=this.activity;if(e){this.activity={kind:`speaking`,lastSoundAtMs:t,voicedMs:r+32};return}t-n>=v(r,this.expectedSpeechMs)&&this.stop(`pause`);return}default:return Q(this.activity)}}stopPauseDetection=()=>{this.closed=!0,this.worklet&&(this.worklet.port.onmessage=null,this.worklet.port.close(),this.worklet.disconnect()),this.worklet=null,this.detector?.clear(),this.detector=null;let e=this.audioContext;this.audioContext=null,e&&e.close().catch(X)};stopTracks(){for(let e of this.stream.getTracks())e.stop()}cleanup(){for(let e of this.stream.getAudioTracks())e.removeEventListener(`ended`,this.interrupted),e.removeEventListener(`mute`,this.interrupted);this.stopPauseDetection(),this.stopTracks(),this.timer!==null&&clearTimeout(this.timer),this.signal.removeEventListener(`abort`,this.cancelled),this.recorder.removeEventListener(`dataavailable`,this.data),this.recorder.removeEventListener(`stop`,this.stopped),this.recorder.removeEventListener(`error`,this.failed)}};async function J(e,t,n){let r=-1,i=0,a=0,o=0,s=0,c=0,l=0,u=new Float32Array(512);try{for(let d=0;d<e.length;d+=u.length){t.throwIfAborted(),u.fill(0);let f=Math.min(u.length,e.length-d);u.set(e.subarray(d,d+f));let p=0;for(let e of u)p+=e*e;let m=Math.sqrt(p/f);if(o=Math.max(o,m),!(await n.probability(u)>=.5&&m>=R)){s+=p,c+=f;continue}r<0&&(r=d),i=d+f,a+=f,l+=p}}finally{u.fill(0),n.clear()}let d=Math.sqrt(s/Math.max(1,c)),f=Math.sqrt(l/Math.max(1,a)),p=a&&c?20*Math.log10(Math.max(f,1e-8)/Math.max(d,1e-8)):null,m={backgroundRms:d,speechToBackgroundDb:p,noise:d>=R&&(p===null||p<10)?`high`:c?`normal`:`unknown`},h=a/x*1e3;return r<0||h<600?{kind:`insufficient_audio`,voicedMs:h,peakFrameRms:o,...m}:{kind:`captured`,first:r,last:i,voicedMs:h,peakFrameRms:o,...m}}function Y(e){for(let t=0;t<e.numberOfChannels;t+=1)e.getChannelData(t).fill(0)}function X(){}function Z(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}function Q(e){throw Error(`Unexpected speech activity: ${String(e)}`)}export{n as C,r as S,g as _,B as a,s as b,A as c,S as d,x as f,h as g,y as h,V as i,M as l,v as m,H as n,I as o,C as p,K as r,F as s,L as t,N as u,m as v,i as w,f as x,p as y};
@@ -1 +1 @@
1
- import{S as e,_ as t,a as n,b as r,c as i,d as a,f as o,g as s,h as c,i as l,l as u,m as d,n as f,o as p,p as m,r as h,s as g,t as _,u as v,v as y,y as b}from"./chunks/microphone-CU5gQG6w.js";export{y as BrowserInferenceModel,_ as InsufficientVoiceAudioError,v as MAX_SAMPLES,a as SAMPLE_RATE,b as VoiceAssetLoadError,p as VoiceMicrophoneInterruptedError,e as configureVoiceCapture,c as createVoiceActivity,m as endPauseMs,d as expectedSpeechMs,s as levelDetector,g as openVoiceMicrophone,o as pcm16Wav,f as recordVoice,i as rememberVoiceMicrophone,h as requireVoiceForeground,l as requireVoiceUserAction,u as useSystemVoiceMicrophone,t as voiceActivityModel,r as voiceInferenceEngine,n as voiceQualityMessage};
1
+ import{C as e,_ as t,a as n,b as r,c as i,d as a,f as o,g as s,h as c,i as l,l as u,m as d,n as f,o as p,p as m,r as h,s as g,t as _,u as v,v as y,x as b,y as x}from"./chunks/microphone-BQVF_tVV.js";export{x as BrowserInferenceModel,_ as InsufficientVoiceAudioError,a as MAX_SAMPLES,o as SAMPLE_RATE,r as VoiceAssetLoadError,g as VoiceMicrophoneInterruptedError,f as VoiceUserActionNeededError,e as configureVoiceCapture,s as createVoiceActivity,d as endPauseMs,c as expectedSpeechMs,t as levelDetector,i as openVoiceMicrophone,m as pcm16Wav,h as recordVoice,u as rememberVoiceMicrophone,l as requireVoiceForeground,n as requireVoiceUserAction,v as useSystemVoiceMicrophone,y as voiceActivityModel,b as voiceInferenceEngine,p as voiceQualityMessage};
@@ -1,13 +1,10 @@
1
+ import { ROW_EASE } from "./rowMotion.js";
1
2
  //#region src/card/phraseCard.ts
2
3
  const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
3
4
  const WIDTH = 308;
4
5
  const MID = 32;
5
6
  const SEGMENTS = 6;
6
7
  const GAP = 8;
7
- const FADE_OUT_MS = 150;
8
- const FADE_IN_MS = 300;
9
- const FADE_IN_DELAY_MS = 60;
10
- const ROW_STAGGER_MS = 40;
11
8
  const RIBBON_MOVE_MS = 180;
12
9
  const FLATTEN_MS = 200;
13
10
  const OPEN_MS = 200;
@@ -217,7 +214,7 @@ var PhraseCard = class {
217
214
  ["action", prompt.actionRow]
218
215
  ].filter(([key]) => this.rows[key] !== next[key] || key === "head" && this.rows.headline !== next.headline).map(([, element]) => element);
219
216
  prompt.phrase.classList.toggle("seams-voiceid-phrase-dim", next.dim);
220
- if (changed.length > 0 && !this.motion.matches) await Promise.all(changed.map((element) => fade(element, FADE_OUT_MS)));
217
+ if (changed.length > 0 && !this.motion.matches) await Promise.all(changed.map((element) => fade(element, 150)));
221
218
  if (this.stale(version)) return;
222
219
  const ribbonTop = prompt.ribbon.getBoundingClientRect().top;
223
220
  const heightBefore = prompt.element.getBoundingClientRect().height;
@@ -232,7 +229,7 @@ var PhraseCard = class {
232
229
  duration: RIBBON_MOVE_MS,
233
230
  easing: EASE_MOVE
234
231
  });
235
- for (const [index, element] of changed.entries()) fadeIn(element, this.motion.matches, FADE_IN_DELAY_MS + index * ROW_STAGGER_MS);
232
+ for (const [index, element] of changed.entries()) fadeIn(element, this.motion.matches, 20 + index * 30);
236
233
  }
237
234
  markedText(text) {
238
235
  if (typeof text === "string") return [text];
@@ -447,8 +444,8 @@ function fadeIn(element, reduced, delay) {
447
444
  transform: "none"
448
445
  }], {
449
446
  delay: reduced ? 0 : delay,
450
- duration: reduced ? 150 : FADE_IN_MS,
451
- easing: EASE_IN_OUT,
447
+ duration: reduced ? 150 : 200,
448
+ easing: ROW_EASE,
452
449
  fill: "both"
453
450
  });
454
451
  }
@@ -1,5 +1,6 @@
1
1
  import { styleNonce } from "../config.js";
2
2
  import { useSystemVoiceMicrophone } from "../microphoneInput.js";
3
+ import { ROW_EASE } from "./rowMotion.js";
3
4
  import { t as VoiceVisual } from "../chunks/visual-Di3ojTED.js";
4
5
  //#region src/card/prompt.ts
5
6
  const CSS = `
@@ -60,7 +61,7 @@ const CSS = `
60
61
  .seams-voiceid-prompt > .seams-voiceid-status { grid-area: 4 / 1; }
61
62
  .seams-voiceid-progress-slot { grid-row: 5; grid-column: 1; min-width: 0; align-self: center; }
62
63
  .seams-voiceid-progress-slot:empty { visibility: hidden; }
63
- .seams-voiceid-activation-surface .seams-voiceid-record-slot { transition: opacity 300ms cubic-bezier(0.2, 0, 0, 1) 140ms, visibility 0s linear 0s; }
64
+ .seams-voiceid-activation-surface .seams-voiceid-record-slot { transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1) 80ms, visibility 0s linear 0s; }
64
65
  .seams-voiceid-activation-surface[data-preparation="loading"] .seams-voiceid-record-slot { opacity: 0; visibility: hidden; transition: none; }
65
66
  @media (prefers-reduced-motion: reduce) {
66
67
  .seams-voiceid-prompt { transform: none; transition: opacity 150ms ease; }
@@ -316,8 +317,9 @@ const CSS = `
316
317
  font-weight: 500;
317
318
  border: 0;
318
319
  border-radius: var(--seams-shape-control, 10px);
319
- background: var(--seams-colors-textPrimary, #eff2f3);
320
- color: var(--seams-colors-colorBackground, #192024);
320
+ /* The theme's main button. A page that sets no button colours keeps the ink button. */
321
+ background: var(--seams-colors-buttonBackground, var(--seams-colors-textPrimary, #eff2f3));
322
+ color: var(--seams-colors-textButton, var(--seams-colors-colorBackground, #192024));
321
323
  }
322
324
  .seams-voiceid-prompt .seams-voiceid-primary {
323
325
  transition: opacity 180ms ease, visibility 0s linear 0s;
@@ -576,23 +578,7 @@ var VoicePrompt = class {
576
578
  this.record.type = "button";
577
579
  this.record.className = "seams-voiceid-primary";
578
580
  this.record.textContent = "Record";
579
- this.cancel = document.createElement("button");
580
- this.cancel.type = "button";
581
- this.cancel.className = "seams-voiceid-close";
582
- const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
583
- closeIcon.setAttribute("viewBox", "0 0 24 24");
584
- closeIcon.setAttribute("width", "20");
585
- closeIcon.setAttribute("height", "20");
586
- closeIcon.setAttribute("aria-hidden", "true");
587
- const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
588
- closePath.setAttribute("d", "M6 6l12 12M18 6L6 18");
589
- closePath.setAttribute("fill", "none");
590
- closePath.setAttribute("stroke", "currentColor");
591
- closePath.setAttribute("stroke-width", "1.75");
592
- closePath.setAttribute("stroke-linecap", "round");
593
- closeIcon.append(closePath);
594
- this.cancel.append(closeIcon);
595
- this.cancel.setAttribute("aria-label", "Cancel");
581
+ this.cancel = cornerButton(document, "Cancel", CLOSE_PATH);
596
582
  this.progress = document.createElement("div");
597
583
  this.progress.className = "seams-voiceid-progress-slot";
598
584
  const slot = document.createElement("div");
@@ -610,13 +596,22 @@ var VoicePrompt = class {
610
596
  this.sizes.observe(this.element);
611
597
  }
612
598
  /**
613
- * In an app's dialog with a step before this card, the corner control returns to that step.
614
- * It still ends the card as cancelled; the Wallet tells the app that the person went back.
599
+ * In an app's dialog with a step before this card, Back in the leading corner returns to that
600
+ * step. With `close`, Close stays in the trailing corner and ends the whole dialog; without it,
601
+ * Back takes Close's place. Each one ends the card as cancelled. The result is the Back control.
615
602
  */
616
- showBack() {
603
+ showBack(close) {
604
+ if (close) {
605
+ this.cancel.setAttribute("aria-label", "Close");
606
+ const back = cornerButton(this.element.ownerDocument, "Back", BACK_PATH);
607
+ back.classList.add("seams-voiceid-back");
608
+ this.cancel.before(back);
609
+ return back;
610
+ }
617
611
  this.cancel.classList.add("seams-voiceid-back");
618
612
  this.cancel.setAttribute("aria-label", "Back");
619
- this.cancel.querySelector("path")?.setAttribute("d", "m15 18-6-6 6-6");
613
+ this.cancel.querySelector("path")?.setAttribute("d", BACK_PATH);
614
+ return this.cancel;
620
615
  }
621
616
  /**
622
617
  * Moves the rows to a new state. The rows whose content changes fade out together, take their
@@ -973,11 +968,6 @@ function fadeIn(element, reduced, delay) {
973
968
  function assertNever(value) {
974
969
  throw new Error(`Unexpected capture event: ${String(value)}`);
975
970
  }
976
- const ROW_OUT_MS = 150;
977
- const ROW_IN_MS = 300;
978
- const ROW_IN_DELAY_MS = 60;
979
- const ROW_STAGGER_MS = 40;
980
- const ROW_EASE = "cubic-bezier(0.2, 0, 0, 1)";
981
971
  const ROW_MOTION = "seams-voiceid-row";
982
972
  /** A frame that never reaches the card's size stops being followed after this time. */
983
973
  const FRAME_FILL_LIMIT_MS = 800;
@@ -1008,7 +998,7 @@ function fadeRowOut(row) {
1008
998
  const from = Number(getComputedStyle(row).opacity);
1009
999
  cancelRowMotion(row);
1010
1000
  return row.animate([{ opacity: from }, { opacity: 0 }], {
1011
- duration: ROW_OUT_MS * from,
1001
+ duration: 150 * from,
1012
1002
  easing: "ease-in",
1013
1003
  fill: "forwards",
1014
1004
  id: ROW_MOTION
@@ -1028,8 +1018,8 @@ function fadeRowsIn(rows) {
1028
1018
  opacity: to,
1029
1019
  transform: "none"
1030
1020
  }], {
1031
- delay: reduced ? 0 : ROW_IN_DELAY_MS + index * ROW_STAGGER_MS,
1032
- duration: reduced ? 150 : ROW_IN_MS,
1021
+ delay: reduced ? 0 : 20 + index * 30,
1022
+ duration: reduced ? 150 : 200,
1033
1023
  easing: ROW_EASE,
1034
1024
  fill: "backwards",
1035
1025
  id: ROW_MOTION
@@ -1039,7 +1029,7 @@ function fadeRowsIn(rows) {
1039
1029
  function ignoreCancel() {}
1040
1030
  function continueFade({ row, opacity }) {
1041
1031
  row.animate([{ opacity }, { opacity: 1 }], {
1042
- duration: ROW_IN_MS * (1 - opacity),
1032
+ duration: 200 * (1 - opacity),
1043
1033
  easing: ROW_EASE,
1044
1034
  id: ROW_MOTION
1045
1035
  });
@@ -1065,10 +1055,33 @@ function leaveSurface(element) {
1065
1055
  element.style.pointerEvents = "none";
1066
1056
  const from = Number(getComputedStyle(element).opacity);
1067
1057
  element.animate([{ opacity: from }, { opacity: 0 }], {
1068
- duration: ROW_OUT_MS * from,
1058
+ duration: 150 * from,
1069
1059
  easing: "ease-in",
1070
1060
  fill: "forwards"
1071
1061
  }).finished.catch(ignoreCancel).finally(() => element.remove());
1072
1062
  }
1063
+ const CLOSE_PATH = "M6 6l12 12M18 6L6 18";
1064
+ const BACK_PATH = "m15 18-6-6 6-6";
1065
+ /** A control in a corner of the card, with an icon and no text. */
1066
+ function cornerButton(document, label, path) {
1067
+ const button = document.createElement("button");
1068
+ button.type = "button";
1069
+ button.className = "seams-voiceid-close";
1070
+ button.setAttribute("aria-label", label);
1071
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1072
+ icon.setAttribute("viewBox", "0 0 24 24");
1073
+ icon.setAttribute("width", "20");
1074
+ icon.setAttribute("height", "20");
1075
+ icon.setAttribute("aria-hidden", "true");
1076
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "path");
1077
+ line.setAttribute("d", path);
1078
+ line.setAttribute("fill", "none");
1079
+ line.setAttribute("stroke", "currentColor");
1080
+ line.setAttribute("stroke-width", "1.75");
1081
+ line.setAttribute("stroke-linecap", "round");
1082
+ icon.append(line);
1083
+ button.append(icon);
1084
+ return button;
1085
+ }
1073
1086
  //#endregion
1074
1087
  export { VoicePrompt, leaveSurface };
@@ -0,0 +1,8 @@
1
+ //#region src/card/rowMotion.ts
2
+ const ROW_OUT_MS = 150;
3
+ const ROW_IN_MS = 200;
4
+ const ROW_IN_DELAY_MS = 20;
5
+ const ROW_STAGGER_MS = 30;
6
+ const ROW_EASE = "cubic-bezier(0.2, 0, 0, 1)";
7
+ //#endregion
8
+ export { ROW_EASE, ROW_IN_DELAY_MS, ROW_IN_MS, ROW_OUT_MS, ROW_STAGGER_MS };
@@ -2,7 +2,7 @@ import { captureConfig } from "../config.js";
2
2
  import { voiceActivityModel } from "../voiceActivity.js";
3
3
  import { expectedSpeechMs } from "../speechPause.js";
4
4
  import { VoiceMicrophoneInterruptedError } from "../microphoneInput.js";
5
- import { recordVoice, voiceQualityMessage } from "../microphone.js";
5
+ import { VoiceUserActionNeededError, recordVoice, voiceQualityMessage } from "../microphone.js";
6
6
  import { PhraseCard } from "./phraseCard.js";
7
7
  import { VoicePrompt, leaveSurface } from "./prompt.js";
8
8
  //#region src/card/surface.ts
@@ -24,12 +24,17 @@ var CardSurface = class {
24
24
  unmounted;
25
25
  outer;
26
26
  detector;
27
+ pressed;
28
+ /** Back, when the card shows it apart from Close. */
29
+ back;
27
30
  recorder = null;
28
31
  recording = null;
29
32
  choice = null;
30
33
  view = null;
31
34
  started = false;
32
35
  closed = false;
36
+ /** The next recording starts with no press. A card that continues another never does. */
37
+ autoStart;
33
38
  /** The voiced time of the whole sentence, after which a shorter pause ends a recording. */
34
39
  expectedSpeechMs;
35
40
  /** The speech detector has started; before that, a recording waits for it to load. */
@@ -39,6 +44,7 @@ var CardSurface = class {
39
44
  const outer = input.signal;
40
45
  this.outer = outer;
41
46
  this.detector = input.detector;
47
+ this.autoStart = input.autoStart === true;
42
48
  this.expectedSpeechMs = expectedSpeechMs(sentence);
43
49
  const handedOver = document.querySelector(".seams-voiceid-activation-surface[data-seams-voiceid-handoff]");
44
50
  if (input.detector === "level") this.speechDetectorReady = true;
@@ -56,12 +62,16 @@ var CardSurface = class {
56
62
  delete this.prompt.phrase.dataset.voiceidPhrase;
57
63
  this.prompt.status.textContent = PROMPT_MESSAGE;
58
64
  this.prompt.record.textContent = "Record";
59
- if (frame.back) this.prompt.showBack();
65
+ const controls = frame.controls ?? "close";
66
+ this.pressed = frame.pressed;
67
+ const back = controls === "close" ? null : this.prompt.showBack(controls === "back_and_close");
68
+ this.back = back === this.prompt.cancel ? null : back;
60
69
  const handedOverPrompt = handedOver?.querySelector(":scope > .seams-voiceid-prompt") ?? null;
61
70
  const rows = handedOverPrompt ? handedOverRows(handedOverPrompt) : null;
62
71
  if (handedOverPrompt) {
63
72
  this.prompt.continueFrom(handedOverPrompt);
64
73
  this.prompt.record.hidden = true;
74
+ this.autoStart = false;
65
75
  }
66
76
  this.card = new PhraseCard(this.prompt, {
67
77
  label: PROMPT_LABEL,
@@ -71,7 +81,9 @@ var CardSurface = class {
71
81
  }, rows);
72
82
  this.element.append(this.prompt.element);
73
83
  this.prompt.record.addEventListener("click", this.press);
74
- this.prompt.cancel.addEventListener("click", this.cancel);
84
+ const corner = controls === "back" ? this.pressBack : this.pressClose;
85
+ this.prompt.cancel.addEventListener("click", corner);
86
+ this.back?.addEventListener("click", this.pressBack);
75
87
  this.element.addEventListener("cancel", this.cancel);
76
88
  outer.addEventListener("abort", this.cancel);
77
89
  if (handedOver) handedOver.replaceWith(this.element);
@@ -91,7 +103,8 @@ var CardSurface = class {
91
103
  if (this.view) {
92
104
  this.view = null;
93
105
  this.card.showPrompt();
94
- }
106
+ } else if (this.autoStart) this.begin(recording);
107
+ this.autoStart = false;
95
108
  return recording.promise;
96
109
  }
97
110
  show(view) {
@@ -113,7 +126,9 @@ var CardSurface = class {
113
126
  this.outer.removeEventListener("abort", this.cancel);
114
127
  this.element.removeEventListener("cancel", this.cancel);
115
128
  this.prompt.record.removeEventListener("click", this.press);
116
- this.prompt.cancel.removeEventListener("click", this.cancel);
129
+ this.prompt.cancel.removeEventListener("click", this.pressBack);
130
+ this.prompt.cancel.removeEventListener("click", this.pressClose);
131
+ this.back?.removeEventListener("click", this.pressBack);
117
132
  this.card.dispose();
118
133
  this.prompt.dispose();
119
134
  leaveSurface(this.element);
@@ -125,12 +140,15 @@ var CardSurface = class {
125
140
  return;
126
141
  }
127
142
  if (!this.recording || this.started) return;
143
+ this.begin(this.recording);
144
+ };
145
+ begin(recording) {
128
146
  this.started = true;
129
147
  this.prompt.record.disabled = true;
130
148
  this.prompt.status.textContent = this.speechDetectorReady ? "Opening microphone…" : `Loading ${captureConfig().label}…`;
131
- this.attempt(this.recording);
132
- };
133
- async attempt(recording) {
149
+ this.attempt(recording, this.autoStart);
150
+ }
151
+ async attempt(recording, unpressed = false) {
134
152
  const recorder = new AbortController();
135
153
  this.recorder = recorder;
136
154
  try {
@@ -160,6 +178,10 @@ var CardSurface = class {
160
178
  this.retry(error.message);
161
179
  return;
162
180
  }
181
+ if (unpressed && error instanceof VoiceUserActionNeededError) {
182
+ this.retry(PROMPT_MESSAGE, "Record");
183
+ return;
184
+ }
163
185
  this.started = false;
164
186
  this.recording = null;
165
187
  recording.reject(error instanceof Error ? error : new Error(String(error)));
@@ -173,9 +195,9 @@ var CardSurface = class {
173
195
  if (event.kind === "processing") this.card.beginCheck();
174
196
  else this.prompt.capture(event);
175
197
  };
176
- retry(message) {
198
+ retry(message, action = "Try again") {
177
199
  this.started = false;
178
- this.prompt.record.textContent = "Try again";
200
+ this.prompt.record.textContent = action;
179
201
  this.prompt.ready(message);
180
202
  this.prompt.record.focus();
181
203
  }
@@ -185,6 +207,13 @@ var CardSurface = class {
185
207
  settle?.(choice);
186
208
  }
187
209
  /** A close during a check is not possible: the button is hidden while the caller works. */
210
+ pressBack = () => this.pressControl("back");
211
+ pressClose = () => this.pressControl("close");
212
+ pressControl(control) {
213
+ if (this.view?.kind === "check") return;
214
+ this.pressed?.(control);
215
+ this.cancel();
216
+ }
188
217
  cancel = () => {
189
218
  if (this.view?.kind === "check" && !this.outer.aborted) return;
190
219
  if (this.recording) {
package/dist/esm/index.js CHANGED
@@ -4,5 +4,5 @@ import { createVoiceActivity, levelDetector, voiceActivityModel } from "./voiceA
4
4
  import { endPauseMs, expectedSpeechMs } from "./speechPause.js";
5
5
  import { MAX_SAMPLES, SAMPLE_RATE, pcm16Wav } from "./wav.js";
6
6
  import { VoiceMicrophoneInterruptedError, openVoiceMicrophone, rememberVoiceMicrophone, useSystemVoiceMicrophone } from "./microphoneInput.js";
7
- import { InsufficientVoiceAudioError, recordVoice, requireVoiceForeground, requireVoiceUserAction, voiceQualityMessage } from "./microphone.js";
8
- export { BrowserInferenceModel, InsufficientVoiceAudioError, MAX_SAMPLES, SAMPLE_RATE, VoiceAssetLoadError, VoiceMicrophoneInterruptedError, configureVoiceCapture, createVoiceActivity, endPauseMs, expectedSpeechMs, levelDetector, openVoiceMicrophone, pcm16Wav, recordVoice, rememberVoiceMicrophone, requireVoiceForeground, requireVoiceUserAction, useSystemVoiceMicrophone, voiceActivityModel, voiceInferenceEngine, voiceQualityMessage };
7
+ import { InsufficientVoiceAudioError, VoiceUserActionNeededError, recordVoice, requireVoiceForeground, requireVoiceUserAction, voiceQualityMessage } from "./microphone.js";
8
+ export { BrowserInferenceModel, InsufficientVoiceAudioError, MAX_SAMPLES, SAMPLE_RATE, VoiceAssetLoadError, VoiceMicrophoneInterruptedError, VoiceUserActionNeededError, configureVoiceCapture, createVoiceActivity, endPauseMs, expectedSpeechMs, levelDetector, openVoiceMicrophone, pcm16Wav, recordVoice, rememberVoiceMicrophone, requireVoiceForeground, requireVoiceUserAction, useSystemVoiceMicrophone, voiceActivityModel, voiceInferenceEngine, voiceQualityMessage };
@@ -36,6 +36,21 @@ function requireVoiceForeground() {
36
36
  throw new Error(`Keep ${place} open and focused to use ${label}.`);
37
37
  }
38
38
  }
39
+ /**
40
+ * The browser starts audio only after the person acts on the page (a click or a key). A
41
+ * recording that starts by itself gets this error, and the caller then shows its Record control.
42
+ */
43
+ var VoiceUserActionNeededError = class extends Error {
44
+ constructor() {
45
+ super(`Select Record to use ${captureConfig().label}.`);
46
+ this.name = "VoiceUserActionNeededError";
47
+ }
48
+ };
49
+ /** The time that a permitted audio context needs at most to start, after its setup. */
50
+ const AUDIO_START_MS = 1e3;
51
+ function elapsed(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
39
54
  function cancelled() {
40
55
  return /* @__PURE__ */ new Error(`${captureConfig().label} recording was cancelled.`);
41
56
  }
@@ -243,7 +258,8 @@ var VoiceRecording = class {
243
258
  worklet.port.onmessage = this.receiveFrame;
244
259
  context.createMediaStreamSource(this.stream).connect(worklet);
245
260
  worklet.connect(context.destination);
246
- await resumed;
261
+ await Promise.race([resumed.catch(() => {}), elapsed(AUDIO_START_MS)]);
262
+ if (context.state !== "running") throw new VoiceUserActionNeededError();
247
263
  }
248
264
  receiveFrame = (event) => {
249
265
  if (!(event.data instanceof Float32Array) || event.data.length !== 512) {
@@ -425,4 +441,4 @@ function assertNeverActivity(activity) {
425
441
  throw new Error(`Unexpected speech activity: ${String(activity)}`);
426
442
  }
427
443
  //#endregion
428
- export { InsufficientVoiceAudioError, recordVoice, requireVoiceForeground, requireVoiceUserAction, voiceQualityMessage };
444
+ export { InsufficientVoiceAudioError, VoiceUserActionNeededError, recordVoice, requireVoiceForeground, requireVoiceUserAction, voiceQualityMessage };
@@ -9,7 +9,7 @@ configureVoiceCapture({ assetBase: '../assets/' });
9
9
  const ENROLLMENT_PHRASES = [
10
10
  'My voice is my signature for this wallet.',
11
11
  'I send money only when I say so.',
12
- 'Seams, please learn how I sound today.',
12
+ 'Please learn how I sound today.',
13
13
  ];
14
14
 
15
15
  const log = document.querySelector('#log');
@@ -18,7 +18,13 @@ const detector = () => (document.querySelector('#level').checked ? 'level' : 'mo
18
18
 
19
19
  /** Shows one sentence, records it and returns its WAV, or null when the person closed the card. */
20
20
  async function recordSentence(sentence, signal) {
21
- const surface = openPhraseCard({ sentence, signal, detector: detector(), frame: { parent } });
21
+ const surface = openPhraseCard({
22
+ sentence,
23
+ signal,
24
+ detector: detector(),
25
+ autoStart: document.querySelector('#auto').checked,
26
+ frame: { parent },
27
+ });
22
28
  try {
23
29
  const recording = await surface.record();
24
30
  if (recording.kind === 'cancelled') return null;
@@ -17,6 +17,7 @@
17
17
  <h1>voice-capture</h1>
18
18
  <p>This page uses only <code>@seams/voice-capture</code>. The audio stays in the page.</p>
19
19
  <label><input type="checkbox" id="level" /> Record with no model download (the detector <code>level</code>)</label>
20
+ <label><input type="checkbox" id="auto" /> Start the recording with no press on Record (<code>autoStart</code>)</label>
20
21
  <div class="row">
21
22
  <button class="start" id="sentence">Record one sentence</button>
22
23
  <button class="start" id="enroll">Record three enrollment phrases</button>
@@ -1,4 +1,4 @@
1
- export { openPhraseCard, type PhraseCardFrame, type PhraseCardInput } from './surface';
1
+ export { openPhraseCard, type PhraseCardControl, type PhraseCardControls, type PhraseCardFrame, type PhraseCardInput, } from './surface';
2
2
  export type { PhraseCheckView, PhraseChoice, PhraseRecording, PhraseResultView, PhraseSegmentState, PhraseSurface, PhraseText, PhraseView, } from './types';
3
3
  export { leaveSurface, VoicePrompt, type PhraseDisplay, type PromptChange } from './prompt';
4
4
  export { PhraseCard, type HandedOverRows, type PromptCopy } from './phraseCard';
@@ -46,10 +46,11 @@ export declare class VoicePrompt {
46
46
  /** The text to show for a phrase, such as the phrase with its commas. */
47
47
  display?: PhraseDisplay);
48
48
  /**
49
- * In an app's dialog with a step before this card, the corner control returns to that step.
50
- * It still ends the card as cancelled; the Wallet tells the app that the person went back.
49
+ * In an app's dialog with a step before this card, Back in the leading corner returns to that
50
+ * step. With `close`, Close stays in the trailing corner and ends the whole dialog; without it,
51
+ * Back takes Close's place. Each one ends the card as cancelled. The result is the Back control.
51
52
  */
52
- showBack(): void;
53
+ showBack(close: boolean): HTMLButtonElement;
53
54
  /**
54
55
  * Moves the rows to a new state. The rows whose content changes fade out together, take their
55
56
  * new content and fade in one after another; the other rows stay still. A change that comes
@@ -0,0 +1,5 @@
1
+ export declare const ROW_OUT_MS = 150;
2
+ export declare const ROW_IN_MS = 200;
3
+ export declare const ROW_IN_DELAY_MS = 20;
4
+ export declare const ROW_STAGGER_MS = 30;
5
+ export declare const ROW_EASE = "cubic-bezier(0.2, 0, 0, 1)";
@@ -6,19 +6,35 @@ export type PhraseCardFrame = Readonly<{
6
6
  parent?: HTMLElement;
7
7
  /** More class names for the surface element, such as the host's theme class. */
8
8
  className?: string;
9
- /** Shows the Back control of the prompt. */
10
- back?: boolean;
9
+ /**
10
+ * The corner controls of the card. `close` (default): Close in the trailing corner. `back`: Back
11
+ * in the leading corner, in place of Close. `back_and_close`: both. Each one, and Escape, ends
12
+ * the card as cancelled.
13
+ */
14
+ controls?: PhraseCardControls;
15
+ /** Runs when the person presses Back or Close, before the card ends. Escape is not a press. */
16
+ pressed?(control: PhraseCardControl): void;
11
17
  /** The host sizes its frame to the surface, so the card follows the frame. */
12
18
  hugged?: boolean;
13
19
  /** Runs when the surface is in the document. Its return value runs when the surface closes. */
14
20
  mounted?(element: HTMLElement): (() => void) | void;
15
21
  }>;
22
+ /** The corner controls of a phrase card. */
23
+ export type PhraseCardControls = 'close' | 'back' | 'back_and_close';
24
+ /** One corner control of a phrase card. */
25
+ export type PhraseCardControl = 'back' | 'close';
16
26
  export type PhraseCardInput = Readonly<{
17
27
  sentence: string;
18
28
  signal: AbortSignal;
19
29
  frame?: PhraseCardFrame;
20
30
  /** The speech detector of the recordings. Default: `model`. */
21
31
  detector?: VoiceRecordingOptions['detector'];
32
+ /**
33
+ * The first recording starts when the caller asks for it, with no press on Record. Use it
34
+ * when the person just asked for this card, with a click or a key. If the browser does not
35
+ * start audio without a press, the card stays with its Record button.
36
+ */
37
+ autoStart?: boolean;
22
38
  }>;
23
39
  /**
24
40
  * Shows the given sentence in the prompt. The surface records clips with the quality check, and
@@ -1,5 +1,5 @@
1
1
  export { configureVoiceCapture, type VoiceCaptureConfig, type VoiceMicrophonePreference, type VoiceMicrophonePreferenceStore, } from './config';
2
- export { recordVoice, requireVoiceForeground, requireVoiceUserAction, voiceQualityMessage, InsufficientVoiceAudioError, type VoiceActivityEvent, type VoiceCaptureEvent, type VoiceCaptureObserver, type VoiceCaptureQuality, type VoiceRecordingOptions, type VoiceRecordingResult, } from './microphone';
2
+ export { recordVoice, requireVoiceForeground, requireVoiceUserAction, voiceQualityMessage, InsufficientVoiceAudioError, VoiceUserActionNeededError, type VoiceActivityEvent, type VoiceCaptureEvent, type VoiceCaptureObserver, type VoiceCaptureQuality, type VoiceRecordingOptions, type VoiceRecordingResult, } from './microphone';
3
3
  export { openVoiceMicrophone, rememberVoiceMicrophone, useSystemVoiceMicrophone, VoiceMicrophoneInterruptedError, type VoiceMicrophone, } from './microphoneInput';
4
4
  export { endPauseMs, expectedSpeechMs } from './speechPause';
5
5
  export { createVoiceActivity, levelDetector, voiceActivityModel, type SpeechDetector, } from './voiceActivity';
@@ -73,6 +73,13 @@ export type VoiceRecordingOptions = Readonly<{
73
73
  export declare function requireVoiceUserAction(): void;
74
74
  /** Throws unless the page is visible and has the focus. */
75
75
  export declare function requireVoiceForeground(): void;
76
+ /**
77
+ * The browser starts audio only after the person acts on the page (a click or a key). A
78
+ * recording that starts by itself gets this error, and the caller then shows its Record control.
79
+ */
80
+ export declare class VoiceUserActionNeededError extends Error {
81
+ constructor();
82
+ }
76
83
  /**
77
84
  * Records one utterance at 16 kHz. The recording ends after a pause, or after 8 s. A recording
78
85
  * with less than 0.6 s of speech is `insufficient_audio`. The samples hold the speech with 0.2 s
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seams/voice-capture",
3
- "version": "0.67.1",
3
+ "version": "0.68.0",
4
4
  "type": "module",
5
5
  "description": "Records one spoken sentence in the browser, with speech detection, a quality check and a phrase card",
6
6
  "license": "Apache-2.0",