Media — Audio record, playback, mic gate, formats (privacy + widgets)
Contrato cross-surface para gravação e reprodução de áudio em apps Koder (não-voice). Toggles em Settings (`media.audio.mic`, `media.audio.playback_in_background`); defaults seguros; codecs canônicos (Opus/AAC/MP3 in; Opus out via kodec); widgets `KoderAudioPlayer` + `KoderAudioRecorder` em `engines/sdk/koder_kit`. Recording indicator obrigatório. Upload size cap 100 MB. `<APP>-AUDIO-*` error map. **Relação com voice:** `specs/voice/wake-word.kmd` cobre wake-word + Talk Mode (ouvir o usuário ativamente, sub-domínio). Este spec cobre áudio genérico (tocar/gravar como mídia). Mic permission é compartilhada; voice toggles continuam separados em Settings.
Normative cross-surface spec para audio I/O em apps Koder, excluindo voice/wake-word (coberto em
specs/voice/wake-word.kmd). Implementação obrigatória via widgetsKoderAudio*emengines/sdk/koder_kit(Flutter) — nunca rolaraudioplayersupstream ou<audio>raw local.
Scope
Aplica-se a todo app Koder que grave OU reproduza áudio fora do domínio voice/wake-word: audio memos (recorded notes), podcast playback, voicemail, audio attachments em chat, ringtones, sound effects de UI, music streaming.
Fora de scope (cobertos por voice/wake-word.kmd): wake-word detection (ring buffer pré-wake), Talk Mode (always-listening), barge-in TTS cancellation.
Surfaces cobertas: Flutter mobile (Android + iOS), Flutter desktop (Linux + macOS + Windows), Flutter web ou templ+HTMX, TV (raro; playback de ringtones em smart-TV apps). CLI/TUI delegam ao desktop player externo.
1 — MUST: expose "Áudio" toggle group in Settings
Sub-seção "Áudio" do agrupamento "Mídia"; deve conter, na ordem:
- Microfone (
media.audio.mic) — gate à gravação de áudio - Reproduzir em background (
media.audio.playback_in_background)— checkbox; quando ON, audio continua tocando ao sair do app (iOS exige entitlement
audiono Info.plist) - Qualidade de gravação (
media.audio.record_quality) — dropdownvoice(32 kbps Opus) /music(96 kbps Opus) /lossless(FLAC) - Echo cancellation (
media.audio.aec) — toggle (relevante emgravação ambiental); default ON
media.audio.mic é separado de voice.enabled da §1 do voice/wake-word.kmd. Voice toggles controlam wake-word + Talk Mode; este toggle controla gravação manual (push-to-talk, memos).
2 — MUST: defaults seguros em fresh install
| Chave | Default | Notas |
|---|---|---|
media.audio.mic |
OFF | Privacy-by-default; primeiro uso dispara permission prompt do SO |
media.audio.playback_in_background |
OFF | iOS exige entitlement; user opt-in via toggle |
media.audio.record_quality |
voice |
32 kbps Opus equilibra inteligibilidade e tamanho de upload |
media.audio.aec |
ON | Reduz echo de loudspeaker em gravações; OFF útil só pra music/instrument capture |
3 — MUST: privacidade + recording indicator
- NUNCA auto-upload de gravação (user confirma antes do POST)
- NUNCA keep mic open em background além de 5 s pós-leave (a menos
que
playback_in_backgroundativo E o app esteja em sessão de gravação explícita — caso raríssimo, exige consent toast) - Recording indicator obrigatório quando capture ativo: ícone +
timer no app; em mobile, system status bar (mic dot) é OS-managed
- Mic permission é compartilhada com
voice.enabledno SO; estetoggle controla uso, não permissão de SO
4 — MUST: widget surface no koder_kit
| Widget | Função |
|---|---|
KoderAudioPlayer |
Playback de KoderAudioRef ou URL; controls (playpauseseekspeedAirPlay-like) |
KoderAudioRecorder |
Gravação com VU meter + startstoppause; gates media.audio.mic; emite chunks streaming |
KoderAudioPicker |
Sheet "Gravar áudio" / "Escolher arquivo"; retorna KoderAudioRef |
KoderAudioWaveform |
Render de waveform pré-computado (para preview de memo, attachment); lazy |
KoderRingtonePicker |
Helper específico para tom de notificação; restringe a duração ≤ 30 s |
KoderAudioRef: {uri, mime, durationMs, sampleRate, channels,
codec, sizeBytes, koderUserId?, workspaceId?}.
4.1 — MUST: contrato de estado dos controles de transporte (state machine)
Os controles do KoderAudioPlayer (playpauseseek/stop) têm comportamento normativo e testável — não basta existirem, têm que se comportar. Estados: idle → playing ⇄ paused → completed. Transições obrigatórias (Rn = testável):
| # | Ação a partir de | DEVE | NÃO PODE |
|---|---|---|---|
| R1 | playing → Pause |
ir a paused preservando a posição atual (positionMs inalterado) |
reiniciar (positionMs→0), parar, ou recomeçar a tocar |
| R2 | paused → Play/Resume |
continuar de positionMs (retomar de onde parou) |
recomeçar do início |
| R3 | qualquer → Stop | ir a idle e zerar positionMs→0 |
manter posição (isso é Pause, não Stop) |
| R4 | playing/paused → Seek(t) |
mover positionMs→t mantendo o estado (paused continua paused) |
trocar de estado ou auto-play um paused |
| R5 | fim do áudio | ir a completed com positionMs=durationMs; replay parte de 0 |
reiniciar sozinho em loop (a menos que loop-mode ON explícito) |
| R6 | toggle play/pause repetido | ser idempotente por estado (Pause em paused é no-op) |
acumular efeitos / saltar posição |
| R7 | tapcliquedrag na seek bar (a waveform/linha do tempo) | fazer seek pra posição correspondente ao ponto tocado (positionMs = (dx/largura)·durationMs) e mover o playhead/cursor pra lá imediatamente; preserva o estado (R4) |
ignorar o toque / cursor não se mover (bug do Dek: tocar na waveform não movia o cursor) |
Teste obrigatório (headless, no widget): o piso são R1 ("tocar → pausar → posição preservada e estado=paused") e R7 ("tap na waveform → playhead salta pra posição tocada") — os dois bugs reais do Dek (Pause reiniciava o áudio; tap na waveform não movia o cursor). A lógica de R7 é testável headless no widget injetando a largura do seek bar e o dx do toque → assere seek(positionMs) no controller. Este contrato DEVE ser gerado como TDD automaticamente e rodar como gate de release de todo componente que reproduz áudio (ver binding de gerador /k-test — categoria media-control). Testar uma vez no KoderAudioPlayer cobre todos os consumidores (headless-first + reuse-first): por isso o widget compartilhado é mandatório, não opcional.
5 — MUST: format support
Decode (via engines/kodec): Opus, AAC, MP3, FLAC, OGG Vorbis, WAV.
Encode (default): Opus em OGG container. FLAC quando record_quality = lossless.
Sample rate: 16 kHz mono para voice, 48 kHz stereo para music e lossless.
Upload size cap: 100 MB. Exceder → erro <APP>-AUDIO-SIZE-001.
6 — MUST: error surface
| Cenário | ID | Texto pt-BR |
|---|---|---|
| Permissão de mic negada | <APP>-AUDIO-MIC-001 |
"Permita o acesso ao microfone para gravar áudio." |
| Codec de decode não suportado | <APP>-AUDIO-CDC-001 |
"Este áudio usa codec não suportado ($codec)." |
| Tamanho excede 100 MB | <APP>-AUDIO-SIZE-001 |
"Áudio muito grande. Reduza qualidade ou duração." |
| Upload falhou | <APP>-AUDIO-NET-001 |
"Falha ao enviar o áudio. Tente novamente." |
| Hardware indisponível (mic ocupado outro app) | <APP>-AUDIO-HW-001 |
"Microfone em uso por outro app. Feche-o e tente novamente." |
| Background playback sem entitlement (iOS) | <APP>-AUDIO-BG-001 |
"Ative 'Reproduzir em background' em Ajustes para continuar." |
7 — Observability
- Counters:
media.audio.recorded,media.audio.uploaded,media.audio.playback_started,media.audio.upload_error - Latency histograms:
media.audio.encode_ms,media.audio.upload_ms,media.audio.first_sample_ms - Não emitir métricas que vazem conteúdo (transcript, file path,
hash do audio) — apenas counters + latência
7.1 — MUST: segurança de volume em testes que emitem som
Antes de iniciar QUALQUER teste que emita som (playback real em device/ emuladordesktop — categorias audio, media-control, ou qualquer teste de UI e2e que dispare reprodução), o harness de teste DEVE reduzir o volume de saída do dispositivo ao menor índice NÃO-NULO (logo acima do mudo) e restaurar o nível anterior ao fim. Diretiva do Owner (2026-06-07).
- NÃO mutar (volume 0): um teste pode asserir que o som é produzido; mutar
mudaria o comportamento sob teste. Use o menor passo audível.
- Helper canônico (mecanismo):
meta/tests/lib/quiet-audio-for-tests.sh(
quietantes,restoredepois; auto-detecta o alvo). Comandos por plataforma:- Android (emulador/device):
cmd media_session volume --stream 3 --set 1(STREAM_MUSIC → índice 1); restore ao índice salvo.
- Linux (CI/desktop):
wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.01(pipewire)/
pactl set-sink-volume @DEFAULT_SINK@ 1%(pulse) /amixer set Master 1%(alsa).
- Android (emulador/device):
- Wiring obrigatório: o gerador
/k-testda categoriaaudio/media-controlemite o
setUp/pré-passo que chama o helper, e o runner o invoca antes do lote de testes sonoros (verprojects/koder-stack#186). Testes headless puros (sem playback real, ex.:MediaTransportController) emitem 0 som → no-op.
8 — Adoption checklist (per app)
- [ ] Importa
KoderMediaSettingsTile(sub-seção Áudio visível) - [ ] Usa
KoderAudioPlayer/KoderAudioRecorder(nuncaaudioplayersupstream raw) - [ ] Defaults da §2 respeitados
- [ ] Recording indicator implementado (§3)
- [ ] Codec layer via
engines/kodec - [ ] Error map da §6 implementado
- [ ] Upload path respeita
multi-tenancy/contract.kmd - [ ] Se o app também tem voice/wake-word: separar UX (Settings "Voz"
e "Áudio" coexistem; cross-link aceito mas não obrigatório)
Relação com voice/wake-word.kmd
Voice ≠ Audio:
- Voice (
specs/voice/wake-word.kmd): capability "ouvir o usuárioativamente" — wake-word detection (ring buffer pré-wake), Talk Mode (always-listening), STT pipeline, barge-in. Settings group "Voz".
- Audio (este spec): capability "tocar/gravar áudio como mídia" —
memos, attachments, podcast, music, ringtones. Settings group "Áudio".
Mic permission do SO é compartilhada; cada toggle em Settings é uso (não permissão). Se um app usa ambos, manter os 2 sub-grupos em Settings é a UX preferida (clareza de intent: o usuário pode desligar voice mas manter mic pra audio memos).
Non-normative — referências
- Sibling specs:
specs/media/image.kmd,specs/media/video.kmd,specs/media/document.kmd - Voice (sub-domain):
specs/voice/wake-word.kmd(mic gateprecedent + ring buffer privacy)
- Codec engine:
engines/kodec/(canônica) - Implementation surface:
engines/sdk/koder_kit/lib/src/media/