tempest-react-sdk 0.75.0 → 0.76.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.
package/README.md CHANGED
@@ -287,6 +287,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
287
287
  | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`), `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
288
288
  | `charts` _(subpath `tempest-react-sdk/charts`, opt. peer `recharts`)_ | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DEFAULT_CHART_COLORS` — themed recharts wrappers (recharts externalized; install it only if you use charts) |
289
289
  | `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2065 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one of 46 balanced shards** (40 slugs each) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces — sharding by range rather than by initial letter took the worst fetch from 19.10 KB to 4.78 KB brotli. Unknown slug renders `fallback` and never throws; lucide's 258 deprecated aliases keep resolving |
290
+ | `neural` _(subpath `tempest-react-sdk/neural`, opt. peer `@sapphi-red/web-noise-suppressor`)_ | `isNeuralSuppressionSupported`, `prepareNeuralSuppressor`, `createNeuralSuppressor`, types `NeuralSuppressorAssets`, `NeuralSuppressor` — RNNoise noise suppression: a recurrent model in an `AudioWorklet` separates speech from non-speech on a 48 kHz context and hands back a denoised `MediaStreamTrack` to publish in place of the capture (peer externalized; install it only if you use neural) |
290
291
  | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
291
292
  | `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
292
293
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
@@ -325,6 +326,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
325
326
  | `vision` _(subpath `tempest-react-sdk/vision`, opt. peer `onnxruntime-web`)_ | `Classifier`, `Detector`, `Segmenter`, `DetectClassify` (fused detect→classify pipeline, optionally with a segmentation stage) + `warmup()`, FP16 models, `cache` (Cache Storage), video/`VideoFrame` inputs, `raiseOnEmpty`/`NoDetectionsError`, `LetterboxPipeline`, `readFusionSpec` and results/labels/preprocess — on-device ONNX vision inference, vendored from `@mauriciobenjamin700/ort-vision-sdk-web` (onnxruntime-web externalized; install it only if you use vision). Plus browser capture primitives: `useCameraStream` (rear-camera `getUserMedia` + classified errors + `retry()`), `computeImageLuminance`/`isLuminanceAcceptable`/`LowLuminanceError`/`LUMINANCE_SAMPLE_MAX_EDGE` (BT.709 brightness), `useLiveLuminance` (live rAF sampling) |
326
327
  | `tabular` _(subpath `tempest-react-sdk/tabular`, opt. peer `onnxruntime-web`)_ | `TabularPredictor`, `useTabularPredictor` — scikit-learn models exported by `tempest-fastapi-sdk`'s `export_sklearn_to_onnx`, running in the browser with no inference server. Offline by design: `fetchModelBytes`/`isModelCached`/`cacheModelBytes`/`clearModelCache` (Cache Storage, cache-first) plus `configureOrtAssets`/`ortAssetUrls`/`ORT_WASM_ASSETS` for the runtime's WebAssembly binaries, which ONNX Runtime Web fetches at session creation. Import the default `onnxruntime-web` entry — the WebGPU build has no `ai.onnx.ml` kernels |
327
328
  | `imaging` _(subpath `tempest-react-sdk/imaging`, no deps)_ | `resizeImage`, `cropImage`, `rotateImage`, `flipImage`, `compressToTarget` (binary-searches quality into a byte budget), `createThumbnails` (several sizes from one decode), `captureFrame` (one frame out of a `<video>`, at the instant you ask for), `decodeImage`/`readImageInfo`, `encodeImage`, `supportsImageType`/`bestSupportedType`, plus `useImagePreview`/`useImageProcessing`. Canvas only — decodes with EXIF orientation applied (phone photos arrive upright), reports the format actually produced (asking for AVIF where there is no encoder silently returns PNG), and re-encoding drops EXIF, which is the privacy step as much as the bandwidth one. `captureFrame` is the one with teeth: `seeked` does not mean the new frame is readable, so the naive seek-then-draw returns the neighbouring frame with no error — and a `MediaRecorder` blob reports no duration until it is probed for one, which it does for you |
329
+ | `neural` _(subpath `tempest-react-sdk/neural`, opt. peer `@sapphi-red/web-noise-suppressor`)_ | `isNeuralSuppressionSupported`, `prepareNeuralSuppressor`, `createNeuralSuppressor`, types `NeuralSuppressorAssets`, `NeuralSuppressor` — RNNoise noise suppression: a recurrent model in an `AudioWorklet` separates speech from non-speech on a 48 kHz context and hands back a denoised `MediaStreamTrack` to publish in place of the capture (peer externalized; install it only if you use neural) |
328
330
  | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
329
331
  | `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
330
332
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
@@ -0,0 +1,2 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@sapphi-red/web-noise-suppressor");function t(){return typeof AudioWorkletNode<`u`&&typeof window<`u`&&window.isSecureContext}var n=null,r=new WeakMap;async function i(t,i){let a=r.get(t);return a||(a=t.audioWorklet.addModule(i.workletUrl).then(()=>void 0,e=>{throw r.delete(t),e}),r.set(t,a)),await a,n===null&&(n=(0,e.loadRnnoise)({url:i.wasmUrl,simdUrl:i.simdWasmUrl}).catch(e=>{throw n=null,e})),n}async function a(t,n,r,a){if(t.sampleRate!==48e3)return null;let o;try{o=await i(t,r)}catch{return null}let s;try{s=new e.RnnoiseWorkletNode(t,{maxChannels:a.maxChannels,wasmBinary:o})}catch{return null}let c=t.createMediaStreamSource(new MediaStream([n])),l=t.createMediaStreamDestination();c.connect(s),s.connect(l);let u=l.stream.getAudioTracks()[0];return u?(u.contentHint=n.contentHint,{track:u,release:()=>{c.disconnect(),s.disconnect(),l.disconnect(),s.destroy(),u.stop()}}):(c.disconnect(),s.disconnect(),l.disconnect(),s.destroy(),null)}exports.createNeuralSuppressor=a,exports.isNeuralSuppressionSupported=t,exports.prepareNeuralSuppressor=i;
2
+ //# sourceMappingURL=neural.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"neural.cjs","names":[],"sources":["../src/neural/index.ts"],"sourcesContent":["import { RnnoiseWorkletNode, loadRnnoise } from \"@sapphi-red/web-noise-suppressor\";\n\n/**\n * Where the suppressor's three pieces live, as URLs a bundler hands over.\n *\n * The `@sapphi-red/web-noise-suppressor` package ships the runnable assets next\n * to its code, but it cannot say where they are from inside a library: the\n * `?url` suffix that resolves a module to its served address is a bundler\n * feature, and every bundler answers it differently. So this subpath takes the\n * three resolved addresses as arguments and does the honest part — the graph,\n * the load-once semantics, the per-context registration — here.\n */\nexport interface NeuralSuppressorAssets {\n /**\n * The `AudioWorklet` processor module the model runs its DSP in\n * (`rnnoiseWorklet.js`).\n */\n workletUrl: string;\n /** The SIMD build of the model's wasm (`rnnoise_simd.wasm`). */\n simdWasmUrl: string;\n /** The baseline build, used where the engine lacks SIMD (`rnnoise.wasm`). */\n wasmUrl: string;\n}\n\n/**\n * Whether this browser can run a worklet-based noise suppressor.\n *\n * Answered from the two things the path needs that nothing but the platform can\n * supply: `AudioWorkletNode` runs custom DSP on the audio thread, and a secure\n * context is what the browser requires before letting a module near the\n * microphone graph. Everything else — the models, the wasm — ships with the\n * caller, so it cannot be missing. The context must also run at 48 kHz, which\n * is checked at build time rather than here because the caller owns how its\n * context was opened.\n *\n * Deliberately conservative: anything it cannot establish answers `false`, and\n * a `false` sends the caller back to the browser's own `noiseSuppression`\n * constraint, which is weaker but always there.\n *\n * @returns True when a neural suppressor can be built on top of a capture.\n */\nexport function isNeuralSuppressionSupported(): boolean {\n return (\n typeof AudioWorkletNode !== \"undefined\" &&\n typeof window !== \"undefined\" &&\n window.isSecureContext\n );\n}\n\n/**\n * The one wasm binary every suppressor shares, fetched once.\n *\n * `loadRnnoise` compiles the whole model, and the answer is identical for every\n * graph that opens afterwards — a mesh can build a suppressor per published\n * track and a settings dialog may build a second for its monitor within a\n * single frame. Held as the promise rather than the result so the first two\n * callers race into one fetch instead of two. Reset on failure, so the next\n * call retries instead of replaying a dead promise.\n */\nlet loadingBinary: Promise<ArrayBuffer> | null = null;\n\n/**\n * The contexts the worklet module has already been registered on, mapped to the\n * registration's own promise.\n *\n * `addModule` is per-context and throws on a name it already holds, so the\n * module records the moment a context is ready and skips the call next time.\n * The promise is what makes a fast caller that cannot wait — a mesh can open a\n * microphone and a preview within a single frame — race into one `addModule`\n * instead of two: had this been a set, the second caller would see the context\n * absent while the first was still awaiting and register the module twice.\n * Mapped to a promise rather than the module itself the way the wasm is,\n * because the registration belongs to one context while the wasm is shared,\n * and nothing here should keep a dead context alive — the value is dropped\n * with the context. On failure the entry is removed, so the next attempt\n * actually retries instead of replaying the broken registration.\n */\nconst readyContexts = new WeakMap<AudioContext, Promise<void>>();\n\n/**\n * The compiled model for this context, loading what is not ready yet.\n *\n * Idempotent across calls: the wasm fetch and the module registration both\n * happen exactly once. The wasm is shared by every suppressor, while the module\n * registration belongs to one context.\n *\n * @param context - The context the suppression graph will run in.\n * @param assets - The resolved asset addresses from {@link NeuralSuppressorAssets}.\n * @returns The compiled model binary, ready to hand to a worklet node.\n * @throws Whatever the fetch or the compile threw, for the caller to fall back on.\n */\nexport async function prepareNeuralSuppressor(\n context: AudioContext,\n assets: NeuralSuppressorAssets,\n): Promise<ArrayBuffer> {\n let ready = readyContexts.get(context);\n if (!ready) {\n ready = context.audioWorklet.addModule(assets.workletUrl).then(\n () => undefined,\n (error: unknown) => {\n readyContexts.delete(context);\n throw error;\n },\n );\n readyContexts.set(context, ready);\n }\n await ready;\n if (loadingBinary === null) {\n loadingBinary = loadRnnoise({ url: assets.wasmUrl, simdUrl: assets.simdWasmUrl }).catch(\n (error: unknown) => {\n loadingBinary = null;\n throw error;\n },\n );\n }\n return loadingBinary;\n}\n\n/**\n * A denoised track plus the graph that has to come down with it.\n *\n * `track` is not the captured one — it is the output of a `MediaStream`\n * destination the worklet feeds — so stopping the published track is not enough\n * to free the microphone. `release` closes the graph; the captured track behind\n * it belongs to the caller.\n */\nexport interface NeuralSuppressor {\n /** The track to publish in place of the captured one. */\n track: MediaStreamTrack;\n /** Tears the graph down. Does not stop the source track it was built from. */\n release(): void;\n}\n\n/**\n * Build a suppressor graph on top of a captured microphone track.\n *\n * The model runs on the audio thread through an `AudioWorkletNode` — a\n * recurrent network trained on speech that separates a voice from everything\n * that is not one: keyboards, fans, traffic, a room full of people. The\n * browser's `noiseSuppression` constraint answers the same question with a\n * stationary-noise gate that either lets a transient through or eats the voice\n * with it.\n *\n * Prefer {@link prepareNeuralSuppressor} before the capture opens: the fallback\n * to the browser's own suppression is a `getUserMedia` constraint, and a\n * constraint cannot be added to a capture that already exists. A caller that\n * did not preload still works — the load happens here, idempotently.\n *\n * The model assumes 48 kHz. A context opened at any other rate answers `null`\n * before anything is fetched, because feeding speech samples of the wrong rate\n * into a network trained on 48 kHz produces mush, not a voice.\n *\n * Failure is not fatal and is reported as `null` — whether the wasm would not\n * load or the worklet node could not be built: by the time this runs the caller\n * has already committed to the neural path, so the honest outcome is a call\n * without suppression rather than a microphone that will not open. {@link\n * prepareNeuralSuppressor}, which exists to be called *before* the capture,\n * still throws so the caller can pick a different fallback while one is\n * reachable.\n *\n * @param context - The shared context, prepared at 48 kHz.\n * @param source - The captured microphone track.\n * @param assets - The resolved asset addresses from {@link NeuralSuppressorAssets}.\n * @param options - `maxChannels` is the channel count of the capture; the model\n * is mono, and a stereo capture asks for two so a second channel is not lost.\n * @returns The denoised track and its teardown, or `null` when this context or\n * this graph cannot host the suppressor.\n */\nexport async function createNeuralSuppressor(\n context: AudioContext,\n source: MediaStreamTrack,\n assets: NeuralSuppressorAssets,\n options: { maxChannels: number },\n): Promise<NeuralSuppressor | null> {\n if (context.sampleRate !== 48_000) return null;\n\n let binary: ArrayBuffer;\n try {\n binary = await prepareNeuralSuppressor(context, assets);\n } catch {\n return null;\n }\n\n let node: RnnoiseWorkletNode;\n try {\n node = new RnnoiseWorkletNode(context, {\n maxChannels: options.maxChannels,\n wasmBinary: binary,\n });\n } catch {\n return null;\n }\n\n const input = context.createMediaStreamSource(new MediaStream([source]));\n const output = context.createMediaStreamDestination();\n input.connect(node);\n node.connect(output);\n\n const track = output.stream.getAudioTracks()[0];\n if (!track) {\n input.disconnect();\n node.disconnect();\n output.disconnect();\n node.destroy();\n return null;\n }\n track.contentHint = source.contentHint;\n\n return {\n track,\n release: () => {\n input.disconnect();\n node.disconnect();\n output.disconnect();\n node.destroy();\n track.stop();\n },\n };\n}\n"],"mappings":"qHAyCA,SAAgB,GAAwC,CACpD,OACI,OAAO,iBAAqB,KAC5B,OAAO,OAAW,KAClB,OAAO,eAEf,CAYA,IAAI,EAA6C,KAkB3C,EAAgB,IAAI,QAc1B,eAAsB,EAClB,EACA,EACoB,CACpB,IAAI,EAAQ,EAAc,IAAI,CAAO,EAoBrC,OAnBK,IACD,EAAQ,EAAQ,aAAa,UAAU,EAAO,UAAU,CAAC,CAAC,SAChD,IAAA,GACL,GAAmB,CAEhB,MADA,EAAc,OAAO,CAAO,EACtB,CACV,CACJ,EACA,EAAc,IAAI,EAAS,CAAK,GAEpC,MAAM,EACF,IAAkB,OAClB,GAAA,EAAgB,EAAA,YAAA,CAAY,CAAE,IAAK,EAAO,QAAS,QAAS,EAAO,WAAY,CAAC,CAAC,CAAC,MAC7E,GAAmB,CAEhB,KADA,GAAgB,KACV,CACV,CACJ,GAEG,CACX,CAoDA,eAAsB,EAClB,EACA,EACA,EACA,EACgC,CAChC,GAAI,EAAQ,aAAe,KAAQ,OAAO,KAE1C,IAAI,EACJ,GAAI,CACA,EAAS,MAAM,EAAwB,EAAS,CAAM,CAC1D,MAAQ,CACJ,OAAO,IACX,CAEA,IAAI,EACJ,GAAI,CACA,EAAO,IAAI,EAAA,mBAAmB,EAAS,CACnC,YAAa,EAAQ,YACrB,WAAY,CAChB,CAAC,CACL,MAAQ,CACJ,OAAO,IACX,CAEA,IAAM,EAAQ,EAAQ,wBAAwB,IAAI,YAAY,CAAC,CAAM,CAAC,CAAC,EACjE,EAAS,EAAQ,6BAA6B,EACpD,EAAM,QAAQ,CAAI,EAClB,EAAK,QAAQ,CAAM,EAEnB,IAAM,EAAQ,EAAO,OAAO,eAAe,CAAC,CAAC,GAU7C,OATK,GAOL,EAAM,YAAc,EAAO,YAEpB,CACH,QACA,YAAe,CACX,EAAM,WAAW,EACjB,EAAK,WAAW,EAChB,EAAO,WAAW,EAClB,EAAK,QAAQ,EACb,EAAM,KAAK,CACf,CACJ,IAjBI,EAAM,WAAW,EACjB,EAAK,WAAW,EAChB,EAAO,WAAW,EAClB,EAAK,QAAQ,EACN,KAcf"}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Build a suppressor graph on top of a captured microphone track.
3
+ *
4
+ * The model runs on the audio thread through an `AudioWorkletNode` — a
5
+ * recurrent network trained on speech that separates a voice from everything
6
+ * that is not one: keyboards, fans, traffic, a room full of people. The
7
+ * browser's `noiseSuppression` constraint answers the same question with a
8
+ * stationary-noise gate that either lets a transient through or eats the voice
9
+ * with it.
10
+ *
11
+ * Prefer {@link prepareNeuralSuppressor} before the capture opens: the fallback
12
+ * to the browser's own suppression is a `getUserMedia` constraint, and a
13
+ * constraint cannot be added to a capture that already exists. A caller that
14
+ * did not preload still works — the load happens here, idempotently.
15
+ *
16
+ * The model assumes 48 kHz. A context opened at any other rate answers `null`
17
+ * before anything is fetched, because feeding speech samples of the wrong rate
18
+ * into a network trained on 48 kHz produces mush, not a voice.
19
+ *
20
+ * Failure is not fatal and is reported as `null` — whether the wasm would not
21
+ * load or the worklet node could not be built: by the time this runs the caller
22
+ * has already committed to the neural path, so the honest outcome is a call
23
+ * without suppression rather than a microphone that will not open. {@link
24
+ * prepareNeuralSuppressor}, which exists to be called *before* the capture,
25
+ * still throws so the caller can pick a different fallback while one is
26
+ * reachable.
27
+ *
28
+ * @param context - The shared context, prepared at 48 kHz.
29
+ * @param source - The captured microphone track.
30
+ * @param assets - The resolved asset addresses from {@link NeuralSuppressorAssets}.
31
+ * @param options - `maxChannels` is the channel count of the capture; the model
32
+ * is mono, and a stereo capture asks for two so a second channel is not lost.
33
+ * @returns The denoised track and its teardown, or `null` when this context or
34
+ * this graph cannot host the suppressor.
35
+ */
36
+ export declare function createNeuralSuppressor(context: AudioContext, source: MediaStreamTrack, assets: NeuralSuppressorAssets, options: {
37
+ maxChannels: number;
38
+ }): Promise<NeuralSuppressor | null>;
39
+
40
+ /**
41
+ * Whether this browser can run a worklet-based noise suppressor.
42
+ *
43
+ * Answered from the two things the path needs that nothing but the platform can
44
+ * supply: `AudioWorkletNode` runs custom DSP on the audio thread, and a secure
45
+ * context is what the browser requires before letting a module near the
46
+ * microphone graph. Everything else — the models, the wasm — ships with the
47
+ * caller, so it cannot be missing. The context must also run at 48 kHz, which
48
+ * is checked at build time rather than here because the caller owns how its
49
+ * context was opened.
50
+ *
51
+ * Deliberately conservative: anything it cannot establish answers `false`, and
52
+ * a `false` sends the caller back to the browser's own `noiseSuppression`
53
+ * constraint, which is weaker but always there.
54
+ *
55
+ * @returns True when a neural suppressor can be built on top of a capture.
56
+ */
57
+ export declare function isNeuralSuppressionSupported(): boolean;
58
+
59
+ /**
60
+ * A denoised track plus the graph that has to come down with it.
61
+ *
62
+ * `track` is not the captured one — it is the output of a `MediaStream`
63
+ * destination the worklet feeds — so stopping the published track is not enough
64
+ * to free the microphone. `release` closes the graph; the captured track behind
65
+ * it belongs to the caller.
66
+ */
67
+ export declare interface NeuralSuppressor {
68
+ /** The track to publish in place of the captured one. */
69
+ track: MediaStreamTrack;
70
+ /** Tears the graph down. Does not stop the source track it was built from. */
71
+ release(): void;
72
+ }
73
+
74
+ /**
75
+ * Where the suppressor's three pieces live, as URLs a bundler hands over.
76
+ *
77
+ * The `@sapphi-red/web-noise-suppressor` package ships the runnable assets next
78
+ * to its code, but it cannot say where they are from inside a library: the
79
+ * `?url` suffix that resolves a module to its served address is a bundler
80
+ * feature, and every bundler answers it differently. So this subpath takes the
81
+ * three resolved addresses as arguments and does the honest part — the graph,
82
+ * the load-once semantics, the per-context registration — here.
83
+ */
84
+ export declare interface NeuralSuppressorAssets {
85
+ /**
86
+ * The `AudioWorklet` processor module the model runs its DSP in
87
+ * (`rnnoiseWorklet.js`).
88
+ */
89
+ workletUrl: string;
90
+ /** The SIMD build of the model's wasm (`rnnoise_simd.wasm`). */
91
+ simdWasmUrl: string;
92
+ /** The baseline build, used where the engine lacks SIMD (`rnnoise.wasm`). */
93
+ wasmUrl: string;
94
+ }
95
+
96
+ /**
97
+ * The compiled model for this context, loading what is not ready yet.
98
+ *
99
+ * Idempotent across calls: the wasm fetch and the module registration both
100
+ * happen exactly once. The wasm is shared by every suppressor, while the module
101
+ * registration belongs to one context.
102
+ *
103
+ * @param context - The context the suppression graph will run in.
104
+ * @param assets - The resolved asset addresses from {@link NeuralSuppressorAssets}.
105
+ * @returns The compiled model binary, ready to hand to a worklet node.
106
+ * @throws Whatever the fetch or the compile threw, for the caller to fall back on.
107
+ */
108
+ export declare function prepareNeuralSuppressor(context: AudioContext, assets: NeuralSuppressorAssets): Promise<ArrayBuffer>;
109
+
110
+ export { }
package/dist/neural.js ADDED
@@ -0,0 +1,48 @@
1
+ import { RnnoiseWorkletNode as e, loadRnnoise as t } from "@sapphi-red/web-noise-suppressor";
2
+ //#region src/neural/index.ts
3
+ function n() {
4
+ return typeof AudioWorkletNode < "u" && typeof window < "u" && window.isSecureContext;
5
+ }
6
+ var r = null, i = /* @__PURE__ */ new WeakMap();
7
+ async function a(e, n) {
8
+ let a = i.get(e);
9
+ return a || (a = e.audioWorklet.addModule(n.workletUrl).then(() => void 0, (t) => {
10
+ throw i.delete(e), t;
11
+ }), i.set(e, a)), await a, r === null && (r = t({
12
+ url: n.wasmUrl,
13
+ simdUrl: n.simdWasmUrl
14
+ }).catch((e) => {
15
+ throw r = null, e;
16
+ })), r;
17
+ }
18
+ async function o(t, n, r, i) {
19
+ if (t.sampleRate !== 48e3) return null;
20
+ let o;
21
+ try {
22
+ o = await a(t, r);
23
+ } catch {
24
+ return null;
25
+ }
26
+ let s;
27
+ try {
28
+ s = new e(t, {
29
+ maxChannels: i.maxChannels,
30
+ wasmBinary: o
31
+ });
32
+ } catch {
33
+ return null;
34
+ }
35
+ let c = t.createMediaStreamSource(new MediaStream([n])), l = t.createMediaStreamDestination();
36
+ c.connect(s), s.connect(l);
37
+ let u = l.stream.getAudioTracks()[0];
38
+ return u ? (u.contentHint = n.contentHint, {
39
+ track: u,
40
+ release: () => {
41
+ c.disconnect(), s.disconnect(), l.disconnect(), s.destroy(), u.stop();
42
+ }
43
+ }) : (c.disconnect(), s.disconnect(), l.disconnect(), s.destroy(), null);
44
+ }
45
+ //#endregion
46
+ export { o as createNeuralSuppressor, n as isNeuralSuppressionSupported, a as prepareNeuralSuppressor };
47
+
48
+ //# sourceMappingURL=neural.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"neural.js","names":[],"sources":["../src/neural/index.ts"],"sourcesContent":["import { RnnoiseWorkletNode, loadRnnoise } from \"@sapphi-red/web-noise-suppressor\";\n\n/**\n * Where the suppressor's three pieces live, as URLs a bundler hands over.\n *\n * The `@sapphi-red/web-noise-suppressor` package ships the runnable assets next\n * to its code, but it cannot say where they are from inside a library: the\n * `?url` suffix that resolves a module to its served address is a bundler\n * feature, and every bundler answers it differently. So this subpath takes the\n * three resolved addresses as arguments and does the honest part — the graph,\n * the load-once semantics, the per-context registration — here.\n */\nexport interface NeuralSuppressorAssets {\n /**\n * The `AudioWorklet` processor module the model runs its DSP in\n * (`rnnoiseWorklet.js`).\n */\n workletUrl: string;\n /** The SIMD build of the model's wasm (`rnnoise_simd.wasm`). */\n simdWasmUrl: string;\n /** The baseline build, used where the engine lacks SIMD (`rnnoise.wasm`). */\n wasmUrl: string;\n}\n\n/**\n * Whether this browser can run a worklet-based noise suppressor.\n *\n * Answered from the two things the path needs that nothing but the platform can\n * supply: `AudioWorkletNode` runs custom DSP on the audio thread, and a secure\n * context is what the browser requires before letting a module near the\n * microphone graph. Everything else — the models, the wasm — ships with the\n * caller, so it cannot be missing. The context must also run at 48 kHz, which\n * is checked at build time rather than here because the caller owns how its\n * context was opened.\n *\n * Deliberately conservative: anything it cannot establish answers `false`, and\n * a `false` sends the caller back to the browser's own `noiseSuppression`\n * constraint, which is weaker but always there.\n *\n * @returns True when a neural suppressor can be built on top of a capture.\n */\nexport function isNeuralSuppressionSupported(): boolean {\n return (\n typeof AudioWorkletNode !== \"undefined\" &&\n typeof window !== \"undefined\" &&\n window.isSecureContext\n );\n}\n\n/**\n * The one wasm binary every suppressor shares, fetched once.\n *\n * `loadRnnoise` compiles the whole model, and the answer is identical for every\n * graph that opens afterwards — a mesh can build a suppressor per published\n * track and a settings dialog may build a second for its monitor within a\n * single frame. Held as the promise rather than the result so the first two\n * callers race into one fetch instead of two. Reset on failure, so the next\n * call retries instead of replaying a dead promise.\n */\nlet loadingBinary: Promise<ArrayBuffer> | null = null;\n\n/**\n * The contexts the worklet module has already been registered on, mapped to the\n * registration's own promise.\n *\n * `addModule` is per-context and throws on a name it already holds, so the\n * module records the moment a context is ready and skips the call next time.\n * The promise is what makes a fast caller that cannot wait — a mesh can open a\n * microphone and a preview within a single frame — race into one `addModule`\n * instead of two: had this been a set, the second caller would see the context\n * absent while the first was still awaiting and register the module twice.\n * Mapped to a promise rather than the module itself the way the wasm is,\n * because the registration belongs to one context while the wasm is shared,\n * and nothing here should keep a dead context alive — the value is dropped\n * with the context. On failure the entry is removed, so the next attempt\n * actually retries instead of replaying the broken registration.\n */\nconst readyContexts = new WeakMap<AudioContext, Promise<void>>();\n\n/**\n * The compiled model for this context, loading what is not ready yet.\n *\n * Idempotent across calls: the wasm fetch and the module registration both\n * happen exactly once. The wasm is shared by every suppressor, while the module\n * registration belongs to one context.\n *\n * @param context - The context the suppression graph will run in.\n * @param assets - The resolved asset addresses from {@link NeuralSuppressorAssets}.\n * @returns The compiled model binary, ready to hand to a worklet node.\n * @throws Whatever the fetch or the compile threw, for the caller to fall back on.\n */\nexport async function prepareNeuralSuppressor(\n context: AudioContext,\n assets: NeuralSuppressorAssets,\n): Promise<ArrayBuffer> {\n let ready = readyContexts.get(context);\n if (!ready) {\n ready = context.audioWorklet.addModule(assets.workletUrl).then(\n () => undefined,\n (error: unknown) => {\n readyContexts.delete(context);\n throw error;\n },\n );\n readyContexts.set(context, ready);\n }\n await ready;\n if (loadingBinary === null) {\n loadingBinary = loadRnnoise({ url: assets.wasmUrl, simdUrl: assets.simdWasmUrl }).catch(\n (error: unknown) => {\n loadingBinary = null;\n throw error;\n },\n );\n }\n return loadingBinary;\n}\n\n/**\n * A denoised track plus the graph that has to come down with it.\n *\n * `track` is not the captured one — it is the output of a `MediaStream`\n * destination the worklet feeds — so stopping the published track is not enough\n * to free the microphone. `release` closes the graph; the captured track behind\n * it belongs to the caller.\n */\nexport interface NeuralSuppressor {\n /** The track to publish in place of the captured one. */\n track: MediaStreamTrack;\n /** Tears the graph down. Does not stop the source track it was built from. */\n release(): void;\n}\n\n/**\n * Build a suppressor graph on top of a captured microphone track.\n *\n * The model runs on the audio thread through an `AudioWorkletNode` — a\n * recurrent network trained on speech that separates a voice from everything\n * that is not one: keyboards, fans, traffic, a room full of people. The\n * browser's `noiseSuppression` constraint answers the same question with a\n * stationary-noise gate that either lets a transient through or eats the voice\n * with it.\n *\n * Prefer {@link prepareNeuralSuppressor} before the capture opens: the fallback\n * to the browser's own suppression is a `getUserMedia` constraint, and a\n * constraint cannot be added to a capture that already exists. A caller that\n * did not preload still works — the load happens here, idempotently.\n *\n * The model assumes 48 kHz. A context opened at any other rate answers `null`\n * before anything is fetched, because feeding speech samples of the wrong rate\n * into a network trained on 48 kHz produces mush, not a voice.\n *\n * Failure is not fatal and is reported as `null` — whether the wasm would not\n * load or the worklet node could not be built: by the time this runs the caller\n * has already committed to the neural path, so the honest outcome is a call\n * without suppression rather than a microphone that will not open. {@link\n * prepareNeuralSuppressor}, which exists to be called *before* the capture,\n * still throws so the caller can pick a different fallback while one is\n * reachable.\n *\n * @param context - The shared context, prepared at 48 kHz.\n * @param source - The captured microphone track.\n * @param assets - The resolved asset addresses from {@link NeuralSuppressorAssets}.\n * @param options - `maxChannels` is the channel count of the capture; the model\n * is mono, and a stereo capture asks for two so a second channel is not lost.\n * @returns The denoised track and its teardown, or `null` when this context or\n * this graph cannot host the suppressor.\n */\nexport async function createNeuralSuppressor(\n context: AudioContext,\n source: MediaStreamTrack,\n assets: NeuralSuppressorAssets,\n options: { maxChannels: number },\n): Promise<NeuralSuppressor | null> {\n if (context.sampleRate !== 48_000) return null;\n\n let binary: ArrayBuffer;\n try {\n binary = await prepareNeuralSuppressor(context, assets);\n } catch {\n return null;\n }\n\n let node: RnnoiseWorkletNode;\n try {\n node = new RnnoiseWorkletNode(context, {\n maxChannels: options.maxChannels,\n wasmBinary: binary,\n });\n } catch {\n return null;\n }\n\n const input = context.createMediaStreamSource(new MediaStream([source]));\n const output = context.createMediaStreamDestination();\n input.connect(node);\n node.connect(output);\n\n const track = output.stream.getAudioTracks()[0];\n if (!track) {\n input.disconnect();\n node.disconnect();\n output.disconnect();\n node.destroy();\n return null;\n }\n track.contentHint = source.contentHint;\n\n return {\n track,\n release: () => {\n input.disconnect();\n node.disconnect();\n output.disconnect();\n node.destroy();\n track.stop();\n },\n };\n}\n"],"mappings":";;AAyCA,SAAgB,IAAwC;CACpD,OACI,OAAO,mBAAqB,OAC5B,OAAO,SAAW,OAClB,OAAO;AAEf;AAYA,IAAI,IAA6C,MAkB3C,oBAAgB,IAAI,QAAqC;AAc/D,eAAsB,EAClB,GACA,GACoB;CACpB,IAAI,IAAQ,EAAc,IAAI,CAAO;CAoBrC,OAnBK,MACD,IAAQ,EAAQ,aAAa,UAAU,EAAO,UAAU,CAAC,CAAC,WAChD,KAAA,IACL,MAAmB;EAEhB,MADA,EAAc,OAAO,CAAO,GACtB;CACV,CACJ,GACA,EAAc,IAAI,GAAS,CAAK,IAEpC,MAAM,GACF,MAAkB,SAClB,IAAgB,EAAY;EAAE,KAAK,EAAO;EAAS,SAAS,EAAO;CAAY,CAAC,CAAC,CAAC,OAC7E,MAAmB;EAEhB,MADA,IAAgB,MACV;CACV,CACJ,IAEG;AACX;AAoDA,eAAsB,EAClB,GACA,GACA,GACA,GACgC;CAChC,IAAI,EAAQ,eAAe,MAAQ,OAAO;CAE1C,IAAI;CACJ,IAAI;EACA,IAAS,MAAM,EAAwB,GAAS,CAAM;CAC1D,QAAQ;EACJ,OAAO;CACX;CAEA,IAAI;CACJ,IAAI;EACA,IAAO,IAAI,EAAmB,GAAS;GACnC,aAAa,EAAQ;GACrB,YAAY;EAChB,CAAC;CACL,QAAQ;EACJ,OAAO;CACX;CAEA,IAAM,IAAQ,EAAQ,wBAAwB,IAAI,YAAY,CAAC,CAAM,CAAC,CAAC,GACjE,IAAS,EAAQ,6BAA6B;CAEpD,AADA,EAAM,QAAQ,CAAI,GAClB,EAAK,QAAQ,CAAM;CAEnB,IAAM,IAAQ,EAAO,OAAO,eAAe,CAAC,CAAC;CAU7C,OATK,KAOL,EAAM,cAAc,EAAO,aAEpB;EACH;EACA,eAAe;GAKX,AAJA,EAAM,WAAW,GACjB,EAAK,WAAW,GAChB,EAAO,WAAW,GAClB,EAAK,QAAQ,GACb,EAAM,KAAK;EACf;CACJ,MAjBI,EAAM,WAAW,GACjB,EAAK,WAAW,GAChB,EAAO,WAAW,GAClB,EAAK,QAAQ,GACN;AAcf"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tempest-react-sdk",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "description": "SDK público da Tempest com componentes, hooks e integrações para projetos React.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,6 +87,11 @@
87
87
  "import": "./dist/vision.js",
88
88
  "require": "./dist/vision.cjs"
89
89
  },
90
+ "./neural": {
91
+ "types": "./dist/neural.d.ts",
92
+ "import": "./dist/neural.js",
93
+ "require": "./dist/neural.cjs"
94
+ },
90
95
  "./br": {
91
96
  "types": "./dist/br.d.ts",
92
97
  "import": "./dist/br.js",
@@ -151,6 +156,7 @@
151
156
  "zustand": "^4.0.0 || ^5.0.0"
152
157
  },
153
158
  "peerDependencies": {
159
+ "@sapphi-red/web-noise-suppressor": "^0.4.1",
154
160
  "@tiptap/react": "^2.0.0 || ^3.0.0",
155
161
  "@tiptap/starter-kit": "^2.0.0 || ^3.0.0",
156
162
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0 || ^6.0.0",
@@ -183,12 +189,16 @@
183
189
  },
184
190
  "onnxruntime-web": {
185
191
  "optional": true
192
+ },
193
+ "@sapphi-red/web-noise-suppressor": {
194
+ "optional": true
186
195
  }
187
196
  },
188
197
  "devDependencies": {
189
198
  "@eslint/js": "^10.0.1",
190
199
  "@microsoft/api-extractor": "^7.58.12",
191
200
  "@playwright/test": "^1.62.0",
201
+ "@sapphi-red/web-noise-suppressor": "^0.4.1",
192
202
  "@size-limit/preset-small-lib": "^13.0.1",
193
203
  "@tanstack/react-query": "^5.90.0",
194
204
  "@testing-library/jest-dom": "^7.0.0",