From 7d9a98a01eaad82d2a207b2cd72c5583e36cf5bc Mon Sep 17 00:00:00 2001 From: solsTiCe d'Hiver Date: Mon, 9 Mar 2026 17:43:34 +0100 Subject: [PATCH 1/3] Migrate from sigv4 to aws_client * made with codex (gpt-5.4 medium), not tested yet --- .../plugins/GeneratedPluginRegistrant.java | 26 ++++---- android/local.properties | 4 +- ios/Flutter/ephemeral/flutter_lldb_helper.py | 32 ++++++++++ ios/Flutter/ephemeral/flutter_lldbinit | 5 ++ ios/Runner/GeneratedPluginRegistrant.h | 2 +- ios/Runner/GeneratedPluginRegistrant.m | 2 +- lib/src/amazon/audio/audio_client.dart | 63 ++++++++++++------- lib/src/amazon/audio/audio_handler.dart | 38 ++++------- lib/src/amazon/audio/audio_responses.dart | 2 +- lib/src/amazon/common/config.dart | 7 +++ lib/src/amazon/voices/voice_model.dart | 28 +++++++++ lib/src/amazon/voices/voices_client.dart | 40 +++++------- lib/src/amazon/voices/voices_handler.dart | 30 +++++---- linux/flutter/generated_plugin_registrant.cc | 2 +- linux/flutter/generated_plugin_registrant.h | 2 +- linux/flutter/generated_plugins.cmake | 24 +++---- pubspec.yaml | 4 +- 17 files changed, 186 insertions(+), 125 deletions(-) create mode 100644 ios/Flutter/ephemeral/flutter_lldb_helper.py create mode 100644 ios/Flutter/ephemeral/flutter_lldbinit diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 2e56763..539ab02 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -1,23 +1,19 @@ package io.flutter.plugins; -import io.flutter.plugin.common.PluginRegistry; +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; /** * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. */ +@Keep public final class GeneratedPluginRegistrant { - public static void registerWith(PluginRegistry registry) { - if (alreadyRegisteredWith(registry)) { - return; - } - } - - private static boolean alreadyRegisteredWith(PluginRegistry registry) { - final String key = GeneratedPluginRegistrant.class.getCanonicalName(); - if (registry.hasPlugin(key)) { - return true; - } - registry.registrarFor(key); - return false; - } + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + } } diff --git a/android/local.properties b/android/local.properties index 092754b..c93fbb9 100644 --- a/android/local.properties +++ b/android/local.properties @@ -1,2 +1,2 @@ -sdk.dir=/Users/marko/Library/Android/sdk -flutter.sdk=/Users/marko/.flutter-sdk \ No newline at end of file +sdk.dir=/opt/android-sdk +flutter.sdk=/home/solstice/Applications/flutter \ No newline at end of file diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 0000000..a88caf9 --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 0000000..e3ba6fb --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/ios/Runner/GeneratedPluginRegistrant.h b/ios/Runner/GeneratedPluginRegistrant.h index 83ca4b8..7a89092 100644 --- a/ios/Runner/GeneratedPluginRegistrant.h +++ b/ios/Runner/GeneratedPluginRegistrant.h @@ -12,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN @interface GeneratedPluginRegistrant : NSObject -+ (void)registerWithRegistry:(NSObject *)registry; ++ (void)registerWithRegistry:(NSObject*)registry; @end NS_ASSUME_NONNULL_END diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index b03adda..efe65ec 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -8,7 +8,7 @@ @implementation GeneratedPluginRegistrant -+ (void)registerWithRegistry:(NSObject *)registry { ++ (void)registerWithRegistry:(NSObject*)registry { } @end diff --git a/lib/src/amazon/audio/audio_client.dart b/lib/src/amazon/audio/audio_client.dart index af4cd80..1ad74d1 100644 --- a/lib/src/amazon/audio/audio_client.dart +++ b/lib/src/amazon/audio/audio_client.dart @@ -1,33 +1,48 @@ -import 'package:cloud_text_to_speech/src/common/http/base_client.dart'; -import 'package:http/http.dart' as http; -import 'package:sigv4/sigv4.dart'; +import 'package:aws_client/polly_2016_06_10.dart' as polly; -import '../common/config.dart'; +import 'package:cloud_text_to_speech/src/amazon/common/config.dart'; -class AudioClientAmazon extends BaseClient { - AudioClientAmazon({required http.Client client}) : super(client: client); - - @override - Future send(http.BaseRequest request) { - request.headers['Content-Type'] = "application/json"; - - final sigv4Client = Sigv4Client( - keyId: ConfigAmazon.keyId, - accessKey: ConfigAmazon.accessKey, +class AudioClientAmazon { + Future synthesizeSpeech({ + required String audioFormat, + required String text, + required String voiceCode, + required List supportedEngines, + }) async { + final client = polly.Polly( region: ConfigAmazon.region, - serviceName: 'polly', + credentials: ConfigAmazon.credentials, ); - final headers = sigv4Client.signedHeaders( - request.url.toString(), - method: request.method, - query: request.url.queryParameters, - headers: request.headers, - body: request is http.Request ? request.body : null, - ); + try { + return await client.synthesizeSpeech( + outputFormat: polly.OutputFormat.fromString(audioFormat), + text: text, + textType: polly.TextType.ssml, + voiceId: polly.VoiceId.fromString(voiceCode), + engine: _resolveEngine(supportedEngines), + ); + } finally { + client.close(); + } + } + + polly.Engine? _resolveEngine(List supportedEngines) { + const preferredOrder = [ + 'standard', + 'neural', + 'long-form', + 'generative', + ]; - request.headers.addAll(headers); + for (final engineName in preferredOrder) { + if (supportedEngines.contains(engineName)) { + return polly.Engine.fromString(engineName); + } + } - return client.send(request); + return supportedEngines.isEmpty + ? null + : polly.Engine.fromString(supportedEngines.first); } } diff --git a/lib/src/amazon/audio/audio_handler.dart b/lib/src/amazon/audio/audio_handler.dart index db21846..665a87f 100644 --- a/lib/src/amazon/audio/audio_handler.dart +++ b/lib/src/amazon/audio/audio_handler.dart @@ -1,43 +1,29 @@ -import 'dart:convert'; +import 'dart:typed_data'; import 'package:cloud_text_to_speech/src/amazon/audio/audio_client.dart'; import 'package:cloud_text_to_speech/src/amazon/audio/audio_request_param.dart'; -import 'package:cloud_text_to_speech/src/amazon/audio/audio_response_mapper.dart'; import 'package:cloud_text_to_speech/src/amazon/audio/audio_responses.dart'; -import 'package:cloud_text_to_speech/src/amazon/common/constants.dart'; +import 'package:cloud_text_to_speech/src/amazon/common/aws_exception_mapper.dart'; import 'package:cloud_text_to_speech/src/amazon/ssml/ssml.dart'; -import 'package:http/http.dart' as http; class AudioHandlerAmazon { Future getAudio(AudioRequestParamsAmazon params) async { - final client = http.Client(); - final audioClient = AudioClientAmazon(client: client); - final mapper = AudioResponseMapperAmazon(); + final audioClient = AudioClientAmazon(); try { final ssml = SsmlAmazon(text: params.text, rate: params.rate, pitch: params.pitch); - final Map body = { - 'OutputFormat': params.audioFormat, - 'Text': ssml.sanitizedSsml, - 'TextType': 'ssml', - 'VoiceId': params.voice.code, - // 'Engine': params.voice.engines - }; + final response = await audioClient.synthesizeSpeech( + audioFormat: params.audioFormat, + text: ssml.sanitizedSsml, + voiceCode: params.voice.code, + supportedEngines: params.voice.engines, + ); - final String bodyJson = jsonEncode(body); - - final response = await audioClient.post(Uri.parse(EndpointsAmazon.tts), - body: bodyJson); - final audioResponse = mapper.map(response); - if (audioResponse is AudioSuccessAmazon) { - return audioResponse; - } else { - throw audioResponse; - } - } catch (e) { - rethrow; + return AudioSuccessAmazon(audio: response.audioStream ?? Uint8List(0)); + } catch (error) { + throw mapAudioExceptionAmazon(error); } } } diff --git a/lib/src/amazon/audio/audio_responses.dart b/lib/src/amazon/audio/audio_responses.dart index 96abf53..dec1473 100644 --- a/lib/src/amazon/audio/audio_responses.dart +++ b/lib/src/amazon/audio/audio_responses.dart @@ -37,7 +37,7 @@ class AudioFailedUnsupportedAmazon extends AudioResponseAmazon { "Unsupported Media Type It's possible that the wrong Content-Type was provided. Content-Type should be set to application/ssml+xml."); } -class AudioFailedTooManyRequestAmazon extends BaseResponse { +class AudioFailedTooManyRequestAmazon extends AudioResponseAmazon { AudioFailedTooManyRequestAmazon() : super( code: 429, diff --git a/lib/src/amazon/common/config.dart b/lib/src/amazon/common/config.dart index 44b8055..a1c9eda 100644 --- a/lib/src/amazon/common/config.dart +++ b/lib/src/amazon/common/config.dart @@ -1,3 +1,5 @@ +import 'package:aws_client/polly_2016_06_10.dart' show AwsClientCredentials; + ///Holds all configurations class ConfigAmazon { static late final String _keyId; @@ -35,4 +37,9 @@ class ConfigAmazon { } return ConfigAmazon._region; } + + static AwsClientCredentials get credentials => AwsClientCredentials( + accessKey: keyId, + secretKey: accessKey, + ); } diff --git a/lib/src/amazon/voices/voice_model.dart b/lib/src/amazon/voices/voice_model.dart index c7de34e..1ac9f2d 100644 --- a/lib/src/amazon/voices/voice_model.dart +++ b/lib/src/amazon/voices/voice_model.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:aws_client/polly_2016_06_10.dart' as polly; import 'package:cloud_text_to_speech/src/common/locale/locale_helpers.dart'; import 'package:cloud_text_to_speech/src/common/locale/locale_model.dart'; import 'package:cloud_text_to_speech/src/common/tts/tts_providers.dart'; @@ -45,6 +46,33 @@ class VoiceAmazon extends VoiceUniversal { factory VoiceAmazon.fromJson(Map json) => _$VoiceAmazonFromJson(json); + factory VoiceAmazon.fromPollyVoice(polly.Voice voice) { + final supportedEngines = voice.supportedEngines + ?.map((engine) => engine.value.toLowerCase()) + .toList(growable: false) ?? + const []; + final code = voice.id?.value; + final nativeName = voice.name; + final gender = voice.gender?.value; + final languageCode = voice.languageCode?.value; + + if (code == null || + nativeName == null || + gender == null || + languageCode == null) { + throw Exception('Incomplete Polly voice payload for ${voice.name}'); + } + + return VoiceAmazon( + engines: supportedEngines, + code: code, + name: code, + nativeName: nativeName, + gender: gender, + locale: _toLocale(languageCode), + ); + } + static List _toEngines(List supportedEngines) { if (supportedEngines.isNotEmpty) { return supportedEngines.map((e) => (e as String).toLowerCase()).toList(); diff --git a/lib/src/amazon/voices/voices_client.dart b/lib/src/amazon/voices/voices_client.dart index 67e742e..51a68b8 100644 --- a/lib/src/amazon/voices/voices_client.dart +++ b/lib/src/amazon/voices/voices_client.dart @@ -1,32 +1,26 @@ import 'package:cloud_text_to_speech/src/amazon/common/config.dart'; -import 'package:cloud_text_to_speech/src/common/http/base_client.dart'; -import 'package:http/http.dart' as http; -import 'package:http/retry.dart'; -import 'package:sigv4/sigv4.dart'; +import 'package:aws_client/polly_2016_06_10.dart' as polly; -class VoicesClientAmazon extends BaseClient { - VoicesClientAmazon({required http.Client client}) - : super(client: RetryClient(client)); - - @override - Future send(http.BaseRequest request) { - final sigv4Client = Sigv4Client( - keyId: ConfigAmazon.keyId, - accessKey: ConfigAmazon.accessKey, +class VoicesClientAmazon { + Future> describeVoices() async { + final client = polly.Polly( region: ConfigAmazon.region, - serviceName: 'polly', + credentials: ConfigAmazon.credentials, ); - final headers = sigv4Client.signedHeaders( - request.url.toString(), - method: request.method, - query: request.url.queryParameters, - headers: request.headers, - body: request is http.Request ? request.body : null, - ); + try { + final voices = []; + String? nextToken; - request.headers.addAll(headers); + do { + final response = await client.describeVoices(nextToken: nextToken); + voices.addAll(response.voices ?? const []); + nextToken = response.nextToken; + } while (nextToken != null && nextToken.isNotEmpty); - return client.send(request); + return voices; + } finally { + client.close(); + } } } diff --git a/lib/src/amazon/voices/voices_handler.dart b/lib/src/amazon/voices/voices_handler.dart index 90054ac..21e11c8 100644 --- a/lib/src/amazon/voices/voices_handler.dart +++ b/lib/src/amazon/voices/voices_handler.dart @@ -1,25 +1,23 @@ -import 'package:cloud_text_to_speech/src/amazon/common/constants.dart'; +import 'package:cloud_text_to_speech/src/amazon/common/aws_exception_mapper.dart'; import 'package:cloud_text_to_speech/src/amazon/voices/voices_client.dart'; -import 'package:cloud_text_to_speech/src/amazon/voices/voices_response_mapper.dart'; +import 'package:cloud_text_to_speech/src/amazon/voices/voice_model.dart'; import 'package:cloud_text_to_speech/src/amazon/voices/voices_responses.dart'; -import 'package:http/http.dart' as http; +import 'package:cloud_text_to_speech/src/common/utils/helpers.dart'; class VoicesHandlerAmazon { Future getVoices() async { - final client = http.Client(); - final voiceClient = VoicesClientAmazon(client: client); - + final voiceClient = VoicesClientAmazon(); try { - final mapper = VoicesResponseMapperAmazon(); - final response = await voiceClient.get(Uri.parse(EndpointsAmazon.voices)); - final voicesResponse = mapper.map(response); - if (voicesResponse is VoicesSuccessAmazon) { - return voicesResponse; - } else { - throw voicesResponse; - } - } catch (e) { - rethrow; + var voices = (await voiceClient.describeVoices()) + .map(VoiceAmazon.fromPollyVoice) + .toList(growable: false); + + voices = Helpers.removeVoiceDuplicates(voices); + voices = Helpers.sortVoices(voices); + + return VoicesSuccessAmazon(voices: voices); + } catch (error) { + throw mapVoicesExceptionAmazon(error); } } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3946716..e71a16d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,5 +7,5 @@ #include "generated_plugin_registrant.h" -void fl_register_plugins(FlPluginRegistry *registry) { +void fl_register_plugins(FlPluginRegistry* registry) { } diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h index ab2c472..e0f0a47 100644 --- a/linux/flutter/generated_plugin_registrant.h +++ b/linux/flutter/generated_plugin_registrant.h @@ -10,6 +10,6 @@ #include // Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry *registry); +void fl_register_plugins(FlPluginRegistry* registry); #endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1ac5f4e..2e1de87 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,21 +3,21 @@ # list(APPEND FLUTTER_PLUGIN_LIST - ) +) list(APPEND FLUTTER_FFI_PLUGIN_LIST - ) +) set(PLUGIN_BUNDLED_LIBRARIES) -foreach (plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach (plugin) +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) -foreach (ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach (ffi_plugin) +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/pubspec.yaml b/pubspec.yaml index 2d7a3a7..2921b39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: build_runner: ^2.4.6 locale_names: ^1.1.1 xml: ^6.3.0 - sigv4: ^5.0.0 + aws_client: ^0.7.1 dev_dependencies: flutter_test: @@ -28,4 +28,4 @@ topics: - text-to-speech - google-cloud - azure - - aws \ No newline at end of file + - aws From b370bd16de2c5d7ed9ab30ea6f60cc9e8e4b1cb0 Mon Sep 17 00:00:00 2001 From: solsTiCe d'Hiver Date: Mon, 9 Mar 2026 17:45:51 +0100 Subject: [PATCH 2/3] Add tests * made by codex (gpt-5.4 medium) --- .gitignore | 1 + .../amazon/common/aws_exception_mapper.dart | 91 ++++++++++++ test/cloud_text_to_speech_live_test.dart | 110 +++++++++++++++ test/live_tts_credentials.example.json | 9 ++ test/support/live_tts_credentials.dart | 132 ++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 lib/src/amazon/common/aws_exception_mapper.dart create mode 100644 test/cloud_text_to_speech_live_test.dart create mode 100644 test/live_tts_credentials.example.json create mode 100644 test/support/live_tts_credentials.dart diff --git a/.gitignore b/.gitignore index 96486fd..8b96df2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ migrate_working_dir/ .dart_tool/ .packages build/ +test/live_tts_credentials.local.json diff --git a/lib/src/amazon/common/aws_exception_mapper.dart b/lib/src/amazon/common/aws_exception_mapper.dart new file mode 100644 index 0000000..91a8888 --- /dev/null +++ b/lib/src/amazon/common/aws_exception_mapper.dart @@ -0,0 +1,91 @@ +import 'package:aws_client/polly_2016_06_10.dart' as polly; +import 'package:cloud_text_to_speech/src/amazon/audio/audio_responses.dart'; +import 'package:cloud_text_to_speech/src/amazon/voices/voices_responses.dart'; + +VoicesResponseAmazon mapVoicesExceptionAmazon(Object error) { + if (error is polly.InvalidNextTokenException) { + return VoicesFailedBadRequestAmazon(reasonPhrase: error.message); + } + if (error is polly.ServiceFailureException) { + return VoicesFailedBadGateWayAmazon(); + } + if (_looksUnauthorized(error)) { + return VoicesFailedUnauthorizedAmazon(); + } + if (_looksRateLimited(error)) { + return VoicesFailedTooManyRequestsAmazon(); + } + + return VoicesFailedUnknownErrorAmazon( + code: _statusCodeFrom(error) ?? 500, + reason: error.toString(), + ); +} + +AudioResponseAmazon mapAudioExceptionAmazon(Object error) { + if (error is polly.InvalidLexiconException || + error is polly.InvalidSampleRateException || + error is polly.InvalidSsmlException || + error is polly.LanguageNotSupportedException || + error is polly.LexiconNotFoundException || + error is polly.TextLengthExceededException || + error is polly.EngineNotSupportedException) { + return AudioFailedBadRequestAmazon(reasonPhrase: _messageFrom(error)); + } + if (error is polly.MarksNotSupportedForFormatException || + error is polly.SsmlMarksNotSupportedForTextTypeException) { + return AudioFailedUnsupportedAmazon(); + } + if (error is polly.ServiceFailureException) { + return AudioFailedBadGatewayAmazon(); + } + if (_looksUnauthorized(error)) { + return AudioFailedUnauthorizedAmazon(); + } + if (_looksRateLimited(error)) { + return AudioFailedTooManyRequestAmazon(); + } + if (error is FormatException || error is ArgumentError) { + return AudioFailedUnsupportedAmazon(); + } + + return AudioFailedUnknownErrorAmazon( + code: _statusCodeFrom(error) ?? 500, + reason: error.toString(), + ); +} + +String? _messageFrom(Object error) { + return switch (error) { + polly.InvalidLexiconException(message: final message) => message, + polly.InvalidSampleRateException(message: final message) => message, + polly.InvalidSsmlException(message: final message) => message, + polly.LanguageNotSupportedException(message: final message) => message, + polly.LexiconNotFoundException(message: final message) => message, + polly.TextLengthExceededException(message: final message) => message, + polly.EngineNotSupportedException(message: final message) => message, + _ => null, + }; +} + +bool _looksUnauthorized(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('accessdenied') || + message.contains('access denied') || + message.contains('invalidclienttokenid') || + message.contains('missing authentication token') || + message.contains('signaturedoesnotmatch') || + message.contains('unrecognizedclient') || + message.contains('expiredtoken') || + message.contains('403'); +} + +bool _looksRateLimited(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('throttl') || message.contains('429'); +} + +int? _statusCodeFrom(Object error) { + final match = RegExp(r'\b([1-5]\d\d)\b').firstMatch(error.toString()); + return match == null ? null : int.tryParse(match.group(1)!); +} diff --git a/test/cloud_text_to_speech_live_test.dart b/test/cloud_text_to_speech_live_test.dart new file mode 100644 index 0000000..fcc2ea5 --- /dev/null +++ b/test/cloud_text_to_speech_live_test.dart @@ -0,0 +1,110 @@ +import 'package:cloud_text_to_speech/cloud_text_to_speech.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/live_tts_credentials.dart'; + +void main() { + final credentials = LiveTtsCredentials.load(); + var initialized = false; + + Future ensureInitialized() async { + if (initialized || !credentials.hasAnyProvider) { + return; + } + + TtsUniversal.init( + provider: credentials.initialProvider, + googleParams: credentials.googleParams, + microsoftParams: credentials.microsoftParams, + amazonParams: credentials.amazonParams, + withLogs: false, + ); + + initialized = true; + } + + VoiceUniversal pickVoice(List voices, String provider) { + final providerVoices = + voices.where((voice) => voice.provider == provider).toList(); + + expect(providerVoices, isNotEmpty, + reason: 'Expected at least one voice for provider "$provider".'); + + for (final voice in providerVoices) { + if (voice.locale.code.startsWith('en-')) { + return voice; + } + } + + return providerVoices.first; + } + + Future> fetchVoicesFor(String provider) async { + await ensureInitialized(); + TtsUniversal.setProvider(provider); + final response = await TtsUniversal.getVoices(); + + expect(response.code, 200); + expect(response.voices, isNotEmpty); + + return response.voices; + } + + group('TtsUniversal live integration', () { + for (final provider in TtsProviders.allSingle()) { + test( + '$provider getVoices returns at least one voice', + () async { + final voices = await fetchVoicesFor(provider); + expect( + voices.any((voice) => voice.provider == provider), + isTrue, + ); + }, + skip: credentials.skipReasonForProvider(provider), + ); + + test( + '$provider convertTts returns non-empty audio', + () async { + final voices = await fetchVoicesFor(provider); + final voice = pickVoice(voices, provider); + + final response = await TtsUniversal.convertTts( + TtsParamsUniversal( + voice: voice, + audioFormat: AudioOutputFormatUniversal.mp3_64k, + text: credentials.sampleText, + rate: 'default', + pitch: 'default', + ), + ); + + expect(response.code, 200); + expect(response.audio, isNotEmpty); + }, + skip: credentials.skipReasonForProvider(provider), + ); + } + + test( + 'combine mode aggregates voices from configured providers', + () async { + await ensureInitialized(); + TtsUniversal.setProvider(TtsProviders.combine); + + final response = await TtsUniversal.getVoices(); + final providersInResponse = + response.voices.map((voice) => voice.provider).toSet(); + + expect(response.code, 200); + expect(response.voices, isNotEmpty); + + for (final provider in credentials.configuredProviders) { + expect(providersInResponse.contains(provider), isTrue); + } + }, + skip: credentials.skipReasonForProvider(TtsProviders.combine), + ); + }); +} diff --git a/test/live_tts_credentials.example.json b/test/live_tts_credentials.example.json new file mode 100644 index 0000000..fe8fc1b --- /dev/null +++ b/test/live_tts_credentials.example.json @@ -0,0 +1,9 @@ +{ + "googleApiKey": "", + "microsoftSubscriptionKey": "", + "microsoftRegion": "eastus", + "amazonKeyId": "", + "amazonAccessKey": "", + "amazonRegion": "us-east-1", + "sampleText": "This is a live integration test for cloud_text_to_speech." +} diff --git a/test/support/live_tts_credentials.dart b/test/support/live_tts_credentials.dart new file mode 100644 index 0000000..c9295fa --- /dev/null +++ b/test/support/live_tts_credentials.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cloud_text_to_speech/cloud_text_to_speech.dart'; + +class LiveTtsCredentials { + LiveTtsCredentials({ + this.googleApiKey, + this.microsoftSubscriptionKey, + this.microsoftRegion, + this.amazonKeyId, + this.amazonAccessKey, + this.amazonRegion, + required this.sampleText, + }); + + factory LiveTtsCredentials.load() { + final file = File(_localCredentialsPath); + if (!file.existsSync()) { + return LiveTtsCredentials( + sampleText: _defaultSampleText, + ); + } + + final dynamic decoded = jsonDecode(file.readAsStringSync()); + if (decoded is! Map) { + throw const FormatException( + 'Expected a JSON object in test/live_tts_credentials.local.json.', + ); + } + + return LiveTtsCredentials( + googleApiKey: _readString(decoded, 'googleApiKey'), + microsoftSubscriptionKey: + _readString(decoded, 'microsoftSubscriptionKey'), + microsoftRegion: _readString(decoded, 'microsoftRegion'), + amazonKeyId: _readString(decoded, 'amazonKeyId'), + amazonAccessKey: _readString(decoded, 'amazonAccessKey'), + amazonRegion: _readString(decoded, 'amazonRegion'), + sampleText: + _readString(decoded, 'sampleText') ?? _defaultSampleText, + ); + } + + static const String _localCredentialsPath = + 'test/live_tts_credentials.local.json'; + static const String _defaultSampleText = + 'This is a live integration test for cloud_text_to_speech.'; + + final String? googleApiKey; + final String? microsoftSubscriptionKey; + final String? microsoftRegion; + final String? amazonKeyId; + final String? amazonAccessKey; + final String? amazonRegion; + final String sampleText; + + bool get hasGoogle => + _hasValue(googleApiKey); + + bool get hasMicrosoft => + _hasValue(microsoftSubscriptionKey) && _hasValue(microsoftRegion); + + bool get hasAmazon => + _hasValue(amazonKeyId) && + _hasValue(amazonAccessKey) && + _hasValue(amazonRegion); + + bool get hasAnyProvider => configuredProviders.isNotEmpty; + + List get configuredProviders => [ + if (hasGoogle) TtsProviders.google, + if (hasMicrosoft) TtsProviders.microsoft, + if (hasAmazon) TtsProviders.amazon, + ]; + + String get initialProvider => + configuredProviders.length > 1 + ? TtsProviders.combine + : configuredProviders.first; + + String? skipReasonForProvider(String provider) { + if (!hasAnyProvider) { + return 'Missing $_localCredentialsPath. Copy the example file and add credentials.'; + } + + return switch (provider) { + TtsProviders.google => + hasGoogle ? null : 'Google credentials are missing in $_localCredentialsPath.', + TtsProviders.microsoft => + hasMicrosoft ? null : 'Microsoft credentials are missing in $_localCredentialsPath.', + TtsProviders.amazon => + hasAmazon ? null : 'Amazon credentials are missing in $_localCredentialsPath.', + TtsProviders.combine => configuredProviders.length > 1 + ? null + : 'At least two configured providers are required for combine mode.', + _ => 'Unknown provider: $provider', + }; + } + + InitParamsGoogle? get googleParams => + hasGoogle ? InitParamsGoogle(apiKey: googleApiKey!) : null; + + InitParamsMicrosoft? get microsoftParams => hasMicrosoft + ? InitParamsMicrosoft( + subscriptionKey: microsoftSubscriptionKey!, + region: microsoftRegion!, + ) + : null; + + InitParamsAmazon? get amazonParams => hasAmazon + ? InitParamsAmazon( + keyId: amazonKeyId!, + accessKey: amazonAccessKey!, + region: amazonRegion!, + ) + : null; + + static String? _readString(Map json, String key) { + final value = json[key]; + if (value == null) { + return null; + } + if (value is! String) { + throw FormatException('Expected "$key" to be a string.'); + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + static bool _hasValue(String? value) => value != null && value.isNotEmpty; +} From 54dc4cef24a0b5cb9ae6da1a18fad0990321dfaa Mon Sep 17 00:00:00 2001 From: solsTiCe d'Hiver Date: Mon, 9 Mar 2026 17:47:06 +0100 Subject: [PATCH 3/3] Fix amazon client because of unsupported voice Ids * by using a raw (low-level) AWS protocol instead of polly aws_cleint * made with codex (gpt-5.4 medium) --- lib/src/amazon/audio/audio_client.dart | 40 +++++---- lib/src/amazon/audio/audio_handler.dart | 4 +- .../amazon/common/aws_exception_mapper.dart | 83 +++++++++---------- lib/src/amazon/voices/voice_model.dart | 28 ------- lib/src/amazon/voices/voices_client.dart | 34 ++++++-- lib/src/amazon/voices/voices_handler.dart | 2 +- 6 files changed, 90 insertions(+), 101 deletions(-) diff --git a/lib/src/amazon/audio/audio_client.dart b/lib/src/amazon/audio/audio_client.dart index 1ad74d1..9fc35fa 100644 --- a/lib/src/amazon/audio/audio_client.dart +++ b/lib/src/amazon/audio/audio_client.dart @@ -1,33 +1,45 @@ -import 'package:aws_client/polly_2016_06_10.dart' as polly; +// ignore_for_file: implementation_imports +import 'dart:typed_data'; + +import 'package:aws_client/src/shared/shared.dart' as aws; import 'package:cloud_text_to_speech/src/amazon/common/config.dart'; class AudioClientAmazon { - Future synthesizeSpeech({ + Future synthesizeSpeech({ required String audioFormat, required String text, required String voiceCode, required List supportedEngines, }) async { - final client = polly.Polly( + final protocol = aws.RestJsonProtocol( + service: const aws.ServiceMetadata(endpointPrefix: 'polly'), region: ConfigAmazon.region, credentials: ConfigAmazon.credentials, ); try { - return await client.synthesizeSpeech( - outputFormat: polly.OutputFormat.fromString(audioFormat), - text: text, - textType: polly.TextType.ssml, - voiceId: polly.VoiceId.fromString(voiceCode), - engine: _resolveEngine(supportedEngines), + final response = await protocol.sendRaw( + method: 'POST', + requestUri: '/v1/speech', + exceptionFnMap: const {}, + payload: { + 'OutputFormat': audioFormat, + 'Text': text, + 'TextType': 'ssml', + 'VoiceId': voiceCode, + if (_resolveEngine(supportedEngines) case final engine?) + 'Engine': engine, + }, ); + + return await response.stream.toBytes(); } finally { - client.close(); + protocol.close(); } } - polly.Engine? _resolveEngine(List supportedEngines) { + String? _resolveEngine(List supportedEngines) { const preferredOrder = [ 'standard', 'neural', @@ -37,12 +49,10 @@ class AudioClientAmazon { for (final engineName in preferredOrder) { if (supportedEngines.contains(engineName)) { - return polly.Engine.fromString(engineName); + return engineName; } } - return supportedEngines.isEmpty - ? null - : polly.Engine.fromString(supportedEngines.first); + return supportedEngines.isEmpty ? null : supportedEngines.first; } } diff --git a/lib/src/amazon/audio/audio_handler.dart b/lib/src/amazon/audio/audio_handler.dart index 665a87f..7dd4d8f 100644 --- a/lib/src/amazon/audio/audio_handler.dart +++ b/lib/src/amazon/audio/audio_handler.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:cloud_text_to_speech/src/amazon/audio/audio_client.dart'; import 'package:cloud_text_to_speech/src/amazon/audio/audio_request_param.dart'; import 'package:cloud_text_to_speech/src/amazon/audio/audio_responses.dart'; @@ -21,7 +19,7 @@ class AudioHandlerAmazon { supportedEngines: params.voice.engines, ); - return AudioSuccessAmazon(audio: response.audioStream ?? Uint8List(0)); + return AudioSuccessAmazon(audio: response); } catch (error) { throw mapAudioExceptionAmazon(error); } diff --git a/lib/src/amazon/common/aws_exception_mapper.dart b/lib/src/amazon/common/aws_exception_mapper.dart index 91a8888..67d69da 100644 --- a/lib/src/amazon/common/aws_exception_mapper.dart +++ b/lib/src/amazon/common/aws_exception_mapper.dart @@ -1,19 +1,24 @@ -import 'package:aws_client/polly_2016_06_10.dart' as polly; +// ignore_for_file: implementation_imports + +import 'package:aws_client/src/shared/shared.dart' as aws; import 'package:cloud_text_to_speech/src/amazon/audio/audio_responses.dart'; import 'package:cloud_text_to_speech/src/amazon/voices/voices_responses.dart'; VoicesResponseAmazon mapVoicesExceptionAmazon(Object error) { - if (error is polly.InvalidNextTokenException) { - return VoicesFailedBadRequestAmazon(reasonPhrase: error.message); - } - if (error is polly.ServiceFailureException) { - return VoicesFailedBadGateWayAmazon(); - } - if (_looksUnauthorized(error)) { - return VoicesFailedUnauthorizedAmazon(); - } - if (_looksRateLimited(error)) { - return VoicesFailedTooManyRequestsAmazon(); + if (error is aws.GenericAwsException) { + final code = int.tryParse(error.code); + if (code == 400) { + return VoicesFailedBadRequestAmazon(reasonPhrase: error.message); + } + if (code == 401 || code == 403 || _looksUnauthorized(error)) { + return VoicesFailedUnauthorizedAmazon(); + } + if (code == 429 || _looksRateLimited(error)) { + return VoicesFailedTooManyRequestsAmazon(); + } + if (code == 500 || code == 502) { + return VoicesFailedBadGateWayAmazon(); + } } return VoicesFailedUnknownErrorAmazon( @@ -23,27 +28,26 @@ VoicesResponseAmazon mapVoicesExceptionAmazon(Object error) { } AudioResponseAmazon mapAudioExceptionAmazon(Object error) { - if (error is polly.InvalidLexiconException || - error is polly.InvalidSampleRateException || - error is polly.InvalidSsmlException || - error is polly.LanguageNotSupportedException || - error is polly.LexiconNotFoundException || - error is polly.TextLengthExceededException || - error is polly.EngineNotSupportedException) { - return AudioFailedBadRequestAmazon(reasonPhrase: _messageFrom(error)); - } - if (error is polly.MarksNotSupportedForFormatException || - error is polly.SsmlMarksNotSupportedForTextTypeException) { - return AudioFailedUnsupportedAmazon(); - } - if (error is polly.ServiceFailureException) { - return AudioFailedBadGatewayAmazon(); - } - if (_looksUnauthorized(error)) { - return AudioFailedUnauthorizedAmazon(); - } - if (_looksRateLimited(error)) { - return AudioFailedTooManyRequestAmazon(); + if (error is aws.GenericAwsException) { + final code = int.tryParse(error.code); + final type = (error.type ?? '').toLowerCase(); + if (type.contains('marksnotsupported') || + type.contains('ssmlmarksnotsupportedfortexttype') || + code == 415) { + return AudioFailedUnsupportedAmazon(); + } + if (code == 400) { + return AudioFailedBadRequestAmazon(reasonPhrase: error.message); + } + if (code == 401 || code == 403 || _looksUnauthorized(error)) { + return AudioFailedUnauthorizedAmazon(); + } + if (code == 429 || _looksRateLimited(error)) { + return AudioFailedTooManyRequestAmazon(); + } + if (code == 500 || code == 502) { + return AudioFailedBadGatewayAmazon(); + } } if (error is FormatException || error is ArgumentError) { return AudioFailedUnsupportedAmazon(); @@ -55,19 +59,6 @@ AudioResponseAmazon mapAudioExceptionAmazon(Object error) { ); } -String? _messageFrom(Object error) { - return switch (error) { - polly.InvalidLexiconException(message: final message) => message, - polly.InvalidSampleRateException(message: final message) => message, - polly.InvalidSsmlException(message: final message) => message, - polly.LanguageNotSupportedException(message: final message) => message, - polly.LexiconNotFoundException(message: final message) => message, - polly.TextLengthExceededException(message: final message) => message, - polly.EngineNotSupportedException(message: final message) => message, - _ => null, - }; -} - bool _looksUnauthorized(Object error) { final message = error.toString().toLowerCase(); return message.contains('accessdenied') || diff --git a/lib/src/amazon/voices/voice_model.dart b/lib/src/amazon/voices/voice_model.dart index 1ac9f2d..c7de34e 100644 --- a/lib/src/amazon/voices/voice_model.dart +++ b/lib/src/amazon/voices/voice_model.dart @@ -1,6 +1,5 @@ import 'dart:ui'; -import 'package:aws_client/polly_2016_06_10.dart' as polly; import 'package:cloud_text_to_speech/src/common/locale/locale_helpers.dart'; import 'package:cloud_text_to_speech/src/common/locale/locale_model.dart'; import 'package:cloud_text_to_speech/src/common/tts/tts_providers.dart'; @@ -46,33 +45,6 @@ class VoiceAmazon extends VoiceUniversal { factory VoiceAmazon.fromJson(Map json) => _$VoiceAmazonFromJson(json); - factory VoiceAmazon.fromPollyVoice(polly.Voice voice) { - final supportedEngines = voice.supportedEngines - ?.map((engine) => engine.value.toLowerCase()) - .toList(growable: false) ?? - const []; - final code = voice.id?.value; - final nativeName = voice.name; - final gender = voice.gender?.value; - final languageCode = voice.languageCode?.value; - - if (code == null || - nativeName == null || - gender == null || - languageCode == null) { - throw Exception('Incomplete Polly voice payload for ${voice.name}'); - } - - return VoiceAmazon( - engines: supportedEngines, - code: code, - name: code, - nativeName: nativeName, - gender: gender, - locale: _toLocale(languageCode), - ); - } - static List _toEngines(List supportedEngines) { if (supportedEngines.isNotEmpty) { return supportedEngines.map((e) => (e as String).toLowerCase()).toList(); diff --git a/lib/src/amazon/voices/voices_client.dart b/lib/src/amazon/voices/voices_client.dart index 51a68b8..6e282b6 100644 --- a/lib/src/amazon/voices/voices_client.dart +++ b/lib/src/amazon/voices/voices_client.dart @@ -1,26 +1,44 @@ +// ignore_for_file: implementation_imports + +import 'package:aws_client/src/shared/shared.dart' as aws; import 'package:cloud_text_to_speech/src/amazon/common/config.dart'; -import 'package:aws_client/polly_2016_06_10.dart' as polly; class VoicesClientAmazon { - Future> describeVoices() async { - final client = polly.Polly( + Future>> describeVoices() async { + final protocol = aws.RestJsonProtocol( + service: const aws.ServiceMetadata(endpointPrefix: 'polly'), region: ConfigAmazon.region, credentials: ConfigAmazon.credentials, ); try { - final voices = []; + final voices = >[]; String? nextToken; do { - final response = await client.describeVoices(nextToken: nextToken); - voices.addAll(response.voices ?? const []); - nextToken = response.nextToken; + final response = await protocol.send( + method: 'GET', + requestUri: '/v1/voices', + queryParams: nextToken == null + ? null + : >{ + 'NextToken': [nextToken], + }, + exceptionFnMap: const {}, + ); + + final responseVoices = response['Voices'] as List? ?? const []; + voices.addAll( + responseVoices.map( + (voice) => Map.from(voice as Map), + ), + ); + nextToken = response['NextToken'] as String?; } while (nextToken != null && nextToken.isNotEmpty); return voices; } finally { - client.close(); + protocol.close(); } } } diff --git a/lib/src/amazon/voices/voices_handler.dart b/lib/src/amazon/voices/voices_handler.dart index 21e11c8..f78a55a 100644 --- a/lib/src/amazon/voices/voices_handler.dart +++ b/lib/src/amazon/voices/voices_handler.dart @@ -9,7 +9,7 @@ class VoicesHandlerAmazon { final voiceClient = VoicesClientAmazon(); try { var voices = (await voiceClient.describeVoices()) - .map(VoiceAmazon.fromPollyVoice) + .map(VoiceAmazon.fromJson) .toList(growable: false); voices = Helpers.removeVoiceDuplicates(voices);