flet-audio-recorder 0.85.0.dev2__tar.gz → 0.85.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {flet_audio_recorder-0.85.0.dev2/src/flet_audio_recorder.egg-info → flet_audio_recorder-0.85.0.dev4}/PKG-INFO +3 -3
  2. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/README.md +1 -1
  3. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/pyproject.toml +2 -2
  4. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flet_audio_recorder/__init__.py +6 -0
  5. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flet_audio_recorder/audio_recorder.py +55 -17
  6. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flet_audio_recorder/types.py +85 -1
  7. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4/src/flet_audio_recorder.egg-info}/PKG-INFO +3 -3
  8. flet_audio_recorder-0.85.0.dev4/src/flet_audio_recorder.egg-info/requires.txt +1 -0
  9. flet_audio_recorder-0.85.0.dev4/src/flutter/flet_audio_recorder/lib/src/audio_recorder.dart +470 -0
  10. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flutter/flet_audio_recorder/pubspec.yaml +1 -0
  11. flet_audio_recorder-0.85.0.dev2/src/flet_audio_recorder.egg-info/requires.txt +0 -1
  12. flet_audio_recorder-0.85.0.dev2/src/flutter/flet_audio_recorder/lib/src/audio_recorder.dart +0 -91
  13. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/LICENSE +0 -0
  14. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/setup.cfg +0 -0
  15. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flet_audio_recorder.egg-info/SOURCES.txt +0 -0
  16. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flet_audio_recorder.egg-info/dependency_links.txt +0 -0
  17. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flet_audio_recorder.egg-info/top_level.txt +0 -0
  18. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flutter/flet_audio_recorder/lib/flet_audio_recorder.dart +0 -0
  19. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flutter/flet_audio_recorder/lib/src/extension.dart +0 -0
  20. {flet_audio_recorder-0.85.0.dev2 → flet_audio_recorder-0.85.0.dev4}/src/flutter/flet_audio_recorder/lib/src/utils/audio_recorder.dart +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-audio-recorder
3
- Version: 0.85.0.dev2
3
+ Version: 0.85.0.dev4
4
4
  Summary: Adds audio recording support to Flet apps.
5
5
  Author-email: Flet contributors <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/flet-dev/flet/issues
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: flet==0.85.0.dev2
14
+ Requires-Dist: flet==0.85.0.dev4
15
15
  Dynamic: license-file
16
16
 
17
17
  # flet-audio-recorder
@@ -59,4 +59,4 @@ To install the `flet-audio-recorder` package and add it to your project dependen
59
59
 
60
60
  ### Examples
61
61
 
62
- For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/examples/services/audio_recorder).
62
+ For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/examples/extensions/audio_recorder).
@@ -43,4 +43,4 @@ To install the `flet-audio-recorder` package and add it to your project dependen
43
43
 
44
44
  ### Examples
45
45
 
46
- For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/examples/services/audio_recorder).
46
+ For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/examples/extensions/audio_recorder).
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "flet-audio-recorder"
3
- version = "0.85.0.dev2"
3
+ version = "0.85.0.dev4"
4
4
  description = "Adds audio recording support to Flet apps."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Flet contributors", email = "hello@flet.dev" }]
7
7
  license = "Apache-2.0"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
- "flet==0.85.0.dev2",
10
+ "flet==0.85.0.dev4",
11
11
  ]
12
12
 
13
13
  [project.urls]
@@ -6,6 +6,9 @@ from .types import (
6
6
  AudioRecorderConfiguration,
7
7
  AudioRecorderState,
8
8
  AudioRecorderStateChangeEvent,
9
+ AudioRecorderStreamEvent,
10
+ AudioRecorderUploadEvent,
11
+ AudioRecorderUploadSettings,
9
12
  InputDevice,
10
13
  IosAudioCategoryOption,
11
14
  IosRecorderConfiguration,
@@ -19,6 +22,9 @@ __all__ = [
19
22
  "AudioRecorderConfiguration",
20
23
  "AudioRecorderState",
21
24
  "AudioRecorderStateChangeEvent",
25
+ "AudioRecorderStreamEvent",
26
+ "AudioRecorderUploadEvent",
27
+ "AudioRecorderUploadSettings",
22
28
  "InputDevice",
23
29
  "IosAudioCategoryOption",
24
30
  "IosRecorderConfiguration",
@@ -7,6 +7,9 @@ from .types import (
7
7
  AudioEncoder,
8
8
  AudioRecorderConfiguration,
9
9
  AudioRecorderStateChangeEvent,
10
+ AudioRecorderStreamEvent,
11
+ AudioRecorderUploadEvent,
12
+ AudioRecorderUploadSettings,
10
13
  InputDevice,
11
14
  )
12
15
 
@@ -18,9 +21,8 @@ class AudioRecorder(ft.Service):
18
21
  """
19
22
  A control that allows you to record audio from your device.
20
23
 
21
- This control can record audio using different
22
- audio encoders and also allows configuration
23
- of various audio recording parameters such as
24
+ This control can record audio using different audio encoders and also allows
25
+ configuration of various audio recording parameters such as
24
26
  noise suppression, echo cancellation, and more.
25
27
  """
26
28
 
@@ -33,40 +35,74 @@ class AudioRecorder(ft.Service):
33
35
 
34
36
  on_state_change: Optional[ft.EventHandler[AudioRecorderStateChangeEvent]] = None
35
37
  """
36
- Event handler that is called when the state of the audio recorder changes.
38
+ Called when recording state changes.
39
+ """
40
+
41
+ on_upload: Optional[ft.EventHandler[AudioRecorderUploadEvent]] = None
42
+ """
43
+ Called when streaming upload progress or errors are available.
44
+ """
45
+
46
+ on_stream: Optional[ft.EventHandler[AudioRecorderStreamEvent]] = None
47
+ """
48
+ Called when a raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` \
49
+ recording chunk is available.
37
50
  """
38
51
 
39
52
  async def start_recording(
40
53
  self,
41
54
  output_path: Optional[str] = None,
42
55
  configuration: Optional[AudioRecorderConfiguration] = None,
56
+ upload: Optional[AudioRecorderUploadSettings] = None,
43
57
  ) -> bool:
44
58
  """
45
- Starts recording audio and saves it to the specified output path.
59
+ Starts recording audio and saves it to a file or streams it.
60
+
61
+ If neither `upload` nor :attr:`on_stream` is used, `output_path` must be
62
+ provided on platforms other than web.
46
63
 
47
- If not on the web, the `output_path` parameter must be provided.
64
+ When streaming, use :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` as
65
+ the encoder. In that case, emitted or uploaded
66
+ :attr:`~flet_audio_recorder.AudioRecorderStreamEvent.chunk`s contain raw PCM16
67
+ data. In some use cases, these chunks can be wrapped in a container such as
68
+ WAV if the output must be directly playable as an audio file.
48
69
 
49
70
  Args:
50
71
  output_path: The file path where the audio will be saved.
51
72
  It must be specified if not on web.
52
- configuration: The configuration for the audio recorder.
53
- If `None`, the `AudioRecorder.configuration` will be used.
73
+ configuration: The configuration for the audio recorder. If `None`, the
74
+ :attr:`flet_audio_recorder.AudioRecorder.configuration` will be used.
75
+ upload: Upload settings to stream recording bytes directly
76
+ to a destination, for example a URL returned by
77
+ :meth:`flet.Page.get_upload_url`.
54
78
 
55
79
  Returns:
56
80
  `True` if recording was successfully started, `False` otherwise.
57
81
 
58
82
  Raises:
59
- ValueError: If `output_path` is not provided on platforms other than web.
83
+ ValueError: If `output_path` is not provided on platforms other than web
84
+ when neither streaming nor uploads are requested.
85
+ ValueError: If streaming is requested with an encoder other than
86
+ :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS`.
60
87
  """
61
- if not (self.page.web or output_path):
88
+ is_streaming = upload is not None or self.on_stream is not None
89
+ if not is_streaming and not (self.page.web or output_path):
62
90
  raise ValueError("output_path must be provided on platforms other than web")
91
+
92
+ effective_configuration = (
93
+ configuration if configuration is not None else self.configuration
94
+ )
95
+ if is_streaming and effective_configuration.encoder != AudioEncoder.PCM16BITS:
96
+ raise ValueError(
97
+ "Streaming recordings require AudioEncoder.PCM16BITS as encoder."
98
+ )
99
+
63
100
  return await self._invoke_method(
64
101
  method_name="start_recording",
65
102
  arguments={
66
103
  "output_path": output_path,
67
- "configuration": configuration
68
- if configuration is not None
69
- else self.configuration,
104
+ "configuration": effective_configuration,
105
+ "upload": upload,
70
106
  },
71
107
  )
72
108
 
@@ -81,10 +117,11 @@ class AudioRecorder(ft.Service):
81
117
 
82
118
  async def stop_recording(self) -> Optional[str]:
83
119
  """
84
- Stops the audio recording and optionally returns the path to the saved file.
120
+ Stops the audio recording and optionally returns the recording location.
85
121
 
86
122
  Returns:
87
- The file path where the audio was saved or `None` if not applicable.
123
+ The local file path where the audio was saved, a Blob URL on web, or
124
+ `None` when streaming (i.e. when `upload` or :attr:`on_stream` is set).
88
125
  """
89
126
  return await self._invoke_method("stop_recording")
90
127
 
@@ -141,9 +178,10 @@ class AudioRecorder(ft.Service):
141
178
 
142
179
  async def has_permission(self) -> bool:
143
180
  """
144
- Checks if the app has permission to record audio.
181
+ Checks if the app has permission to record audio, requesting it if needed.
145
182
 
146
183
  Returns:
147
- `True` if the app has permission, `False` otherwise.
184
+ `True` if permission is already granted or granted after the request;
185
+ `False` otherwise.
148
186
  """
149
187
  return await self._invoke_method("has_permission")
@@ -14,6 +14,9 @@ __all__ = [
14
14
  "AudioRecorderConfiguration",
15
15
  "AudioRecorderState",
16
16
  "AudioRecorderStateChangeEvent",
17
+ "AudioRecorderStreamEvent",
18
+ "AudioRecorderUploadEvent",
19
+ "AudioRecorderUploadSettings",
17
20
  "InputDevice",
18
21
  "IosAudioCategoryOption",
19
22
  "IosRecorderConfiguration",
@@ -38,13 +41,62 @@ class AudioRecorderStateChangeEvent(ft.Event["AudioRecorder"]):
38
41
  """
39
42
  Event payload for recorder state transitions.
40
43
 
41
- Emitted by `AudioRecorder` when recording state changes.
44
+ Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_state_change`.
42
45
  """
43
46
 
44
47
  state: AudioRecorderState
45
48
  """The new state of the audio recorder."""
46
49
 
47
50
 
51
+ @dataclass
52
+ class AudioRecorderUploadEvent(ft.Event["AudioRecorder"]):
53
+ """
54
+ Event payload for streaming recording uploads.
55
+
56
+ Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_upload` for
57
+ uploads started with :meth:`flet_audio_recorder.AudioRecorder.start_recording`.
58
+ """
59
+
60
+ file_name: Optional[str] = None
61
+ """Name provided by :attr:`AudioRecorderUploadSettings.file_name`."""
62
+
63
+ progress: Optional[float] = None
64
+ """
65
+ Upload progress from `0.0` to `1.0`.
66
+
67
+ Streaming uploads do not know their total size until recording stops, so
68
+ :attr:`bytes_uploaded` is usually the best progress indicator while recording is
69
+ active.
70
+ """
71
+
72
+ bytes_uploaded: Optional[int] = None
73
+ """Number of bytes uploaded so far."""
74
+
75
+ error: Optional[str] = None
76
+ """Error message if the upload failed."""
77
+
78
+
79
+ @dataclass
80
+ class AudioRecorderStreamEvent(ft.Event["AudioRecorder"]):
81
+ """
82
+ Event payload for raw recording stream chunks.
83
+
84
+ Delivered by :attr:`flet_audio_recorder.AudioRecorder.on_stream`.
85
+ """
86
+
87
+ chunk: bytes
88
+ """
89
+ Raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` audio bytes emitted by \
90
+ :class:`~flet_audio_recorder.AudioRecorder`.
91
+ """
92
+
93
+ sequence: int
94
+ """Incremental chunk number."""
95
+
96
+ bytes_streamed: int
97
+ """Total number of bytes delivered through :attr:`chunk` so far."""
98
+
99
+
48
100
  class AudioEncoder(Enum):
49
101
  """
50
102
  Represents the different audio encoders for audio recording.
@@ -357,3 +409,35 @@ class AudioRecorderConfiguration:
357
409
  """
358
410
  iOS specific configuration.
359
411
  """
412
+
413
+
414
+ @ft.value
415
+ class AudioRecorderUploadSettings:
416
+ """
417
+ Upload settings for streaming recordings.
418
+
419
+ Note:
420
+ Uploads started by :meth:`flet_audio_recorder.AudioRecorder.start_recording`
421
+ send raw :attr:`~flet_audio_recorder.AudioEncoder.PCM16BITS` bytes. They do
422
+ not add a playable audio container such as WAV.
423
+ """
424
+
425
+ upload_url: str
426
+ """
427
+ Destination URL, for example one returned by :meth:`flet.Page.get_upload_url`.
428
+ """
429
+
430
+ method: str = "PUT"
431
+ """
432
+ HTTP method to use when uploading the streamed bytes.
433
+ """
434
+
435
+ headers: Optional[dict[str, str]] = None
436
+ """
437
+ HTTP headers sent with the upload request.
438
+ """
439
+
440
+ file_name: Optional[str] = None
441
+ """
442
+ Friendly name reported in upload events.
443
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-audio-recorder
3
- Version: 0.85.0.dev2
3
+ Version: 0.85.0.dev4
4
4
  Summary: Adds audio recording support to Flet apps.
5
5
  Author-email: Flet contributors <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/flet-dev/flet/issues
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: flet==0.85.0.dev2
14
+ Requires-Dist: flet==0.85.0.dev4
15
15
  Dynamic: license-file
16
16
 
17
17
  # flet-audio-recorder
@@ -59,4 +59,4 @@ To install the `flet-audio-recorder` package and add it to your project dependen
59
59
 
60
60
  ### Examples
61
61
 
62
- For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/examples/services/audio_recorder).
62
+ For examples, see [these](https://github.com/flet-dev/flet/tree/main/sdk/python/examples/extensions/audio_recorder).
@@ -0,0 +1,470 @@
1
+ import 'dart:async';
2
+ import 'dart:typed_data';
3
+
4
+ import 'package:flet/flet.dart';
5
+ import 'package:flutter/widgets.dart';
6
+ import 'package:http/http.dart' as http;
7
+ import 'package:record/record.dart';
8
+
9
+ import 'utils/audio_recorder.dart';
10
+
11
+ class AudioRecorderService extends FletService {
12
+ AudioRecorderService({required super.control});
13
+
14
+ AudioRecorder? recorder;
15
+ StreamSubscription<RecordState>? _onStateChangedSubscription;
16
+
17
+ // Subscription to the raw PCM audio stream produced by `recorder.startStream`.
18
+ // Only active while a streaming recording (upload and/or Python-side stream)
19
+ // is in progress; `null` for regular file-based recordings.
20
+ StreamSubscription<Uint8List>? _recordStreamSubscription;
21
+
22
+ // Holds the state of the current streaming recording (upload request,
23
+ // bytes counter, completion signal). `null` when no streaming is active.
24
+ _StreamingSession? _streamSession;
25
+
26
+ @override
27
+ void init() {
28
+ super.init();
29
+ debugPrint("AudioRecorder.init($hashCode)");
30
+ control.addInvokeMethodListener(_invokeMethod);
31
+
32
+ recorder = AudioRecorder();
33
+
34
+ _onStateChangedSubscription = recorder!.onStateChanged().listen((state) {
35
+ _onStateChanged.call(state);
36
+ });
37
+ }
38
+
39
+ void _onStateChanged(RecordState state) {
40
+ var stateMap = {
41
+ RecordState.record: "recording",
42
+ RecordState.pause: "paused",
43
+ RecordState.stop: "stopped",
44
+ };
45
+ control.triggerEvent("state_change", stateMap[state]);
46
+ }
47
+
48
+ Future<dynamic> _invokeMethod(String name, dynamic args) async {
49
+ debugPrint("AudioRecorder.$name($args)");
50
+ switch (name) {
51
+ case "start_recording":
52
+ final config = parseRecordConfig(args["configuration"]);
53
+ final upload = args["upload"];
54
+ final stream = control.hasEventHandler("stream");
55
+ if (config != null && await recorder!.hasPermission()) {
56
+ // If either upload or stream is requested, switch to the
57
+ // streaming code path (PCM chunks instead of file output).
58
+ if (upload != null || stream) {
59
+ return await _startStreamingRecording(config, upload, stream);
60
+ }
61
+
62
+ final out = control.backend.getAssetSource(args["output_path"] ?? "");
63
+ if (!isWebPlatform() && !out.isFile) {
64
+ // on non-web platforms, the output path must be a valid file path
65
+ return false;
66
+ }
67
+
68
+ await recorder!.start(config, path: out.path);
69
+ return true;
70
+ }
71
+ return false;
72
+ case "stop_recording":
73
+ // For streaming recordings there is no output file to return; instead
74
+ // we stop the recorder and wait for the audio stream's `onDone` handler
75
+ // (`_finishStreamingRecording`) to flush and close the upload request.
76
+ if (_streamSession != null) {
77
+ await recorder!.stop();
78
+ await _streamSession?.completed.future;
79
+ return null;
80
+ }
81
+ return await recorder!.stop();
82
+ case "cancel_recording":
83
+ // Tear down any in-flight streaming session before cancelling the
84
+ // recorder so partial uploads are aborted and listeners are notified.
85
+ if (_streamSession != null) {
86
+ await _cancelStreamingRecording("Recording cancelled");
87
+ }
88
+ await recorder!.cancel();
89
+ break;
90
+ case "resume_recording":
91
+ await recorder!.resume();
92
+ break;
93
+ case "pause_recording":
94
+ await recorder!.pause();
95
+ break;
96
+ case "is_supported_encoder":
97
+ var encoder = parseAudioEncoder(args["encoder"]);
98
+ if (encoder != null) {
99
+ return await recorder!.isEncoderSupported(encoder);
100
+ }
101
+ break;
102
+ case "is_paused":
103
+ return await recorder!.isPaused();
104
+ case "is_recording":
105
+ return await recorder!.isRecording();
106
+ case "has_permission":
107
+ return await recorder!.hasPermission();
108
+ case "get_input_devices":
109
+ List<InputDevice> devices = await recorder!.listInputDevices();
110
+ return devices.asMap().map((k, v) {
111
+ return MapEntry(v.id, v.label);
112
+ });
113
+ default:
114
+ throw Exception("Unknown AudioRecorder method: $name");
115
+ }
116
+ }
117
+
118
+ @override
119
+ void dispose() {
120
+ debugPrint("AudioRecorder(${control.id}).dispose()");
121
+ _onStateChangedSubscription?.cancel();
122
+ _recordStreamSubscription?.cancel();
123
+ _streamSession?.dispose();
124
+ recorder?.dispose();
125
+ control.removeInvokeMethodListener(_invokeMethod);
126
+ super.dispose();
127
+ }
128
+
129
+ /// Starts a streaming recording.
130
+ ///
131
+ /// Depending on the arguments, the raw PCM chunks produced by the recorder
132
+ /// are either:
133
+ /// * forwarded to a remote HTTP endpoint via a chunked `StreamedRequest`
134
+ /// (when [uploadArgs] is provided), and/or
135
+ /// * pushed to Python as "stream" events (when [stream] is `true`).
136
+ ///
137
+ /// Both sinks can be active at the same time. Returns `true` if the
138
+ /// recorder successfully started streaming, `false` otherwise.
139
+ Future<bool> _startStreamingRecording(
140
+ RecordConfig config,
141
+ Map<dynamic, dynamic>? uploadArgs,
142
+ bool stream,
143
+ ) async {
144
+ // Defensive cleanup: ensure no previous streaming session is lingering
145
+ // before we start a new one (e.g. if the user restarts without stopping).
146
+ await _recordStreamSubscription?.cancel();
147
+ await _streamSession?.dispose();
148
+ _recordStreamSubscription = null;
149
+ _streamSession = null;
150
+
151
+ final uploadConfig = uploadArgs != null
152
+ ? _UploadConfig.fromMap(Map<String, dynamic>.from(uploadArgs))
153
+ : null;
154
+
155
+ // Build a chunked HTTP request up front. We don't call `.send()` yet — that
156
+ // happens after the audio subscription is wired up so the first chunks are not lost.
157
+ http.StreamedRequest? request;
158
+ if (uploadConfig != null) {
159
+ final uploadUrl =
160
+ _getFullUploadUrl(control.backend.pageUri, uploadConfig.url);
161
+ request = http.StreamedRequest(uploadConfig.method, Uri.parse(uploadUrl));
162
+ if (uploadConfig.headers != null) {
163
+ request.headers.addAll(uploadConfig.headers!);
164
+ }
165
+ }
166
+
167
+ final session = _StreamingSession(
168
+ stream: stream, uploadConfig: uploadConfig, request: request);
169
+ _streamSession = session;
170
+
171
+ try {
172
+ final audioStream = await recorder!.startStream(config);
173
+
174
+ // Emit an initial 0% progress event so listeners can show an upload
175
+ // started state before any bytes are produced by the microphone.
176
+ if (uploadConfig != null) {
177
+ _sendUploadEvent(
178
+ fileName: uploadConfig.fileName, progress: 0.0, bytesUploaded: 0);
179
+ }
180
+
181
+ _recordStreamSubscription = audioStream.listen(
182
+ (chunk) {
183
+ // For every audio chunk produced by the recorder: count it, feed
184
+ // it to the HTTP upload sink (if any), and forward it to Python
185
+ // (if a stream handler is subscribed).
186
+ session.bytesSent += chunk.length;
187
+ session.request?.sink.add(chunk);
188
+
189
+ if (session.request != null) {
190
+ _sendUploadEvent(
191
+ fileName: uploadConfig?.fileName,
192
+ bytesUploaded: session.bytesSent,
193
+ );
194
+ }
195
+
196
+ if (session.stream) {
197
+ _sendStreamEvent(
198
+ chunk,
199
+ sequence: session.nextSequence(),
200
+ bytesStreamed: session.bytesSent,
201
+ );
202
+ }
203
+ },
204
+ onError: (error) async {
205
+ if (uploadConfig != null) {
206
+ _sendUploadEvent(
207
+ fileName: uploadConfig.fileName,
208
+ error: error.toString(),
209
+ );
210
+ }
211
+ await _cancelStreamingRecording();
212
+ },
213
+ onDone: () async {
214
+ // The recorder stopped normally — finalize the upload and notify.
215
+ await _finishStreamingRecording();
216
+ },
217
+ cancelOnError: true,
218
+ );
219
+
220
+ // Kick off the HTTP request now that the sink will receive chunks.
221
+ session.startUpload();
222
+ return true;
223
+ } catch (error) {
224
+ // Anything thrown while starting the stream (permissions, network,
225
+ // recorder errors) is reported and the session is discarded.
226
+ if (uploadConfig != null) {
227
+ _sendUploadEvent(
228
+ fileName: uploadConfig.fileName,
229
+ error: error.toString(),
230
+ );
231
+ }
232
+ session.complete();
233
+ _streamSession = null;
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /// Finalizes a streaming recording after the audio stream's `onDone` fires.
239
+ ///
240
+ /// Closes the HTTP request sink, awaits the server response, and emits a
241
+ /// final progress or error event to Python. Always resets the streaming
242
+ /// state, even on failure, so a new recording can be started afterwards.
243
+ Future<void> _finishStreamingRecording() async {
244
+ final session = _streamSession;
245
+ if (session == null) {
246
+ return;
247
+ }
248
+
249
+ try {
250
+ // Closing the sink signals the end of the chunked request body so the
251
+ // server can finish processing and return a response.
252
+ await session.request?.sink.close();
253
+ final responseFuture = session.responseFuture;
254
+ if (session.request != null && responseFuture != null) {
255
+ final response = await responseFuture;
256
+ // successful
257
+ if (response.statusCode >= 200 && response.statusCode <= 204) {
258
+ _sendUploadEvent(
259
+ fileName: session.uploadConfig?.fileName,
260
+ progress: 1.0,
261
+ bytesUploaded: session.bytesSent,
262
+ );
263
+ } else {
264
+ // not successful
265
+ final body = await http.Response.fromStream(response);
266
+ _sendUploadEvent(
267
+ fileName: session.uploadConfig?.fileName,
268
+ error:
269
+ "Upload endpoint returned code ${response.statusCode}: ${body.body}",
270
+ );
271
+ }
272
+ }
273
+ } catch (error) {
274
+ if (session.uploadConfig != null) {
275
+ _sendUploadEvent(
276
+ fileName: session.uploadConfig?.fileName,
277
+ error: error.toString(),
278
+ );
279
+ }
280
+ } finally {
281
+ // Whatever happened, release the subscription and signal the
282
+ // `stop_recording` awaiter that the session has fully wound down.
283
+ session.complete();
284
+ await _recordStreamSubscription?.cancel();
285
+ _recordStreamSubscription = null;
286
+ _streamSession = null;
287
+ }
288
+ }
289
+
290
+ /// Aborts the current streaming recording.
291
+ ///
292
+ /// Called when the user cancels a recording or the audio stream emits an
293
+ /// error. Closes the upload sink without waiting for a response, notifies
294
+ /// Python with [error] (defaulting to "Recording cancelled"), and resets
295
+ /// the streaming state.
296
+ Future<void> _cancelStreamingRecording([String? error]) async {
297
+ final session = _streamSession;
298
+ if (session == null) {
299
+ return;
300
+ }
301
+
302
+ try {
303
+ await _recordStreamSubscription?.cancel();
304
+ _recordStreamSubscription = null;
305
+ await session.request?.sink.close();
306
+ if (session.uploadConfig != null) {
307
+ _sendUploadEvent(
308
+ fileName: session.uploadConfig?.fileName,
309
+ error: error ?? "Recording cancelled",
310
+ );
311
+ }
312
+ } finally {
313
+ session.complete();
314
+ _streamSession = null;
315
+ }
316
+ }
317
+
318
+ /// Fires the "upload" event on the Python-side control with the current
319
+ /// upload progress or an error message. Any field may be null when not
320
+ /// applicable (e.g. `progress` is null for per-chunk progress pings).
321
+ void _sendUploadEvent({
322
+ String? fileName,
323
+ double? progress,
324
+ int? bytesUploaded,
325
+ String? error,
326
+ }) {
327
+ control.triggerEvent("upload", {
328
+ "file_name": fileName,
329
+ "progress": progress,
330
+ "bytes_uploaded": bytesUploaded,
331
+ "error": error,
332
+ });
333
+ }
334
+
335
+ /// Fires the "stream" event with a single PCM [chunk] and its monotonically
336
+ /// increasing [sequence] number, allowing the Python side to reassemble the
337
+ /// audio in order and detect gaps.
338
+ void _sendStreamEvent(
339
+ Uint8List chunk, {
340
+ required int sequence,
341
+ required int bytesStreamed,
342
+ }) {
343
+ control.triggerEvent("stream", {
344
+ "chunk": chunk,
345
+ "sequence": sequence,
346
+ "bytes_streamed": bytesStreamed,
347
+ });
348
+ }
349
+
350
+ /// Resolves a possibly-relative [uploadUrl] against the current [pageUri].
351
+ ///
352
+ /// If [uploadUrl] already contains an authority (scheme + host) it is used
353
+ /// verbatim; otherwise its path/query are combined with the page's
354
+ /// scheme/host/port so that relative upload endpoints work out of the box.
355
+ String _getFullUploadUrl(Uri pageUri, String uploadUrl) {
356
+ final uploadUri = Uri.parse(uploadUrl);
357
+ if (uploadUri.hasAuthority) {
358
+ return uploadUrl;
359
+ }
360
+ return Uri(
361
+ scheme: pageUri.scheme,
362
+ host: pageUri.host,
363
+ port: pageUri.port,
364
+ path: uploadUri.path,
365
+ query: uploadUri.query,
366
+ ).toString();
367
+ }
368
+ }
369
+
370
+ /// Value object describing where/how the streamed recording should be
371
+ /// uploaded. Built from the `upload` dict passed from Python on `start_recording`.
372
+ class _UploadConfig {
373
+ const _UploadConfig({
374
+ required this.url,
375
+ required this.method,
376
+ this.headers,
377
+ this.fileName,
378
+ });
379
+
380
+ /// Parses the raw map received from Python.
381
+ factory _UploadConfig.fromMap(Map<String, dynamic> value) {
382
+ final headers = value["headers"];
383
+ return _UploadConfig(
384
+ url: value["upload_url"],
385
+ method: (value["method"] ?? "PUT").toString().toUpperCase(),
386
+ headers: headers != null ? Map<String, String>.from(headers) : null,
387
+ fileName: value["file_name"],
388
+ );
389
+ }
390
+
391
+ /// Destination URL — may be absolute or relative to the Flet page URI.
392
+ final String url;
393
+
394
+ /// HTTP method to use for the upload (e.g. `PUT` or `POST`).
395
+ final String method;
396
+
397
+ /// Optional request headers (e.g. auth, content-type).
398
+ final Map<String, String>? headers;
399
+
400
+ /// Optional file name echoed back in upload events so Python listeners
401
+ /// can correlate progress updates with a specific recording.
402
+ final String? fileName;
403
+ }
404
+
405
+ /// Bundles everything needed to track a single streaming recording:
406
+ /// * whether Python is listening to `stream` events,
407
+ /// * the upload config and HTTP request (if uploading),
408
+ /// * byte/sequence counters, and
409
+ /// * a `Completer` used by `stop_recording` to await clean shutdown.
410
+ class _StreamingSession {
411
+ _StreamingSession({required this.stream, this.uploadConfig, this.request})
412
+ : completed = Completer<void>();
413
+
414
+ /// `true` when Python has a handler attached for the "stream" event.
415
+ final bool stream;
416
+
417
+ /// Upload target for this session, or `null` when only streaming to Python.
418
+ final _UploadConfig? uploadConfig;
419
+
420
+ /// Chunked HTTP request receiving the PCM bytes, or `null` when not uploading.
421
+ final http.StreamedRequest? request;
422
+
423
+ /// Resolves once the session has fully torn down (success, error, or
424
+ /// cancellation). Awaited by `stop_recording` so callers can rely on the
425
+ /// upload being flushed before the method returns.
426
+ final Completer<void> completed;
427
+
428
+ /// Future of the server response to the chunked upload. Populated by
429
+ /// [startUpload] and awaited by `_finishStreamingRecording`.
430
+ Future<http.StreamedResponse>? responseFuture;
431
+
432
+ /// Total number of audio bytes produced so far — used for progress events
433
+ /// and as the final uploaded-bytes count.
434
+ int bytesSent = 0;
435
+
436
+ /// Monotonic counter for stream events, incremented by [nextSequence].
437
+ int _sequence = 0;
438
+
439
+ /// Starts sending the chunked request. Must be called after the audio
440
+ /// subscription has been attached so no chunks are dropped.
441
+ void startUpload() {
442
+ if (request != null) {
443
+ responseFuture = request!.send();
444
+ }
445
+ }
446
+
447
+ /// Returns the next sequence number for a "stream" event (starts at 1).
448
+ int nextSequence() {
449
+ _sequence += 1;
450
+ return _sequence;
451
+ }
452
+
453
+ /// Marks the session as fully wound down. Safe to call multiple times.
454
+ void complete() {
455
+ if (!completed.isCompleted) {
456
+ completed.complete();
457
+ }
458
+ }
459
+
460
+ /// Best-effort cleanup used when the service itself is being disposed
461
+ /// (e.g. the control is removed from the page mid-recording).
462
+ Future<void> dispose() async {
463
+ try {
464
+ await request?.sink.close();
465
+ } catch (_) {
466
+ // Ignore sink shutdown errors during service disposal.
467
+ }
468
+ complete();
469
+ }
470
+ }
@@ -12,6 +12,7 @@ dependencies:
12
12
  sdk: flutter
13
13
 
14
14
  collection: ^1.16.0
15
+ http: ^1.2.2
15
16
  record: ^6.2.0
16
17
 
17
18
  flet:
@@ -1,91 +0,0 @@
1
- import 'dart:async';
2
-
3
- import 'package:flet/flet.dart';
4
- import 'package:flutter/widgets.dart';
5
- import 'package:record/record.dart';
6
-
7
- import 'utils/audio_recorder.dart';
8
-
9
- class AudioRecorderService extends FletService {
10
- AudioRecorderService({required super.control});
11
-
12
- AudioRecorder? recorder;
13
- StreamSubscription? _onStateChangedSubscription;
14
-
15
- @override
16
- void init() {
17
- super.init();
18
- debugPrint("AudioRecorder.init($hashCode)");
19
- control.addInvokeMethodListener(_invokeMethod);
20
-
21
- recorder = AudioRecorder();
22
-
23
- _onStateChangedSubscription = recorder!.onStateChanged().listen((state) {
24
- _onStateChanged.call(state);
25
- });
26
- }
27
-
28
- void _onStateChanged(RecordState state) {
29
- var stateMap = {
30
- RecordState.record: "recording",
31
- RecordState.pause: "paused",
32
- RecordState.stop: "stopped",
33
- };
34
- control.triggerEvent("state_change", stateMap[state]);
35
- }
36
-
37
- Future<dynamic> _invokeMethod(String name, dynamic args) async {
38
- debugPrint("AudioRecorder.$name($args)");
39
- switch (name) {
40
- case "start_recording":
41
- final config = parseRecordConfig(args["configuration"]);
42
- if (config != null && await recorder!.hasPermission()) {
43
- final out = control.backend.getAssetSource(args["output_path"] ?? "");
44
- if (!isWebPlatform() && !out.isFile) {
45
- // on non-web/IO platforms, the output path must be a valid file path
46
- return false;
47
- }
48
-
49
- await recorder!.start(config, path: out.path);
50
- return true;
51
- }
52
- return false;
53
- case "stop_recording":
54
- return await recorder!.stop();
55
- case "cancel_recording":
56
- await recorder!.cancel();
57
- case "resume_recording":
58
- await recorder!.resume();
59
- case "pause_recording":
60
- await recorder!.pause();
61
- case "is_supported_encoder":
62
- var encoder = parseAudioEncoder(args["encoder"]);
63
- if (encoder != null) {
64
- return await recorder!.isEncoderSupported(encoder);
65
- }
66
- break;
67
- case "is_paused":
68
- return await recorder!.isPaused();
69
- case "is_recording":
70
- return await recorder!.isRecording();
71
- case "has_permission":
72
- return await recorder!.hasPermission();
73
- case "get_input_devices":
74
- List<InputDevice> devices = await recorder!.listInputDevices();
75
- return devices.asMap().map((k, v) {
76
- return MapEntry(v.id, v.label);
77
- });
78
- default:
79
- throw Exception("Unknown AudioRecorder method: $name");
80
- }
81
- }
82
-
83
- @override
84
- void dispose() {
85
- debugPrint("AudioRecorder(${control.id}).dispose()");
86
- _onStateChangedSubscription?.cancel();
87
- recorder?.dispose();
88
- control.removeInvokeMethodListener(_invokeMethod);
89
- super.dispose();
90
- }
91
- }