Ready.
Este plano é autocontido e deve ser executado sem depender do histórico da conversa, de outputs de planners, ou de qualquer contexto externo não citado aqui. A implementação deve seguir exatamente a direção definida neste documento: manter o TTS nativo como padrão, adicionar Microsoft Edge Speech como provedor experimental sem chave, e adicionar suporte inicial a provedores com chave por meio de um provedor OpenAI-compatible.
O CodeWalk atualmente lê mensagens em voz alta usando apenas o TTS nativo/plataforma via flutter_tts, encapsulado em ReadAloudService. O usuário quer ampliar o recurso para incluir serviços cloud, começando por opções gratuitas, especialmente Microsoft Edge Speech experimental sem chave, e também preparar suporte a serviços com API key como OpenAI e outros compatíveis.
O sistema atual é limitado porque:
ReadAloudServiceestá acoplado diretamente aFlutterTts.- O caminho atual assume que o motor TTS fala diretamente; provedores cloud retornam áudio binário que precisa ser tocado por um player.
- A configuração de voz é apenas
String? readAloudVoicee ainda usalocale: 'en-US'hardcoded no TTS nativo. - Chaves API não existem hoje e não podem ser armazenadas em JSON/settings comuns.
- O botão de read-aloud não diferencia falhas de engine nativo, falhas de rede, chave ausente, quota, ou provider experimental quebrado.
- A extração de texto para fala usa stripping Markdown básico, suficiente para o TTS nativo atual mas fraco para vozes cloud de maior qualidade/custo.
Ao fim da implementação:
- O TTS nativo atual continua funcionando como padrão e sem regressões.
- A arquitetura de TTS passa a aceitar provedores plugáveis.
- O usuário pode selecionar
Sistema/Nativo,Microsoft Edge Speech (experimental)ouOpenAI-compatibleemSettings > Speech. - O provedor Edge experimental funciona sem chave API, é opt-in, mostra aviso claro de privacidade/instabilidade, e possui fallback manual para nativo quando falhar.
- O provedor OpenAI-compatible aceita
baseUrl,apiKey,model,voice,responseFormat=mp3espeed, usandoPOST /v1/audio/speeche tocando o áudio retornado. - API keys são armazenadas somente em secure storage, nunca em
ExperienceSettings.toJson()ou logs. - Síntese assíncrona e playback remoto respeitam cancelamento: stop, troca de sessão, background e novo envio impedem áudio antigo de começar depois.
- O texto enviado ao TTS é melhor sanitizado para Markdown complexo.
BEHAVIOR.md, testes e, se necessário,ADR.mddocumentam o novo comportamento e os riscos do Edge experimental/cloud TTS.
Projeto: CodeWalk, cliente Flutter mobile/desktop para servidores OpenCode-compatible.
Regras relevantes:
- Mobile e desktop devem continuar suportados; priorizar UX mobile.
BEHAVIOR.mddescreve comportamento implementado e deve ser atualizado após a mudança.- Mudanças de comportamento precisam preservar ADR-023: o cliente não deve alterar semântica oficial de servidor/API/eventos OpenCode.
- Esta feature é client-side e não altera endpoints OpenCode; portanto é compatível com ADR-023 se não enviar dados ao servidor OpenCode nem modificar lifecycle de chat.
- Cloud TTS envia texto do assistente a terceiros; isso exige aviso de privacidade e preferencialmente uma ADR/nota explícita por causa de Edge experimental não oficial.
- Não usar
tool/i18n/generate_arb.dartglobalmente. Ao adicionar strings, seguir workflow seguro de tradução já usado no projeto.
Arquivos atuais relevantes:
-
lib/presentation/services/read_aloud_service.dart- Define
ReadAloudService extends ChangeNotifier. - Usa
FlutterTtsdiretamente. - API atual:
state,activeMessageId,isSpeaking,progress,isAvailable,speak({messageId, text, rate, pitch, voice}),pause(),stop(),stopIfReading(messageId),dispose(),getVoices(),getLanguages(). - Aplica voz com
setVoice({'name': voice, 'locale': 'en-US'}); isso deve ser corrigido.
- Define
-
lib/presentation/widgets/chat_message/chat_message_content.dart- Renderiza o botão de read-aloud para mensagens do assistente.
- Extrai texto de
TextParte remove Markdown básico. - Chama
readAloudService.speak(...)comreadAloudRate,readAloudPitchereadAloudVoice.
-
lib/domain/entities/experience_settings.dart- Campos atuais:
readAloudEnabled,readAloudRate,readAloudPitch,readAloudVoice. - Serializa settings em JSON. Não adicionar secrets/API keys aqui.
- Campos atuais:
-
lib/presentation/providers/settings_provider.dart- Getters/setters e persistência dos campos de read-aloud.
-
lib/presentation/pages/settings/sections/speech_settings_section.dart_buildReadAloudCardhoje mostra enable, speed e pitch.- Não possui voice picker visível apesar de existirem strings/fields.
-
lib/core/di/injection_container.dart- Registra
ReadAloudService.newcomo lazy singleton.
- Registra
-
lib/presentation/services/sound_service.dart- Usa
audioplayerscomUrlSourceeBytesSourcepara sons curtos. - Não reutilizar
SoundServicediretamente para TTS longo; criar player próprio gerenciado porReadAloudServiceou backend de áudio TTS.
- Usa
-
pubspec.yaml- Já possui
flutter_tts: ^4.2.5. - Já possui
audioplayers: ^6.6.0. - Já possui HTTP/Dio usados pelo app.
- Já possui
-
test/unit/services/read_aloud_service_test.dart- Testa o wrapper atual com fake
FlutterTts.
- Testa o wrapper atual com fake
-
test/unit/providers/settings_provider_test.dart- Testa persistência/clamping dos settings.
-
BEHAVIOR.md- Tem seção
Text-to-Speech (TTS)com botão read-aloud, toggle, auto-stop, settings de speed/pitch, disabled state e Markdown stripping.
- Tem seção
Referências externas que moldam a decisão:
- OpenChamber usa Kokoro local para read-aloud recente, não Edge/Microsoft no changelog pesquisado.
- LobeHub/LobeChat possui
EdgeSpeechTTS,MicrosoftTTS,OpenAITTS,OpenAISTT, cache local e configuração de TTS. - Edge TTS usa endpoint não oficial/reverse-engineered do Microsoft Edge Read Aloud, sem SLA e com risco de quebra/rate-limit/ToS.
- OpenAI-compatible TTS usa
POST /v1/audio/speechcommodel,input,voice,response_format,speedeAuthorization: Bearer <key>, retornando áudio binário.
- Manter o TTS nativo como padrão. Novas instalações e usuários existentes continuam em
nativeaté optarem por outro provedor. - Adicionar Edge como experimental e opt-in. O rótulo deve ser
Microsoft Edge Speech (experimental)e deve explicar que é não oficial, pode parar de funcionar, e envia texto à Microsoft. - Adicionar
OpenAI-compatiblecomo primeiro provedor com chave. Ele cobre OpenAI oficial e proxies/serviços compatíveis com/v1/audio/speech. - Não implementar ElevenLabs/Azure oficial nesta primeira entrega. Apenas preparar a arquitetura para adicionar esses provedores depois. Não criar UI morta para eles neste primeiro PR.
- Não armazenar API keys em
ExperienceSettings. Secrets ficam em secure storage por provider. - Separar síntese e playback. O backend nativo fala internamente via
flutter_tts; backends cloud sintetizam áudio e oReadAloudServicetoca bytes comAudioPlayer. - Não implementar streaming progressivo no primeiro passo. A primeira versão baixa/sintetiza o áudio completo e depois toca. Isso reduz risco em Edge/OpenAI-compatible e simplifica cancelamento.
- Progressão de UI simples no v1. Botão no chat continua play/stop. Pausa, seekbar e cache podem ser entregas posteriores.
- Corrigir o contrato de voz. Substituir o uso simples de
readAloudVoicepor metadados suficientes: provider, voice id, locale e model/base URL quando necessário. - Implementar cancelamento por geração. Cada chamada
speak()deve receber um generation id interno; sestop()ou outra chamada invalidar a geração, resultados tardios devem ser descartados.
Arquivos:
lib/domain/entities/experience_settings.dartlib/presentation/providers/settings_provider.darttest/unit/domain/experience_settings_test.dartse existir; caso não exista, criar ou adicionar no conjunto de testes apropriado.test/unit/providers/settings_provider_test.dart
Executar:
- Em
experience_settings.dart, adicionar enum:
enum ReadAloudProvider {
native,
edgeExperimental,
openAiCompatible,
}- Adicionar helpers:
String readAloudProviderKey(ReadAloudProvider provider) {
return switch (provider) {
ReadAloudProvider.native => 'native',
ReadAloudProvider.edgeExperimental => 'edge_experimental',
ReadAloudProvider.openAiCompatible => 'openai_compatible',
};
}
ReadAloudProvider readAloudProviderFromKey(String value) {
return switch (value.trim().toLowerCase()) {
'edge_experimental' || 'edge' || 'microsoft_edge' =>
ReadAloudProvider.edgeExperimental,
'openai_compatible' || 'openai' || 'openai-compatible' =>
ReadAloudProvider.openAiCompatible,
_ => ReadAloudProvider.native,
};
}- Adicionar campos persistidos não secretos a
ExperienceSettings:
final ReadAloudProvider readAloudProvider;
final String? readAloudVoiceId;
final String? readAloudVoiceLocale;
final String? readAloudModel;
final String? readAloudBaseUrl;
final String readAloudResponseFormat;- Defaults obrigatórios:
readAloudProvider: ReadAloudProvider.native,
readAloudVoiceId: null,
readAloudVoiceLocale: null,
readAloudModel: 'gpt-4o-mini-tts',
readAloudBaseUrl: 'https://api.openai.com/v1',
readAloudResponseFormat: 'mp3',-
Manter
readAloudVoicetemporariamente para migração/compatibilidade se remover de uma vez quebrar muitos testes. Se mantido, tratar como alias legado e migrar seu valor parareadAloudVoiceIdquandoreadAloudVoiceIdestiver ausente. -
Atualizar constructor,
copyWith,toJsonefromJson. -
Garantir que
toJson()nunca inclua API key. -
Em
SettingsProvider, adicionar getters/setters:
ReadAloudProvider get readAloudProvider => _settings.readAloudProvider;
String? get readAloudVoiceId => _settings.readAloudVoiceId;
String? get readAloudVoiceLocale => _settings.readAloudVoiceLocale;
String? get readAloudModel => _settings.readAloudModel;
String? get readAloudBaseUrl => _settings.readAloudBaseUrl;
String get readAloudResponseFormat => _settings.readAloudResponseFormat;- Adicionar setters com trim, normalização e persistência:
setReadAloudProvider(ReadAloudProvider value)setReadAloudVoice({String? id, String? locale})setReadAloudModel(String? value)setReadAloudBaseUrl(String? value)setReadAloudResponseFormat(String value)
- Normalizar
baseUrlremovendo/final. Valor vazio volta parahttps://api.openai.com/v1.
Validação:
- Testar default provider
native. - Testar roundtrip JSON para provider/model/baseUrl/voice/locale/format.
- Testar que nenhuma API key aparece em
ExperienceSettings.toJson(). - Testar migração de
readAloudVoicelegado parareadAloudVoiceIdse o alias for mantido.
Arquivos:
- Novo:
lib/core/auth/tts_api_key_storage.dart lib/core/di/injection_container.dart- Teste novo:
test/unit/core/auth/tts_api_key_storage_test.dart
Executar:
- Criar classe baseada no padrão de
OAuthTokenStorage, mas com namespace próprio:
abstract class TtsApiKeyStorageBackend {
Future<String?> read({required String key});
Future<void> write({required String key, required String value});
Future<void> delete({required String key});
}
class TtsApiKeyStorage {
TtsApiKeyStorage({required TtsApiKeyStorageBackend backend})
: _backend = backend;
final TtsApiKeyStorageBackend _backend;
String _key(ReadAloudProvider provider) {
return '${AppConstants.secureStorageNamespace}::tts_api_key::${readAloudProviderKey(provider)}';
}
Future<String?> read(ReadAloudProvider provider) async {
return _backend.read(key: _key(provider));
}
Future<void> write(ReadAloudProvider provider, String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
await delete(provider);
return;
}
await _backend.write(key: _key(provider), value: trimmed);
}
Future<void> delete(ReadAloudProvider provider) async {
await _backend.delete(key: _key(provider));
}
}-
Implementar backend com
FlutterSecureStorage, igual ao padrão existente. -
Registrar em DI.
-
Garantir que exceções não loguem o valor da chave.
Validação:
- Testar write/read/delete.
- Testar trim.
- Testar valor vazio apaga a chave.
- Testar namespace por provider.
Arquivos:
- Novo:
lib/presentation/services/tts/tts_backend.dart - Novo:
lib/presentation/services/tts/native_tts_backend.dart - Atualizar:
lib/presentation/services/read_aloud_service.dart - Atualizar:
lib/core/di/injection_container.dart - Atualizar:
test/unit/services/read_aloud_service_test.dart
Executar:
- Criar modelos:
enum TtsPlaybackMode {
nativeEngine,
generatedAudio,
}
class TtsVoiceOption {
const TtsVoiceOption({
required this.id,
required this.label,
this.locale,
this.providerMetadata = const <String, String>{},
});
final String id;
final String label;
final String? locale;
final Map<String, String> providerMetadata;
}
class TtsSynthesisRequest {
const TtsSynthesisRequest({
required this.text,
required this.rate,
required this.pitch,
this.voiceId,
this.voiceLocale,
this.model,
this.baseUrl,
this.responseFormat = 'mp3',
this.apiKey,
});
final String text;
final double rate;
final double pitch;
final String? voiceId;
final String? voiceLocale;
final String? model;
final String? baseUrl;
final String responseFormat;
final String? apiKey;
}
sealed class TtsSynthesisResult {
const TtsSynthesisResult();
}
class NativeTtsStarted extends TtsSynthesisResult {
const NativeTtsStarted();
}
class GeneratedTtsAudio extends TtsSynthesisResult {
const GeneratedTtsAudio({required this.bytes, required this.mimeType});
final Uint8List bytes;
final String mimeType;
}
abstract class TtsBackend {
ReadAloudProvider get provider;
TtsPlaybackMode get playbackMode;
Future<bool> get isAvailable;
Future<List<TtsVoiceOption>> getVoices();
Future<TtsSynthesisResult> speakOrSynthesize(TtsSynthesisRequest request);
Future<void> stop();
Future<void> pause();
void dispose();
}-
Implement
NativeTtsBackendusingFlutterTts. -
Move all direct
_tts.*calls out ofReadAloudServiceintoNativeTtsBackend. -
Fix voice locale:
if (request.voiceId != null) {
await _tts.setVoice({
'name': request.voiceId!,
if (request.voiceLocale != null) 'locale': request.voiceLocale!,
});
}If voiceLocale is null and backend voice list contains the selected id, derive locale from that list before calling setVoice.
-
Refactor
ReadAloudServiceso it no longer importsflutter_ttsdirectly. -
Keep external
ReadAloudService.speak(...)compatible initially, but extend parameters to include provider/config or make it receive provider/config fromSettingsProvidercall site in a later step. -
Preserve state behavior for native provider.
Validação:
- Existing
ReadAloudServicetests still pass after updating fakes to fakeTtsBackendinstead ofFlutterTts. - Test selected voice passes locale when provided.
- Test native backend handles empty voice locale without hardcoded
en-US.
Arquivos:
lib/presentation/services/read_aloud_service.dart- Testes em
test/unit/services/read_aloud_service_test.dart
Executar:
-
Adicionar
AudioPlayerdedicado aoReadAloudServicepara backendsgeneratedAudio. -
Não reutilizar
SoundService, pois TTS longo precisa de estado, cancelamento, active message id e dispose próprio. -
Em
ReadAloudService, adicionar contador interno:
int _generation = 0;- Em cada
speak():
- Incrementar
_generation. - Guardar
final generation = _generation. - Parar playback anterior.
- Definir
_activeMessageId. - Definir estado de carregamento se for necessário adicionar enum
loading; se não adicionar, manterplayingapenas quando áudio começar e usar uma flag privada_isPreparingpara evitar UI complexa. - Chamar backend.
- Antes de tocar áudio retornado, checar
if (generation != _generation) return;. - Se for
GeneratedTtsAudio, tocar comAudioPlayer.play(BytesSource(result.bytes)). - Ao completar player, checar geração e resetar estado.
- Em
stop():
- Incrementar
_generation. - Chamar
backend.stop(). - Chamar
_audioPlayer.stop(). - Resetar estado para idle e limpar active message id.
- Em
pause():
- Para native, delegar
backend.pause(). - Para generated audio, chamar
_audioPlayer.pause()e estadopausedse existir UI para retomar; se a UI continua play/stop,pause()pode permanecer apenas para testes/compatibilidade.
- Escutar eventos do
AudioPlayer:
- completion → idle.
- error → idle + lastError.
- duration/position se disponíveis → progress por tempo, não por caracteres.
- Redefinir
progress:
- Native: manter null ou melhor esforço se
flutter_ttsfornecer progress real. - Generated audio:
position / durationquando duration > 0. - Nunca usar character count para providers cloud.
Validação:
- Testar backend lento:
stop()antes de retornar áudio impede playback. - Testar duas chamadas
speak()seguidas: apenas a segunda toca. - Testar completion reseta active message.
- Testar erro de player reseta estado e não crasha.
Arquivos:
- Novo:
lib/presentation/services/tts/openai_compatible_tts_backend.dart - Atualizar DI.
- Testes novos em
test/unit/services/openai_compatible_tts_backend_test.dart
Contrato obrigatório:
- Endpoint:
POST {baseUrl}/audio/speech - Se
baseUrldefault forhttps://api.openai.com/v1, endpoint final éhttps://api.openai.com/v1/audio/speech. - Headers:
Authorization: Bearer <apiKey>Content-Type: application/jsonAccept: audio/mpegquando responseFormat formp3.
- Body:
{
"model": "gpt-4o-mini-tts",
"input": "texto a sintetizar",
"voice": "alloy",
"response_format": "mp3",
"speed": 1.0
}Mapeamento:
model:request.model ?? 'gpt-4o-mini-tts'input:request.textvoice:request.voiceId ?? 'alloy'response_format:request.responseFormat, defaultmp3speed: mapearreadAloudRateatual para range OpenAI-compatible.
Decisão de speed:
- O slider atual
readAloudRatevai de 0.0 a 1.0. - Para OpenAI-compatible, converter para
speedde 0.5 a 2.0 inicialmente:
double openAiSpeedFromReadAloudRate(double rate) {
return (0.5 + (rate.clamp(0.0, 1.0) * 1.5)).clamp(0.5, 2.0);
}- Não usar
pitchem OpenAI-compatible; o controle deve aparecer desabilitado/oculto para esse provider na UI.
Erros:
- 401/403 →
invalidApiKey. - 429 →
rateLimitedOrQuota. - 400 →
invalidRequestcom mensagem sanitizada. - 5xx/network →
providerUnavailable. - Nunca incluir texto completo nem API key em logs/erros.
Validação:
- Testar body exato.
- Testar baseUrl com e sem slash final.
- Testar API key ausente retorna erro configurável antes da chamada HTTP.
- Testar status 401/429/500 mapeados.
- Testar bytes retornados viram
GeneratedTtsAudiocom mimeaudio/mpegpara mp3.
Arquivos:
- Novo:
lib/presentation/services/tts/edge_experimental_tts_backend.dart - Testes:
test/unit/services/edge_experimental_tts_backend_test.dart
Decisão:
- Implementar Edge como provider experimental, opt-in, sem chave do usuário.
- Usar endpoint público reverse-engineered do Edge Read Aloud conforme libs conhecidas.
- Encapsular todos os detalhes em backend isolado.
- Nunca tornar Edge default.
Contrato técnico mínimo:
- Voices list endpoint conhecido por bibliotecas Edge TTS:
https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list
- WebSocket synthesis endpoint conhecido por bibliotecas Edge TTS:
wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?...
- O backend deve construir SSML com texto escapado.
- O backend deve receber frames binários de áudio e montar
Uint8Listfinal. - O backend deve ignorar/metadados de word boundary na primeira versão.
Regras de segurança:
- Escapar XML/SSML:
&,<,>,",'. - Limitar tamanho por request; se texto for muito longo, dividir em chunks seguros e concatenar áudio somente se isso for confiável. Na primeira versão, preferir limitar e retornar erro amigável para texto muito longo em Edge, em vez de implementar chunking frágil.
- Definir timeout de conexão e síntese.
- Tratar 403/close inesperado como
edgeUnavailable.
UI/label obrigatório:
- Label:
Microsoft Edge Speech (experimental). - Descrição:
Uses an unofficial Microsoft Edge Read Aloud endpoint. It may stop working, be rate-limited, or fail without notice. Text is sent to Microsoft.
Validação:
- Testar SSML escaping.
- Testar parsing de frames em unidade com frames fake.
- Testar timeout.
- Testar erro quando endpoint fecha antes de áudio.
- Testar que nenhuma credencial é esperada.
Observação importante:
- Se a implementação direta do protocolo Edge em Dart se mostrar grande demais ou instável, a entrega deve parar após a arquitetura + OpenAI-compatible, e Edge deve ser marcado como bloqueado por protocolo/ToS até decisão explícita. Não criar implementação parcial que pareça funcionar mas falhe silenciosamente.
Arquivos:
lib/presentation/services/read_aloud_service.dartlib/presentation/widgets/chat_message/chat_message_content.dartlib/core/di/injection_container.dart
Executar:
-
Registrar backends no DI por provider.
-
ReadAloudServicedeve escolher backend com base em provider passado na chamada ou provider configurado via dependência de settings. A mudança mais explícita e testável é passar config na chamada a partir do widget. -
Atualizar chamada em
chat_message_content.dart:
readAloudService.speak(
messageId: message.id,
text: text,
provider: settingsProvider.readAloudProvider,
rate: settingsProvider.readAloudRate,
pitch: settingsProvider.readAloudPitch,
voiceId: settingsProvider.readAloudVoiceId,
voiceLocale: settingsProvider.readAloudVoiceLocale,
model: settingsProvider.readAloudModel,
baseUrl: settingsProvider.readAloudBaseUrl,
responseFormat: settingsProvider.readAloudResponseFormat,
);-
ReadAloudServicebusca API key emTtsApiKeyStoragequando provider foropenAiCompatible. -
Se provider exigir key e key estiver ausente, não chamar HTTP; setar erro amigável e resetar estado.
-
Se provider cloud falhar, não cair automaticamente para nativo sem informar. A queda automática pode vazar comportamento inesperado; a UI deve mostrar erro e o usuário pode mudar provider para nativo.
Validação:
- Testar seleção de backend por provider.
- Testar provider com key ausente.
- Testar provider nativo continua igual.
Arquivos:
- Novo:
lib/presentation/services/tts/read_aloud_text_extractor.dart - Atualizar:
lib/presentation/widgets/chat_message/chat_message_content.dart - Testes:
test/unit/services/read_aloud_text_extractor_test.dart
Executar:
-
Mover a lógica de
_extractReadableTexte regex para helper testável. -
O helper deve:
- Remover fenced code blocks inteiros ou substituí-los por frase curta
Code block omitted.dependendo de preferência UX. Decisão: omitir code blocks no v1 para reduzir ruído/custo. - Remover inline code preservando texto interno curto somente se tiver menos de 80 chars; caso maior, omitir.
- Converter links
[label](url)paralabel. - Remover imagens
ou usaraltquando existir. - Remover headings markers.
- Remover blockquote markers.
- Remover listas
-,*,1.mantendo conteúdo. - Remover tabelas Markdown ou convertê-las para frases simples. Decisão v1: omitir linhas de tabela quando contiverem múltiplos
|. - Remover HTML tags simples.
- Decodificar/normalizar whitespace.
- Escapar SSML/XML no backend que usar SSML, não no extractor geral.
chat_message_content.dartpassa a chamar o helper.
Validação:
- Testar bold/italic/link/image/headings/blockquote existentes.
- Testar fenced code block.
- Testar tabela.
- Testar lista numerada e bullet.
- Testar texto vazio após stripping não chama TTS.
Arquivos:
lib/presentation/pages/settings/sections/speech_settings_section.dartlib/l10n/app_en.arbe demais ARB conforme workflow seguro.lib/presentation/providers/settings_provider.dart
Executar:
- Em
_buildReadAloudCard, adicionar seletor de provider acima dos sliders:
System / NativeMicrosoft Edge Speech (experimental)OpenAI-compatible
- Para
native:
- Mostrar speed.
- Mostrar pitch.
- Mostrar voice picker quando
ReadAloudService.getVoices()retornar vozes. - Voice picker deve salvar id e locale.
- Para
edgeExperimental:
- Mostrar card de aviso experimental/privacidade.
- Mostrar voice picker Edge quando a lista de vozes carregar.
- Mostrar speed.
- Ocultar ou desabilitar pitch se o backend não mapear pitch com segurança.
- Mostrar botão
Test voice.
- Para
openAiCompatible:
- Mostrar base URL.
- Mostrar API key field com valor mascarado.
- Mostrar botão
Save keyou salvar on submit. - Mostrar model field default
gpt-4o-mini-tts. - Mostrar voice field/dropdown com defaults:
alloy,ash,ballad,coral,echo,fable,nova,onyx,sage,shimmer,verse. - Mostrar response format
mp3inicialmente. Não expor outros formatos no v1. - Mostrar speed.
- Ocultar/desabilitar pitch com explicação
Pitch is not supported by this provider. - Mostrar botão
Test voice.
- Mobile UX:
- Usar cards expansíveis ou seções condicionais; não mostrar todos os campos de todos os providers ao mesmo tempo.
- Evitar layout horizontal largo.
- Erros:
- Mostrar SnackBar ou texto inline para key ausente, key inválida, quota/rate limit, network/provider unavailable.
Validação:
- Widget test ou golden simples em mobile width para provider selector e campos condicionais.
- Testar que trocar provider persiste.
- Testar API key não aparece em settings JSON.
Arquivos:
lib/presentation/services/read_aloud_service.dartlib/presentation/widgets/chat_message/chat_message_content.dartlib/presentation/pages/settings/sections/speech_settings_section.dart- ARB files.
Executar:
- Adicionar ao
ReadAloudService:
String? get lastErrorMessage;
ReadAloudErrorKind? get lastErrorKind;- Definir enum:
enum ReadAloudErrorKind {
unavailable,
missingApiKey,
invalidApiKey,
rateLimitedOrQuota,
network,
providerUnavailable,
cancelled,
unknown,
}-
Não mostrar erro para
cancelled. -
No botão de mensagem, quando
speak()falhar imediatamente oulastErrorKindfor setado, mostrar SnackBar curto. -
Textos obrigatórios:
- Edge warning:
Microsoft Edge Speech is experimental and uses an unofficial Microsoft Edge Read Aloud endpoint. It may stop working or be rate-limited. Message text is sent to Microsoft. - Cloud warning:
Cloud TTS sends the selected assistant message text to the configured provider. - Missing key:
Add an API key in Settings > Speech to use this TTS provider. - Invalid key:
The TTS API key was rejected by the provider. - Quota/rate limit:
The TTS provider reported a quota or rate limit. Try again later or switch providers.
Validação:
- Testar erro sem key.
- Testar 401.
- Testar 429.
- Testar cancelamento sem SnackBar.
Arquivos:
BEHAVIOR.mdADR.mdvia fluxo/adrkeeper se disponível.
Executar:
- Atualizar
BEHAVIOR.mdseção TTS:
- Provider selection.
- Native default.
- Edge experimental opt-in e warning.
- OpenAI-compatible API key provider.
- Secrets stored securely.
- Cloud privacy warning.
- Auto-stop/cancelamento para síntese em andamento.
- Markdown cleanup ampliado.
- Criar ADR ou adicionar nota em ADR existente:
- Título sugerido:
Cloud Text-to-Speech Providers with Experimental Edge Speech. - Decisão: client-only TTS providers; Edge experimental opt-in; OpenAI-compatible with secure storage.
- ADR-023 compatibility: no OpenCode server contract changes.
- Riscos: Edge unofficial, ToS/SLA/rate limits, third-party text egress, API key handling.
- Mitigações: opt-in, warnings, secure storage, no default Edge, native fallback available, no automatic silent fallback.
Validação:
- Docs refletem comportamento implementado, não promessa futura.
Executar validação na ordem:
- Formatação/análise focada:
export PATH="$HOME/flutter/bin:$PATH" && dart format lib test
export PATH="$HOME/flutter/bin:$PATH" && flutter analyze lib/presentation/services/read_aloud_service.dart lib/presentation/services/tts lib/domain/entities/experience_settings.dart lib/presentation/providers/settings_provider.dart lib/presentation/pages/settings/sections/speech_settings_section.dart- Testes unitários focados:
export PATH="$HOME/flutter/bin:$PATH" && flutter test test/unit/services/read_aloud_service_test.dart
export PATH="$HOME/flutter/bin:$PATH" && flutter test test/unit/services/read_aloud_text_extractor_test.dart
export PATH="$HOME/flutter/bin:$PATH" && flutter test test/unit/providers/settings_provider_test.dart- Testes adicionais criados:
export PATH="$HOME/flutter/bin:$PATH" && flutter test test/unit/services/openai_compatible_tts_backend_test.dart
export PATH="$HOME/flutter/bin:$PATH" && flutter test test/unit/services/edge_experimental_tts_backend_test.dart
export PATH="$HOME/flutter/bin:$PATH" && flutter test test/unit/core/auth/tts_api_key_storage_test.dart- Gate completo quando código estiver estável:
make checkNão usar make precommit neste projeto para validação normal; o projeto orienta usar make check e make android separadamente.
Mitigação: API keys só em TtsApiKeyStorage com flutter_secure_storage; nunca adicionar keys a ExperienceSettings.toJson(); sanitizar erros/logs; criar teste que serializa settings e confirma ausência da key.
Mitigação: Edge opt-in, label experimental, warning explícito, timeout, erro claro, nunca default, ADR documentando risco. Não criar fallback automático silencioso; usuário escolhe nativo se quiser estabilidade.
Mitigação: generation id obrigatório; toda resposta tardia é descartada se geração mudou; testes com backend lento e stop/session switch.
Mitigação: progressive disclosure por provider; cards condicionais; campos de API key/model/baseUrl apenas para OpenAI-compatible.
Mitigação: mostrar/desabilitar controles conforme provider. OpenAI-compatible usa speed derivado de rate; pitch oculto/desabilitado. Native mantém pitch.
Mitigação: helper testável; omitir code blocks/tabelas no v1; normalizar listas/links/headings.
Mitigação: v1 toca bytes completos; testar Android e desktop; se houver problemas graves, trocar para just_audio em mudança separada, não misturar sem necessidade.
-
audioplayersconsegue tocarBytesSourceMP3 retornado por OpenAI-compatible em Android e desktop.- Validação: teste manual com áudio curto real ou fake integration.
- Fallback: se falhar, adicionar
just_audioem PR separado e adaptar apenas o playback gerado.
-
Edge protocol pode ser implementado em Dart com escopo aceitável.
- Validação: spike isolado do backend com voz curta.
- Fallback: entregar arquitetura + OpenAI-compatible e marcar Edge bloqueado por protocolo/ToS até decisão explícita.
-
Existing settings migration tolera novos campos nullable/default.
- Validação: unit test com JSON antigo sem novos campos.
- Fallback: adicionar parsing defensivo e defaults explícitos.
-
Cloud TTS deve enviar apenas texto visível do assistente, não tool metadata, hidden state, logs ou API keys.
- Validação: testes do extractor com diferentes message parts.
- Fallback: restringir a
TextPartsanitizado e omitir qualquer parte não reconhecida.
None.
O plano está pronto para implementação. A única condição de parada durante execução é se o protocolo Edge experimental se mostrar instável/grande demais para implementar com segurança; nesse caso, a execução deve entregar a arquitetura + OpenAI-compatible e registrar Edge como bloqueado, sem shippar implementação parcial.
Testes obrigatórios:
-
ExperienceSettings:- defaults antigos continuam válidos;
- provider default native;
- JSON roundtrip provider/model/baseUrl/voice/locale/format;
- API key ausente do JSON.
-
TtsApiKeyStorage:- write/read/delete;
- trim;
- empty deletes;
- namespace por provider.
-
ReadAloudService:- native speak ainda funciona;
- generated audio toca bytes;
- stop cancela síntese lenta;
- segunda chamada invalida primeira;
- completion reseta estado;
- erro reseta estado e define erro amigável;
- cancelamento não mostra erro.
-
OpenAiCompatibleTtsBackend:- request body correto;
- headers corretos;
- baseUrl normalizado;
- status 401/429/500 mapeados;
- bytes retornados viram áudio.
-
EdgeExperimentalTtsBackend:- SSML escaping;
- timeout;
- close/error mapping;
- voices parsing se implementado.
-
ReadAloudTextExtractor:- markdown básico existente;
- fenced code blocks;
- tables;
- lists;
- headings;
- links/images;
- whitespace.
-
UI/settings:
- provider selector aparece;
- campos condicionais por provider;
- key field não mostra segredo;
- warning Edge/cloud aparece;
- pitch oculto/desabilitado para OpenAI-compatible.
Comece por estes arquivos, nesta ordem:
lib/domain/entities/experience_settings.dart— adicionar enum/config não secreto.lib/presentation/providers/settings_provider.dart— getters/setters.lib/core/auth/tts_api_key_storage.dart— secure storage.lib/presentation/services/tts/tts_backend.dart— interface/modelos.lib/presentation/services/tts/native_tts_backend.dart— wrapper deflutter_ttscom locale corrigido.lib/presentation/services/read_aloud_service.dart— controlador com generation cancel e generated-audio playback.lib/presentation/services/tts/openai_compatible_tts_backend.dart— provider com API key.lib/presentation/services/tts/edge_experimental_tts_backend.dart— provider experimental, se spike confirmar viabilidade.lib/presentation/services/tts/read_aloud_text_extractor.dart— helper sanitizador.lib/presentation/widgets/chat_message/chat_message_content.dart— call site e extractor.lib/presentation/pages/settings/sections/speech_settings_section.dart— UI.lib/core/di/injection_container.dart— DI.- Testes.
BEHAVIOR.mde ADR.
Strict sequencing:
- Não implemente Edge antes da abstração e do cancelamento por generation id.
- Não implemente OpenAI-compatible antes do secure storage.
- Não adicione API key a settings JSON em nenhum momento.
- Não exponha Edge como default.
- Não silencie erros cloud.
- Cache local de áudio por hash.
- Streaming progressivo real.
- Seekbar/progress UI avançado.
- ElevenLabs provider específico.
- Azure Speech provider específico.
- Google Cloud TTS.
- Amazon Polly.
- Per-agent TTS settings.
- Sumarização automática de respostas longas com modelo local/remoto.
- Envio de TTS através do servidor OpenCode.
- Mudanças no protocolo/API/event stream OpenCode.