Supressão neural de ruído (RNNoise)
O subpath tempest-react-sdk/neural liga um modelo de rede neural recorrente — o RNNoise — sobre um microfone aberto pelo navegador e devolve um MediaStreamTrack sem ruído, pronto para publicar. É a resposta neural para o mesmo problema que a constraint noiseSuppression do getUserMedia ataca com um gate de ruído estacionário: separar a sua voz de teclado, ventoinha, trânsito e de uma sala cheia de gente — sem deixar o transiente passar e sem comer a voz com ele.
O modelo roda num AudioWorklet, em um contexto do AudioContext de 48 kHz (a taxa que ele assume). Você é quem serve os três assets — o módulo do worklet e os dois .wasm — porque a biblioteca não tem como saber onde o seu bundler publica os arquivos que ela não descobriu sozinha.
npm install tempest-react-sdk @sapphi-red/web-noise-suppressor
import {
createNeuralSuppressor,
isNeuralSuppressionSupported,
} from "tempest-react-sdk/neural";
import rnnoiseWorkletUrl from "@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url";
import rnnoiseSimdWasmUrl from "@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url";
import rnnoiseWasmUrl from "@sapphi-red/web-noise-suppressor/rnnoise.wasm?url";
async function startDenoisedCapture(): Promise<{ track: MediaStreamTrack; release?: () => void }> {
if (!isNeuralSuppressionSupported()) {
const plain = await navigator.mediaDevices.getUserMedia({ audio: true });
return { track: plain.getAudioTracks()[0] };
}
const context = new AudioContext({ sampleRate: 48_000 });
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: false },
});
const source = stream.getAudioTracks()[0];
const suppressor = await createNeuralSuppressor(
context,
source,
{
workletUrl: rnnoiseWorkletUrl,
simdWasmUrl: rnnoiseSimdWasmUrl,
wasmUrl: rnnoiseWasmUrl,
},
{ maxChannels: 1 },
);
if (suppressor === null) return { track: source };
return { track: suppressor.track, release: suppressor.release };
}
Por que um subpath à parte
O RNNoise não cabe no barrel do @tempest síncrono — nem no audio. Dois motivos:
- O worklet é um arquivo que você tem que servir.
context.audioWorklet.addModulerecebe uma URL, não um bundle; o consumidor precisa hospedarrnnoiseWorklet.jse os.wasmdo modelo. Um módulo que depende de três URLs que ele não controla não pode ser chamado por uma função síncrona comocreateVoiceChain. - O load é assíncrono. O modelo compila em wasm; quem abre o microfone precisa decidir antes qual caminho vai usar (o fallback é uma constraint do
getUserMedia, e constraint não se adiciona a uma captura já aberta).
Por isso o subpath existe: a superfície honesta é um Promise<NeuralSuppressor | null>, e o pacote que entrega os assets é um peer opcional instalado por quem usa o subpath.
Peer opcional
@sapphi-red/web-noise-suppressor é dependência opcional: npm install @sapphi-red/web-noise-suppressor só é necessário quando o seu app importa tempest-react-sdk/neural.
O pacote e os três assets
O peer publica, além do código, os arquivos que o worklet precisa:
| Asset | Import no Vite | Para que serve |
|---|---|---|
| Módulo do worklet | @sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url |
O DSP que addModule registra no contexto |
| Modelo SIMD | @sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url |
Onde o engine tem SIMD |
| Modelo base | @sapphi-red/web-noise-suppressor/rnnoise.wasm?url |
Fallback para engine sem SIMD |
O sufixo ?url é um recurso de bundler: ele resolve o módulo para a URL servida. É isso que transforma import de asset num string que o addModule e o loadRnnoise sabem usar.
Offline
Os .wasm são navegados uma vez e compilados. Para o app funcionar offline, precache os três assets junto com o app-shell (o tempestPwaManifest do subpath tempest-react-sdk/vite aceita os mesmos ?url).
Preparando antes de abrir o microfone
prepareNeuralSuppressor(context, assets) registra o módulo do worklet no contexto e compila o modelo — exatamente uma vez para cada um, por mais vezes que você chame. Ele dispara exceção em falha, porque existe para ser chamado antes da captura, enquanto ainda dá para escolher outro fallback:
import { isNeuralSuppressionSupported, prepareNeuralSuppressor } from "tempest-react-sdk/neural";
const context = new AudioContext({ sampleRate: 48_000 });
const neuralAvailable = isNeuralSuppressionSupported()
&& (await prepareNeuralSuppressor(context, assets).then(() => true, () => false));
const stream = await navigator.mediaDevices.getUserMedia({
audio: { noiseSuppression: !neuralAvailable, echoCancellation: true },
});
Com o lado neural pronto, você pede noiseSuppression: false ao getUserMedia. Os dois filtros respondem a mesma pergunta; rodar os dois faz a voz chegar duas vezes filtrada, e é disso que vem o timbre robótico de supressão empilhada.
Montando o grafo
createNeuralSuppressor(context, source, assets, { maxChannels }) monta fonte → worklet → MediaStreamDestination dentro do seu contexto e devolve:
track— o track de saída, que você publica no lugar do capturado;release()— fecha o grafo e para o track; não para a fonte, que é do chamador.
maxChannels é o número de canais da captura: o modelo é mono, e pedir o canal certo evita que o segundo canal de uma captura estéreo seja descartado.
Quando devolve null
Silêncio não é erro. createNeuralSuppressor devolve null quando:
- o contexto não roda a 48 kHz — alimentar um modelo treinado em 48 kHz com amostras de outra taxa produz borrão, não voz (e nada é buscado antes dessa checagem);
- o
.wasmnão carregou ou o nó do worklet não pôde ser construído.
Nesses casos o chamador já se comprometeu com o caminho neural, então o desfecho honesto é uma chamada sem supressão — não um microfone que não abre. prepareNeuralSuppressor, que existe para rodar antes da captura, ainda lança exceção, porque ali o fallback ainda está ao alcance.
API
| Símbolo | O que é |
|---|---|
isNeuralSuppressionSupported() |
true quando o navegador tem AudioWorkletNode e contexto seguro |
prepareNeuralSuppressor(context, assets) |
Registra o módulo do worklet e compila o modelo; idempotente, lança exceção em falha |
createNeuralSuppressor(context, source, assets, { maxChannels }) |
Monta o grafo e devolve NeuralSuppressor — ou null quando o contexto não roda a 48 kHz ou o grafo falhou |
NeuralSuppressor |
{ track, release() } — o track denoised e o teardown do grafo |
NeuralSuppressorAssets |
{ workletUrl, simdWasmUrl, wasmUrl } — as URLs resolvidas dos três assets |
Recapitulando
- O peer instala sob demanda:
@sapphi-red/web-noise-suppressorsó entra no seu bundle se você importar o subpath. - Os assets são
?url: você os serve; a lib cuida do grafo, do load-once e do registro por contexto. - O contexto precisa de 48 kHz; qualquer outra taxa devolve
nullsem nem buscar o modelo. - Sem empilhar filtros: com o lado neural ligado, peça
noiseSuppression: falseaogetUserMedia. - Prepare o modelo antes de abrir o microfone, senão você não tem para onde cair quando ele falhar.