React Native Web
Tocando os mesmos WAVs no Expo Web e no app React Native
Quando o app e o Web tocam sons diferentes, o problema não é só estético. Você perde a capacidade de validar duração, mixagem e feedback do jogo no ambiente mais rápido de testar.
O bug parecia ser o som de derrota.
No Daily Sudoku: Offline Puzzle, adicionamos dois efeitos novos: um para partida perdida e outro para recompensa diária de
créditos. Os WAVs existiam, os módulos TypeScript existiam, os exports do pacote compartilhado existiam, e no app nativo o
caminho usava expo-audio.
No Web, porém, a validação enganava. O jogador fazia uma ação, alguma coisa tocava, mas aquela coisa não era o mesmo arquivo WAV. Era um tom sintético curto criado com Web Audio. A interface parecia ter feedback, só que o teste não dizia nada sobre o asset real, a duração real ou o comportamento real do efeito novo.
Essa diferença é fácil de aceitar cedo demais em apps Expo. O Web vira uma versão “boa o bastante para dev”, enquanto Android e iOS ficam como donos da verdade. Para layout isso às vezes é inevitável. Canvas, Skia e APIs nativas podem ter limites reais no browser. Para sons curtos em WAV, a desculpa era fraca.
O falso positivo do fallback
O helper de áudio tinha uma bifurcação simples:
if (Platform.OS === "web") {
playWebSoundEffect(effect);
continue;
}
Esse playWebSoundEffect não tocava o asset. Ele criava um oscilador, escolhia uma frequência para cada evento e agendava um
decay curto. Para um correct, soava como um bip positivo. Para um mistake, soava como um bip baixo. Era útil quando nada
mais existia. Depois que o projeto passou a ter uma série game-soft-*, virou ruído arquitetural.
O problema não era o fallback existir. O problema era ele ser o caminho principal.
Quando o fallback é o caminho principal, três coisas ruins acontecem. Primeiro, o Web deixa de detectar arquivo quebrado, export ausente ou URI inválida. Segundo, a equipe aprova um comportamento sonoro que não é o que o usuário escutará no app. Terceiro, bugs de sobreposição ficam mais difíceis de entender, porque a duração sintética não representa a duração do WAV.
Foi exatamente o tipo de bug que apareceu na derrota. O som novo parecia não tocar. Parte da investigação mostrou uma sobreposição com o som de erro. Outra parte mostrou que a Web nem estava tentando tocar o WAV de derrota.
A decisão: Web toca o asset primeiro
A correção boa não foi inventar outro motor de áudio. Foi baixar a ambição.
O app já tinha um mapa de efeitos para fontes nativas:
const nativeSoundSourceByEffect = {
correct: gameSoftCorrectSound,
mistake: gameSoftMistakeSound,
loss: gameSoftLossSound,
dailyReward: gameSoftDailyRewardSound,
};
Esse mapa precisava continuar sendo a fonte de verdade. No nativo, expo-audio recebe a fonte e cria um player. No Web, o
browser precisa de uma URL. Em React Native Web, o caminho prático foi resolver o asset empacotado com Image.resolveAssetSource
e passar a URI para Audio.
O fluxo ficou mais honesto:
- se a plataforma é nativa, cria ou reutiliza
AudioPlayerdeexpo-audio; - se a plataforma é Web, resolve o mesmo módulo de asset para uma URI e cria
new Audio(uri); - se o browser não tiver
Audio, se a URI não resolver ou se o playback falhar, só então cai no tom sintético.
Essa ordem importa. O fallback continua ajudando em diagnóstico e browsers estranhos, mas ele não mascara o caso normal.
Reutilizar player também vale no Web
Efeitos de jogo são curtos e podem repetir rápido. Criar um Audio novo a cada toque funciona em exemplos pequenos, mas em jogo
real isso vira alocação desnecessária e comportamento menos previsível.
Mantivemos um cache por efeito:
const webPlayerByEffect = new Map<GameSoundEffect, WebAudioElement>();
Quando o efeito toca de novo, o player existente é pausado, volta para currentTime = 0, recebe o volume atual e chama
play(). Isso espelha a intenção do caminho nativo, que também reutiliza player por efeito e chama seekTo(0) antes de tocar.
Esse detalhe parece pequeno, mas ajuda na paridade. Um erro repetido, uma unidade completa ou uma sequência de auto-complete devem se comportar como eventos do mesmo sistema, não como uma coleção de players descartáveis competindo entre si.
Logs de áudio precisam ser desligáveis
Durante a investigação, colocar logs em cada som foi útil. O log mostrou quando um evento foi mapeado, quando o modo de áudio foi configurado, quando um player foi criado, quando um asset Web foi tocado e quando o fallback sintético entrou.
Só que log útil em diagnóstico pode virar lixo em produção. Um jogador preenchendo muitas células gera bastante atividade. Por isso a instrumentação entrou atrás de config de ambiente:
observability:
sound:
enabled: true
prefix: "[sound]"
O app já tinha @apps/observability com logging global. Não valia criar outro pacote nem outro sistema. O helper de áudio só
precisava de wrappers locais que leem observability.sound, aplicam o prefixo configurado e delegam para logInfo ou
logWarning.
Na prática, o console fica legível:
[SUDOKU-OBSERVER] [sound] created web bundled sound player
[SUDOKU-OBSERVER] [sound] playing web bundled sound
O prefixo global ainda identifica o observador do app. O prefixo de som separa o subsistema. A mensagem final diz o que aconteceu.
Como provar que o Web tocou o WAV
Ouvir o som não basta. Browser, autoplay policy, volume do sistema e aba em segundo plano confundem qualquer teste manual.
A validação que resolveu a dúvida teve duas partes.
Primeiro, rodamos uma ação real no jogo pelo Playwright: abrir a aba local existente, continuar um Sudoku, selecionar uma célula
vazia e inserir um número. A ação gerou um evento de erro, então o helper tentou tocar mistake.
Segundo, olhamos os sinais que não dependem do ouvido:
- console com
playing web bundled sound; - URI contendo
packages/audio-assets/assets/sounds/game-soft-mistake.wav; - requisição de rede
.wavretornando206 Partial Content.
Esse 206 é normal para áudio. O browser pode pedir uma faixa do arquivo em vez de baixar tudo de uma vez. O ponto importante
é que existiu uma requisição para o WAV real, não só a criação de um oscilador.
Esse tipo de evidência muda a conversa. Antes, “funcionou no Web” significava “algum som saiu”. Depois, passou a significar “o mesmo asset empacotado foi carregado e o caminho de playback principal rodou”.
O que não foi unificado
Paridade não é fingir que Web e nativo são iguais.
O app ainda pode ter diferenças reais. Canvas/Skia no React Native Web, por exemplo, pode esbarrar em APIs que não existem ou em comportamento de fonte diferente. Áudio curto em WAV não estava nessa categoria. O browser sabe tocar WAV. O Metro sabe servir o asset. O trabalho era ligar as peças na ordem certa.
Também não tentamos trocar expo-audio por HTMLAudioElement em todas as plataformas. Seria uma abstração errada. No nativo,
o player de Expo continua sendo o caminho natural. No Web, o elemento Audio é a API de plataforma mais direta para tocar um
arquivo já resolvido.
O objetivo foi reduzir diferenças onde elas não compram nada.
Checklist que ficou
Para efeitos sonoros curtos em Expo/React Native com Web:
- mantenha um único mapa de efeito para asset;
- resolva o asset para URI no Web;
- toque WAV real como caminho principal;
- deixe o tom sintético apenas como fallback;
- cacheie player por efeito;
- registre criação, playback e fallback com logs desligáveis;
- valide no Playwright por console e requisição
.wav; - teste nativo separadamente, porque política de áudio, foco e volume ainda são de plataforma.
Essa solução não é sofisticada. Esse é o mérito dela.
O erro anterior era manter uma diferença técnica depois que ela já não era necessária. O Web tinha virado um simulador sonoro do app, não o app usando os mesmos assets. Para um jogo casual, isso basta para esconder exatamente os bugs que você quer encontrar cedo: som curto demais, som longo demais, efeito que se sobrepõe, arquivo que não carrega, feedback que parece certo no ambiente errado.
Quando a plataforma consegue tocar o mesmo arquivo, faça ela tocar o mesmo arquivo.