From 324dd2fd7f3511e0b783e0397bf54eef81f4c682 Mon Sep 17 00:00:00 2001 From: FlyingWaffle Date: Thu, 27 Mar 2025 19:28:59 -0500 Subject: [PATCH 1/7] aws test --- team_b/yappy/lib/services/client.dart | 231 +++ .../lib/services/event_stream/exceptions.dart | 19 + .../lib/services/event_stream/header.dart | 73 + .../services/event_stream/header_codec.dart | 207 +++ .../lib/services/event_stream/message.dart | 33 + .../services/event_stream/message_signer.dart | 87 ++ .../services/event_stream/stream_codec.dart | 110 ++ team_b/yappy/lib/services/exceptions.dart | 203 +++ team_b/yappy/lib/services/models.dart | 1264 +++++++++++++++++ team_b/yappy/lib/services/protocol.dart | 165 +++ team_b/yappy/lib/services/speech_state.dart | 210 ++- team_b/yappy/lib/services/transcription.dart | 86 ++ team_b/yappy/pubspec.yaml | 9 +- 13 files changed, 2660 insertions(+), 37 deletions(-) create mode 100644 team_b/yappy/lib/services/client.dart create mode 100644 team_b/yappy/lib/services/event_stream/exceptions.dart create mode 100644 team_b/yappy/lib/services/event_stream/header.dart create mode 100644 team_b/yappy/lib/services/event_stream/header_codec.dart create mode 100644 team_b/yappy/lib/services/event_stream/message.dart create mode 100644 team_b/yappy/lib/services/event_stream/message_signer.dart create mode 100644 team_b/yappy/lib/services/event_stream/stream_codec.dart create mode 100644 team_b/yappy/lib/services/exceptions.dart create mode 100644 team_b/yappy/lib/services/models.dart create mode 100644 team_b/yappy/lib/services/protocol.dart create mode 100644 team_b/yappy/lib/services/transcription.dart diff --git a/team_b/yappy/lib/services/client.dart b/team_b/yappy/lib/services/client.dart new file mode 100644 index 00000000..3b677e2f --- /dev/null +++ b/team_b/yappy/lib/services/client.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aws_common/aws_common.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:http2/http2.dart'; + +import 'event_stream/message.dart'; +import 'event_stream/message_signer.dart'; +import 'event_stream/stream_codec.dart'; +import 'exceptions.dart'; +import 'models.dart'; +import 'protocol.dart'; + +/// A client for the Amazon Transcribe Streaming API. +final class TranscribeStreamingClient { + /// Creates a [TranscribeStreamingClient] with a given [region] and + /// [credentialsProvider]. + const TranscribeStreamingClient({ + required this.region, + required this.credentialsProvider, + }); + + /// Specifies the AWS region to use. + final String region; + + /// Specifies the credentials provider to use. + final AWSCredentialsProvider credentialsProvider; + + /// Starts a HTTP/2 stream where audio is streamed to Amazon Transcribe + /// and the [TranscriptEvent]s are streamed to your application. + Future< + ( + StartStreamTranscriptionResponse, + StreamSink, + Stream, + )> startStreamTranscription( + StartStreamTranscriptionRequest request, + ) async { + final (response, audioStreamSink, eventStreamMessages) = + await send(request); + + return ( + StartStreamTranscriptionResponse.fromHeaders(response.headers), + audioStreamSink, + eventStreamMessages.transform(StreamTransformer.fromHandlers( + handleData: + (EventStreamMessage event, EventSink sink) { + sink.add(TranscriptEvent.fromJson(utf8.decode(event.payload))); + }, + )), + ); + } + + /// Starts a HTTP/2 stream where audio is streamed to Amazon Transcribe + /// and the raw [EventStreamMessage]s are streamed to your application. + Future< + ( + AWSHttpResponse, + StreamSink, + Stream, + )> send( + TranscribeStreamingRequest request, + ) async { + final uri = + Uri.https('transcribestreaming.$region.amazonaws.com', request.path); + + final socket = await SecureSocket.connect( + uri.host, + 443, + supportedProtocols: ['h2'], + ); + + final connection = ClientTransportConnection.viaSocket(socket); + + final awsHttpRequest = AWSHttpRequest( + method: AWSHttpMethod.post, + uri: uri, + headers: { + AWSHeaders.target: request.target, + AWSHeaders.contentType: 'application/vnd.amazon.eventstream', + }, + body: null, + ); + + final credentialScope = AWSCredentialScope( + region: region, + service: AWSService.transcribeStreaming, + ); + + final signer = AWSSigV4Signer( + credentialsProvider: credentialsProvider, + ); + + final signedRequest = await signer.sign( + awsHttpRequest, + credentialScope: credentialScope, + ); + + final messageSigner = await EventStreamMessageSigner.create( + region: region, + signer: signer, + priorSignature: signedRequest.signature, + ); + + final headers = signedRequest.headers..addAll(request.toHeaders()); + + final clientTransportStream = connection.makeRequest([ + Header.ascii(':method', 'POST'), + Header.ascii(':path', signedRequest.path), + Header.ascii(':scheme', 'https'), + ...headers + .map((String key, String value) => + MapEntry(key, Header.ascii(key, value))) + .values, + ]); + + final responseHeadersCompleter = Completer>(); + final responseBodyCompleter = Completer>(); + late final bool hasResponseBody; + + final eventStreamMessageController = StreamController(); + final audioStreamController = StreamController(); + StreamSubscription? audioStreamSubscription; + + final incomingMessagesSubscription = + clientTransportStream.incomingMessages.listen( + (event) { + try { + if (event is HeadersStreamMessage) { + if (responseHeadersCompleter.isCompleted) { + throw const ProtocolException( + 'HeadersStreamMessage received after response headers were already completed.', + ); + } + + final headers = CaseInsensitiveMap({}); + headers.addEntries(event.headers.map((header) => + MapEntry(utf8.decode(header.name), utf8.decode(header.value)))); + hasResponseBody = int.parse(headers['content-length'] ?? '0') > 0; + responseHeadersCompleter.complete(headers); + } else if (event is DataStreamMessage) { + if (!responseHeadersCompleter.isCompleted) { + throw const ProtocolException( + 'DataStreamMessage received before response headers were completed.', + ); + } + + if (hasResponseBody && !responseBodyCompleter.isCompleted) { + responseBodyCompleter.complete(event.bytes); + return; + } + + final eventStreamMessage = + EventStreamCodec.decode(Uint8List.fromList(event.bytes)); + + final messageType = + eventStreamMessage.getHeaderValue(':message-type'); + + if (messageType == 'event') { + eventStreamMessageController.sink.add(eventStreamMessage); + } else if (messageType == 'exception') { + throw TranscribeStreamingServiceException.createFromResponse( + eventStreamMessage.getHeaderValue(':exception-type') ?? '', + eventStreamMessage.getHeaderValue(':content-type') ?? '', + eventStreamMessage.payload, + ); + } else { + throw UnexpectedMessageTypeException( + messageType, + eventStreamMessage, + ); + } + } + } catch (e) { + eventStreamMessageController.sink.addError(e); + } + }, + onDone: () async { + await audioStreamSubscription?.cancel(); + await audioStreamController.close(); + await eventStreamMessageController.close(); + await clientTransportStream.outgoingMessages.close(); + await connection.finish(); + }, + ); + + final responseHeaders = await responseHeadersCompleter.future; + final statusCode = int.parse(responseHeaders[':status']!); + List? responseBody; + + if (hasResponseBody) { + responseBody = await responseBodyCompleter.future; + } + + if (statusCode >= 400) { + await incomingMessagesSubscription.cancel(); + await clientTransportStream.outgoingMessages.close(); + await connection.finish(); + + throw TranscribeStreamingServiceException.createFromResponse( + (responseHeaders['x-amzn-errortype'] ?? statusCode.toString()) + .split(':') + .first, + responseHeaders['content-type'] ?? '', + responseBody, + ); + } + + audioStreamSubscription = audioStreamController.stream + .transform(AudioDataChunker(request.chunkSize)) + .transform(const AudioEventEncoder()) + .transform(const EventStreamEncoder()) + .transform(AudioMessageSigner(messageSigner)) + .transform(const EventStreamEncoder()) + .transform(const DataStreamMessageEncoder()) + .listen(clientTransportStream.outgoingMessages.add); + + return ( + AWSHttpResponse( + statusCode: statusCode, + headers: responseHeaders, + body: responseBody, + ), + audioStreamController.sink, + eventStreamMessageController.stream, + ); + } +} diff --git a/team_b/yappy/lib/services/event_stream/exceptions.dart b/team_b/yappy/lib/services/event_stream/exceptions.dart new file mode 100644 index 00000000..3c3a8205 --- /dev/null +++ b/team_b/yappy/lib/services/event_stream/exceptions.dart @@ -0,0 +1,19 @@ +import '../exceptions.dart'; + +/// The event stream message coding/decoding exception. +abstract class EventStreamException extends TranscribeStreamingException { + /// Creates a [EventStreamException] with a given message. + const EventStreamException(super.message); +} + +/// The event stream message cannot be decoded. +final class EventStreamDecodeException extends EventStreamException { + /// Creates a [EventStreamDecodeException] with a given message. + const EventStreamDecodeException(super.message); +} + +/// The event stream message header cannot be decoded. +final class EventStreamHeaderDecodeException extends EventStreamException { + /// Creates a [EventStreamHeaderDecodeException] with a given message. + const EventStreamHeaderDecodeException(super.message); +} diff --git a/team_b/yappy/lib/services/event_stream/header.dart b/team_b/yappy/lib/services/event_stream/header.dart new file mode 100644 index 00000000..b35cb38e --- /dev/null +++ b/team_b/yappy/lib/services/event_stream/header.dart @@ -0,0 +1,73 @@ +import 'dart:typed_data'; + +/// Base class for all Event Stream message headers. +sealed class EventStreamHeader { + /// Creates an [EventStreamHeader] with a given name and a value. + const EventStreamHeader(this.name, this.value); + + /// The header name. + final String name; + + /// The header value. + final T value; + + /// A string representation of this object. + @override + String toString() => '$runtimeType(name: $name, value: $value)'; +} + +/// [EventStreamHeader] with a boolean value. +final class EventStreamBoolHeader extends EventStreamHeader { + /// Creates an [EventStreamBoolHeader] with a given name and a boolean value. + const EventStreamBoolHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a byte value. +final class EventStreamByteHeader extends EventStreamHeader { + /// Creates an [EventStreamByteHeader] with a given name and a byte value. + const EventStreamByteHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a short value. +final class EventStreamShortHeader extends EventStreamHeader { + /// Creates an [EventStreamShortHeader] with a given name and a short value. + const EventStreamShortHeader(super.name, super.value); +} + +/// [EventStreamHeader] with an integer value. +final class EventStreamIntegerHeader extends EventStreamHeader { + /// Creates an [EventStreamIntegerHeader] with a given name and an integer + const EventStreamIntegerHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a long value. +final class EventStreamLongHeader extends EventStreamHeader { + /// Creates an [EventStreamLongHeader] with a given name and a long value. + const EventStreamLongHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a byte array value. +final class EventStreamByteArrayHeader extends EventStreamHeader { + /// Creates an [EventStreamByteArrayHeader] with a given name and a byte array + /// value. + const EventStreamByteArrayHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a string value. +final class EventStreamStringHeader extends EventStreamHeader { + /// Creates an [EventStreamStringHeader] with a given name and a string value. + const EventStreamStringHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a timestamp value. +final class EventStreamTimestampHeader extends EventStreamHeader { + /// Creates an [EventStreamTimestampHeader] with a given name and a timestamp + /// value. + const EventStreamTimestampHeader(super.name, super.value); +} + +/// [EventStreamHeader] with a UUID value. +final class EventStreamUuidHeader extends EventStreamHeader { + /// Creates an [EventStreamUuidHeader] with a given name and a UUID value. + const EventStreamUuidHeader(super.name, super.value); +} diff --git a/team_b/yappy/lib/services/event_stream/header_codec.dart b/team_b/yappy/lib/services/event_stream/header_codec.dart new file mode 100644 index 00000000..e002017d --- /dev/null +++ b/team_b/yappy/lib/services/event_stream/header_codec.dart @@ -0,0 +1,207 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:uuid/uuid.dart'; + +// import 'exceptions.dart'; +import 'header.dart'; + +/// Possible types of [EventStreamHeader]. +enum EventStreamHeaderType { + boolTrue, + boolFalse, + byte, + short, + integer, + long, + byteArray, + string, + timestamp, + uuid, +} + +/// Codec for encoding and decoding [EventStreamHeader]. +final class EventStreamHeaderCodec { + /// Decodes [Uint8List] into [EventStreamHeader]. + static List decode(Uint8List bytes) { + final out = []; + int position = 0; + final headers = ByteData.view(bytes.buffer); + + while (position < headers.lengthInBytes) { + final nameLength = headers.getUint8(position++); + final name = utf8.decode(bytes.sublist(position, position + nameLength)); + position += nameLength; + + final type = EventStreamHeaderType.values[headers.getUint8(position++)]; + late EventStreamHeader header; + + switch (type) { + case EventStreamHeaderType.boolTrue: + header = EventStreamBoolHeader(name, true); + break; + case EventStreamHeaderType.boolFalse: + header = EventStreamBoolHeader(name, false); + break; + case EventStreamHeaderType.byte: + header = EventStreamByteHeader(name, headers.getInt8(position)); + position++; + break; + case EventStreamHeaderType.short: + header = EventStreamShortHeader( + name, + headers.getInt16(position, Endian.big), + ); + position += 2; + break; + case EventStreamHeaderType.integer: + header = EventStreamIntegerHeader( + name, + headers.getInt32(position, Endian.big), + ); + position += 4; + break; + case EventStreamHeaderType.long: + header = EventStreamLongHeader( + name, + headers.getInt64(position, Endian.big), + ); + position += 8; + break; + case EventStreamHeaderType.byteArray: + final binaryLength = headers.getUint16(position, Endian.big); + position += 2; + header = EventStreamByteArrayHeader( + name, + bytes.sublist(position, position + binaryLength), + ); + position += binaryLength; + break; + case EventStreamHeaderType.string: + final stringLength = headers.getUint16(position, Endian.big); + position += 2; + header = EventStreamStringHeader( + name, + utf8.decode(bytes.sublist(position, position + stringLength)), + ); + position += stringLength; + break; + case EventStreamHeaderType.timestamp: + header = EventStreamTimestampHeader( + name, + DateTime.fromMillisecondsSinceEpoch( + headers.getInt64(position, Endian.big), + ), + ); + position += 8; + break; + case EventStreamHeaderType.uuid: + header = EventStreamUuidHeader( + name, + Uuid.unparse(bytes.sublist(position, position + 16)), + ); + position += 16; + break; + // default: + // throw const EventStreamHeaderDecodeException( + // 'Unrecognized header type', + // ); + } + + out.add(header); + } + + return out; + } + + /// Encodes a list of [EventStreamHeader]s into [Uint8List]. + static Uint8List encodeHeaders(List headers) { + final bytes = []; + + for (final header in headers) { + bytes.addAll(EventStreamHeaderCodec.encodeHeader(header)); + } + + return Uint8List.fromList(bytes); + } + + /// Encodes a single [EventStreamHeader] into [Uint8List]. + static Uint8List encodeHeader(EventStreamHeader header) { + final bytes = []; + + final nameBytes = utf8.encode(header.name); + bytes.add(nameBytes.length); + bytes.addAll(nameBytes); + + switch (header) { + case EventStreamBoolHeader(:var value): + bytes.add( + value + ? EventStreamHeaderType.boolTrue.index + : EventStreamHeaderType.boolFalse.index, + ); + break; + case EventStreamByteHeader(:var value): + bytes.add(EventStreamHeaderType.byte.index); + bytes.add(value); + break; + case EventStreamShortHeader(:var value): + bytes.add(EventStreamHeaderType.short.index); + bytes.addAll(_encodeInt16(value)); + break; + case EventStreamIntegerHeader(:var value): + bytes.add(EventStreamHeaderType.integer.index); + bytes.addAll(_encodeInt32(value)); + break; + case EventStreamLongHeader(:var value): + bytes.add(EventStreamHeaderType.long.index); + bytes.addAll(_encodeInt64(value)); + break; + case EventStreamByteArrayHeader(:var value): + bytes.add(EventStreamHeaderType.byteArray.index); + bytes.addAll(_encodeUint16(value.lengthInBytes)); + bytes.addAll(value); + break; + case EventStreamStringHeader(:var value): + bytes.add(EventStreamHeaderType.string.index); + final valueBytes = utf8.encode(value); + bytes.addAll(_encodeUint16(valueBytes.length)); + bytes.addAll(valueBytes); + break; + case EventStreamTimestampHeader(:var value): + bytes.add(EventStreamHeaderType.timestamp.index); + bytes.addAll(_encodeInt64(value.millisecondsSinceEpoch)); + break; + case EventStreamUuidHeader(:var value): + bytes.add(EventStreamHeaderType.uuid.index); + bytes.addAll(Uuid.parse(value)); + break; + } + + return Uint8List.fromList(bytes); + } + + static Uint8List _encodeUint16(int value) { + final bytes = Uint8List(2); + ByteData.view(bytes.buffer).setUint16(0, value, Endian.big); + return bytes; + } + + static Uint8List _encodeInt16(int value) { + final bytes = Uint8List(2); + ByteData.view(bytes.buffer).setInt16(0, value, Endian.big); + return bytes; + } + + static Uint8List _encodeInt32(int value) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setInt32(0, value, Endian.big); + return bytes; + } + + static Uint8List _encodeInt64(int value) { + final bytes = Uint8List(8); + ByteData.view(bytes.buffer).setInt64(0, value, Endian.big); + return bytes; + } +} diff --git a/team_b/yappy/lib/services/event_stream/message.dart b/team_b/yappy/lib/services/event_stream/message.dart new file mode 100644 index 00000000..2004a624 --- /dev/null +++ b/team_b/yappy/lib/services/event_stream/message.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import 'header.dart'; + +/// An Event Stream message. +final class EventStreamMessage { + /// Creates an [EventStreamMessage] with a given headers and payload. + const EventStreamMessage({ + required this.headers, + required this.payload, + }); + + /// The message headers. + final List headers; + + /// The message payload. + final Uint8List payload; + + /// Returns the value of the header with the specified [name] or `null` + /// if there is no such header. + dynamic getHeaderValue(String name) { + try { + return headers.firstWhere((header) => header.name == name).value; + } catch (_) { + return null; + } + } + + /// A string representation of this object. + @override + String toString() => + 'EventStreamMessage(headers: $headers, payload: $payload)'; +} diff --git a/team_b/yappy/lib/services/event_stream/message_signer.dart b/team_b/yappy/lib/services/event_stream/message_signer.dart new file mode 100644 index 00000000..94135a24 --- /dev/null +++ b/team_b/yappy/lib/services/event_stream/message_signer.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'package:aws_common/aws_common.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; + +import 'header.dart'; +import 'header_codec.dart'; +import 'message.dart'; + +/// Signs [Uint8List] payload into [EventStreamMessage]. +final class EventStreamMessageSigner { + EventStreamMessageSigner._create( + this._region, + this._signer, + this._credentials, + this._priorSignature, + ); + + final String _region; + final AWSSigV4Signer _signer; + final AWSCredentials _credentials; + + String _priorSignature; + + /// Creates a new [EventStreamMessageSigner]. + static Future create({ + required String region, + required AWSSigV4Signer signer, + required String priorSignature, + }) async { + final credentials = await signer.credentialsProvider.retrieve(); + + return EventStreamMessageSigner._create( + region, + signer, + credentials, + priorSignature, + ); + } + + /// Signs [Uint8List] payload into [EventStreamMessage]. + EventStreamMessage sign(Uint8List payload) { + final credentialScope = AWSCredentialScope( + region: _region, + service: AWSService.transcribeStreaming, + ); + + final signingKey = _signer.algorithm.deriveSigningKey( + _credentials, + credentialScope, + ); + + final List messageHeaders = [ + EventStreamTimestampHeader(':date', credentialScope.dateTime.dateTime), + ]; + + final nonSignatureHeaders = + EventStreamHeaderCodec.encodeHeaders(messageHeaders); + + final sb = StringBuffer() + ..writeln('${_signer.algorithm}-PAYLOAD') + ..writeln(credentialScope.dateTime) + ..writeln(credentialScope) + ..writeln(_priorSignature) + ..writeln(_hexHash(nonSignatureHeaders)) + ..write(_hexHash(payload)); + final stringToSign = sb.toString(); + + final signature = _signer.algorithm.sign(stringToSign, signingKey); + + messageHeaders.add( + EventStreamByteArrayHeader( + ':chunk-signature', + Uint8List.fromList(hex.decode(signature)), + ), + ); + + _priorSignature = signature; + + return EventStreamMessage(headers: messageHeaders, payload: payload); + } + + String _hexHash(Uint8List payload) => + hex.encode(sha256.convert(payload).bytes); +} diff --git a/team_b/yappy/lib/services/event_stream/stream_codec.dart b/team_b/yappy/lib/services/event_stream/stream_codec.dart new file mode 100644 index 00000000..9543c5bc --- /dev/null +++ b/team_b/yappy/lib/services/event_stream/stream_codec.dart @@ -0,0 +1,110 @@ +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; + +import 'exceptions.dart'; +import 'header_codec.dart'; +import 'message.dart'; + +/// Codec for encoding and decoding [EventStreamMessage]. +final class EventStreamCodec { + static const preludeMemberLength = 4; + static const preludeLength = preludeMemberLength * 2; + static const checksumLength = 4; + static const minimumMessageLength = preludeLength + checksumLength * 2; + + /// Decodes [Uint8List] into [EventStreamMessage]. + static EventStreamMessage decode(Uint8List message) { + final byteLength = message.lengthInBytes; + + if (byteLength < minimumMessageLength) { + throw const EventStreamDecodeException( + 'Provided message is too short to accommodate event stream ' + 'message overhead', + ); + } + + final view = ByteData.view(message.buffer); + + final messageLength = view.getUint32(0, Endian.big); + + if (byteLength != messageLength) { + throw const EventStreamDecodeException( + 'Reported message length does not match received message length', + ); + } + + final headerLength = view.getUint32(preludeMemberLength, Endian.big); + final expectedPreludeChecksum = view.getUint32(preludeLength, Endian.big); + final expectedMessageChecksum = + view.getUint32(byteLength - checksumLength, Endian.big); + + final preludeChecksum = getCrc32(message.sublist(0, preludeLength)); + if (expectedPreludeChecksum != preludeChecksum) { + throw EventStreamDecodeException( + 'The prelude checksum specified in the message ' + '($expectedPreludeChecksum) does not match the calculated CRC32 ' + 'checksum ($preludeChecksum)', + ); + } + + final messageChecksum = + getCrc32(message.sublist(0, byteLength - checksumLength)); + if (expectedMessageChecksum != messageChecksum) { + throw EventStreamDecodeException( + 'The message checksum ($messageChecksum) did not match ' + 'the expected value of $expectedMessageChecksum', + ); + } + + final encodedHeaders = message.sublist(preludeLength + checksumLength, + preludeLength + checksumLength + headerLength); + final payload = message.sublist( + preludeLength + checksumLength + headerLength, + byteLength - checksumLength, + ); + + return EventStreamMessage( + headers: EventStreamHeaderCodec.decode(encodedHeaders), + payload: payload, + ); + } + + /// Encodes [EventStreamMessage] into [Uint8List]. + static Uint8List encode(EventStreamMessage message) { + final headers = EventStreamHeaderCodec.encodeHeaders(message.headers); + final length = headers.lengthInBytes + + message.payload.lengthInBytes + + minimumMessageLength; + + final out = Uint8List(length); + final view = + ByteData.view(out.buffer, out.offsetInBytes, out.lengthInBytes); + + view.setUint32(0, length, Endian.big); + view.setUint32(preludeMemberLength, headers.lengthInBytes, Endian.big); + view.setUint32( + preludeLength, + getCrc32(out.sublist(0, preludeLength)), + Endian.big, + ); + out.setRange( + preludeLength + checksumLength, + preludeLength + checksumLength + headers.lengthInBytes, + headers, + ); + out.setRange( + preludeLength + checksumLength + headers.lengthInBytes, + length - checksumLength, + message.payload, + ); + + view.setUint32( + length - checksumLength, + getCrc32(out.sublist(0, length - checksumLength)), + Endian.big, + ); + + return out; + } +} diff --git a/team_b/yappy/lib/services/exceptions.dart b/team_b/yappy/lib/services/exceptions.dart new file mode 100644 index 00000000..ddd55dd7 --- /dev/null +++ b/team_b/yappy/lib/services/exceptions.dart @@ -0,0 +1,203 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'event_stream/message.dart'; + +/// Base class for all exceptions generated by the library. +abstract class TranscribeStreamingException implements Exception { + /// Creates a [TranscribeStreamingException] with the given message. + const TranscribeStreamingException(this.message); + + /// The exception message. + final String message; + + /// A string representation of this object. + @override + String toString() => '$runtimeType: $message'; +} + +/// Base class for all exceptions generated by AWS Transcribe Streaming service. +abstract class TranscribeStreamingServiceException + extends TranscribeStreamingException { + /// Creates a [TranscribeStreamingServiceException] with the given exception + /// name and message. + const TranscribeStreamingServiceException(this.exceptionName, super.message); + + /// The exception name. + final String exceptionName; + + /// Creates an exception from AWS Transcribe Streaming service response. + factory TranscribeStreamingServiceException.createFromResponse( + String exceptionType, + String contentType, + List? body, + ) => + _createTranscribeStreamingServiceException( + exceptionType, + contentType, + body, + ); + + /// A string representation of this object. + @override + String toString() => '$exceptionName: $message'; +} + +/// Base class for all server-side exceptions generated by AWS Transcribe +/// Streaming service. +abstract class TranscribeStreamingServiceServerException + extends TranscribeStreamingServiceException { + /// Creates a [TranscribeStreamingServiceServerException] with the given + /// exception name and message. + const TranscribeStreamingServiceServerException(super.name, super.message); +} + +/// Base class for all client-side exceptions generated by AWS Transcribe +/// Streaming service. +abstract class TranscribeStreamingServiceClientException + extends TranscribeStreamingServiceException { + /// Creates a [TranscribeStreamingServiceClientException] with the given + /// exception name and message. + const TranscribeStreamingServiceClientException(super.name, super.message); +} + +/// One or more arguments of [StartStreamTranscriptionRequest] were invalid. +/// +/// For example, `mediaEncoding` or `languageCode` used not valid values. +/// Check the specified parameters and try your request again. +final class BadRequestException + extends TranscribeStreamingServiceClientException { + /// Creates a [BadRequestException] with the given message. + const BadRequestException(String message) : super(name, message); + + /// The exception name. + static const name = 'BadRequestException'; +} + +/// The provided request signature is not valid. +final class InvalidSignatureException + extends TranscribeStreamingServiceClientException { + /// Creates a [InvalidSignatureException] with the given message. + const InvalidSignatureException(String message) : super(name, message); + + /// The exception name. + static const name = 'InvalidSignatureException'; +} + +/// A new stream started with the same session ID. +/// +/// The current stream has been terminated. +final class ConflictException + extends TranscribeStreamingServiceClientException { + /// Creates a [ConflictException] with the given message. + const ConflictException(String message) : super(name, message); + + /// The exception name. + static const name = 'ConflictException'; +} + +/// A problem occurred while processing the audio. +/// +/// Amazon Transcribe terminated processing. +final class InternalFailureException + extends TranscribeStreamingServiceServerException { + /// Creates a [InternalFailureException] with the given message. + const InternalFailureException(String message) : super(name, message); + + /// The exception name. + static const name = 'InternalFailureException'; +} + +/// Your client has exceeded one of the Amazon Transcribe limits. +/// +/// This is typically the audio length limit. +/// Break your audio stream into smaller chunks and try your request again. +final class LimitExceededException + extends TranscribeStreamingServiceClientException { + /// Creates a [LimitExceededException] with the given message. + const LimitExceededException(String message) : super(name, message); + + /// The exception name. + static const name = 'LimitExceededException'; +} + +/// The service is currently unavailable. +/// +/// Try your request later. +final class ServiceUnavailableException + extends TranscribeStreamingServiceServerException { + /// Creates a [ServiceUnavailableException] with the given message. + const ServiceUnavailableException(String message) : super(name, message); + + /// The exception name. + static const name = 'ServiceUnavailableException'; +} + +/// The received exception has unexpected type. +final class UnexpectedExceptionTypeException + extends TranscribeStreamingServiceException { + /// Creates a [UnexpectedExceptionTypeException] with the given type + /// and message. + const UnexpectedExceptionTypeException(this.exceptionType, String message) + : super('UnexpectedExceptionTypeException', '$exceptionType: $message'); + + /// The exception type. + final String exceptionType; +} + +/// The received message has unexpected type. +final class UnexpectedMessageTypeException + extends TranscribeStreamingException { + /// Creates a [UnexpectedMessageTypeException] with the given message type + /// and Event Stream message. + const UnexpectedMessageTypeException( + this.messageType, + this.eventStreamMessage, + ) : super('$messageType: $eventStreamMessage'); + + /// The message type. + final dynamic messageType; + + /// The received message. + final EventStreamMessage eventStreamMessage; +} + +/// Data interchange protocol violation. +/// +/// For example, several header messages are received, or body message +/// is received before headers. +final class ProtocolException extends TranscribeStreamingException { + /// Creates a [ProtocolException] with the given message. + const ProtocolException(super.message); +} + +TranscribeStreamingServiceException _createTranscribeStreamingServiceException( + String exceptionType, + String contentType, + List? body, +) { + final responseBody = utf8.decode(body ?? Uint8List(0)); + String message = responseBody; + + if (responseBody.isNotEmpty && contentType.contains('json')) { + final jsonResponse = json.decode(responseBody) as Map; + + if (jsonResponse.containsKey('Message')) { + message = jsonResponse['Message']; + } else if (jsonResponse.containsKey('message')) { + message = jsonResponse['message']; + } + } + + return switch (exceptionType) { + BadRequestException.name || '400' => BadRequestException(message), + InvalidSignatureException.name => InvalidSignatureException(message), + ConflictException.name || '409' => ConflictException(message), + LimitExceededException.name || '429' => LimitExceededException(message), + InternalFailureException.name || '500' => InternalFailureException(message), + ServiceUnavailableException.name || + '503' => + ServiceUnavailableException(message), + _ => UnexpectedExceptionTypeException(exceptionType, message), + }; +} diff --git a/team_b/yappy/lib/services/models.dart b/team_b/yappy/lib/services/models.dart new file mode 100644 index 00000000..501675d4 --- /dev/null +++ b/team_b/yappy/lib/services/models.dart @@ -0,0 +1,1264 @@ +import 'dart:convert'; + +/// A base class for a request to the Amazon Transcribe Streaming API. +abstract class TranscribeStreamingRequest { + /// Creates a [TranscribeStreamingRequest]. + const TranscribeStreamingRequest(); + + /// The path for the request, for example `/stream-transcription` + String get path; + + /// The target for the request, + /// for example `com.amazonaws.transcribe.Transcribe.StartStreamTranscription` + String get target; + + /// The duration of each audio chunk in milliseconds. + int get chunkDurationMs; + + /// The chunk size for the audio stream. Zero value disables chunking. + int get chunkSize; + + /// Returns the headers for the request. + Map toHeaders(); +} + +/// Starts a HTTP/2 stream where audio is streamed to Amazon Transcribe +/// and the transcription results are streamed to your application. +class StartStreamTranscriptionRequest extends TranscribeStreamingRequest { + /// Specifies the language code that represents the language spoken + /// in your audio. + /// + /// If you're unsure of the language spoken in your audio, consider using + /// [identifyLanguage] to enable automatic language identification. + /// + /// For a list of languages supported with Amazon Transcribe streaming, refer + /// to the [Supported languages](https://docs.aws.amazon.com/transcribe/latest/dg/supported-languages.html) table. + final LanguageCode? languageCode; + + /// The sample rate of the input audio (in hertz). + /// + /// Low-quality audio, such as telephone audio, is typically around 8,000 Hz. + /// + /// High-quality audio typically ranges from 16,000 Hz to 48,000 Hz. + /// + /// Note that the sample rate you specify must match that of your audio. + final int mediaSampleRateHertz; + + /// Specifies the encoding of your input audio. + /// + /// Supported formats are: + /// * FLAC + /// * OPUS-encoded audio in an Ogg container + /// * PCM (only signed 16-bit little-endian audio formats, which does not include WAV) + /// + /// For more information, see + /// [Media formats](https://docs.aws.amazon.com/transcribe/latest/dg/how-input.html#how-input-audio). + final MediaEncoding mediaEncoding; + + /// Specifies the name of the custom vocabulary that you want to use + /// when processing your transcription. + /// + /// Note that vocabulary names are case sensitive. + /// + /// If the language of the specified custom vocabulary doesn't match + /// the language identified in your media, the custom vocabulary + /// is not applied to your transcription. + /// + /// This parameter is **not** intended for use with the [identifyLanguage] + /// parameter If you're including [identifyLanguage] in your request and + /// want to use one or more custom vocabularies with your transcription, + /// use the [vocabularyNames] parameter instead. + /// + /// For more information, see [Custom vocabularies](https://docs.aws.amazon.com/transcribe/latest/dg/custom-vocabulary.html). + final String? vocabularyName; + + /// Specifies a name for your transcription session. + /// + /// If you don't include this parameter in your request, Amazon Transcribe + /// generates an ID and returns it in the response. + /// + /// You can use a session ID to retry a streaming session. + final String? sessionId; + + /// Specifies the name of the custom vocabulary filter that you want to use + /// when processing your transcription. + /// + /// Note that vocabulary filter names are case sensitive. + /// + /// If the language of the specified custom vocabulary filter doesn't match + /// the language identified in your media, the vocabulary filter + /// is not applied to your transcription. + /// + /// This parameter is **not** intended for use with the [identifyLanguage] + /// parameter If you're including [identifyLanguage] in your request and + /// want to use one or more vocabulary filters with your transcription, + /// use the [vocabularyFilterNames] parameter instead. + /// + /// For more information, see [Using vocabulary filtering with unwanted words](https://docs.aws.amazon.com/transcribe/latest/dg/vocabulary-filtering.html). + final String? vocabularyFilterName; + + /// Specifies how you want your vocabulary filter applied to your transcript. + /// + /// To replace words with `***`, choose `mask`. + /// + /// To delete words, choose `remove`. + /// + /// To flag words without changing them, choose `tag`. + final VocabularyFilterMethod? vocabularyFilterMethod; + + /// Enables speaker partitioning (diarization) in your transcription output. + /// + /// Speaker partitioning labels the speech from individual speakers in your + /// media file. + /// + /// For more information, see [Partitioning speakers (diarization)](https://docs.aws.amazon.com/transcribe/latest/dg/diarization.html). + final bool? showSpeakerLabel; + + /// Enables channel identification in multi-channel audio. + /// + /// Channel identification transcribes the audio on each channel + /// independently, then appends the output for each channel into + /// one transcript. + /// + /// If you have multi-channel audio and do not enable channel identification, + /// your audio is transcribed in a continuous manner and your transcript + /// is not separated by channel. + /// + /// For more information, see [Transcribing multi-channel audio](https://docs.aws.amazon.com/transcribe/latest/dg/channel-id.html). + final bool? enableChannelIdentification; + + /// Specifies the number of channels in your audio stream. + /// + /// Up to two channels are supported. + final int? numberOfChannels; + + /// Enables partial result stabilization for your transcription. + /// + /// Partial result stabilization can reduce latency in your output, + /// but may impact accuracy. + /// + /// For more information, see [Partial-result stabilization](https://docs.aws.amazon.com/transcribe/latest/dg/streaming.html#streaming-partial-result-stabilization). + final bool? enablePartialResultsStabilization; + + /// Specifies the level of stability to use when you enable partial results + /// stabilization. + /// + /// Low stability provides the highest accuracy. + /// High stability transcribes faster, but with slightly lower accuracy. + /// + /// For more information, see [Partial-result stabilization](https://docs.aws.amazon.com/transcribe/latest/dg/streaming.html#streaming-partial-result-stabilization). + final PartialResultsStability? partialResultsStability; + + /// Labels all personally identifiable information (PII) identified + /// in your transcript. + /// + /// Content identification is performed at the segment level. + /// PII specified in [piiEntityTypes] is flagged upon complete transcription + /// of an audio segment. + /// + /// You can’t set [contentIdentificationType] and [contentRedactionType] + /// in the same request. If you set both, your request returns a + /// `BadRequestException`. + /// + /// For more information, see [Redacting or identifying personally identifiable information](https://docs.aws.amazon.com/transcribe/latest/dg/pii-redaction.html). + final ContentIdentificationType? contentIdentificationType; + + /// Redacts all personally identifiable information (PII) identified + /// in your transcript. + /// + /// Content redaction is performed at the segment level. + /// PII specified in [piiEntityTypes] is redacted upon complete transcription + /// of an audio segment. + /// + /// You can’t set [contentRedactionType] and [contentIdentificationType] + /// in the same request. If you set both, your request returns a + /// `BadRequestException`. + /// + /// For more information, see [Redacting or identifying personally identifiable information](https://docs.aws.amazon.com/transcribe/latest/dg/pii-redaction.html). + final ContentRedactionType? contentRedactionType; + + /// Specifies which types of personally identifiable information (PII) + /// you want to redact in your transcript. + /// + /// You can include as many types as you'd like, or you can select `ALL`. + /// + /// To include [piiEntityTypes] in your request, you must also include either + /// [contentIdentificationType] or [contentRedactionType]. + /// + /// Values must be comma-separated and can include: + /// * `BANK_ACCOUNT_NUMBER` + /// * `BANK_ROUTING` + /// * `CREDIT_DEBIT_NUMBER` + /// * `CREDIT_DEBIT_CVV` + /// * `CREDIT_DEBIT_EXPIRY` + /// * `PIN` + /// * `EMAIL` + /// * `ADDRESS` + /// * `NAME` + /// * `PHONE` + /// * `SSN` + /// * `ALL` + final String? piiEntityTypes; + + /// Specifies the name of the custom language model that you want to use + /// when processing your transcription. + /// + /// Note that language model names are case sensitive. + /// + /// The language of the specified language model must match the language code + /// you specify in your transcription request. If the languages don't match, + /// the custom language model isn't applied. There are no errors or warnings + /// associated with a language mismatch. + /// + /// For more information, see [Custom language models](https://docs.aws.amazon.com/transcribe/latest/dg/custom-language-models.html). + final String? languageModelName; + + /// Enables automatic language identification for your transcription. + /// + /// If you include [identifyLanguage], you can optionally include a list + /// of language codes, using [languageOptions], that you think may be present + /// in your audio stream. Including language options can improve + /// transcription accuracy. + /// + /// You can also include a preferred language using [preferredLanguage]. + /// Adding a preferred language can help Amazon Transcribe identify + /// the language faster than if you omit this parameter. + /// + /// If you have multi-channel audio that contains different languages + /// on each channel, and you've enabled channel identification, + /// automatic language identification identifies the dominant language + /// on each audio channel. + /// + /// Note that you must include either [languageCode] or [identifyLanguage] + /// or [identifyMultipleLanguages] in your request. If you include more than + /// one of these parameters, your transcription job fails. + /// + /// Streaming language identification can't be combined with custom language + /// models or redaction. + final bool? identifyLanguage; + + /// Specifies two or more language codes that represent the languages + /// you think may be present in your media. + /// + /// Including more than five is not recommended. If you're unsure + /// what languages are present, do not include this parameter. + /// + /// Including language options can improve the accuracy of language + /// identification. + /// + /// If you include [languageOptions] in your request, you must also + /// include [identifyLanguage]. + /// + /// For a list of languages supported with Amazon Transcribe streaming, + /// refer to the [Supported languages](https://docs.aws.amazon.com/transcribe/latest/dg/supported-languages.html) + /// + /// You can only include one language dialect per language per stream. + /// For example, you cannot include `en-US` and `en-AU` in the same request. + final String? languageOptions; + + /// Specifies a preferred language from the subset of languages codes + /// you specified in [languageOptions]. + /// + /// You can only use this parameter if you've included [identifyLanguage] + /// and [languageOptions] in your request. + final String? preferredLanguage; + + /// Enables automatic multi-language identification in your transcription + /// job request. + /// + /// Use this parameter if your stream contains more than one language. + /// If your stream contains only one language, use IdentifyLanguage instead. + /// + /// If you include [identifyMultipleLanguages], you can optionally include + /// a list of language codes, using [languageOptions], that you think may be + /// present in your stream. + /// Including [languageOptions] restricts [identifyMultipleLanguages] to + /// only the language options that you specify, which can improve + /// transcription accuracy. + /// + /// If you want to apply a custom vocabulary or a custom vocabulary filter + /// to your automatic multiple language identification request, include + /// [vocabularyNames] or [vocabularyFilterNames]. + /// + /// Note that you must include one of [languageCode], [identifyLanguage], + /// or [identifyMultipleLanguages] in your request. If you include more than + /// one of these parameters, your transcription job fails. + final bool? identifyMultipleLanguages; + + /// Specifies the names of the custom vocabularies that you want to use + /// when processing your transcription. + /// + /// Note that vocabulary names are case sensitive. + /// + /// If none of the languages of the specified custom vocabularies match + /// the language identified in your media, your job fails. + /// + /// This parameter is only intended for use **with** the [identifyLanguage] + /// parameter. If you're **not** including [identifyLanguage] in your request + /// and want to use a custom vocabulary with your transcription, + /// use the [vocabularyName] parameter instead. + /// + /// For more information, see [Custom vocabularies](https://docs.aws.amazon.com/transcribe/latest/dg/custom-vocabulary.html). + final String? vocabularyNames; + + /// Specifies the names of the custom vocabulary filters that you want to use + /// when processing your transcription. + /// + /// Note that vocabulary filter names are case sensitive. + /// + /// If none of the languages of the specified custom vocabulary filters match + /// the language identified in your media, your job fails. + /// + /// This parameter is only intended for use **with** the [identifyLanguage] + /// parameter. If you're **not** including [identifyLanguage] in your request + /// and want to use a custom vocabulary filter with your transcription, + /// use the [vocabularyFilterName] parameter instead. + /// + /// For more information, see [Using vocabulary filtering with unwanted words](https://docs.aws.amazon.com/transcribe/latest/dg/vocabulary-filtering.html). + final String? vocabularyFilterNames; + + /// Creates a [StartStreamTranscriptionRequest] to start a streaming + /// transcription. + const StartStreamTranscriptionRequest({ + this.languageCode, + required this.mediaSampleRateHertz, + required this.mediaEncoding, + this.vocabularyName, + this.sessionId, + this.vocabularyFilterName, + this.vocabularyFilterMethod, + this.showSpeakerLabel, + this.enableChannelIdentification, + this.numberOfChannels, + this.enablePartialResultsStabilization, + this.partialResultsStability, + this.contentIdentificationType, + this.contentRedactionType, + this.piiEntityTypes, + this.languageModelName, + this.identifyLanguage, + this.languageOptions, + this.preferredLanguage, + this.identifyMultipleLanguages, + this.vocabularyNames, + this.vocabularyFilterNames, + }) : assert(languageCode != null || + identifyLanguage != null || + identifyMultipleLanguages != null), + assert(mediaSampleRateHertz >= 8000 && mediaSampleRateHertz <= 48000); + + @override + String get path => '/stream-transcription'; + + @override + String get target => + 'com.amazonaws.transcribe.Transcribe.StartStreamTranscription'; + + @override + int get chunkDurationMs => 200; + + @override + int get chunkSize => switch (mediaEncoding) { + MediaEncoding.pcm => mediaSampleRateHertz * 2 * chunkDurationMs ~/ 1000, + _ => 0, + }; + + @override + Map toHeaders() => { + if (languageCode != null) + 'x-amzn-transcribe-language-code': languageCode!.value, + 'x-amzn-transcribe-sample-rate': mediaSampleRateHertz.toString(), + 'x-amzn-transcribe-media-encoding': mediaEncoding.value, + if (vocabularyName != null) + 'x-amzn-transcribe-vocabulary-name': vocabularyName!, + if (sessionId != null) 'x-amzn-transcribe-session-id': sessionId!, + if (vocabularyFilterName != null) + 'x-amzn-transcribe-vocabulary-filter-name': vocabularyFilterName!, + if (vocabularyFilterMethod != null) + 'x-amzn-transcribe-vocabulary-filter-method': + vocabularyFilterMethod!.value, + if (showSpeakerLabel != null) + 'x-amzn-transcribe-show-speaker-label': showSpeakerLabel!.toString(), + if (enableChannelIdentification != null) + 'x-amzn-transcribe-enable-channel-identification': + enableChannelIdentification!.toString(), + if (numberOfChannels != null) + 'x-amzn-transcribe-number-of-channels': numberOfChannels!.toString(), + if (enablePartialResultsStabilization != null) + 'x-amzn-transcribe-enable-partial-results-stabilization': + enablePartialResultsStabilization!.toString(), + if (partialResultsStability != null) + 'x-amzn-transcribe-partial-results-stability': + partialResultsStability!.value, + if (contentIdentificationType != null) + 'x-amzn-transcribe-content-identification-type': + contentIdentificationType!.value, + if (contentRedactionType != null) + 'x-amzn-transcribe-content-redaction-type': + contentRedactionType!.value, + if (piiEntityTypes != null) + 'x-amzn-transcribe-pii-entity-types': piiEntityTypes!, + if (languageModelName != null) + 'x-amzn-transcribe-language-model-name': languageModelName!, + if (identifyLanguage != null) + 'x-amzn-transcribe-identify-language': identifyLanguage!.toString(), + if (languageOptions != null) + 'x-amzn-transcribe-language-options': languageOptions!, + if (preferredLanguage != null) + 'x-amzn-transcribe-preferred-language': preferredLanguage!, + if (identifyMultipleLanguages != null) + 'x-amzn-transcribe-identify-multiple-languages': + identifyMultipleLanguages!.toString(), + if (vocabularyNames != null) + 'x-amzn-transcribe-vocabulary-names': vocabularyNames!, + if (vocabularyFilterNames != null) + 'x-amzn-transcribe-vocabulary-filter-names': vocabularyFilterNames!, + }; +} + +/// Response for the [StartStreamTranscriptionRequest]. +final class StartStreamTranscriptionResponse { + /// Provides the identifier for your streaming request. + final String? requestId; + + /// Provides the language code that you specified in your request. + final LanguageCode? languageCode; + + /// Provides the sample rate that you specified in your request. + final int? mediaSampleRateHertz; + + /// Provides the media encoding you specified in your request. + final MediaEncoding? mediaEncoding; + + /// Provides the name of the custom vocabulary that you specified + /// in your request. + final String? vocabularyName; + + /// Provides the identifier for your transcription session. + final String? sessionId; + + /// Provides the name of the custom vocabulary filter that you specified + /// in your request. + final String? vocabularyFilterName; + + /// Provides the vocabulary filtering method used in your transcription. + final VocabularyFilterMethod? vocabularyFilterMethod; + + /// Shows whether speaker partitioning was enabled for your transcription. + final bool? showSpeakerLabel; + + /// Shows whether channel identification was enabled for your transcription. + final bool? enableChannelIdentification; + + /// Provides the number of channels that you specified in your request. + final int? numberOfChannels; + + /// Shows whether partial results stabilization was enabled + /// for your transcription. + final bool? enablePartialResultsStabilization; + + /// Provides the stabilization level used for your transcription. + final PartialResultsStability? partialResultsStability; + + /// Shows whether content identification was enabled for your transcription. + final ContentIdentificationType? contentIdentificationType; + + /// Shows whether content redaction was enabled for your transcription. + final ContentRedactionType? contentRedactionType; + + /// Lists the PII entity types you specified in your request. + final String? piiEntityTypes; + + /// Provides the name of the custom language model that you specified + /// in your request. + final String? languageModelName; + + /// Shows whether automatic language identification was enabled + /// for your transcription. + final bool? identifyLanguage; + + /// Provides the language codes that you specified in your request. + final String? languageOptions; + + /// Provides the preferred language that you specified in your request. + final LanguageCode? preferredLanguage; + + /// Shows whether automatic multi-language identification was enabled + /// for your transcription. + final bool? identifyMultipleLanguages; + + /// Provides the names of the custom vocabularies that you specified + /// in your request. + final String? vocabularyNames; + + /// Provides the names of the custom vocabulary filters that you specified + /// in your request. + final String? vocabularyFilterNames; + + /// Creates a [StartStreamTranscriptionResponse] from the values of + /// the headers of a response from the Amazon Transcribe Streaming API. + const StartStreamTranscriptionResponse({ + this.requestId, + this.languageCode, + this.mediaSampleRateHertz, + this.mediaEncoding, + this.vocabularyName, + this.sessionId, + this.vocabularyFilterName, + this.vocabularyFilterMethod, + this.showSpeakerLabel, + this.enableChannelIdentification, + this.numberOfChannels, + this.enablePartialResultsStabilization, + this.partialResultsStability, + this.contentIdentificationType, + this.contentRedactionType, + this.piiEntityTypes, + this.languageModelName, + this.identifyLanguage, + this.languageOptions, + this.preferredLanguage, + this.identifyMultipleLanguages, + this.vocabularyNames, + this.vocabularyFilterNames, + }); + + /// Creates a [StartStreamTranscriptionResponse] from the headers of a + /// response from the Amazon Transcribe Streaming API. + factory StartStreamTranscriptionResponse.fromHeaders( + Map headers) { + return StartStreamTranscriptionResponse( + requestId: headers['x-amzn-request-id'], + languageCode: headers['x-amzn-transcribe-language-code'] != null + ? LanguageCode.fromValue(headers['x-amzn-transcribe-language-code']!) + : null, + mediaSampleRateHertz: headers['x-amzn-transcribe-sample-rate'] != null + ? int.parse(headers['x-amzn-transcribe-sample-rate']!) + : null, + mediaEncoding: headers['x-amzn-transcribe-media-encoding'] != null + ? MediaEncoding.fromValue( + headers['x-amzn-transcribe-media-encoding']!) + : null, + vocabularyName: headers['x-amzn-transcribe-vocabulary-name'], + sessionId: headers['x-amzn-transcribe-session-id'], + vocabularyFilterName: headers['x-amzn-transcribe-vocabulary-filter-name'], + vocabularyFilterMethod: + headers['x-amzn-transcribe-vocabulary-filter-method'] != null + ? VocabularyFilterMethod.fromValue( + headers['x-amzn-transcribe-vocabulary-filter-method']!) + : null, + showSpeakerLabel: headers['x-amzn-transcribe-show-speaker-label'] != null + ? headers['x-amzn-transcribe-show-speaker-label'] == 'true' + : null, + enableChannelIdentification: + headers['x-amzn-transcribe-enable-channel-identification'] != null + ? headers['x-amzn-transcribe-enable-channel-identification'] == + 'true' + : null, + numberOfChannels: headers['x-amzn-transcribe-number-of-channels'] != null + ? int.parse(headers['x-amzn-transcribe-number-of-channels']!) + : null, + enablePartialResultsStabilization: headers[ + 'x-amzn-transcribe-enable-partial-results-stabilization'] != + null + ? headers['x-amzn-transcribe-enable-partial-results-stabilization'] == + 'true' + : null, + partialResultsStability: + headers['x-amzn-transcribe-partial-results-stability'] != null + ? PartialResultsStability.fromValue( + headers['x-amzn-transcribe-partial-results-stability']!) + : null, + contentIdentificationType: + headers['x-amzn-transcribe-content-identification-type'] != null + ? ContentIdentificationType.fromValue( + headers['x-amzn-transcribe-content-identification-type']!) + : null, + contentRedactionType: + headers['x-amzn-transcribe-content-redaction-type'] != null + ? ContentRedactionType.fromValue( + headers['x-amzn-transcribe-content-redaction-type']!) + : null, + piiEntityTypes: headers['x-amzn-transcribe-pii-entity-types'], + languageModelName: headers['x-amzn-transcribe-language-model-name'], + identifyLanguage: headers['x-amzn-transcribe-identify-language'] != null + ? headers['x-amzn-transcribe-identify-language'] == 'true' + : null, + languageOptions: headers['x-amzn-transcribe-language-options'], + preferredLanguage: headers['x-amzn-transcribe-preferred-language'] != null + ? LanguageCode.fromValue( + headers['x-amzn-transcribe-preferred-language']!) + : null, + identifyMultipleLanguages: + headers['x-amzn-transcribe-identify-multiple-languages'] != null + ? headers['x-amzn-transcribe-identify-multiple-languages'] == + 'true' + : null, + vocabularyNames: headers['x-amzn-transcribe-vocabulary-names'], + vocabularyFilterNames: + headers['x-amzn-transcribe-vocabulary-filter-names'], + ); + } + + /// Returns the headers for the response. + Map toHeaders() { + return { + if (requestId != null) 'x-amzn-request-id': requestId, + if (languageCode != null) + 'x-amzn-transcribe-language-code': languageCode?.value, + if (mediaSampleRateHertz != null) + 'x-amzn-transcribe-sample-rate': mediaSampleRateHertz, + if (mediaEncoding != null) + 'x-amzn-transcribe-media-encoding': mediaEncoding?.value, + if (vocabularyName != null) + 'x-amzn-transcribe-vocabulary-name': vocabularyName, + if (sessionId != null) 'x-amzn-transcribe-session-id': sessionId, + if (vocabularyFilterName != null) + 'x-amzn-transcribe-vocabulary-filter-name': vocabularyFilterName, + if (vocabularyFilterMethod != null) + 'x-amzn-transcribe-vocabulary-filter-method': + vocabularyFilterMethod?.value, + if (showSpeakerLabel != null) + 'x-amzn-transcribe-show-speaker-label': showSpeakerLabel, + if (enableChannelIdentification != null) + 'x-amzn-transcribe-enable-channel-identification': + enableChannelIdentification, + if (numberOfChannels != null) + 'x-amzn-transcribe-number-of-channels': numberOfChannels, + if (enablePartialResultsStabilization != null) + 'x-amzn-transcribe-enable-partial-results-stabilization': + enablePartialResultsStabilization, + if (partialResultsStability != null) + 'x-amzn-transcribe-partial-results-stability': + partialResultsStability?.value, + if (contentIdentificationType != null) + 'x-amzn-transcribe-content-identification-type': + contentIdentificationType?.value, + if (contentRedactionType != null) + 'x-amzn-transcribe-content-redaction-type': contentRedactionType?.value, + if (piiEntityTypes != null) + 'x-amzn-transcribe-pii-entity-types': piiEntityTypes, + if (languageModelName != null) + 'x-amzn-transcribe-language-model-name': languageModelName, + if (identifyLanguage != null) + 'x-amzn-transcribe-identify-language': identifyLanguage, + if (languageOptions != null) + 'x-amzn-transcribe-language-options': languageOptions, + if (preferredLanguage != null) + 'x-amzn-transcribe-preferred-language': preferredLanguage?.value, + if (identifyMultipleLanguages != null) + 'x-amzn-transcribe-identify-multiple-languages': + identifyMultipleLanguages, + if (vocabularyNames != null) + 'x-amzn-transcribe-vocabulary-names': vocabularyNames, + if (vocabularyFilterNames != null) + 'x-amzn-transcribe-vocabulary-filter-names': vocabularyFilterNames, + }; + } +} + +/// Possible values for the `languageCode` parameter of a +/// [StartStreamTranscriptionRequest]. +enum LanguageCode { + deDe('de-DE'), + enAu('en-AU'), + enGb('en-GB'), + enUs('en-US'), + esUs('es-US'), + frCa('fr-CA'), + frFr('fr-FR'), + hiIn('hi-IN'), + itIt('it-IT'), + jaJp('ja-JP'), + koKr('ko-KR'), + ptBr('pt-BR'), + thTh('th-TH'), + zhCn('zh-CN'); + + /// Creates a [LanguageCode] with the given value. + const LanguageCode(this.value); + + /// The language code value. + final String value; + + /// Returns the [LanguageCode] for the given value. + factory LanguageCode.fromValue(String value) { + return LanguageCode.values.firstWhere((e) => e.value == value); + } +} + +/// Possible values for the `mediaEncoding` parameter of a +/// [StartStreamTranscriptionRequest]. +enum MediaEncoding { + flac('flac'), + oggOpus('ogg-opus'), + pcm('pcm'); + + /// Creates a [MediaEncoding] with the given value. + const MediaEncoding(this.value); + + /// The media encoding value. + final String value; + + /// Returns the [MediaEncoding] for the given value. + factory MediaEncoding.fromValue(String value) { + return MediaEncoding.values.firstWhere((e) => e.value == value); + } +} + +/// Possible values for the `vocabularyFilterMethod` parameter of a +/// [StartStreamTranscriptionRequest]. +enum VocabularyFilterMethod { + mask('mask'), + remove('remove'), + tag('tag'); + + /// Creates a [VocabularyFilterMethod] with the given value. + const VocabularyFilterMethod(this.value); + + /// The vocabulary filter method value. + final String value; + + /// Returns the [VocabularyFilterMethod] for the given value. + factory VocabularyFilterMethod.fromValue(String value) { + return VocabularyFilterMethod.values.firstWhere((e) => e.value == value); + } +} + +/// Possible values for the `partialResultsStability` parameter of a +/// [StartStreamTranscriptionRequest]. +enum PartialResultsStability { + high('high'), + low('low'), + medium('medium'); + + /// Creates a [PartialResultsStability] with the given value. + const PartialResultsStability(this.value); + + /// The partial results stability value. + final String value; + + /// Returns the [PartialResultsStability] for the given value. + factory PartialResultsStability.fromValue(String value) { + return PartialResultsStability.values.firstWhere((e) => e.value == value); + } +} + +/// Possible values for the `contentIdentificationType` parameter of a +/// [StartStreamTranscriptionRequest]. +enum ContentIdentificationType { + pII('PII'); + + /// Creates a [ContentIdentificationType] with the given value. + const ContentIdentificationType(this.value); + + /// The content identification type value. + final String value; + + /// Returns the [ContentIdentificationType] for the given value. + factory ContentIdentificationType.fromValue(String value) { + return ContentIdentificationType.values.firstWhere((e) => e.value == value); + } +} + +/// Possible values for the `contentRedactionType` parameter of a +/// [StartStreamTranscriptionRequest]. +enum ContentRedactionType { + pII('PII'); + + /// Creates a [ContentRedactionType] with the given value. + const ContentRedactionType(this.value); + + /// The content redaction type value. + final String value; + + /// Returns the [ContentRedactionType] for the given value. + factory ContentRedactionType.fromValue(String value) { + return ContentRedactionType.values.firstWhere((e) => e.value == value); + } +} + +/// The `TranscriptEvent` associated with a `transcriptEventStream` +/// returned from [TranscribeStreamingClient.startStreamTranscription]. +/// +/// Contains a set of transcription results from one or more audio segments, +/// along with additional information per your request parameters. +final class TranscriptEvent { + /// Contains `Results`, which contains a set of transcription results from + /// one or more audio segments, along with additional information per your + /// request parameters. This can include information relating to alternative + /// transcriptions, channel identification, partial result stabilization, + /// language identification, and other transcription-related data. + final Transcript? transcript; + + /// Creates a [TranscriptEvent] from the given values. + const TranscriptEvent({ + this.transcript, + }); + + /// Creates a [TranscriptEvent] from the given [Map]. + factory TranscriptEvent.fromMap(Map map) { + return TranscriptEvent( + transcript: map['Transcript'] != null + ? Transcript.fromMap(map['Transcript'] as Map) + : null, + ); + } + + /// Creates a [TranscriptEvent] from the given JSON string. + factory TranscriptEvent.fromJson(String source) => + TranscriptEvent.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [TranscriptEvent]. + Map toMap() { + return { + 'Transcript': transcript?.toMap(), + }; + } + + /// Returns the JSON string representation of this [TranscriptEvent]. + String toJson() => json.encode(toMap()); +} + +/// The `Transcript` associated with a [TranscriptEvent]. +/// +/// [Transcript] contains [Result]s, which contains a set of transcription +/// results from one or more audio segments, along with additional information +/// per your request parameters. +final class Transcript { + /// Contains a set of transcription results from one or more audio segments, + /// along with additional information per your request parameters. This can + /// include information relating to alternative transcriptions, channel + /// identification, partial result stabilization, language identification, + /// and other transcription-related data. + final List? results; + + /// Creates a [Transcript] from the given values. + const Transcript({ + this.results, + }); + + /// Creates a [Transcript] from the given [Map]. + factory Transcript.fromMap(Map map) { + return Transcript( + results: map['Results'] != null + ? List.from( + (map['Results'] as List).map( + (x) => Result.fromMap(x as Map), + ), + ) + : null, + ); + } + + /// Creates a [Transcript] from the given JSON string. + factory Transcript.fromJson(String source) => + Transcript.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [Transcript]. + Map toMap() { + return { + 'Results': results?.map((x) => x.toMap()).toList(), + }; + } + + /// Returns the JSON string representation of this [Transcript]. + String toJson() => json.encode(toMap()); +} + +/// The `Result` associated with a [TranscriptEvent]. +/// +/// Contains a set of transcription results from one or more audio segments, +/// along with additional information per your request parameters. This can +/// include information relating to alternative transcriptions, channel +/// identification, partial result stabilization, language identification, +/// and other transcription-related data. +final class Result { + /// Provides a unique identifier for the [Result]. + final String? resultId; + + /// The start time, in milliseconds, of the [Result]. + final num? startTime; + + /// The end time, in milliseconds, of the [Result]. + final num? endTime; + + /// Indicates if the segment is complete. + /// + /// If [isPartial] is `true`, the segment is not complete. + /// If [isPartial] is `false`, the segment is complete. + final bool? isPartial; + + /// A list of possible alternative transcriptions for the input audio. + /// + /// Each alternative may contain one or more of [Item], [Entity], + /// or [Transcript]. + final List? alternatives; + + /// Indicates which audio channel is associated with the [Result]. + final String? channelId; + + /// The language code that represents the language spoken in your stream. + final LanguageCode? languageCode; + + /// The language code of the dominant language identified in your stream. + /// + /// If you enabled channel identification and each channel of your audio + /// contains a different language, you may have more than one result. + final List? languageIdentification; + + /// Creates a [Result] from the given values. + const Result({ + this.resultId, + this.startTime, + this.endTime, + this.isPartial, + this.alternatives, + this.channelId, + this.languageCode, + this.languageIdentification, + }); + + /// Creates a [Result] from the given [Map]. + factory Result.fromMap(Map map) { + return Result( + resultId: map['ResultId'] != null ? map['ResultId'] as String : null, + startTime: map['StartTime'] != null ? map['StartTime'] as num : null, + endTime: map['EndTime'] != null ? map['EndTime'] as num : null, + isPartial: map['IsPartial'] != null ? map['IsPartial'] as bool : null, + alternatives: map['Alternatives'] != null + ? List.from( + (map['Alternatives'] as List).map( + (x) => Alternative.fromMap(x as Map), + ), + ) + : null, + channelId: map['ChannelId'] != null ? map['ChannelId'] as String : null, + languageCode: map['LanguageCode'] != null + ? LanguageCode.fromValue(map['LanguageCode'] as String) + : null, + languageIdentification: map['LanguageIdentification'] != null + ? List.from( + (map['LanguageIdentification'] as List) + .map( + (x) => LanguageWithScore.fromMap(x as Map), + ), + ) + : null, + ); + } + + /// Creates a [Result] from the given JSON string. + factory Result.fromJson(String source) => + Result.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [Result]. + Map toMap() { + return { + 'ResultId': resultId, + 'StartTime': startTime, + 'EndTime': endTime, + 'IsPartial': isPartial, + 'Alternatives': alternatives?.map((x) => x.toMap()).toList(), + 'ChannelId': channelId, + 'LanguageCode': languageCode?.value, + 'LanguageIdentification': + languageIdentification?.map((x) => x.toMap()).toList(), + }; + } + + /// Returns the JSON string representation of this [Result]. + String toJson() => json.encode(toMap()); +} + +/// The language code that represents the language identified in your audio, +/// including the associated confidence score. +/// +/// If you enabled channel identification in your request and each channel +/// contained a different language, you will have more than one +/// [LanguageWithScore] result. +final class LanguageWithScore { + /// The language code of the identified language. + final LanguageCode? languageCode; + + /// The confidence score associated with the identified language code. + /// + /// Confidence scores are values between zero and one; larger values indicate + /// a higher confidence in the identified language. + final double? score; + + /// Creates a [LanguageWithScore] from the given values. + const LanguageWithScore({ + this.languageCode, + this.score, + }); + + /// Creates a [LanguageWithScore] from the given [Map]. + factory LanguageWithScore.fromMap(Map map) { + return LanguageWithScore( + languageCode: map['LanguageCode'] != null + ? LanguageCode.fromValue(map['LanguageCode'] as String) + : null, + score: map['Score'] != null ? map['Score'] as double : null, + ); + } + + /// Creates a [LanguageWithScore] from the given JSON string. + factory LanguageWithScore.fromJson(String source) => + LanguageWithScore.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [LanguageWithScore]. + Map toMap() { + return { + 'LanguageCode': languageCode?.value, + 'Score': score, + }; + } + + /// Returns the JSON string representation of this [LanguageWithScore]. + String toJson() => json.encode(toMap()); +} + +/// A list of possible alternative transcriptions for the input audio. +/// +/// Each alternative may contain one or more of [Item], [Entity], +/// or [Transcript]. +final class Alternative { + /// Contains transcribed text. + final String? transcript; + + /// Contains words, phrases, or punctuation marks in your transcription + /// output. + final List? items; + + /// Contains entities identified as personally identifiable information (PII) + /// in your transcription output. + final List? entities; + + /// Creates an [Alternative] from the given values. + const Alternative({ + this.transcript, + this.items, + this.entities, + }); + + /// Creates an [Alternative] from the given [Map]. + factory Alternative.fromMap(Map map) { + return Alternative( + transcript: + map['Transcript'] != null ? map['Transcript'] as String : null, + items: map['Items'] != null + ? List.from( + (map['Items'] as List).map( + (x) => Item.fromMap(x as Map), + ), + ) + : null, + entities: map['Entities'] != null + ? List.from( + (map['Entities'] as List).map( + (x) => Entity.fromMap(x as Map), + ), + ) + : null, + ); + } + + /// Creates an [Alternative] from the given JSON string. + factory Alternative.fromJson(String source) => + Alternative.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [Alternative]. + Map toMap() { + return { + 'Transcript': transcript, + 'Items': items?.map((x) => x.toMap()).toList(), + 'Entities': entities?.map((x) => x.toMap()).toList(), + }; + } + + /// Returns the JSON string representation of this [Alternative]. + String toJson() => json.encode(toMap()); +} + +/// A word, phrase, or punctuation mark in your transcription output, +/// along with various associated attributes, such as confidence score, type, +/// and start and end times. +final class Item { + /// The start time, in milliseconds, of the transcribed item. + final num? startTime; + + /// The end time, in milliseconds, of the transcribed item. + final num? endTime; + + /// The type of item identified. Options are: `PRONUNCIATION` (spoken words) + /// and `PUNCTUATION`. + final ItemType? type; + + /// The word or punctuation that was transcribed. + final String? content; + + /// Indicates whether the specified item matches a word in the vocabulary + /// filter included in your request. + /// + /// If `true`, there is a vocabulary filter match. + final bool? vocabularyFilterMatch; + + /// If speaker partitioning is enabled, [speaker] labels the speaker of + /// the specified item. + final String? speaker; + + /// The confidence score associated with a word or phrase in your transcript. + /// + /// Confidence scores are values between 0 and 1. A larger value indicates + /// a higher probability that the identified item correctly matches + /// the item spoken in your media. + final double? confidence; + + /// If partial result stabilization is enabled, [stable] indicates whether + /// the specified item is stable (`true`) or if it may change when the segment + /// is complete (`false`). + final bool? stable; + + /// Creates an [Item] from the given values. + const Item({ + this.startTime, + this.endTime, + this.type, + this.content, + this.vocabularyFilterMatch, + this.speaker, + this.confidence, + this.stable, + }); + + /// Creates an [Item] from the given [Map]. + factory Item.fromMap(Map map) { + return Item( + startTime: map['StartTime'] != null ? map['StartTime'] as num : null, + endTime: map['EndTime'] != null ? map['EndTime'] as num : null, + type: map['Type'] != null + ? ItemType.fromValue(map['Type'] as String) + : null, + content: map['Content'] != null ? map['Content'] as String : null, + vocabularyFilterMatch: map['VocabularyFilterMatch'] != null + ? map['VocabularyFilterMatch'] as bool + : null, + speaker: map['Speaker'] != null ? map['Speaker'] as String : null, + confidence: + map['Confidence'] != null ? map['Confidence'] as double : null, + stable: map['Stable'] != null ? map['Stable'] as bool : null, + ); + } + + /// Creates an [Item] from the given JSON string. + factory Item.fromJson(String source) => + Item.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [Item]. + Map toMap() { + return { + 'StartTime': startTime, + 'EndTime': endTime, + 'Type': type?.value, + 'Content': content, + 'VocabularyFilterMatch': vocabularyFilterMatch, + 'Speaker': speaker, + 'Confidence': confidence, + 'Stable': stable, + }; + } + + /// Returns the JSON string representation of this [Item]. + String toJson() => json.encode(toMap()); +} + +/// The type of [Item] identified in a transcription. +enum ItemType { + pronunciation('pronunciation'), + punctuation('punctuation'); + + /// Creates an [ItemType] with the given value. + const ItemType(this.value); + + /// The item type value. + final String value; + + /// Returns the [ItemType] for the given value. + factory ItemType.fromValue(String value) { + return ItemType.values.firstWhere((e) => e.value == value); + } +} + +/// Contains entities identified as personally identifiable information (PII) +/// in your transcription output, along with various associated attributes. +/// +/// Examples include category, confidence score, type, stability score, +/// and start and end times. +final class Entity { + /// The start time, in milliseconds, of the utterance that was identified + /// as PII. + final num? startTime; + + /// The end time, in milliseconds, of the utterance that was identified + /// as PII. + final num? endTime; + + /// The category of information identified. The only category is `PII`. + final String? category; + + /// The type of PII identified. For example, `NAME` or `CREDIT_DEBIT_NUMBER`. + final String? type; + + /// The word or words identified as PII. + final String? content; + + /// The confidence score associated with the identified PII entity in audio. + /// + /// Confidence scores are values between 0 and 1. A larger value indicates + /// a higher probability that the identified entity correctly matches + /// the entity spoken in your media. + final double? confidence; + + /// Creates an [Entity] from the given values. + const Entity({ + this.startTime, + this.endTime, + this.category, + this.type, + this.content, + this.confidence, + }); + + /// Creates an [Entity] from the given [Map]. + factory Entity.fromMap(Map map) { + return Entity( + startTime: map['StartTime'] != null ? map['StartTime'] as num : null, + endTime: map['EndTime'] != null ? map['EndTime'] as num : null, + category: map['Category'] != null ? map['Category'] as String : null, + type: map['Type'] != null ? map['Type'] as String : null, + content: map['Content'] != null ? map['Content'] as String : null, + confidence: + map['Confidence'] != null ? map['Confidence'] as double : null, + ); + } + + /// Creates an [Entity] from the given JSON string. + factory Entity.fromJson(String source) => + Entity.fromMap(json.decode(source) as Map); + + /// Returns the [Map] representation of this [Entity]. + Map toMap() { + return { + 'StartTime': startTime, + 'EndTime': endTime, + 'Category': category, + 'Type': type, + 'Content': content, + 'Confidence': confidence, + }; + } + + /// Returns the JSON string representation of this [Entity]. + String toJson() => json.encode(toMap()); +} diff --git a/team_b/yappy/lib/services/protocol.dart b/team_b/yappy/lib/services/protocol.dart new file mode 100644 index 00000000..88831e38 --- /dev/null +++ b/team_b/yappy/lib/services/protocol.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http2/http2.dart'; + +import 'event_stream/header.dart'; +import 'event_stream/message.dart'; +import 'event_stream/message_signer.dart'; +import 'event_stream/stream_codec.dart'; + +/// Encodes [EventStreamMessage]s into [Uint8List]s. +final class EventStreamEncoder + extends Converter { + const EventStreamEncoder(); + + @override + Uint8List convert(EventStreamMessage input) => EventStreamCodec.encode(input); + + @override + Sink startChunkedConversion(Sink sink) => + _PacketConversionSink(sink, this); +} + +/// Encodes [Uint8List]s into [DataStreamMessage]s. +final class DataStreamMessageEncoder + extends Converter { + const DataStreamMessageEncoder(); + + @override + DataStreamMessage convert(Uint8List input) => DataStreamMessage(input); + + @override + Sink startChunkedConversion(Sink sink) => + _PacketConversionSink(sink, this); +} + +/// Encodes [Uint8List]s into [EventStreamMessage]s. +final class AudioEventEncoder extends Converter { + const AudioEventEncoder(); + + @override + EventStreamMessage convert(Uint8List input) => EventStreamMessage( + headers: [ + const EventStreamStringHeader( + ':content-type', + 'application/octet-stream', + ), + const EventStreamStringHeader(':event-type', 'AudioEvent'), + const EventStreamStringHeader(':message-type', 'event'), + ], + payload: input, + ); + + @override + Sink startChunkedConversion(Sink sink) => + _PacketConversionSink(sink, this); +} + +/// Signs [Uint8List]s into [EventStreamMessage]s. +final class AudioMessageSigner + extends Converter { + const AudioMessageSigner(this._messageSigner); + + final EventStreamMessageSigner _messageSigner; + + @override + EventStreamMessage convert(Uint8List input) => _messageSigner.sign(input); + + @override + Sink startChunkedConversion(Sink sink) => + _PacketConversionSink(sink, this); +} + +/// Joins/splits [Uint8List]s into [Uint8List]s of a fixed size. +final class AudioDataChunker extends Converter { + const AudioDataChunker(this.chunkSize) : assert(chunkSize >= 0); + + /// The size of the chunks to split the audio data into. + /// Zero value disables chunking. + final int chunkSize; + + @override + Uint8List convert(Uint8List input) { + return input; + } + + @override + Sink startChunkedConversion(Sink sink) => + _AudioDataChunkedConversionSink(sink, chunkSize); +} + +final class _AudioDataChunkedConversionSink + implements ChunkedConversionSink { + _AudioDataChunkedConversionSink(this.sink, this._chunkSize) + : assert(_chunkSize > 0), + _buffer = Uint8List(_chunkSize); + + final Sink sink; + final int _chunkSize; + final Uint8List _buffer; + + int _bufferSize = 0; + int _totalSize = 0; + + @override + void add(Uint8List chunk) { + final chunkLength = chunk.length; + + if (_chunkSize == 0) { + sink.add(chunk); + _totalSize += chunkLength; + return; + } + + int offset = 0; + + while (offset < chunkLength) { + final remaining = chunkLength - offset; + final remainingBuffer = _chunkSize - _bufferSize; + final copyLength = + remaining < remainingBuffer ? remaining : remainingBuffer; + _buffer.setRange(_bufferSize, _bufferSize + copyLength, chunk, offset); + _bufferSize += copyLength; + offset += copyLength; + + if (_bufferSize == _chunkSize) { + sink.add(_buffer); + _totalSize += _bufferSize; + _bufferSize = 0; + } + } + } + + @override + void close() { + if (_bufferSize > 0) { + sink.add(_buffer.sublist(0, _bufferSize)); + _totalSize += _bufferSize; + } + + if (_totalSize > 0) { + // Send an empty chunk to signal the end of the audio stream. + sink.add(Uint8List(0)); + } + + sink.close(); + } +} + +final class _PacketConversionSink implements ChunkedConversionSink { + const _PacketConversionSink(this.sink, this.converter); + + final Sink sink; + final Converter converter; + + @override + void add(S chunk) { + sink.add(converter.convert(chunk)); + } + + @override + void close() { + sink.close(); + } +} diff --git a/team_b/yappy/lib/services/speech_state.dart b/team_b/yappy/lib/services/speech_state.dart index e8ea7d8c..0c58cc58 100644 --- a/team_b/yappy/lib/services/speech_state.dart +++ b/team_b/yappy/lib/services/speech_state.dart @@ -11,6 +11,10 @@ import 'offline_model.dart'; import 'speaker_model.dart'; import 'vad_model.dart'; import 'speech_isolate.dart'; +import 'package:aws_common/aws_common.dart'; +import 'client.dart'; +import 'models.dart'; +import 'transcription.dart'; Future createOnlineRecognizer() async { final type = 0; @@ -75,16 +79,19 @@ Future createVoiceActivityDetector() async { class Conversation { final List segments; final String audioFilePath; + String awsTranscription = ''; Conversation({ required this.segments, required this.audioFilePath, + this.awsTranscription = '', }); - // Convert to JSON for persistence + // Update JSON methods Map toJson() => { 'segments': segments.map((s) => s.toJson()).toList(), 'audioFilePath': audioFilePath, + 'awsTranscription': awsTranscription, }; factory Conversation.fromJson(Map json) => Conversation( @@ -92,9 +99,9 @@ class Conversation { .map((s) => RecognizedSegment.fromJson(s)) .toList(), audioFilePath: json['audioFilePath'], + awsTranscription: json['awsTranscription'] ?? '', ); - // Generate a transcript from the conversation String getTranscript({bool includeSpeakerTags = true}) { final buffer = StringBuffer(); RecognizedSegment? lastSegment; @@ -103,16 +110,13 @@ class Conversation { if (segment.text.isEmpty) continue; if (buffer.isNotEmpty) { - // Add a newline if the speaker changes or if this is a new thought - if (lastSegment == null || - lastSegment.speakerId != segment.speakerId) { + if (lastSegment == null || lastSegment.speakerId != segment.speakerId) { buffer.write('\n\n'); } else { buffer.write('\n'); } } - // Add speaker tag if requested and available if (includeSpeakerTags && segment.speakerId != null) { buffer.write('${segment.speakerId}: '); } @@ -123,6 +127,10 @@ class Conversation { return buffer.toString(); } + + String getAwsTranscript() { + return awsTranscription; + } } class RecognizedSegment { @@ -181,6 +189,12 @@ class AudioSegment { } class SpeechState extends ChangeNotifier { + TranscribeStreamingClient? awsClient; + StreamSink? awsAudioStreamSink; + StreamSubscription? awsTranscriptSubscription; + String currentAwsTranscript = ''; + bool isAwsTranscribing = false; + final TextEditingController controller = TextEditingController(); final AudioRecorder audioRecorder = AudioRecorder(); @@ -280,6 +294,95 @@ class SpeechState extends ChangeNotifier { } } + // Initialize AWS credentials and client + Future initializeAwsClient() async { + if (awsClient == null) { + try { + // Use StaticCredentialsProvider for simplicity + // In production, consider using more secure credential providers + final credentialsProvider = StaticCredentialsProvider( + AWSCredentials( + '', // Replace with your idkey + '' // Replace with your secretkey + ), + ); + + // Create AWS Transcribe client + awsClient = TranscribeStreamingClient( + region: 'us-east-2', // Replace with your AWS region + credentialsProvider: credentialsProvider, + ); + + debugPrint('AWS Transcribe client initialized'); + } catch (e) { + debugPrint('Error initializing AWS client: $e'); + } + } + } + + // Method to start AWS transcription + Future startAwsTranscription() async { + if (awsClient == null) { + await initializeAwsClient(); + } + + try { + isAwsTranscribing = true; + currentAwsTranscript = ''; + + // Create AWS Transcribe request + final request = StartStreamTranscriptionRequest( + languageCode: LanguageCode.enUs, // Change according to your needs + mediaSampleRateHertz: sampleRate, // Using existing sample rate + mediaEncoding: MediaEncoding.pcm, + showSpeakerLabel: true, // Enable speaker identification + ); + + // Start streaming + final (response, sink, stream) = await awsClient!.startStreamTranscription(request); + awsAudioStreamSink = sink; + + // Set up listener for transcription events + awsTranscriptSubscription = stream.listen( + (event) { + _processAwsTranscriptEvent(event); + }, + onError: (error) { + debugPrint('AWS Transcription Error: $error'); + }, + onDone: () { + debugPrint('AWS Transcription Stream Done'); + }, + ); + + debugPrint('AWS Transcription Started: ${response.sessionId}'); + } catch (e) { + debugPrint('Error starting AWS transcription: $e'); + isAwsTranscribing = false; + } + } + + // Process transcription events from AWS + void _processAwsTranscriptEvent(TranscriptEvent event) { + if (event.transcript?.results == null || event.transcript!.results!.isEmpty) { + return; + } + + // Build transcription from results + final strategy = PlainTextTranscriptionStrategy(); + final transcriptText = strategy.buildTranscription(event.transcript!.results!); + + // Update current transcript + if (transcriptText.isNotEmpty) { + // Update in the conversation object if available + if (lastConversation != null) { + lastConversation!.awsTranscription = transcriptText; + } + currentAwsTranscript = transcriptText; + notifyListeners(); + } + } + // Helper method to update the displayed text void _updateDisplayText() { final buffer = StringBuffer(); @@ -350,39 +453,39 @@ class SpeechState extends ChangeNotifier { } // Replace the processSegmentOffline method with this version -Future processSegmentOffline(AudioSegment segment) async { - debugPrint('Processing segment ${segment.index} offline (${segment.samples.length} samples)'); - - if (segment.samples.isEmpty) { - debugPrint('Empty samples for segment ${segment.index}, skipping'); - return; - } - - try { - if (speechIsolate == null) { - debugPrint('Speech isolate not initialized, failing silently'); + Future processSegmentOffline(AudioSegment segment) async { + debugPrint('Processing segment ${segment.index} offline (${segment.samples.length} samples)'); + + if (segment.samples.isEmpty) { + debugPrint('Empty samples for segment ${segment.index}, skipping'); return; } - - // Debug current speaker count - debugPrint('Sending segment with current speaker count: $currentSpeakerCount'); - - // Use the isolate to process this segment - await speechIsolate!.processSegment(ProcessSegmentMessage( - samples: segment.samples, - sampleRate: sampleRate, - segmentIndex: segment.index, - recognizerConfigs: { - 'currentSpeakerCount': currentSpeakerCount, - }, - )); - - // Processing will continue asynchronously, and results will be handled by the listener - - } catch (e) { - debugPrint('Error processing segment ${segment.index} offline: $e'); + + try { + if (speechIsolate == null) { + debugPrint('Speech isolate not initialized, failing silently'); + return; + } + + // Debug current speaker count + debugPrint('Sending segment with current speaker count: $currentSpeakerCount'); + + // Use the isolate to process this segment + await speechIsolate!.processSegment(ProcessSegmentMessage( + samples: segment.samples, + sampleRate: sampleRate, + segmentIndex: segment.index, + recognizerConfigs: { + 'currentSpeakerCount': currentSpeakerCount, + }, + )); + + // Processing will continue asynchronously, and results will be handled by the listener + + } catch (e) { + debugPrint('Error processing segment ${segment.index} offline: $e'); + } } -} Future processPendingSegments() async { if (pendingSegments.isEmpty || isProcessingOffline) { @@ -442,6 +545,9 @@ Future processSegmentOffline(AudioSegment segment) async { recognizedSegments.clear(); recordingFilePath = await _createRecordingFilePath(); + // Start AWS transcription in parallel + await startAwsTranscription(); + const config = RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: 16000, @@ -464,6 +570,11 @@ Future processSegmentOffline(AudioSegment segment) async { recordStream.listen( (data) { + // Send audio data to AWS + if (isAwsTranscribing && awsAudioStreamSink != null) { + awsAudioStreamSink!.add(Uint8List.fromList(data)); + } + final samplesFloat32 = convertBytesToFloat32(Uint8List.fromList(data)); // Always add to complete recording and update timestamp @@ -686,6 +797,7 @@ Future processSegmentOffline(AudioSegment segment) async { return Conversation( segments: List.from(recognizedSegments), // Make a copy audioFilePath: recordingFilePath ?? '', + awsTranscription: currentAwsTranscript, ); } @@ -697,6 +809,20 @@ Future processSegmentOffline(AudioSegment segment) async { recordState = RecordState.stop; notifyListeners(); + // Stop AWS transcription + if (isAwsTranscribing) { + try { + await awsAudioStreamSink?.close(); + await awsTranscriptSubscription?.cancel(); + } catch (e) { + debugPrint('Error stopping AWS transcription: $e'); + } finally { + isAwsTranscribing = false; + awsAudioStreamSink = null; + awsTranscriptSubscription = null; + } + } + // Process any remaining audio if (currentSegmentSamples.isNotEmpty) { debugPrint('Processing final segment $currentIndex'); @@ -741,6 +867,9 @@ Future processSegmentOffline(AudioSegment segment) async { // allAudioSamples.clear(); // Final update to display text _updateDisplayText(); + + final awsTranscript = getAwsRecordedText(); + debugPrint(awsTranscript); debugPrint('Recording stopped successfully'); } catch (e) { @@ -761,6 +890,15 @@ Future processSegmentOffline(AudioSegment segment) async { super.dispose(); } + // Add method to get AWS transcription + String getAwsRecordedText() { + if (lastConversation != null) { + return lastConversation!.getAwsTranscript(); + } else { + return "No AWS transcription available."; + } + } + getRecordedText() { if (lastConversation != null) { return lastConversation!.getTranscript(); diff --git a/team_b/yappy/lib/services/transcription.dart b/team_b/yappy/lib/services/transcription.dart new file mode 100644 index 00000000..115e9545 --- /dev/null +++ b/team_b/yappy/lib/services/transcription.dart @@ -0,0 +1,86 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'models.dart'; + +/// Converts a stream of [TranscriptEvent]s into a single [String]. +final class TranscriptEventStreamDecoder + extends Converter { + /// Creates a [TranscriptEventStreamDecoder] with a given + /// [TranscriptionBuildingStrategy]. + const TranscriptEventStreamDecoder(this.transcriptionBuildingStrategy); + + /// The strategy to use for building a transcription from a list of [Result]s. + final TranscriptionBuildingStrategy transcriptionBuildingStrategy; + + @override + String convert(TranscriptEvent input) { + return transcriptionBuildingStrategy + .buildTranscription(input.transcript?.results ?? []); + } + + @override + Sink startChunkedConversion(Sink sink) => + _TranscriptEventStreamConversionSink(sink, transcriptionBuildingStrategy); +} + +final class _TranscriptEventStreamConversionSink + implements ChunkedConversionSink { + _TranscriptEventStreamConversionSink( + this.sink, this.transcriptionBuildingStrategy); + + final Sink sink; + final TranscriptionBuildingStrategy transcriptionBuildingStrategy; + + final LinkedHashMap _results = + LinkedHashMap(); + + @override + void add(TranscriptEvent chunk) { + for (final Result result in chunk.transcript?.results ?? []) { + _results[result.resultId!] = result; + } + + sink.add(transcriptionBuildingStrategy.buildTranscription(_results.values)); + } + + @override + void close() { + _results.clear(); + + sink.close(); + } +} + +/// A strategy for building a transcription from a list of [Result]s. +abstract interface class TranscriptionBuildingStrategy { + /// Creates a [TranscriptionBuildingStrategy]. + const TranscriptionBuildingStrategy(); + + /// Builds a transcription from a list of [Result]s. + String buildTranscription(Iterable results); +} + +/// A transcription strategy that simply concatenates the transcripts into +/// a plain text. +final class PlainTextTranscriptionStrategy + implements TranscriptionBuildingStrategy { + /// Creates a [PlainTextTranscriptionStrategy]. + const PlainTextTranscriptionStrategy(); + + @override + String buildTranscription(Iterable results) { + final buffer = StringBuffer(); + + for (final result in results) { + if (result.alternatives == null) continue; + + for (final alternative in result.alternatives!) { + buffer.write(alternative.transcript); + buffer.write(' '); + } + } + + return buffer.toString().trimRight(); + } +} diff --git a/team_b/yappy/pubspec.yaml b/team_b/yappy/pubspec.yaml index de0f428a..e5d11e00 100644 --- a/team_b/yappy/pubspec.yaml +++ b/team_b/yappy/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: path_provider: ^2.1.5 envied: ^1.1.1 sherpa_onnx: ^1.10.46 - record: ^5.2.1 + record: ^6.0.0 connectivity_plus: ^6.1.3 shared_preferences: ^2.5.2 share_plus: ^10.1.4 # Allows sharing with the phones default apps. @@ -51,6 +51,13 @@ dependencies: flutter_audio_waveforms: ^1.2.1+8 dart_openai: ^5.1.0 provider: ^6.1.4 + aws_common: ^0.7.6 + aws_signature_v4: ^0.6.4 + http2: ^2.3.1 + uuid: ^4.5.1 + convert: ^3.1.2 + crypto: ^3.0.6 + sqflite_common_ffi: ^2.3.5 dev_dependencies: flutter_test: From 2a37abd8cc267bb0dd530d526fffba9b8f1ed88b Mon Sep 17 00:00:00 2001 From: Bernhard Zwahlen Date: Thu, 27 Mar 2025 19:28:59 -0500 Subject: [PATCH 2/7] 'working' aws transcription --- team_b/yappy/lib/env.dart | 11 +- team_b/yappy/lib/industry_menu.dart | 113 ++++------ team_b/yappy/lib/services/speech_state.dart | 221 +++++++++++++++----- team_b/yappy/lib/transcript_dialog.dart | 139 ++++++++++++ team_b/yappy/pubspec.yaml | 1 - 5 files changed, 363 insertions(+), 122 deletions(-) create mode 100644 team_b/yappy/lib/transcript_dialog.dart diff --git a/team_b/yappy/lib/env.dart b/team_b/yappy/lib/env.dart index 49313d57..a764a62e 100644 --- a/team_b/yappy/lib/env.dart +++ b/team_b/yappy/lib/env.dart @@ -1,6 +1,6 @@ import 'package:envied/envied.dart'; -// part 'env.g.dart'; // Uncomment this line to generate the env.g.dart file +part 'env.g.dart'; // Uncomment this line to generate the env.g.dart file // Run `flutter pub run build_runner build` to generate the env.g.dart file @Envied(path: '.env') @@ -9,4 +9,13 @@ abstract class Env { // Use this value instead for local testing: "_Env.apiKey;" // Otherwise, provide an API key within the application's settings while running static String apiKey = ''; + + @EnviedField(varName: 'AWS_REGION') + static const String awsRegion = _Env.awsRegion; + + @EnviedField(varName: 'AWS_ACCESS_KEY', obfuscate: true) + static final String awsAccessKey = _Env.awsAccessKey; + + @EnviedField(varName: 'AWS_SECRET_KEY', obfuscate: true) + static final String awsSecretKey = _Env.awsSecretKey; } diff --git a/team_b/yappy/lib/industry_menu.dart b/team_b/yappy/lib/industry_menu.dart index bb0534ed..e0feacf4 100644 --- a/team_b/yappy/lib/industry_menu.dart +++ b/team_b/yappy/lib/industry_menu.dart @@ -14,6 +14,7 @@ import 'services/model_manager.dart'; import 'services/speech_state.dart'; import 'package:file_picker/file_picker.dart'; import 'package:yappy/services/restaurant_api_module.dart'; +import 'transcript_dialog.dart'; class IndustryMenu extends StatefulWidget { @@ -257,81 +258,57 @@ class _IndustryMenuState extends State { ? null : () async { await widget.speechState.toggleRecording(); - // When speechState.stop happens it needs to store the text in the database - // The new text file needs to get the USERID, create a new Transcript ID, - // The user will be asked to edit the text to ensure accuracy. After hitting save, the text will be saved to the database in the transcript table using the same transcript ID - if (widget.speechState.recordState == - RecordState.stop) { - // Fetch the recorded text - String recordedText = - await widget.speechState.getRecordedText(); - - // Get the user ID (assuming you have a method to get the current user ID) + + if (widget.speechState.recordState == RecordState.stop) { + // Fetch both transcripts + String localTranscript = widget.speechState.getRecordedText(); + String awsTranscript = widget.speechState.getAwsRecordedText(); + + // Get the user ID int userId = 0001; - + // Create a new transcript ID - int transcriptId = - DateTime.now().millisecondsSinceEpoch; - - // Show a dialog to edit the text - TextEditingController controller = - TextEditingController(text: recordedText); + int transcriptId = DateTime.now().millisecondsSinceEpoch; + + // Show the dialog with both transcripts if (!context.mounted) return; showDialog( context: context, builder: (BuildContext context) { - return AlertDialog( - title: Text('Edit Transcript'), - content: TextField( - controller: controller, - decoration: InputDecoration( - hintText: 'Edit the transcript text'), - maxLines: null, - ), - actions: [ - TextButton( - onPressed: () async { - // Save the edited text to the database - await DatabaseHelper().saveTranscript( - userId: userId, - transcriptId: transcriptId, - text: controller.text, - industry: widget.title, + return TranscriptDialog( + localTranscript: localTranscript, + awsTranscript: awsTranscript, + userId: userId, + transcriptId: transcriptId, + industry: widget.title, + onSave: (userId, transcriptId, text, industry) async { + // Save the selected transcript to the database + await DatabaseHelper().saveTranscript( + userId: userId, + transcriptId: transcriptId, + text: text, + industry: industry, + ); + + // Kick off the AI summarization process + var openAIHelper = OpenAIHelper(); + String aiResponse = ''; + try { + aiResponse = await openAIHelper.summarizeTranscription( + userId, industry, transcriptId + ); + } catch (e) { + // Lets the user know that transcription summarization failed + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to summarize transcription: $e')), ); - // Kick off the AI summarization process - var openAIHelper = OpenAIHelper(); - String aiResponse = ''; - try { - aiResponse = await openAIHelper - .summarizeTranscription(userId, - widget.title, transcriptId); - } catch (e) { - // Lets the user know that transcription summarization failed (likely because of a lack of OpenAI API key) - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - 'Failed to summarize transcription: $e')), - ); - } - } - // Place API hook here to parse aiResponse String and populate additional information based on industry: - debugPrint( - aiResponse); // not a necessary statement after implementation - - if (!context.mounted) return; - Navigator.of(context).pop(); - }, - child: Text('Save'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text('Cancel'), - ), - ], + } + } + + // Place API hook here to parse aiResponse + debugPrint(aiResponse); + }, ); }, ); diff --git a/team_b/yappy/lib/services/speech_state.dart b/team_b/yappy/lib/services/speech_state.dart index 0c58cc58..6542a593 100644 --- a/team_b/yappy/lib/services/speech_state.dart +++ b/team_b/yappy/lib/services/speech_state.dart @@ -15,6 +15,7 @@ import 'package:aws_common/aws_common.dart'; import 'client.dart'; import 'models.dart'; import 'transcription.dart'; +import '../env.dart'; Future createOnlineRecognizer() async { final type = 0; @@ -191,9 +192,10 @@ class AudioSegment { class SpeechState extends ChangeNotifier { TranscribeStreamingClient? awsClient; StreamSink? awsAudioStreamSink; - StreamSubscription? awsTranscriptSubscription; + StreamSubscription? awsTranscriptSubscription; String currentAwsTranscript = ''; bool isAwsTranscribing = false; + StringBuffer awsTranscriptBuffer = StringBuffer(); final TextEditingController controller = TextEditingController(); final AudioRecorder audioRecorder = AudioRecorder(); @@ -302,25 +304,25 @@ class SpeechState extends ChangeNotifier { // In production, consider using more secure credential providers final credentialsProvider = StaticCredentialsProvider( AWSCredentials( - '', // Replace with your idkey - '' // Replace with your secretkey + Env.awsAccessKey, // Replace with your idkey + Env.awsSecretKey // Replace with your secretkey ), ); // Create AWS Transcribe client awsClient = TranscribeStreamingClient( - region: 'us-east-2', // Replace with your AWS region + region: Env.awsRegion, // Replace with your AWS region credentialsProvider: credentialsProvider, ); - debugPrint('AWS Transcribe client initialized'); + debugPrint('🚣 AWS Transcribe client initialized'); } catch (e) { - debugPrint('Error initializing AWS client: $e'); + debugPrint('🚣 Error initializing AWS client: $e'); } } } - // Method to start AWS transcription + // Start AWS transcription Future startAwsTranscription() async { if (awsClient == null) { await initializeAwsClient(); @@ -329,23 +331,43 @@ class SpeechState extends ChangeNotifier { try { isAwsTranscribing = true; currentAwsTranscript = ''; + awsTranscriptBuffer.clear(); - // Create AWS Transcribe request + // Create request with proper parameters final request = StartStreamTranscriptionRequest( - languageCode: LanguageCode.enUs, // Change according to your needs - mediaSampleRateHertz: sampleRate, // Using existing sample rate + languageCode: LanguageCode.enUs, + mediaSampleRateHertz: sampleRate, // Should be 16000 mediaEncoding: MediaEncoding.pcm, - showSpeakerLabel: true, // Enable speaker identification + showSpeakerLabel: true, + enablePartialResultsStabilization: true, + partialResultsStability: PartialResultsStability.high, ); + debugPrint('Starting AWS transcription with sample rate $sampleRate'); + // Start streaming final (response, sink, stream) = await awsClient!.startStreamTranscription(request); awsAudioStreamSink = sink; - // Set up listener for transcription events - awsTranscriptSubscription = stream.listen( - (event) { - _processAwsTranscriptEvent(event); + // Use the TranscriptEventStreamDecoder to handle events + final transcriptStream = stream.transform( + TranscriptEventStreamDecoder(CustomTranscriptionStrategy()) + ); + + // Listen to the decoded stream + awsTranscriptSubscription = transcriptStream.listen( + (transcriptText) { + // This is now the full transcript text + if (transcriptText.isNotEmpty) { + currentAwsTranscript = transcriptText; + + // Update in the conversation object if available + if (lastConversation != null) { + lastConversation!.awsTranscription = currentAwsTranscript; + } + + notifyListeners(); + } }, onError: (error) { debugPrint('AWS Transcription Error: $error'); @@ -362,26 +384,50 @@ class SpeechState extends ChangeNotifier { } } - // Process transcription events from AWS - void _processAwsTranscriptEvent(TranscriptEvent event) { - if (event.transcript?.results == null || event.transcript!.results!.isEmpty) { - return; - } - - // Build transcription from results - final strategy = PlainTextTranscriptionStrategy(); - final transcriptText = strategy.buildTranscription(event.transcript!.results!); + // // Process transcription events from AWS + // void _processAwsTranscriptEvent(TranscriptEvent event, StringBuffer fullTranscriptBuffer) { + // if (event.transcript?.results == null || event.transcript!.results!.isEmpty) { + // return; + // } + + // // Clear the buffer and rebuild the full transcript each time + // fullTranscriptBuffer.clear(); - // Update current transcript - if (transcriptText.isNotEmpty) { - // Update in the conversation object if available - if (lastConversation != null) { - lastConversation!.awsTranscription = transcriptText; - } - currentAwsTranscript = transcriptText; - notifyListeners(); - } - } + // // Process all results to build a complete transcript + // for (final result in event.transcript!.results!) { + // // Only add final results to the complete transcript + // if (result.isPartial == false) { + // // Get the transcript text from the first alternative + // if (result.alternatives != null && + // result.alternatives!.isNotEmpty && + // result.alternatives!.first.transcript != null) { + + // final transcript = result.alternatives!.first.transcript!; + + // // Add speaker label if available + // if (result.alternatives!.first.items != null && + // result.alternatives!.first.items!.isNotEmpty && + // result.alternatives!.first.items!.first.speaker != null) { + + // final speaker = result.alternatives!.first.items!.first.speaker; + // fullTranscriptBuffer.write('\n$speaker: $transcript'); + // } else { + // fullTranscriptBuffer.write('\n$transcript'); + // } + // } + // } + // } + + // // Update current transcript + // currentAwsTranscript = fullTranscriptBuffer.toString().trim(); + + // // Update in the conversation object if available + // if (lastConversation != null) { + // lastConversation!.awsTranscription = currentAwsTranscript; + // } + + // notifyListeners(); + // } // Helper method to update the displayed text void _updateDisplayText() { @@ -570,9 +616,13 @@ class SpeechState extends ChangeNotifier { recordStream.listen( (data) { - // Send audio data to AWS + // Send to AWS if (isAwsTranscribing && awsAudioStreamSink != null) { - awsAudioStreamSink!.add(Uint8List.fromList(data)); + try { + awsAudioStreamSink!.add(Uint8List.fromList(data)); + } catch (e) { + debugPrint('Error sending audio to AWS: $e'); + } } final samplesFloat32 = convertBytesToFloat32(Uint8List.fromList(data)); @@ -801,6 +851,28 @@ class SpeechState extends ChangeNotifier { ); } + // Properly stop AWS transcription + Future stopAwsTranscription() async { + if (isAwsTranscribing) { + try { + // Close the audio sink to indicate end of stream + await awsAudioStreamSink?.close(); + + // Give AWS a moment to process final audio + await Future.delayed(const Duration(milliseconds: 500)); + + // Then cancel the subscription + await awsTranscriptSubscription?.cancel(); + } catch (e) { + debugPrint('Error stopping AWS transcription: $e'); + } finally { + isAwsTranscribing = false; + awsAudioStreamSink = null; + awsTranscriptSubscription = null; + } + } + } + Future stopRecording() async { debugPrint('Stopping recording'); @@ -809,19 +881,8 @@ class SpeechState extends ChangeNotifier { recordState = RecordState.stop; notifyListeners(); - // Stop AWS transcription - if (isAwsTranscribing) { - try { - await awsAudioStreamSink?.close(); - await awsTranscriptSubscription?.cancel(); - } catch (e) { - debugPrint('Error stopping AWS transcription: $e'); - } finally { - isAwsTranscribing = false; - awsAudioStreamSink = null; - awsTranscriptSubscription = null; - } - } + // Stop AWS transcription properly + await stopAwsTranscription(); // Process any remaining audio if (currentSegmentSamples.isNotEmpty) { @@ -867,9 +928,6 @@ class SpeechState extends ChangeNotifier { // allAudioSamples.clear(); // Final update to display text _updateDisplayText(); - - final awsTranscript = getAwsRecordedText(); - debugPrint(awsTranscript); debugPrint('Recording stopped successfully'); } catch (e) { @@ -907,3 +965,62 @@ class SpeechState extends ChangeNotifier { } } } + +// Custom transcription strategy that accumulates a complete transcript +class CustomTranscriptionStrategy implements TranscriptionBuildingStrategy { + @override + String buildTranscription(Iterable results) { + final buffer = StringBuffer(); + + // Group results by channel if channel ID is present + final resultsByChannel = >{}; + + for (final result in results) { + if (!result.isPartial!) { + final channelId = result.channelId ?? 'default'; + if (!resultsByChannel.containsKey(channelId)) { + resultsByChannel[channelId] = []; + } + resultsByChannel[channelId]!.add(result); + } + } + + // Process each channel's results + resultsByChannel.forEach((channelId, channelResults) { + // Sort results by start time + channelResults.sort((a, b) => (a.startTime ?? 0).compareTo(b.startTime ?? 0)); + + String? previousSpeaker; + + for (final result in channelResults) { + if (result.alternatives == null || result.alternatives!.isEmpty) continue; + + for (final alternative in result.alternatives!) { + // Check if we have speaker identification + String? currentSpeaker; + if (alternative.items != null && alternative.items!.isNotEmpty) { + currentSpeaker = alternative.items!.first.speaker; + } + + if (currentSpeaker != null) { + int speakerId = int.tryParse(currentSpeaker) ?? 0; + currentSpeaker = "Speaker ${speakerId + 1}"; + + // Add extra newline if speaker changes + if (previousSpeaker != null && previousSpeaker != currentSpeaker) { + buffer.write('\n'); + } + + buffer.write('\n$currentSpeaker: ${alternative.transcript}'); + previousSpeaker = currentSpeaker; // Update previous speaker + } else { + buffer.write('\n${alternative.transcript}'); + previousSpeaker = null; // Reset previous speaker for unmarked text + } + } + } + }); + + return buffer.toString().trim(); + } +} \ No newline at end of file diff --git a/team_b/yappy/lib/transcript_dialog.dart b/team_b/yappy/lib/transcript_dialog.dart new file mode 100644 index 00000000..c50be162 --- /dev/null +++ b/team_b/yappy/lib/transcript_dialog.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +class TranscriptDialog extends StatefulWidget { + final String localTranscript; + final String awsTranscript; + final int userId; + final int transcriptId; + final String industry; + final Function(int userId, int transcriptId, String text, String industry) onSave; + + const TranscriptDialog({ + super.key, + required this.localTranscript, + required this.awsTranscript, + required this.userId, + required this.transcriptId, + required this.industry, + required this.onSave, + }); + + @override + State createState() => _TranscriptDialogState(); +} + +class _TranscriptDialogState extends State { + late TextEditingController _localController; + late TextEditingController _awsController; + bool _showAwsTranscript = false; + + @override + void initState() { + super.initState(); + _localController = TextEditingController(text: widget.localTranscript); + _awsController = TextEditingController(text: widget.awsTranscript); + } + + @override + void dispose() { + _localController.dispose(); + _awsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Text('Edit Transcript'), + Spacer(), + // Toggle switch + ToggleButtons( + isSelected: [!_showAwsTranscript, _showAwsTranscript], + onPressed: (index) { + setState(() { + _showAwsTranscript = index == 1; + }); + }, + borderRadius: BorderRadius.circular(8), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('Local'), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('AWS'), + ), + ], + ), + ], + ), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_showAwsTranscript) + Text( + 'AWS Transcription', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ) + else + Text( + 'Local Transcription', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + SizedBox(height: 8), + TextField( + controller: _showAwsTranscript ? _awsController : _localController, + decoration: InputDecoration( + hintText: 'Edit the transcript text', + border: OutlineInputBorder(), + ), + maxLines: 10, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () async { + // Save the currently selected transcript + final textToSave = _showAwsTranscript + ? _awsController.text + : _localController.text; + + await widget.onSave( + widget.userId, + widget.transcriptId, + textToSave, + widget.industry, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text('Save'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Cancel'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/team_b/yappy/pubspec.yaml b/team_b/yappy/pubspec.yaml index e5d11e00..86d24709 100644 --- a/team_b/yappy/pubspec.yaml +++ b/team_b/yappy/pubspec.yaml @@ -57,7 +57,6 @@ dependencies: uuid: ^4.5.1 convert: ^3.1.2 crypto: ^3.0.6 - sqflite_common_ffi: ^2.3.5 dev_dependencies: flutter_test: From 99b13cd491a73f82464a3c9da0315d4a6c4abebe Mon Sep 17 00:00:00 2001 From: FlyingWaffle Date: Thu, 27 Mar 2025 19:30:06 -0500 Subject: [PATCH 3/7] better integration of AWS settings --- team_b/yappy/lib/industry_menu.dart | 4 ++- team_b/yappy/lib/main.dart | 34 +++++++++++++++++---- team_b/yappy/lib/services/speech_state.dart | 24 ++++++++------- team_b/yappy/lib/settings_page.dart | 12 ++++---- team_b/yappy/lib/transcript_dialog.dart | 31 ++++++++++++++++--- team_b/yappy/pubspec.yaml | 1 + 6 files changed, 77 insertions(+), 29 deletions(-) diff --git a/team_b/yappy/lib/industry_menu.dart b/team_b/yappy/lib/industry_menu.dart index e0feacf4..b2222898 100644 --- a/team_b/yappy/lib/industry_menu.dart +++ b/team_b/yappy/lib/industry_menu.dart @@ -7,6 +7,7 @@ import 'package:record/record.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'main.dart'; import 'services/openai_helper.dart'; import 'services/database_helper.dart'; import 'services/file_handler.dart'; @@ -263,6 +264,7 @@ class _IndustryMenuState extends State { // Fetch both transcripts String localTranscript = widget.speechState.getRecordedText(); String awsTranscript = widget.speechState.getAwsRecordedText(); + bool awsAvailable = await preferences.setBool('awsAvailable', true); // Get the user ID int userId = 0001; @@ -278,6 +280,7 @@ class _IndustryMenuState extends State { return TranscriptDialog( localTranscript: localTranscript, awsTranscript: awsTranscript, + awsAvailable: awsAvailable, userId: userId, transcriptId: transcriptId, industry: widget.title, @@ -306,7 +309,6 @@ class _IndustryMenuState extends State { } } - // Place API hook here to parse aiResponse debugPrint(aiResponse); }, ); diff --git a/team_b/yappy/lib/main.dart b/team_b/yappy/lib/main.dart index 70d1e011..f9921d7f 100644 --- a/team_b/yappy/lib/main.dart +++ b/team_b/yappy/lib/main.dart @@ -1,9 +1,11 @@ +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:yappy/home_page.dart'; -import 'package:yappy/services/database_helper.dart'; import 'package:dart_openai/dart_openai.dart'; -import 'package:yappy/env.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import './home_page.dart'; +import './services/database_helper.dart'; +import './env.dart'; import './toast_widget.dart'; import 'package:provider/provider.dart'; import 'theme_provider.dart'; @@ -22,22 +24,42 @@ void main() async { // Env file setup for local development String apiKey = Env.apiKey; + String awsAccessKey = Env.awsAccessKey; + String awsSecretKey = Env.awsSecretKey; + String awsRegion = Env.awsRegion; if (apiKey.isNotEmpty) { OpenAI.apiKey = apiKey; await preferences.setString('openai_api_key', apiKey); } + if (awsRegion.isNotEmpty && awsAccessKey.isNotEmpty && awsSecretKey.isNotEmpty) + { + await preferences.setString('aws_access_key', awsAccessKey); + await preferences.setString('aws_secret_key', awsSecretKey); + await preferences.setString('aws_region', awsRegion); + await preferences.setBool('awsAvailable', true); + } + else + { + await preferences.setBool('awsAvailable', false); + } + + if (Platform.isLinux || Platform.isWindows) + { + sqfliteFfiInit(); // Init ffi loader based on platform. + databaseFactory = databaseFactoryFfi; + } await dbHelper.database; WidgetsBinding.instance.addPostFrameCallback((_) { - // Shows dialog requesting an OpenAI API key if not set + // Shows dialog requesting a API keys if not set if (apiKey.isEmpty) { showDialog( context: navigatorKey.currentContext!, builder: (BuildContext context) { return AlertDialog( - title: Text('OpenAI API Key Required'), - content: Text('Please add a valid OpenAI API key via the Settings menu.'), + title: Text('API Keys Required'), + content: Text('Please add valid API keys for OpenAI and AWS via the Settings menu.'), actions: [ TextButton( child: Text('OK'), diff --git a/team_b/yappy/lib/services/speech_state.dart b/team_b/yappy/lib/services/speech_state.dart index 6542a593..fb2642da 100644 --- a/team_b/yappy/lib/services/speech_state.dart +++ b/team_b/yappy/lib/services/speech_state.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:record/record.dart'; import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; +import '../main.dart'; import 'utils.dart'; import 'online_model.dart'; import 'offline_model.dart'; @@ -15,7 +16,6 @@ import 'package:aws_common/aws_common.dart'; import 'client.dart'; import 'models.dart'; import 'transcription.dart'; -import '../env.dart'; Future createOnlineRecognizer() async { final type = 0; @@ -304,19 +304,21 @@ class SpeechState extends ChangeNotifier { // In production, consider using more secure credential providers final credentialsProvider = StaticCredentialsProvider( AWSCredentials( - Env.awsAccessKey, // Replace with your idkey - Env.awsSecretKey // Replace with your secretkey + preferences.getString('aws_access_key')!, // Replace with your idkey + preferences.getString('aws_secret_key')! // Replace with your secretkey ), ); // Create AWS Transcribe client awsClient = TranscribeStreamingClient( - region: Env.awsRegion, // Replace with your AWS region + region: preferences.getString('aws_region')!, // Replace with your AWS region credentialsProvider: credentialsProvider, ); + await preferences.setBool('awsAvailable', true); debugPrint('🚣 AWS Transcribe client initialized'); } catch (e) { + await preferences.setBool('awsAvailable', false); debugPrint('🚣 Error initializing AWS client: $e'); } } @@ -343,7 +345,7 @@ class SpeechState extends ChangeNotifier { partialResultsStability: PartialResultsStability.high, ); - debugPrint('Starting AWS transcription with sample rate $sampleRate'); + debugPrint('🚣 Starting AWS transcription with sample rate $sampleRate'); // Start streaming final (response, sink, stream) = await awsClient!.startStreamTranscription(request); @@ -370,16 +372,16 @@ class SpeechState extends ChangeNotifier { } }, onError: (error) { - debugPrint('AWS Transcription Error: $error'); + debugPrint('🚣 AWS Transcription Error: $error'); }, onDone: () { - debugPrint('AWS Transcription Stream Done'); + debugPrint('🚣 AWS Transcription Stream Done'); }, ); - debugPrint('AWS Transcription Started: ${response.sessionId}'); + debugPrint('🚣 AWS Transcription Started: ${response.sessionId}'); } catch (e) { - debugPrint('Error starting AWS transcription: $e'); + debugPrint('🚣 Error starting AWS transcription: $e'); isAwsTranscribing = false; } } @@ -621,7 +623,7 @@ class SpeechState extends ChangeNotifier { try { awsAudioStreamSink!.add(Uint8List.fromList(data)); } catch (e) { - debugPrint('Error sending audio to AWS: $e'); + debugPrint('🚣 Error sending audio to AWS: $e'); } } @@ -864,7 +866,7 @@ class SpeechState extends ChangeNotifier { // Then cancel the subscription await awsTranscriptSubscription?.cancel(); } catch (e) { - debugPrint('Error stopping AWS transcription: $e'); + debugPrint('🚣 Error stopping AWS transcription: $e'); } finally { isAwsTranscribing = false; awsAudioStreamSink = null; diff --git a/team_b/yappy/lib/settings_page.dart b/team_b/yappy/lib/settings_page.dart index 81934068..227ab32d 100644 --- a/team_b/yappy/lib/settings_page.dart +++ b/team_b/yappy/lib/settings_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import './services/model_manager.dart'; -import 'package:yappy/main.dart'; +import './main.dart'; import 'package:yappy/theme_provider.dart'; class SettingsPage extends StatefulWidget { @@ -64,8 +64,8 @@ class _SettingsPageState extends State { children: [ // Original settings items ListTile( - leading: const Icon(Icons.account_circle), - title: const Text('Account'), + leading: const Icon(Icons.settings), + title: const Text('About Yappy'), onTap: () { showDialog( context: context, @@ -90,14 +90,14 @@ class _SettingsPageState extends State { ), ListTile( leading: const Icon(Icons.key), - title: const Text('API Keys'), + title: const Text('OpenAI API Key'), onTap: () { showDialog( context: context, builder: (BuildContext context) { TextEditingController apiKeyController = TextEditingController(); return AlertDialog( - title: const Text('Enter API Key'), + title: const Text('Enter OpenAI API Key'), content: TextField( controller: apiKeyController, decoration: const InputDecoration(hintText: "API Key"), @@ -138,7 +138,7 @@ class _SettingsPageState extends State { // Divider to separate original and new settings const Divider(), - // New model management settings + // Model management settings const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( diff --git a/team_b/yappy/lib/transcript_dialog.dart b/team_b/yappy/lib/transcript_dialog.dart index c50be162..70b30054 100644 --- a/team_b/yappy/lib/transcript_dialog.dart +++ b/team_b/yappy/lib/transcript_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; class TranscriptDialog extends StatefulWidget { final String localTranscript; final String awsTranscript; + final bool awsAvailable; final int userId; final int transcriptId; final String industry; @@ -12,6 +13,7 @@ class TranscriptDialog extends StatefulWidget { super.key, required this.localTranscript, required this.awsTranscript, + required this.awsAvailable, required this.userId, required this.transcriptId, required this.industry, @@ -32,6 +34,9 @@ class _TranscriptDialogState extends State { super.initState(); _localController = TextEditingController(text: widget.localTranscript); _awsController = TextEditingController(text: widget.awsTranscript); + if (!widget.awsAvailable) { + _showAwsTranscript = false; + } } @override @@ -48,13 +53,22 @@ class _TranscriptDialogState extends State { children: [ Text('Edit Transcript'), Spacer(), - // Toggle switch ToggleButtons( isSelected: [!_showAwsTranscript, _showAwsTranscript], onPressed: (index) { - setState(() { - _showAwsTranscript = index == 1; - }); + if (index == 0 || widget.awsAvailable) { + setState(() { + _showAwsTranscript = index == 1; + }); + } else if (index == 1 && !widget.awsAvailable) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('AWS transcription is not available'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.fixed, + ), + ); + } }, borderRadius: BorderRadius.circular(8), children: [ @@ -64,7 +78,14 @@ class _TranscriptDialogState extends State { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text('AWS'), + child: Text( + 'AWS', + style: TextStyle( + color: widget.awsAvailable + ? null + : Theme.of(context).disabledColor, + ), + ), ), ], ), diff --git a/team_b/yappy/pubspec.yaml b/team_b/yappy/pubspec.yaml index 86d24709..e5d11e00 100644 --- a/team_b/yappy/pubspec.yaml +++ b/team_b/yappy/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: uuid: ^4.5.1 convert: ^3.1.2 crypto: ^3.0.6 + sqflite_common_ffi: ^2.3.5 dev_dependencies: flutter_test: From d771b2e65d09bb09b1672551b5a0b7989fd5cba2 Mon Sep 17 00:00:00 2001 From: FlyingWaffle Date: Thu, 27 Mar 2025 19:31:31 -0500 Subject: [PATCH 4/7] fix tests and a missing preference set --- team_b/yappy/lib/env.dart | 2 +- team_b/yappy/lib/settings_page.dart | 79 ++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/team_b/yappy/lib/env.dart b/team_b/yappy/lib/env.dart index a764a62e..89801dcb 100644 --- a/team_b/yappy/lib/env.dart +++ b/team_b/yappy/lib/env.dart @@ -1,6 +1,6 @@ import 'package:envied/envied.dart'; -part 'env.g.dart'; // Uncomment this line to generate the env.g.dart file +// part 'env.g.dart'; // Uncomment this line to generate the env.g.dart file // Run `flutter pub run build_runner build` to generate the env.g.dart file @Envied(path: '.env') diff --git a/team_b/yappy/lib/settings_page.dart b/team_b/yappy/lib/settings_page.dart index 227ab32d..459689ca 100644 --- a/team_b/yappy/lib/settings_page.dart +++ b/team_b/yappy/lib/settings_page.dart @@ -126,6 +126,7 @@ class _SettingsPageState extends State { ); }, ), + SwitchListTile( title: const Text('Dark Mode'), subtitle: const Text('Toggle Dark Mode on or off'), @@ -135,7 +136,83 @@ class _SettingsPageState extends State { }, ), - // Divider to separate original and new settings + const Divider(), + + ListTile( + leading: const Icon(Icons.cloud), + title: const Text('AWS Credentials'), + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + TextEditingController accessKeyController = TextEditingController(); + TextEditingController secretKeyController = TextEditingController(); + TextEditingController regionController = TextEditingController(); + + return AlertDialog( + title: const Text('Enter AWS Credentials'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: accessKeyController, + decoration: const InputDecoration( + hintText: "AWS Access Key", + labelText: "Access Key", + ), + ), + const SizedBox(height: 10), + TextField( + controller: secretKeyController, + decoration: const InputDecoration( + hintText: "AWS Secret Key", + labelText: "Secret Key", + ), + obscureText: true, // Hide sensitive information + ), + const SizedBox(height: 10), + TextField( + controller: regionController, + decoration: const InputDecoration( + hintText: "AWS Region (e.g., us-east-1)", + labelText: "Region", + ), + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Save'), + onPressed: () async { + // Save all AWS credentials + String accessKey = accessKeyController.text; + String secretKey = secretKeyController.text; + String region = regionController.text; + + await preferences.setString('aws_access_key', accessKey); + await preferences.setString('aws_secret_key', secretKey); + await preferences.setString('aws_region', region); + await preferences.setBool('awsAvailable', true); + + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + }, + ), + const Divider(), // Model management settings From 6259674a51fd3e61ca608e2c27c36079d2f6410e Mon Sep 17 00:00:00 2001 From: FlyingWaffle Date: Thu, 27 Mar 2025 19:31:31 -0500 Subject: [PATCH 5/7] fix github actions --- team_b/yappy/lib/env.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/team_b/yappy/lib/env.dart b/team_b/yappy/lib/env.dart index 89801dcb..c4696aa6 100644 --- a/team_b/yappy/lib/env.dart +++ b/team_b/yappy/lib/env.dart @@ -5,17 +5,22 @@ import 'package:envied/envied.dart'; // Run `flutter pub run build_runner build` to generate the env.g.dart file @Envied(path: '.env') abstract class Env { - @EnviedField(varName: 'OPENAI_API_KEY') - // Use this value instead for local testing: "_Env.apiKey;" + // Use the following values instead for local testing: + // "_Env.apiKey;" + // "_Env.awsRegion;" + // "_Env.awsAccessKey;" + // "_Env.awsSecretKey;" // Otherwise, provide an API key within the application's settings while running + + @EnviedField(varName: 'OPENAI_API_KEY') static String apiKey = ''; @EnviedField(varName: 'AWS_REGION') - static const String awsRegion = _Env.awsRegion; + static const String awsRegion = ''; @EnviedField(varName: 'AWS_ACCESS_KEY', obfuscate: true) - static final String awsAccessKey = _Env.awsAccessKey; + static final String awsAccessKey = ''; @EnviedField(varName: 'AWS_SECRET_KEY', obfuscate: true) - static final String awsSecretKey = _Env.awsSecretKey; + static final String awsSecretKey = ''; } From c93becb0fa0c73bd00977930d2f9a65e209ee25e Mon Sep 17 00:00:00 2001 From: Bernhard Zwahlen Date: Thu, 27 Mar 2025 19:31:31 -0500 Subject: [PATCH 6/7] fix aws flakiness, mistake on my part --- team_b/yappy/lib/services/speech_state.dart | 49 +-------------------- 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/team_b/yappy/lib/services/speech_state.dart b/team_b/yappy/lib/services/speech_state.dart index fb2642da..eafa117e 100644 --- a/team_b/yappy/lib/services/speech_state.dart +++ b/team_b/yappy/lib/services/speech_state.dart @@ -361,11 +361,11 @@ class SpeechState extends ChangeNotifier { (transcriptText) { // This is now the full transcript text if (transcriptText.isNotEmpty) { - currentAwsTranscript = transcriptText; + currentAwsTranscript += transcriptText; // Update in the conversation object if available if (lastConversation != null) { - lastConversation!.awsTranscription = currentAwsTranscript; + lastConversation!.awsTranscription += currentAwsTranscript; } notifyListeners(); @@ -386,51 +386,6 @@ class SpeechState extends ChangeNotifier { } } - // // Process transcription events from AWS - // void _processAwsTranscriptEvent(TranscriptEvent event, StringBuffer fullTranscriptBuffer) { - // if (event.transcript?.results == null || event.transcript!.results!.isEmpty) { - // return; - // } - - // // Clear the buffer and rebuild the full transcript each time - // fullTranscriptBuffer.clear(); - - // // Process all results to build a complete transcript - // for (final result in event.transcript!.results!) { - // // Only add final results to the complete transcript - // if (result.isPartial == false) { - // // Get the transcript text from the first alternative - // if (result.alternatives != null && - // result.alternatives!.isNotEmpty && - // result.alternatives!.first.transcript != null) { - - // final transcript = result.alternatives!.first.transcript!; - - // // Add speaker label if available - // if (result.alternatives!.first.items != null && - // result.alternatives!.first.items!.isNotEmpty && - // result.alternatives!.first.items!.first.speaker != null) { - - // final speaker = result.alternatives!.first.items!.first.speaker; - // fullTranscriptBuffer.write('\n$speaker: $transcript'); - // } else { - // fullTranscriptBuffer.write('\n$transcript'); - // } - // } - // } - // } - - // // Update current transcript - // currentAwsTranscript = fullTranscriptBuffer.toString().trim(); - - // // Update in the conversation object if available - // if (lastConversation != null) { - // lastConversation!.awsTranscription = currentAwsTranscript; - // } - - // notifyListeners(); - // } - // Helper method to update the displayed text void _updateDisplayText() { final buffer = StringBuffer(); From 594943d24c9ffd96a915d953cbbea461fc248efe Mon Sep 17 00:00:00 2001 From: Bernhard Zwahlen Date: Thu, 27 Mar 2025 19:35:47 -0500 Subject: [PATCH 7/7] fix conflicts --- team_b/yappy/lib/services/speech_state.dart | 9 ++------ team_b/yappy/lib/settings_page.dart | 23 +++++++++++---------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/team_b/yappy/lib/services/speech_state.dart b/team_b/yappy/lib/services/speech_state.dart index eafa117e..9de82814 100644 --- a/team_b/yappy/lib/services/speech_state.dart +++ b/team_b/yappy/lib/services/speech_state.dart @@ -361,13 +361,8 @@ class SpeechState extends ChangeNotifier { (transcriptText) { // This is now the full transcript text if (transcriptText.isNotEmpty) { - currentAwsTranscript += transcriptText; - - // Update in the conversation object if available - if (lastConversation != null) { - lastConversation!.awsTranscription += currentAwsTranscript; - } - + currentAwsTranscript = transcriptText; + notifyListeners(); } }, diff --git a/team_b/yappy/lib/settings_page.dart b/team_b/yappy/lib/settings_page.dart index 459689ca..068d1983 100644 --- a/team_b/yappy/lib/settings_page.dart +++ b/team_b/yappy/lib/settings_page.dart @@ -88,6 +88,18 @@ class _SettingsPageState extends State { ); }, ), + + SwitchListTile( + title: const Text('Dark Mode'), + subtitle: const Text('Toggle Dark Mode on or off'), + value: themeProvider.isDarkMode, + onChanged: (value) { + themeProvider.toggleTheme(value); + }, + ), + + const Divider(), + ListTile( leading: const Icon(Icons.key), title: const Text('OpenAI API Key'), @@ -127,17 +139,6 @@ class _SettingsPageState extends State { }, ), - SwitchListTile( - title: const Text('Dark Mode'), - subtitle: const Text('Toggle Dark Mode on or off'), - value: themeProvider.isDarkMode, - onChanged: (value) { - themeProvider.toggleTheme(value); - }, - ), - - const Divider(), - ListTile( leading: const Icon(Icons.cloud), title: const Text('AWS Credentials'),