diff --git a/client/pubspec.lock b/client/pubspec.lock index 6292be3ed7..f865fa37eb 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -359,7 +359,7 @@ packages: path: "../packages/flet" relative: true source: path - version: "0.82.2" + version: "0.85.0" flet_ads: dependency: "direct main" description: @@ -911,18 +911,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" media_kit: dependency: transitive description: @@ -1628,10 +1628,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" torch_light: dependency: transitive description: diff --git a/sdk/python/examples/services/audio_recorder/example_1/main.py b/sdk/python/examples/services/audio_recorder/basic/main.py similarity index 100% rename from sdk/python/examples/services/audio_recorder/example_1/main.py rename to sdk/python/examples/services/audio_recorder/basic/main.py diff --git a/sdk/python/examples/services/audio_recorder/example_1/pyproject.toml b/sdk/python/examples/services/audio_recorder/basic/pyproject.toml similarity index 83% rename from sdk/python/examples/services/audio_recorder/example_1/pyproject.toml rename to sdk/python/examples/services/audio_recorder/basic/pyproject.toml index 4f77381365..6c8b1c9984 100644 --- a/sdk/python/examples/services/audio_recorder/example_1/pyproject.toml +++ b/sdk/python/examples/services/audio_recorder/basic/pyproject.toml @@ -1,9 +1,9 @@ [project] -name = "services-audio-recorder-example-1" +name = "services-audio-recorder-basic" version = "1.0.0" description = "Records audio, lists input devices, and checks recorder permissions from one screen." requires-python = ">=3.10" -keywords = ["audio recorder", "example 1", "services", "async"] +keywords = ["audio recorder", "basic", "services", "async"] authors = [{ name = "Flet team", email = "hello@flet.dev" }] dependencies = ["flet", "flet-audio-recorder"] @@ -14,7 +14,7 @@ dev = ["flet-cli", "flet-desktop", "flet-web"] categories = ["Services/AudioRecorder"] [tool.flet.metadata] -title = "Audio recorder example" +title = "Audio recorder basic" controls = ["SafeArea", "Column", "Page", "AppBar", "Text", "Button", "AudioRecorder"] layout_pattern = "inline-actions" complexity = "basic" diff --git a/sdk/python/examples/services/audio_recorder/stream/main.py b/sdk/python/examples/services/audio_recorder/stream/main.py new file mode 100644 index 0000000000..93c24427d9 --- /dev/null +++ b/sdk/python/examples/services/audio_recorder/stream/main.py @@ -0,0 +1,74 @@ +import wave + +import flet as ft +import flet_audio_recorder as far + +SAMPLE_RATE = 44100 +CHANNELS = 1 +BYTES_PER_SAMPLE = 2 +OUTPUT_FILE = "streamed-recording.wav" + + +def main(page: ft.Page): + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + + buffer = bytearray() + + def show_snackbar(message: str): + page.show_dialog(ft.SnackBar(content=message, duration=ft.Duration(seconds=5))) + + def handle_stream(e: far.AudioRecorderStreamEvent): + buffer.extend(e.chunk) + status.value = ( + f"Streaming chunk {e.sequence}; {e.bytes_streamed} bytes collected." + ) + + async def handle_recording_start(e: ft.Event[ft.Button]): + if not await recorder.has_permission(): + show_snackbar("Microphone permission is required.") + return + + buffer.clear() + status.value = "Recording..." + await recorder.start_recording( + configuration=far.AudioRecorderConfiguration( + encoder=far.AudioEncoder.PCM16BITS, + sample_rate=SAMPLE_RATE, + channels=CHANNELS, + ), + ) + + async def handle_recording_stop(e: ft.Event[ft.Button]): + await recorder.stop_recording() + if not buffer: + show_snackbar("Nothing was recorded.") + return + + with wave.open(OUTPUT_FILE, "wb") as wav: + wav.setnchannels(CHANNELS) + wav.setsampwidth(BYTES_PER_SAMPLE) + wav.setframerate(SAMPLE_RATE) + wav.writeframes(buffer) + + status.value = f"Saved {len(buffer)} bytes to {OUTPUT_FILE}." + show_snackbar(status.value) + + recorder = far.AudioRecorder(on_stream=handle_stream) + + page.add( + ft.SafeArea( + content=ft.Column( + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.Text("Record PCM16 audio chunks and save them as a WAV file."), + ft.Button("Start streaming", on_click=handle_recording_start), + ft.Button("Stop and save", on_click=handle_recording_stop), + status := ft.Text(), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/audio_recorder/stream/pyproject.toml b/sdk/python/examples/services/audio_recorder/stream/pyproject.toml new file mode 100644 index 0000000000..d0fff24ec6 --- /dev/null +++ b/sdk/python/examples/services/audio_recorder/stream/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "services-audio-recorder-stream" +version = "1.0.0" +description = "Records PCM16 audio chunks and saves the streamed data as a WAV file." +requires-python = ">=3.10" +keywords = ["audio recorder", "streaming", "services", "async", "save to file"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet", "flet-audio-recorder"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Services/AudioRecorder"] + +[tool.flet.metadata] +title = "Audio recorder stream" +controls = ["SafeArea", "Column", "Page", "AppBar", "Text", "Button", "AudioRecorder"] +layout_pattern = "inline-actions" +complexity = "intermediate" +features = ["audio recording", "streaming chunks", "save to file", "async"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/examples/services/audio_recorder/upload/main.py b/sdk/python/examples/services/audio_recorder/upload/main.py new file mode 100644 index 0000000000..487c0f406a --- /dev/null +++ b/sdk/python/examples/services/audio_recorder/upload/main.py @@ -0,0 +1,78 @@ +import time + +import flet as ft +import flet_audio_recorder as far + + +def main(page: ft.Page): + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + + def show_snackbar(message: str): + page.show_dialog(ft.SnackBar(content=message, duration=ft.Duration(seconds=5))) + + def handle_upload(e: far.AudioRecorderUploadEvent): + if e.error: + status.value = f"Upload error: {e.error}" + elif e.progress == 1: + status.value = f"Upload complete: {e.bytes_uploaded or 0} bytes." + else: + status.value = f"Uploading: {e.bytes_uploaded or 0} bytes sent." + + async def handle_recording_start(e: ft.Event[ft.Button]): + if not await recorder.has_permission(): + show_snackbar("Microphone permission is required.") + return + + file_name = f"recordings/rec-{int(time.time())}.pcm" + try: + upload_url = page.get_upload_url(file_name=file_name, expires=600) + except RuntimeError as ex: + if "FLET_SECRET_KEY" not in str(ex): + raise + status.value = ( + "Uploads require a secret key. " + "Set FLET_SECRET_KEY before running this app." + ) + show_snackbar(status.value) + return + + status.value = "Recording..." + await recorder.start_recording( + upload=far.AudioRecorderUploadSettings( + upload_url=upload_url, + file_name=file_name, + ), + configuration=far.AudioRecorderConfiguration( + encoder=far.AudioEncoder.PCM16BITS, + channels=1, + ), + ) + + async def handle_recording_stop(e: ft.Event[ft.Button]): + await recorder.stop_recording() + show_snackbar( + "Recording stopped. See 'uploads/recordings' folder for the recorded file." + ) + + recorder = far.AudioRecorder(on_upload=handle_upload) + + page.add( + ft.SafeArea( + content=ft.Column( + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.Text("Record PCM16 audio and upload it as it streams."), + ft.Button("Start upload", on_click=handle_recording_start), + ft.Button("Stop recording", on_click=handle_recording_stop), + status := ft.Text(), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run( + main, + upload_dir="uploads", + ) diff --git a/sdk/python/examples/services/audio_recorder/upload/pyproject.toml b/sdk/python/examples/services/audio_recorder/upload/pyproject.toml new file mode 100644 index 0000000000..fec86f01fd --- /dev/null +++ b/sdk/python/examples/services/audio_recorder/upload/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "services-audio-recorder-upload" +version = "1.0.0" +description = "Records PCM16 audio and uploads the streamed bytes to Flet upload storage." +requires-python = ">=3.10" +keywords = ["audio recorder", "upload", "streaming", "services", "async"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet", "flet-audio-recorder"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Services/AudioRecorder"] + +[tool.flet.metadata] +title = "Audio recorder upload" +controls = ["SafeArea", "Column", "Page", "AppBar", "Text", "Button", "AudioRecorder"] +layout_pattern = "inline-actions" +complexity = "intermediate" +features = ["audio recording", "streaming upload", "upload progress", "async"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/packages/flet-audio-recorder/CHANGELOG.md b/sdk/python/packages/flet-audio-recorder/CHANGELOG.md index f18ef68263..f979656304 100644 --- a/sdk/python/packages/flet-audio-recorder/CHANGELOG.md +++ b/sdk/python/packages/flet-audio-recorder/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 0.85.0 + +### Added + +- Added PCM16 streaming to `AudioRecorder`, including `on_stream` chunks and direct upload support via `AudioRecorderUploadSettings` ([#5858](https://github.com/flet-dev/flet/issues/5858)) by @ndonkoHenri. + ## 0.80.0 ### Added diff --git a/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/__init__.py b/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/__init__.py index ca5413e68c..5279de47ba 100644 --- a/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/__init__.py +++ b/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/__init__.py @@ -6,6 +6,9 @@ AudioRecorderConfiguration, AudioRecorderState, AudioRecorderStateChangeEvent, + AudioRecorderStreamEvent, + AudioRecorderUploadEvent, + AudioRecorderUploadSettings, InputDevice, IosAudioCategoryOption, IosRecorderConfiguration, @@ -19,6 +22,9 @@ "AudioRecorderConfiguration", "AudioRecorderState", "AudioRecorderStateChangeEvent", + "AudioRecorderStreamEvent", + "AudioRecorderUploadEvent", + "AudioRecorderUploadSettings", "InputDevice", "IosAudioCategoryOption", "IosRecorderConfiguration", diff --git a/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/audio_recorder.py b/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/audio_recorder.py index bf77a875e0..a31db5b0b0 100644 --- a/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/audio_recorder.py +++ b/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/audio_recorder.py @@ -7,6 +7,9 @@ AudioEncoder, AudioRecorderConfiguration, AudioRecorderStateChangeEvent, + AudioRecorderStreamEvent, + AudioRecorderUploadEvent, + AudioRecorderUploadSettings, InputDevice, ) @@ -18,9 +21,8 @@ class AudioRecorder(ft.Service): """ A control that allows you to record audio from your device. - This control can record audio using different - audio encoders and also allows configuration - of various audio recording parameters such as + This control can record audio using different audio encoders and also allows + configuration of various audio recording parameters such as noise suppression, echo cancellation, and more. """ @@ -33,40 +35,74 @@ class AudioRecorder(ft.Service): on_state_change: Optional[ft.EventHandler[AudioRecorderStateChangeEvent]] = None """ - Event handler that is called when the state of the audio recorder changes. + Called when recording state changes. + """ + + on_upload: Optional[ft.EventHandler[AudioRecorderUploadEvent]] = None + """ + Called when streaming upload progress or errors are available. + """ + + on_stream: Optional[ft.EventHandler[AudioRecorderStreamEvent]] = None + """ + Called when a raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` \ + recording chunk is available. """ async def start_recording( self, output_path: Optional[str] = None, configuration: Optional[AudioRecorderConfiguration] = None, + upload: Optional[AudioRecorderUploadSettings] = None, ) -> bool: """ - Starts recording audio and saves it to the specified output path. + Starts recording audio and saves it to a file or streams it. + + If neither `upload` nor :attr:`on_stream` is used, `output_path` must be + provided on platforms other than web. - If not on the web, the `output_path` parameter must be provided. + When streaming, use :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` as + the encoder. In that case, emitted or uploaded + :attr:`~flet_audio_recorder.AudioRecorderStreamEvent.chunk`s contain raw PCM16 + data. In some use cases, these chunks can be wrapped in a container such as + WAV if the output must be directly playable as an audio file. Args: output_path: The file path where the audio will be saved. It must be specified if not on web. - configuration: The configuration for the audio recorder. - If `None`, the `AudioRecorder.configuration` will be used. + configuration: The configuration for the audio recorder. If `None`, the + :attr:`flet_audio_recorder.AudioRecorder.configuration` will be used. + upload: Upload settings to stream recording bytes directly + to a destination, for example a URL returned by + :meth:`flet.Page.get_upload_url`. Returns: `True` if recording was successfully started, `False` otherwise. Raises: - ValueError: If `output_path` is not provided on platforms other than web. + ValueError: If `output_path` is not provided on platforms other than web + when neither streaming nor uploads are requested. + ValueError: If streaming is requested with an encoder other than + :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS`. """ - if not (self.page.web or output_path): + is_streaming = upload is not None or self.on_stream is not None + if not is_streaming and not (self.page.web or output_path): raise ValueError("output_path must be provided on platforms other than web") + + effective_configuration = ( + configuration if configuration is not None else self.configuration + ) + if is_streaming and effective_configuration.encoder != AudioEncoder.PCM16BITS: + raise ValueError( + "Streaming recordings require AudioEncoder.PCM16BITS as encoder." + ) + return await self._invoke_method( method_name="start_recording", arguments={ "output_path": output_path, - "configuration": configuration - if configuration is not None - else self.configuration, + "configuration": effective_configuration, + "upload": upload, }, ) @@ -81,10 +117,11 @@ async def is_recording(self) -> bool: async def stop_recording(self) -> Optional[str]: """ - Stops the audio recording and optionally returns the path to the saved file. + Stops the audio recording and optionally returns the recording location. Returns: - The file path where the audio was saved or `None` if not applicable. + The local file path where the audio was saved, a Blob URL on web, or + `None` when streaming (i.e. when `upload` or :attr:`on_stream` is set). """ return await self._invoke_method("stop_recording") @@ -141,9 +178,10 @@ async def get_input_devices(self) -> list[InputDevice]: async def has_permission(self) -> bool: """ - Checks if the app has permission to record audio. + Checks if the app has permission to record audio, requesting it if needed. Returns: - `True` if the app has permission, `False` otherwise. + `True` if permission is already granted or granted after the request; + `False` otherwise. """ return await self._invoke_method("has_permission") diff --git a/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/types.py b/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/types.py index b915348470..0f6472d21f 100644 --- a/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/types.py +++ b/sdk/python/packages/flet-audio-recorder/src/flet_audio_recorder/types.py @@ -14,6 +14,9 @@ "AudioRecorderConfiguration", "AudioRecorderState", "AudioRecorderStateChangeEvent", + "AudioRecorderStreamEvent", + "AudioRecorderUploadEvent", + "AudioRecorderUploadSettings", "InputDevice", "IosAudioCategoryOption", "IosRecorderConfiguration", @@ -38,13 +41,62 @@ class AudioRecorderStateChangeEvent(ft.Event["AudioRecorder"]): """ Event payload for recorder state transitions. - Emitted by `AudioRecorder` when recording state changes. + Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_state_change`. """ state: AudioRecorderState """The new state of the audio recorder.""" +@dataclass +class AudioRecorderUploadEvent(ft.Event["AudioRecorder"]): + """ + Event payload for streaming recording uploads. + + Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_upload` for + uploads started with :meth:`flet_audio_recorder.AudioRecorder.start_recording`. + """ + + file_name: Optional[str] = None + """Name provided by :attr:`AudioRecorderUploadSettings.file_name`.""" + + progress: Optional[float] = None + """ + Upload progress from `0.0` to `1.0`. + + Streaming uploads do not know their total size until recording stops, so + :attr:`bytes_uploaded` is usually the best progress indicator while recording is + active. + """ + + bytes_uploaded: Optional[int] = None + """Number of bytes uploaded so far.""" + + error: Optional[str] = None + """Error message if the upload failed.""" + + +@dataclass +class AudioRecorderStreamEvent(ft.Event["AudioRecorder"]): + """ + Event payload for raw recording stream chunks. + + Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_stream`. + """ + + chunk: bytes + """ + Raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` audio bytes emitted by \ + :class:`~flet_audio_recorder.AudioRecorder`. + """ + + sequence: int + """Incremental chunk number.""" + + bytes_streamed: int + """Total number of bytes delivered through :attr:`chunk` so far.""" + + class AudioEncoder(Enum): """ Represents the different audio encoders for audio recording. @@ -357,3 +409,35 @@ class AudioRecorderConfiguration: """ iOS specific configuration. """ + + +@ft.value +class AudioRecorderUploadSettings: + """ + Upload settings for streaming recordings. + + Note: + Uploads started by :meth:`flet_audio_recorder.AudioRecorder.start_recording` + send raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` bytes. They do + not add a playable audio container such as WAV. + """ + + upload_url: str + """ + Destination URL, for example one returned by :meth:`flet.Page.get_upload_url`. + """ + + method: str = "PUT" + """ + HTTP method to use when uploading the streamed bytes. + """ + + headers: Optional[dict[str, str]] = None + """ + HTTP headers sent with the upload request. + """ + + file_name: Optional[str] = None + """ + Friendly name reported in upload events. + """ diff --git a/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/lib/src/audio_recorder.dart b/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/lib/src/audio_recorder.dart index 5ce7f09679..c91b8c7c47 100644 --- a/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/lib/src/audio_recorder.dart +++ b/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/lib/src/audio_recorder.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flet/flet.dart'; import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; import 'package:record/record.dart'; import 'utils/audio_recorder.dart'; @@ -10,7 +12,16 @@ class AudioRecorderService extends FletService { AudioRecorderService({required super.control}); AudioRecorder? recorder; - StreamSubscription? _onStateChangedSubscription; + StreamSubscription? _onStateChangedSubscription; + + // Subscription to the raw PCM audio stream produced by `recorder.startStream`. + // Only active while a streaming recording (upload and/or Python-side stream) + // is in progress; `null` for regular file-based recordings. + StreamSubscription? _recordStreamSubscription; + + // Holds the state of the current streaming recording (upload request, + // bytes counter, completion signal). `null` when no streaming is active. + _StreamingSession? _streamSession; @override void init() { @@ -39,10 +50,18 @@ class AudioRecorderService extends FletService { switch (name) { case "start_recording": final config = parseRecordConfig(args["configuration"]); + final upload = args["upload"]; + final stream = control.hasEventHandler("stream"); if (config != null && await recorder!.hasPermission()) { + // If either upload or stream is requested, switch to the + // streaming code path (PCM chunks instead of file output). + if (upload != null || stream) { + return await _startStreamingRecording(config, upload, stream); + } + final out = control.backend.getAssetSource(args["output_path"] ?? ""); if (!isWebPlatform() && !out.isFile) { - // on non-web/IO platforms, the output path must be a valid file path + // on non-web platforms, the output path must be a valid file path return false; } @@ -51,13 +70,29 @@ class AudioRecorderService extends FletService { } return false; case "stop_recording": + // For streaming recordings there is no output file to return; instead + // we stop the recorder and wait for the audio stream's `onDone` handler + // (`_finishStreamingRecording`) to flush and close the upload request. + if (_streamSession != null) { + await recorder!.stop(); + await _streamSession?.completed.future; + return null; + } return await recorder!.stop(); case "cancel_recording": + // Tear down any in-flight streaming session before cancelling the + // recorder so partial uploads are aborted and listeners are notified. + if (_streamSession != null) { + await _cancelStreamingRecording("Recording cancelled"); + } await recorder!.cancel(); + break; case "resume_recording": await recorder!.resume(); + break; case "pause_recording": await recorder!.pause(); + break; case "is_supported_encoder": var encoder = parseAudioEncoder(args["encoder"]); if (encoder != null) { @@ -84,8 +119,352 @@ class AudioRecorderService extends FletService { void dispose() { debugPrint("AudioRecorder(${control.id}).dispose()"); _onStateChangedSubscription?.cancel(); + _recordStreamSubscription?.cancel(); + _streamSession?.dispose(); recorder?.dispose(); control.removeInvokeMethodListener(_invokeMethod); super.dispose(); } + + /// Starts a streaming recording. + /// + /// Depending on the arguments, the raw PCM chunks produced by the recorder + /// are either: + /// * forwarded to a remote HTTP endpoint via a chunked `StreamedRequest` + /// (when [uploadArgs] is provided), and/or + /// * pushed to Python as "stream" events (when [stream] is `true`). + /// + /// Both sinks can be active at the same time. Returns `true` if the + /// recorder successfully started streaming, `false` otherwise. + Future _startStreamingRecording( + RecordConfig config, + Map? uploadArgs, + bool stream, + ) async { + // Defensive cleanup: ensure no previous streaming session is lingering + // before we start a new one (e.g. if the user restarts without stopping). + await _recordStreamSubscription?.cancel(); + await _streamSession?.dispose(); + _recordStreamSubscription = null; + _streamSession = null; + + final uploadConfig = uploadArgs != null + ? _UploadConfig.fromMap(Map.from(uploadArgs)) + : null; + + // Build a chunked HTTP request up front. We don't call `.send()` yet — that + // happens after the audio subscription is wired up so the first chunks are not lost. + http.StreamedRequest? request; + if (uploadConfig != null) { + final uploadUrl = + _getFullUploadUrl(control.backend.pageUri, uploadConfig.url); + request = http.StreamedRequest(uploadConfig.method, Uri.parse(uploadUrl)); + if (uploadConfig.headers != null) { + request.headers.addAll(uploadConfig.headers!); + } + } + + final session = _StreamingSession( + stream: stream, uploadConfig: uploadConfig, request: request); + _streamSession = session; + + try { + final audioStream = await recorder!.startStream(config); + + // Emit an initial 0% progress event so listeners can show an upload + // started state before any bytes are produced by the microphone. + if (uploadConfig != null) { + _sendUploadEvent( + fileName: uploadConfig.fileName, progress: 0.0, bytesUploaded: 0); + } + + _recordStreamSubscription = audioStream.listen( + (chunk) { + // For every audio chunk produced by the recorder: count it, feed + // it to the HTTP upload sink (if any), and forward it to Python + // (if a stream handler is subscribed). + session.bytesSent += chunk.length; + session.request?.sink.add(chunk); + + if (session.request != null) { + _sendUploadEvent( + fileName: uploadConfig?.fileName, + bytesUploaded: session.bytesSent, + ); + } + + if (session.stream) { + _sendStreamEvent( + chunk, + sequence: session.nextSequence(), + bytesStreamed: session.bytesSent, + ); + } + }, + onError: (error) async { + if (uploadConfig != null) { + _sendUploadEvent( + fileName: uploadConfig.fileName, + error: error.toString(), + ); + } + await _cancelStreamingRecording(); + }, + onDone: () async { + // The recorder stopped normally — finalize the upload and notify. + await _finishStreamingRecording(); + }, + cancelOnError: true, + ); + + // Kick off the HTTP request now that the sink will receive chunks. + session.startUpload(); + return true; + } catch (error) { + // Anything thrown while starting the stream (permissions, network, + // recorder errors) is reported and the session is discarded. + if (uploadConfig != null) { + _sendUploadEvent( + fileName: uploadConfig.fileName, + error: error.toString(), + ); + } + session.complete(); + _streamSession = null; + return false; + } + } + + /// Finalizes a streaming recording after the audio stream's `onDone` fires. + /// + /// Closes the HTTP request sink, awaits the server response, and emits a + /// final progress or error event to Python. Always resets the streaming + /// state, even on failure, so a new recording can be started afterwards. + Future _finishStreamingRecording() async { + final session = _streamSession; + if (session == null) { + return; + } + + try { + // Closing the sink signals the end of the chunked request body so the + // server can finish processing and return a response. + await session.request?.sink.close(); + final responseFuture = session.responseFuture; + if (session.request != null && responseFuture != null) { + final response = await responseFuture; + // successful + if (response.statusCode >= 200 && response.statusCode <= 204) { + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + progress: 1.0, + bytesUploaded: session.bytesSent, + ); + } else { + // not successful + final body = await http.Response.fromStream(response); + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + error: + "Upload endpoint returned code ${response.statusCode}: ${body.body}", + ); + } + } + } catch (error) { + if (session.uploadConfig != null) { + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + error: error.toString(), + ); + } + } finally { + // Whatever happened, release the subscription and signal the + // `stop_recording` awaiter that the session has fully wound down. + session.complete(); + await _recordStreamSubscription?.cancel(); + _recordStreamSubscription = null; + _streamSession = null; + } + } + + /// Aborts the current streaming recording. + /// + /// Called when the user cancels a recording or the audio stream emits an + /// error. Closes the upload sink without waiting for a response, notifies + /// Python with [error] (defaulting to "Recording cancelled"), and resets + /// the streaming state. + Future _cancelStreamingRecording([String? error]) async { + final session = _streamSession; + if (session == null) { + return; + } + + try { + await _recordStreamSubscription?.cancel(); + _recordStreamSubscription = null; + await session.request?.sink.close(); + if (session.uploadConfig != null) { + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + error: error ?? "Recording cancelled", + ); + } + } finally { + session.complete(); + _streamSession = null; + } + } + + /// Fires the "upload" event on the Python-side control with the current + /// upload progress or an error message. Any field may be null when not + /// applicable (e.g. `progress` is null for per-chunk progress pings). + void _sendUploadEvent({ + String? fileName, + double? progress, + int? bytesUploaded, + String? error, + }) { + control.triggerEvent("upload", { + "file_name": fileName, + "progress": progress, + "bytes_uploaded": bytesUploaded, + "error": error, + }); + } + + /// Fires the "stream" event with a single PCM [chunk] and its monotonically + /// increasing [sequence] number, allowing the Python side to reassemble the + /// audio in order and detect gaps. + void _sendStreamEvent( + Uint8List chunk, { + required int sequence, + required int bytesStreamed, + }) { + control.triggerEvent("stream", { + "chunk": chunk, + "sequence": sequence, + "bytes_streamed": bytesStreamed, + }); + } + + /// Resolves a possibly-relative [uploadUrl] against the current [pageUri]. + /// + /// If [uploadUrl] already contains an authority (scheme + host) it is used + /// verbatim; otherwise its path/query are combined with the page's + /// scheme/host/port so that relative upload endpoints work out of the box. + String _getFullUploadUrl(Uri pageUri, String uploadUrl) { + final uploadUri = Uri.parse(uploadUrl); + if (uploadUri.hasAuthority) { + return uploadUrl; + } + return Uri( + scheme: pageUri.scheme, + host: pageUri.host, + port: pageUri.port, + path: uploadUri.path, + query: uploadUri.query, + ).toString(); + } +} + +/// Value object describing where/how the streamed recording should be +/// uploaded. Built from the `upload` dict passed from Python on `start_recording`. +class _UploadConfig { + const _UploadConfig({ + required this.url, + required this.method, + this.headers, + this.fileName, + }); + + /// Parses the raw map received from Python. + factory _UploadConfig.fromMap(Map value) { + final headers = value["headers"]; + return _UploadConfig( + url: value["upload_url"], + method: (value["method"] ?? "PUT").toString().toUpperCase(), + headers: headers != null ? Map.from(headers) : null, + fileName: value["file_name"], + ); + } + + /// Destination URL — may be absolute or relative to the Flet page URI. + final String url; + + /// HTTP method to use for the upload (e.g. `PUT` or `POST`). + final String method; + + /// Optional request headers (e.g. auth, content-type). + final Map? headers; + + /// Optional file name echoed back in upload events so Python listeners + /// can correlate progress updates with a specific recording. + final String? fileName; +} + +/// Bundles everything needed to track a single streaming recording: +/// * whether Python is listening to `stream` events, +/// * the upload config and HTTP request (if uploading), +/// * byte/sequence counters, and +/// * a `Completer` used by `stop_recording` to await clean shutdown. +class _StreamingSession { + _StreamingSession({required this.stream, this.uploadConfig, this.request}) + : completed = Completer(); + + /// `true` when Python has a handler attached for the "stream" event. + final bool stream; + + /// Upload target for this session, or `null` when only streaming to Python. + final _UploadConfig? uploadConfig; + + /// Chunked HTTP request receiving the PCM bytes, or `null` when not uploading. + final http.StreamedRequest? request; + + /// Resolves once the session has fully torn down (success, error, or + /// cancellation). Awaited by `stop_recording` so callers can rely on the + /// upload being flushed before the method returns. + final Completer completed; + + /// Future of the server response to the chunked upload. Populated by + /// [startUpload] and awaited by `_finishStreamingRecording`. + Future? responseFuture; + + /// Total number of audio bytes produced so far — used for progress events + /// and as the final uploaded-bytes count. + int bytesSent = 0; + + /// Monotonic counter for stream events, incremented by [nextSequence]. + int _sequence = 0; + + /// Starts sending the chunked request. Must be called after the audio + /// subscription has been attached so no chunks are dropped. + void startUpload() { + if (request != null) { + responseFuture = request!.send(); + } + } + + /// Returns the next sequence number for a "stream" event (starts at 1). + int nextSequence() { + _sequence += 1; + return _sequence; + } + + /// Marks the session as fully wound down. Safe to call multiple times. + void complete() { + if (!completed.isCompleted) { + completed.complete(); + } + } + + /// Best-effort cleanup used when the service itself is being disposed + /// (e.g. the control is removed from the page mid-recording). + Future dispose() async { + try { + await request?.sink.close(); + } catch (_) { + // Ignore sink shutdown errors during service disposal. + } + complete(); + } } diff --git a/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/pubspec.yaml b/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/pubspec.yaml index 77e8cbd3b2..666c8ad25b 100644 --- a/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/pubspec.yaml +++ b/sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: sdk: flutter collection: ^1.16.0 + http: ^1.2.2 record: 6.1.2 flet: diff --git a/sdk/python/packages/flet/src/flet/controls/material/snack_bar.py b/sdk/python/packages/flet/src/flet/controls/material/snack_bar.py index 53fb9ed7d2..591965ca29 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/snack_bar.py +++ b/sdk/python/packages/flet/src/flet/controls/material/snack_bar.py @@ -157,10 +157,10 @@ class SnackBar(DialogControl): A lightweight message with an optional action which briefly displays at the bottom \ of the screen. + Example: ```python page.show_dialog(ft.SnackBar(ft.Text("Opened snack bar"))) ``` - """ content: Annotated[ diff --git a/website/docs/services/audiorecorder/index.md b/website/docs/services/audiorecorder/index.md index 61a194b634..5f93e6dfb9 100644 --- a/website/docs/services/audiorecorder/index.md +++ b/website/docs/services/audiorecorder/index.md @@ -159,9 +159,49 @@ permissions = ["microphone"] ``` -## Example - +## Examples + +### Basic recording + + + +### Streaming chunks + +On web, [`AudioRecorder.stop_recording()`](index.md#flet_audio_recorder.AudioRecorder.stop_recording) +returns a browser-local Blob URL. Use streaming when your app needs access to the recorded bytes. + +Set [`AudioRecorderConfiguration.encoder`](types/audiorecorderconfiguration.md#flet_audio_recorder.AudioRecorderConfiguration.encoder) +to [`AudioEncoder.PCM16BITS`](types/audioencoder.md#flet_audio_recorder.AudioEncoder.PCM16BITS) +and handle [`AudioRecorder.on_stream`](index.md#flet_audio_recorder.AudioRecorder.on_stream) +to receive [`AudioRecorderStreamEvent.chunk`](types/audiorecorderstreamevent.md#flet_audio_recorder.AudioRecorderStreamEvent.chunk) +bytes in Python. + +[`AudioEncoder.PCM16BITS`](types/audioencoder.md#flet_audio_recorder.AudioEncoder.PCM16BITS) +encoded streams are supported on all platforms. Stream chunks are raw PCM16 bytes and are not directly +playable as an audio file. Wrap the bytes in a container such as WAV in Python when +the destination needs a directly playable recording. + + + +### Streaming upload + +Pass [`AudioRecorderUploadSettings`](types/audiorecorderuploadsettings.md) to +[`AudioRecorder.start_recording()`](index.md#flet_audio_recorder.AudioRecorder.start_recording) +to upload [`AudioEncoder.PCM16BITS`](types/audioencoder.md#flet_audio_recorder.AudioEncoder.PCM16BITS) +recording bytes directly while recording. + +The uploaded file contains raw PCM16 bytes, so a `.pcm` extension is intentional. +See the [streaming chunks example](#streaming-chunks) for inspiration, +if you need to create a playable WAV file. + +:::note +Built-in upload URLs from [`Page.get_upload_url()`](../../controls/page.md#flet.Page.get_upload_url) +require upload storage and a signing key. Run with `upload_dir` and set +[`FLET_SECRET_KEY`](../../reference/environment-variables.md#flet_secret_key). +::: + + ## Description diff --git a/website/docs/services/audiorecorder/types/audiorecorderstreamevent.md b/website/docs/services/audiorecorder/types/audiorecorderstreamevent.md new file mode 100644 index 0000000000..ad7f79d645 --- /dev/null +++ b/website/docs/services/audiorecorder/types/audiorecorderstreamevent.md @@ -0,0 +1,7 @@ +--- +title: "AudioRecorderStreamEvent" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/audiorecorder/types/audiorecorderuploadevent.md b/website/docs/services/audiorecorder/types/audiorecorderuploadevent.md new file mode 100644 index 0000000000..4889fad12d --- /dev/null +++ b/website/docs/services/audiorecorder/types/audiorecorderuploadevent.md @@ -0,0 +1,7 @@ +--- +title: "AudioRecorderUploadEvent" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/audiorecorder/types/audiorecorderuploadsettings.md b/website/docs/services/audiorecorder/types/audiorecorderuploadsettings.md new file mode 100644 index 0000000000..49f4cddf64 --- /dev/null +++ b/website/docs/services/audiorecorder/types/audiorecorderuploadsettings.md @@ -0,0 +1,7 @@ +--- +title: "AudioRecorderUploadSettings" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/sidebars.js b/website/sidebars.js index f0e887ab7d..b517de93de 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -1702,10 +1702,22 @@ module.exports = { "type": "doc", "id": "services/audiorecorder/types/audiorecorderstatechangeevent" }, + { + "type": "doc", + "id": "services/audiorecorder/types/audiorecorderstreamevent" + }, { "type": "doc", "id": "services/audiorecorder/types/audiorecorderstate" }, + { + "type": "doc", + "id": "services/audiorecorder/types/audiorecorderuploadevent" + }, + { + "type": "doc", + "id": "services/audiorecorder/types/audiorecorderuploadsettings" + }, { "type": "doc", "id": "services/audiorecorder/types/inputdevice" diff --git a/website/sidebars.yml b/website/sidebars.yml index 5abf04ebd5..1fed954292 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -349,7 +349,10 @@ docs: - services/audiorecorder/types/audioencoder.md - services/audiorecorder/types/audiorecorderconfiguration.md - services/audiorecorder/types/audiorecorderstatechangeevent.md + - services/audiorecorder/types/audiorecorderstreamevent.md - services/audiorecorder/types/audiorecorderstate.md + - services/audiorecorder/types/audiorecorderuploadevent.md + - services/audiorecorder/types/audiorecorderuploadsettings.md - services/audiorecorder/types/inputdevice.md - services/audiorecorder/types/iosaudiocategoryoption.md - services/audiorecorder/types/iosrecorderconfiguration.md