From 1a2ff2b0d466290c7ee84d45fdd9a7915bcd0b85 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 14:10:27 +0200 Subject: [PATCH 1/7] feat(audio-recorder): add recording streams --- .../src/flet_audio_recorder/__init__.py | 6 + .../src/flet_audio_recorder/audio_recorder.py | 39 ++- .../src/flet_audio_recorder/types.py | 71 +++++ .../lib/src/audio_recorder.dart | 294 +++++++++++++++++- .../flutter/flet_audio_recorder/pubspec.yaml | 1 + 5 files changed, 402 insertions(+), 9 deletions(-) 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..5cfeea56de 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, ) @@ -36,29 +39,49 @@ class AudioRecorder(ft.Service): Event handler that is called when the state of the audio recorder changes. """ + on_upload: Optional[ft.EventHandler[AudioRecorderUploadEvent]] = None + """ + Event handler that is called when a streaming upload reports progress or errors. + """ + + on_stream: Optional[ft.EventHandler[AudioRecorderStreamEvent]] = None + """ + Event handler that is called with raw PCM16 chunks while recording streams. + """ + 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 :attr:`on_stream` nor `upload` is used, `output_path` must be + provided on platforms other than web. - If not on the web, the `output_path` parameter must be provided. + Streaming mode uses :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS`. + The emitted or uploaded chunks contain raw PCM16 data, not a WAV container. 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. + upload: Optional 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. """ - 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") return await self._invoke_method( method_name="start_recording", @@ -67,6 +90,7 @@ async def start_recording( "configuration": configuration if configuration is not None else self.configuration, + "upload": upload, }, ) @@ -84,7 +108,7 @@ async def stop_recording(self) -> Optional[str]: Stops the audio recording and optionally returns the path to the saved file. Returns: - The file path where the audio was saved or `None` if not applicable. + The file path where the audio was saved or `None` when streaming. """ return await self._invoke_method("stop_recording") @@ -141,9 +165,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..458c0247a1 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", @@ -45,6 +48,47 @@ class AudioRecorderStateChangeEvent(ft.Event["AudioRecorder"]): """The new state of the audio recorder.""" +@dataclass +class AudioRecorderUploadEvent(ft.Event["AudioRecorder"]): + """ + Event payload for streaming recording uploads. + """ + + file_name: Optional[str] = None + """Name associated with the current upload.""" + + 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. + """ + + chunk: bytes + """Raw PCM16 audio bytes emitted by the recorder.""" + + sequence: int + """Incremental chunk number.""" + + bytes_streamed: int + """Total number of bytes streamed so far.""" + + class AudioEncoder(Enum): """ Represents the different audio encoders for audio recording. @@ -357,3 +401,30 @@ class AudioRecorderConfiguration: """ iOS specific configuration. """ + + +@ft.value +class AudioRecorderUploadSettings: + """ + Upload settings for streaming recordings. + """ + + 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 + """ + Optional 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..5f6e6a6f29 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,9 @@ class AudioRecorderService extends FletService { AudioRecorderService({required super.control}); AudioRecorder? recorder; - StreamSubscription? _onStateChangedSubscription; + StreamSubscription? _onStateChangedSubscription; + StreamSubscription? _recordStreamSubscription; + _StreamingSession? _streamSession; @override void init() { @@ -39,10 +43,16 @@ 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 (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 +61,24 @@ class AudioRecorderService extends FletService { } return false; case "stop_recording": + if (_streamSession != null) { + await recorder!.stop(); + await _streamSession?.completed.future; + return null; + } return await recorder!.stop(); case "cancel_recording": + 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 +105,277 @@ class AudioRecorderService extends FletService { void dispose() { debugPrint("AudioRecorder(${control.id}).dispose()"); _onStateChangedSubscription?.cancel(); + _recordStreamSubscription?.cancel(); + _streamSession?.dispose(); recorder?.dispose(); control.removeInvokeMethodListener(_invokeMethod); super.dispose(); } + + Future _startStreamingRecording( + RecordConfig config, + Map? uploadArgs, + bool stream, + ) async { + if (config.encoder != AudioEncoder.pcm16bits) { + _sendUploadEvent( + error: "Streaming recordings require PCM16BITS encoder.", + ); + return false; + } + + await _recordStreamSubscription?.cancel(); + await _streamSession?.dispose(); + _recordStreamSubscription = null; + _streamSession = null; + + final uploadConfig = uploadArgs != null + ? _UploadConfig.fromMap(Map.from(uploadArgs)) + : null; + + 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); + + if (uploadConfig != null) { + _sendUploadEvent( + fileName: uploadConfig.fileName, + progress: 0.0, + bytesUploaded: 0, + ); + } + + _recordStreamSubscription = audioStream.listen( + (chunk) { + 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 { + await _finishStreamingRecording(); + }, + cancelOnError: true, + ); + + session.startUpload(); + return true; + } catch (error) { + if (uploadConfig != null) { + _sendUploadEvent( + fileName: uploadConfig.fileName, + error: error.toString(), + ); + } + session.complete(); + _streamSession = null; + return false; + } + } + + Future _finishStreamingRecording() async { + final session = _streamSession; + if (session == null) { + return; + } + + try { + await session.request?.sink.close(); + final responseFuture = session.responseFuture; + if (session.request != null && responseFuture != null) { + final response = await responseFuture; + if (response.statusCode < 200 || response.statusCode > 204) { + final body = await http.Response.fromStream(response); + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + error: + "Upload endpoint returned code ${response.statusCode}: ${body.body}", + ); + } else { + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + progress: 1.0, + bytesUploaded: session.bytesSent, + ); + } + } + } catch (error) { + if (session.uploadConfig != null) { + _sendUploadEvent( + fileName: session.uploadConfig?.fileName, + error: error.toString(), + ); + } + } finally { + session.complete(); + await _recordStreamSubscription?.cancel(); + _recordStreamSubscription = null; + _streamSession = null; + } + } + + 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; + } + } + + void _sendUploadEvent({ + String? fileName, + double? progress, + int? bytesUploaded, + String? error, + }) { + control.triggerEvent("upload", { + "file_name": fileName, + "progress": progress, + "bytes_uploaded": bytesUploaded, + "error": error, + }); + } + + void _sendStreamEvent( + Uint8List chunk, { + required int sequence, + required int bytesStreamed, + }) { + control.triggerEvent("stream", { + "chunk": chunk, + "sequence": sequence, + "bytes_streamed": bytesStreamed, + }); + } + + 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(); + } +} + +class _UploadConfig { + const _UploadConfig({ + required this.url, + required this.method, + this.headers, + this.fileName, + }); + + 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"], + ); + } + + final String url; + final String method; + final Map? headers; + final String? fileName; +} + +class _StreamingSession { + _StreamingSession({required this.stream, this.uploadConfig, this.request}) + : completed = Completer(); + + final bool stream; + final _UploadConfig? uploadConfig; + final http.StreamedRequest? request; + final Completer completed; + Future? responseFuture; + int bytesSent = 0; + int _sequence = 0; + + void startUpload() { + if (request != null) { + responseFuture = request!.send(); + } + } + + int nextSequence() { + _sequence += 1; + return _sequence; + } + + void complete() { + if (!completed.isCompleted) { + completed.complete(); + } + } + + 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: From 99f173872ece200dc363d0d2de32ba29947e449c Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 14:10:43 +0200 Subject: [PATCH 2/7] docs(audio-recorder): add streaming examples --- .../services/audio_recorder/stream/main.py | 82 +++++++++++++++++++ .../audio_recorder/stream/pyproject.toml | 26 ++++++ .../services/audio_recorder/upload/main.py | 69 ++++++++++++++++ .../audio_recorder/upload/pyproject.toml | 26 ++++++ website/docs/services/audiorecorder/index.md | 19 ++++- .../types/audiorecorderstreamevent.md | 7 ++ .../types/audiorecorderuploadevent.md | 7 ++ .../types/audiorecorderuploadsettings.md | 7 ++ website/sidebars.js | 12 +++ website/sidebars.yml | 3 + 10 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 sdk/python/examples/services/audio_recorder/stream/main.py create mode 100644 sdk/python/examples/services/audio_recorder/stream/pyproject.toml create mode 100644 sdk/python/examples/services/audio_recorder/upload/main.py create mode 100644 sdk/python/examples/services/audio_recorder/upload/pyproject.toml create mode 100644 website/docs/services/audiorecorder/types/audiorecorderstreamevent.md create mode 100644 website/docs/services/audiorecorder/types/audiorecorderuploadevent.md create mode 100644 website/docs/services/audiorecorder/types/audiorecorderuploadsettings.md 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..2e12b9b768 --- /dev/null +++ b/sdk/python/examples/services/audio_recorder/stream/main.py @@ -0,0 +1,82 @@ +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 + page.appbar = ft.AppBar(title=ft.Text("Audio Recorder Stream"), center_title=True) + + buffer = bytearray() + status = ft.Text("Waiting to record...") + + def show_snackbar(message: str): + page.show_dialog(ft.SnackBar(content=ft.Text(message))) + + 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( + configuration=far.AudioRecorderConfiguration( + encoder=far.AudioEncoder.PCM16BITS, + sample_rate=SAMPLE_RATE, + channels=CHANNELS, + ), + on_stream=handle_stream, + ) + + page.add( + ft.SafeArea( + content=ft.Column( + 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, + ], + ), + ) + ) + + +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..b0111a80cc --- /dev/null +++ b/sdk/python/examples/services/audio_recorder/upload/main.py @@ -0,0 +1,69 @@ +import time + +import flet as ft +import flet_audio_recorder as far + + +def main(page: ft.Page): + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + page.appbar = ft.AppBar(title=ft.Text("Audio Recorder Upload"), center_title=True) + + upload_status = ft.Text("Waiting to record...") + + def show_snackbar(message: str): + page.show_dialog(ft.SnackBar(content=ft.Text(message))) + + def handle_upload(e: far.AudioRecorderUploadEvent): + if e.error: + upload_status.value = f"Upload error: {e.error}" + elif e.progress == 1: + upload_status.value = f"Upload complete: {e.bytes_uploaded or 0} bytes." + else: + upload_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/recording-{int(time.time())}.pcm" + upload_status.value = "Recording..." + await recorder.start_recording( + upload=far.AudioRecorderUploadSettings( + upload_url=page.get_upload_url(file_name, expires=600), + 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.") + + recorder = far.AudioRecorder( + configuration=far.AudioRecorderConfiguration( + encoder=far.AudioEncoder.PCM16BITS, + channels=1, + ), + on_upload=handle_upload, + ) + + page.add( + ft.SafeArea( + content=ft.Column( + 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), + upload_status, + ], + ), + ) + ) + + +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/website/docs/services/audiorecorder/index.md b/website/docs/services/audiorecorder/index.md index 61a194b634..29d139b0dd 100644 --- a/website/docs/services/audiorecorder/index.md +++ b/website/docs/services/audiorecorder/index.md @@ -159,10 +159,27 @@ permissions = ["microphone"] ``` -## Example +## Streaming + +On web, `stop_recording()` returns a browser-local Blob URL. Use streaming when +the Python app needs access to the recorded bytes. + +Set `AudioRecorderConfiguration.encoder` to `AudioEncoder.PCM16BITS` and either: + +- handle `on_stream` to receive `AudioRecorderStreamEvent.chunk` bytes in Python; +- pass `AudioRecorderUploadSettings` to `start_recording()` to upload bytes directly. + +Streaming emits raw PCM16 bytes. Wrap the data in a container such as WAV in Python +if the destination needs a playable audio file. + +## Examples + + + + ## 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 From d0f143d44b15d2621dbf4eeb2a725cf42e082324 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 14:10:59 +0200 Subject: [PATCH 3/7] chore(audio-recorder): update changelog --- sdk/python/packages/flet-audio-recorder/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sdk/python/packages/flet-audio-recorder/CHANGELOG.md b/sdk/python/packages/flet-audio-recorder/CHANGELOG.md index f18ef68263..0a635fb035 100644 --- a/sdk/python/packages/flet-audio-recorder/CHANGELOG.md +++ b/sdk/python/packages/flet-audio-recorder/CHANGELOG.md @@ -5,6 +5,14 @@ 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 streaming recordings with `AudioRecorder.on_stream`. +- Added direct streaming uploads with `AudioRecorderUploadSettings` and `AudioRecorder.on_upload`. +- Added AudioRecorder streaming and upload examples. + ## 0.80.0 ### Added From 796d07c5421f99e23f346814d0112e7b9a354b4b Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 14:40:51 +0200 Subject: [PATCH 4/7] docs(audio-recorder): refine streaming docs --- client/pubspec.lock | 18 +++---- .../{example_1 => basic}/main.py | 0 .../{example_1 => basic}/pyproject.toml | 6 +-- .../services/audio_recorder/stream/main.py | 16 ++---- .../services/audio_recorder/upload/main.py | 49 +++++++++++-------- .../packages/flet-audio-recorder/CHANGELOG.md | 4 +- .../src/flet_audio_recorder/audio_recorder.py | 31 +++++++----- .../src/flet_audio_recorder/types.py | 27 +++++++--- .../src/flet/controls/material/snack_bar.py | 2 +- website/docs/services/audiorecorder/index.md | 43 ++++++++++++---- 10 files changed, 118 insertions(+), 78 deletions(-) rename sdk/python/examples/services/audio_recorder/{example_1 => basic}/main.py (100%) rename sdk/python/examples/services/audio_recorder/{example_1 => basic}/pyproject.toml (83%) 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 index 2e12b9b768..93c24427d9 100644 --- a/sdk/python/examples/services/audio_recorder/stream/main.py +++ b/sdk/python/examples/services/audio_recorder/stream/main.py @@ -11,13 +11,11 @@ def main(page: ft.Page): page.horizontal_alignment = ft.CrossAxisAlignment.CENTER - page.appbar = ft.AppBar(title=ft.Text("Audio Recorder Stream"), center_title=True) buffer = bytearray() - status = ft.Text("Waiting to record...") def show_snackbar(message: str): - page.show_dialog(ft.SnackBar(content=ft.Text(message))) + page.show_dialog(ft.SnackBar(content=message, duration=ft.Duration(seconds=5))) def handle_stream(e: far.AudioRecorderStreamEvent): buffer.extend(e.chunk) @@ -55,23 +53,17 @@ async def handle_recording_stop(e: ft.Event[ft.Button]): status.value = f"Saved {len(buffer)} bytes to {OUTPUT_FILE}." show_snackbar(status.value) - recorder = far.AudioRecorder( - configuration=far.AudioRecorderConfiguration( - encoder=far.AudioEncoder.PCM16BITS, - sample_rate=SAMPLE_RATE, - channels=CHANNELS, - ), - on_stream=handle_stream, - ) + 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, + status := ft.Text(), ], ), ) diff --git a/sdk/python/examples/services/audio_recorder/upload/main.py b/sdk/python/examples/services/audio_recorder/upload/main.py index b0111a80cc..487c0f406a 100644 --- a/sdk/python/examples/services/audio_recorder/upload/main.py +++ b/sdk/python/examples/services/audio_recorder/upload/main.py @@ -6,31 +6,40 @@ def main(page: ft.Page): page.horizontal_alignment = ft.CrossAxisAlignment.CENTER - page.appbar = ft.AppBar(title=ft.Text("Audio Recorder Upload"), center_title=True) - - upload_status = ft.Text("Waiting to record...") def show_snackbar(message: str): - page.show_dialog(ft.SnackBar(content=ft.Text(message))) + page.show_dialog(ft.SnackBar(content=message, duration=ft.Duration(seconds=5))) def handle_upload(e: far.AudioRecorderUploadEvent): if e.error: - upload_status.value = f"Upload error: {e.error}" + status.value = f"Upload error: {e.error}" elif e.progress == 1: - upload_status.value = f"Upload complete: {e.bytes_uploaded or 0} bytes." + status.value = f"Upload complete: {e.bytes_uploaded or 0} bytes." else: - upload_status.value = f"Uploading: {e.bytes_uploaded or 0} bytes sent." + 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/recording-{int(time.time())}.pcm" - upload_status.value = "Recording..." + 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=page.get_upload_url(file_name, expires=600), + upload_url=upload_url, file_name=file_name, ), configuration=far.AudioRecorderConfiguration( @@ -41,24 +50,21 @@ async def handle_recording_start(e: ft.Event[ft.Button]): async def handle_recording_stop(e: ft.Event[ft.Button]): await recorder.stop_recording() - show_snackbar("Recording stopped.") + show_snackbar( + "Recording stopped. See 'uploads/recordings' folder for the recorded file." + ) - recorder = far.AudioRecorder( - configuration=far.AudioRecorderConfiguration( - encoder=far.AudioEncoder.PCM16BITS, - channels=1, - ), - on_upload=handle_upload, - ) + 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), - upload_status, + status := ft.Text(), ], ), ) @@ -66,4 +72,7 @@ async def handle_recording_stop(e: ft.Event[ft.Button]): if __name__ == "__main__": - ft.run(main, upload_dir="uploads") + ft.run( + main, + upload_dir="uploads", + ) diff --git a/sdk/python/packages/flet-audio-recorder/CHANGELOG.md b/sdk/python/packages/flet-audio-recorder/CHANGELOG.md index 0a635fb035..f979656304 100644 --- a/sdk/python/packages/flet-audio-recorder/CHANGELOG.md +++ b/sdk/python/packages/flet-audio-recorder/CHANGELOG.md @@ -9,9 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Added streaming recordings with `AudioRecorder.on_stream`. -- Added direct streaming uploads with `AudioRecorderUploadSettings` and `AudioRecorder.on_upload`. -- Added AudioRecorder streaming and upload examples. +- 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 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 5cfeea56de..da11a46ff2 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 @@ -21,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. """ @@ -36,17 +35,18 @@ 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 """ - Event handler that is called when a streaming upload reports progress or errors. + Called when streaming upload progress or errors are available. """ on_stream: Optional[ft.EventHandler[AudioRecorderStreamEvent]] = None """ - Event handler that is called with raw PCM16 chunks while recording streams. + Called when a raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` \ + recording chunk is available. """ async def start_recording( @@ -58,18 +58,21 @@ async def start_recording( """ Starts recording audio and saves it to a file or streams it. - If neither :attr:`on_stream` nor `upload` is used, `output_path` must be + If neither `upload` nor :attr:`on_stream` is used, `output_path` must be provided on platforms other than web. - Streaming mode uses :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS`. - The emitted or uploaded chunks contain raw PCM16 data, not a WAV container. + When streaming, use :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` as + encoder, in which case, then emitted or uploaded + :attr:`~flet_audio_recorder.AudioRecorderStreamEvent.chunk`s contain raw PCM16 + data. In some usecases, these chunks could 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. - upload: Optional upload settings to stream recording bytes directly + 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`. @@ -83,6 +86,7 @@ async def start_recording( 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") + return await self._invoke_method( method_name="start_recording", arguments={ @@ -108,7 +112,8 @@ async def stop_recording(self) -> Optional[str]: Stops the audio recording and optionally returns the path to the saved file. Returns: - The file path where the audio was saved or `None` when streaming. + The file path where the audio was saved or `None` when + streaming (i.e. when `upload` or :attr:`on_stream` is set). """ return await self._invoke_method("stop_recording") 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 458c0247a1..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 @@ -41,7 +41,7 @@ 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 @@ -52,18 +52,21 @@ class AudioRecorderStateChangeEvent(ft.Event["AudioRecorder"]): 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 associated with the current upload.""" + """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. + :attr:`bytes_uploaded` is usually the best progress indicator while recording is + active. """ bytes_uploaded: Optional[int] = None @@ -77,16 +80,21 @@ class AudioRecorderUploadEvent(ft.Event["AudioRecorder"]): class AudioRecorderStreamEvent(ft.Event["AudioRecorder"]): """ Event payload for raw recording stream chunks. + + Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_stream`. """ chunk: bytes - """Raw PCM16 audio bytes emitted by the recorder.""" + """ + 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 streamed so far.""" + """Total number of bytes delivered through :attr:`chunk` so far.""" class AudioEncoder(Enum): @@ -407,6 +415,11 @@ class AudioRecorderConfiguration: 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 @@ -421,7 +434,7 @@ class AudioRecorderUploadSettings: headers: Optional[dict[str, str]] = None """ - Optional HTTP headers sent with the upload request. + HTTP headers sent with the upload request. """ file_name: Optional[str] = None 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 29d139b0dd..5f93e6dfb9 100644 --- a/website/docs/services/audiorecorder/index.md +++ b/website/docs/services/audiorecorder/index.md @@ -159,25 +159,48 @@ permissions = ["microphone"] ``` -## Streaming -On web, `stop_recording()` returns a browser-local Blob URL. Use streaming when -the Python app needs access to the recorded bytes. +## Examples -Set `AudioRecorderConfiguration.encoder` to `AudioEncoder.PCM16BITS` and either: +### Basic recording -- handle `on_stream` to receive `AudioRecorderStreamEvent.chunk` bytes in Python; -- pass `AudioRecorderUploadSettings` to `start_recording()` to upload bytes directly. + -Streaming emits raw PCM16 bytes. Wrap the data in a container such as WAV in Python -if the destination needs a playable audio file. +### Streaming chunks -## Examples +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 From 46e906122eb2b20e777563f9916209a25b953ad3 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 16:39:31 +0200 Subject: [PATCH 5/7] improve docs --- .../lib/src/audio_recorder.dart | 136 +++++++++++++++--- 1 file changed, 117 insertions(+), 19 deletions(-) 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 5f6e6a6f29..4fa4461e6a 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 @@ -13,7 +13,14 @@ class AudioRecorderService extends FletService { AudioRecorder? recorder; 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 @@ -46,6 +53,8 @@ class AudioRecorderService extends FletService { 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); } @@ -61,6 +70,9 @@ 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; @@ -68,6 +80,8 @@ class AudioRecorderService extends FletService { } 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"); } @@ -112,11 +126,23 @@ class AudioRecorderService extends FletService { 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 { + // The `record` package only exposes a raw byte stream for PCM16; other + // encoders don't have a portable streaming path, so we fail fast here. if (config.encoder != AudioEncoder.pcm16bits) { _sendUploadEvent( error: "Streaming recordings require PCM16BITS encoder.", @@ -124,6 +150,8 @@ class AudioRecorderService extends FletService { return false; } + // 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; @@ -133,12 +161,12 @@ class AudioRecorderService extends FletService { ? _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, - ); + 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!); @@ -146,25 +174,24 @@ class AudioRecorderService extends FletService { } final session = _StreamingSession( - stream: stream, - uploadConfig: uploadConfig, - request: request, - ); + 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, - ); + 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); @@ -193,14 +220,18 @@ class AudioRecorderService extends FletService { 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, @@ -213,6 +244,11 @@ class AudioRecorderService extends FletService { } } + /// 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) { @@ -220,22 +256,26 @@ class AudioRecorderService extends FletService { } 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; - if (response.statusCode < 200 || response.statusCode > 204) { - final body = await http.Response.fromStream(response); + // successful + if (response.statusCode >= 200 && response.statusCode <= 204) { _sendUploadEvent( fileName: session.uploadConfig?.fileName, - error: - "Upload endpoint returned code ${response.statusCode}: ${body.body}", + progress: 1.0, + bytesUploaded: session.bytesSent, ); } else { + // not successful + final body = await http.Response.fromStream(response); _sendUploadEvent( fileName: session.uploadConfig?.fileName, - progress: 1.0, - bytesUploaded: session.bytesSent, + error: + "Upload endpoint returned code ${response.statusCode}: ${body.body}", ); } } @@ -247,6 +287,8 @@ class AudioRecorderService extends FletService { ); } } 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; @@ -254,6 +296,12 @@ class AudioRecorderService extends FletService { } } + /// 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) { @@ -276,6 +324,9 @@ class AudioRecorderService extends FletService { } } + /// 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, @@ -290,6 +341,9 @@ class AudioRecorderService extends FletService { }); } + /// 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, @@ -302,6 +356,11 @@ class AudioRecorderService extends FletService { }); } + /// 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) { @@ -317,6 +376,8 @@ class AudioRecorderService extends FletService { } } +/// 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, @@ -325,6 +386,7 @@ class _UploadConfig { this.fileName, }); + /// Parses the raw map received from Python. factory _UploadConfig.fromMap(Map value) { final headers = value["headers"]; return _UploadConfig( @@ -335,41 +397,77 @@ class _UploadConfig { ); } + /// 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(); + : 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(); From 26a7ae06cb584e7f2781c7c12897805fce4d08b8 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 16:43:08 +0200 Subject: [PATCH 6/7] enforce PCM16BITS encoder for streaming recordings --- .../src/flet_audio_recorder/audio_recorder.py | 14 +++++++++++--- .../lib/src/audio_recorder.dart | 9 --------- 2 files changed, 11 insertions(+), 12 deletions(-) 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 da11a46ff2..5c49dcad96 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 @@ -82,18 +82,26 @@ async def start_recording( Raises: 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`. """ 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, }, ) 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 4fa4461e6a..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 @@ -141,15 +141,6 @@ class AudioRecorderService extends FletService { Map? uploadArgs, bool stream, ) async { - // The `record` package only exposes a raw byte stream for PCM16; other - // encoders don't have a portable streaming path, so we fail fast here. - if (config.encoder != AudioEncoder.pcm16bits) { - _sendUploadEvent( - error: "Streaming recordings require PCM16BITS encoder.", - ); - return false; - } - // 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(); From dcbf3c67ef8e959061a0e2e4339f82b4d286fe44 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 16 Apr 2026 22:57:40 +0200 Subject: [PATCH 7/7] clarify streaming behavior and recording return values --- .../src/flet_audio_recorder/audio_recorder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 5c49dcad96..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 @@ -62,9 +62,9 @@ async def start_recording( provided on platforms other than web. When streaming, use :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` as - encoder, in which case, then emitted or uploaded + the encoder. In that case, emitted or uploaded :attr:`~flet_audio_recorder.AudioRecorderStreamEvent.chunk`s contain raw PCM16 - data. In some usecases, these chunks could be wrapped in a container such as + 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: @@ -117,11 +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` when - streaming (i.e. when `upload` or :attr:`on_stream` is set). + 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")