Skip to content

Commit 86350e9

Browse files
khanhnwincynthiajoannatebosch
authored
feat(firebaseai): Add flutter_soloud for sound output in Live API audio streaming example. (#17305)
* add soloud for sound output, but record is failing on iOS * restarting my simulator fixed it... but incorporating my AudioInput class! * add JS scripts for flutter_soloud on web * add gitignore to example * Clean up before review * Apply suggestions from code review Co-authored-by: Nate Bosch <nbosch1@gmail.com> * review comments * remove linux example folder * some update for the example running generated files --------- Co-authored-by: Cynthia J <cynthiajoan@gmail.com> Co-authored-by: Cynthia J <cynthiajoan@users.noreply.github.com> Co-authored-by: Nate Bosch <nbosch1@gmail.com>
1 parent 57c0913 commit 86350e9

File tree

35 files changed

+637
-1047
lines changed

35 files changed

+637
-1047
lines changed

melos.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ scripts:
290290
--ignore "**/generated/**" \
291291
--ignore "**/flutter/generated_plugin_registrant.h" \
292292
--ignore "**/flutter/generated_plugin_registrant.cc" \
293+
--ignore "**/android/app/build.gradle.kts" \
294+
--ignore "**/android/build.gradle.kts" \
295+
--ignore "**/android/settings.gradle.kts" \
296+
--ignore "**/RunnerTests/RunnerTests.swift" \
293297
.
294298
description: Add a license header to all necessary files.
295299

@@ -326,6 +330,10 @@ scripts:
326330
--ignore "**/generated/**" \
327331
--ignore "**/flutter/generated_plugin_registrant.h" \
328332
--ignore "**/flutter/generated_plugin_registrant.cc" \
333+
--ignore "**/android/app/build.gradle.kts" \
334+
--ignore "**/android/build.gradle.kts" \
335+
--ignore "**/android/settings.gradle.kts" \
336+
--ignore "**/RunnerTests/RunnerTests.swift" \
329337
.
330338
description: Add a license header to all necessary files.
331339

packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart

Lines changed: 29 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
14-
import 'dart:typed_data';
1514
import 'dart:async';
16-
import 'dart:developer';
15+
import 'dart:developer' as developer;
1716

1817
import 'package:flutter/material.dart';
1918
import 'package:firebase_ai/firebase_ai.dart';
19+
20+
import '../utils/audio_input.dart';
21+
import '../utils/audio_output.dart';
2022
import '../widgets/message_widget.dart';
21-
import '../utils/audio_player.dart';
22-
import '../utils/audio_recorder.dart';
2323

2424
class BidiPage extends StatefulWidget {
2525
const BidiPage({super.key, required this.title, required this.model});
@@ -48,11 +48,9 @@ class _BidiPageState extends State<BidiPage> {
4848
bool _recording = false;
4949
late LiveGenerativeModel _liveModel;
5050
late LiveSession _session;
51-
final _audioManager = AudioStreamManager();
52-
final _audioRecorder = InMemoryAudioRecorder();
53-
var _chunkBuilder = BytesBuilder();
54-
var _audioIndex = 0;
5551
StreamController<bool> _stopController = StreamController<bool>();
52+
final AudioOutput _audioOutput = AudioOutput();
53+
final AudioInput _audioInput = AudioInput();
5654

5755
@override
5856
void initState() {
@@ -65,13 +63,20 @@ class _BidiPageState extends State<BidiPage> {
6563
],
6664
);
6765

66+
// ignore: deprecated_member_use
6867
_liveModel = FirebaseAI.vertexAI().liveGenerativeModel(
6968
model: 'gemini-2.0-flash-exp',
7069
liveGenerationConfig: config,
7170
tools: [
7271
Tool.functionDeclarations([lightControlTool]),
7372
],
7473
);
74+
_initAudio();
75+
}
76+
77+
Future<void> _initAudio() async {
78+
await _audioOutput.init();
79+
await _audioInput.init();
7580
}
7681

7782
void _scrollDown() {
@@ -89,13 +94,7 @@ class _BidiPageState extends State<BidiPage> {
8994
@override
9095
void dispose() {
9196
if (_sessionOpening) {
92-
_audioManager.stopAudioPlayer();
93-
_audioManager.disposeAudioPlayer();
94-
95-
_audioRecorder.stopRecording();
96-
9797
_stopController.close();
98-
9998
_sessionOpening = false;
10099
_session.close();
101100
}
@@ -234,7 +233,7 @@ class _BidiPageState extends State<BidiPage> {
234233
_sessionOpening = true;
235234
_stopController = StreamController<bool>();
236235
unawaited(
237-
processMessagesContinuously(
236+
_processMessagesContinuously(
238237
stopSignal: _stopController,
239238
),
240239
);
@@ -243,8 +242,6 @@ class _BidiPageState extends State<BidiPage> {
243242
await _stopController.close();
244243

245244
await _session.close();
246-
await _audioManager.stopAudioPlayer();
247-
await _audioManager.disposeAudioPlayer();
248245
_sessionOpening = false;
249246
}
250247

@@ -258,21 +255,25 @@ class _BidiPageState extends State<BidiPage> {
258255
_recording = true;
259256
});
260257
try {
261-
await _audioRecorder.checkPermission();
262-
final audioRecordStream = _audioRecorder.startRecordingStream();
258+
var inputStream = await _audioInput.startRecordingStream();
259+
await _audioOutput.playStream();
263260
// Map the Uint8List stream to InlineDataPart stream
264-
final mediaChunkStream = audioRecordStream.map((data) {
265-
return InlineDataPart('audio/pcm', data);
266-
});
267-
await _session.sendMediaStream(mediaChunkStream);
261+
if (inputStream != null) {
262+
final inlineDataStream = inputStream.map((data) {
263+
return InlineDataPart('audio/pcm', data);
264+
});
265+
266+
await _session.sendMediaStream(inlineDataStream);
267+
}
268268
} catch (e) {
269+
developer.log(e.toString());
269270
_showError(e.toString());
270271
}
271272
}
272273

273274
Future<void> _stopRecording() async {
274275
try {
275-
await _audioRecorder.stopRecording();
276+
await _audioInput.stopRecording();
276277
} catch (e) {
277278
_showError(e.toString());
278279
}
@@ -298,7 +299,7 @@ class _BidiPageState extends State<BidiPage> {
298299
});
299300
}
300301

301-
Future<void> processMessagesContinuously({
302+
Future<void> _processMessagesContinuously({
302303
required StreamController<bool> stopSignal,
303304
}) async {
304305
bool shouldContinue = true;
@@ -335,11 +336,8 @@ class _BidiPageState extends State<BidiPage> {
335336
if (message.modelTurn != null) {
336337
await _handleLiveServerContent(message);
337338
}
338-
if (message.turnComplete != null && message.turnComplete!) {
339-
await _handleTurnComplete();
340-
}
341339
if (message.interrupted != null && message.interrupted!) {
342-
log('Interrupted: $response');
340+
developer.log('Interrupted: $response');
343341
}
344342
} else if (message is LiveServerToolCall && message.functionCalls != null) {
345343
await _handleLiveServerToolCall(message);
@@ -355,7 +353,7 @@ class _BidiPageState extends State<BidiPage> {
355353
} else if (part is InlineDataPart) {
356354
await _handleInlineDataPart(part);
357355
} else {
358-
log('receive part with type ${part.runtimeType}');
356+
developer.log('receive part with type ${part.runtimeType}');
359357
}
360358
}
361359
}
@@ -376,29 +374,7 @@ class _BidiPageState extends State<BidiPage> {
376374

377375
Future<void> _handleInlineDataPart(InlineDataPart part) async {
378376
if (part.mimeType.startsWith('audio')) {
379-
_chunkBuilder.add(part.bytes);
380-
_audioIndex++;
381-
if (_audioIndex == 15) {
382-
Uint8List chunk = await audioChunkWithHeader(
383-
_chunkBuilder.toBytes(),
384-
24000,
385-
);
386-
_audioManager.addAudio(chunk);
387-
_chunkBuilder.clear();
388-
_audioIndex = 0;
389-
}
390-
}
391-
}
392-
393-
Future<void> _handleTurnComplete() async {
394-
if (_chunkBuilder.isNotEmpty) {
395-
Uint8List chunk = await audioChunkWithHeader(
396-
_chunkBuilder.toBytes(),
397-
24000,
398-
);
399-
_audioManager.addAudio(chunk);
400-
_audioIndex = 0;
401-
_chunkBuilder.clear();
377+
_audioOutput.addAudioStream(part.bytes);
402378
}
403379
}
404380

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'package:flutter/material.dart';
16+
import 'package:record/record.dart';
17+
import 'dart:typed_data';
18+
19+
class AudioInput extends ChangeNotifier {
20+
final _recorder = AudioRecorder();
21+
final AudioEncoder _encoder = AudioEncoder.pcm16bits;
22+
bool isRecording = false;
23+
bool isPaused = false;
24+
Stream<Uint8List>? audioStream;
25+
26+
Future<void> init() async {
27+
await _checkPermission();
28+
}
29+
30+
@override
31+
void dispose() {
32+
_recorder.dispose();
33+
super.dispose();
34+
}
35+
36+
Future<void> _checkPermission() async {
37+
final hasPermission = await _recorder.hasPermission();
38+
if (!hasPermission) {
39+
throw MicrophonePermissionDeniedException(
40+
'App does not have mic permissions',
41+
);
42+
}
43+
}
44+
45+
Future<Stream<Uint8List>?> startRecordingStream() async {
46+
var recordConfig = RecordConfig(
47+
encoder: _encoder,
48+
sampleRate: 24000,
49+
numChannels: 1,
50+
echoCancel: true,
51+
noiseSuppress: true,
52+
androidConfig: const AndroidRecordConfig(
53+
audioSource: AndroidAudioSource.voiceCommunication,
54+
),
55+
iosConfig: const IosRecordConfig(categoryOptions: []),
56+
);
57+
await _recorder.listInputDevices();
58+
audioStream = await _recorder.startStream(recordConfig);
59+
isRecording = true;
60+
notifyListeners();
61+
return audioStream;
62+
}
63+
64+
Future<void> stopRecording() async {
65+
await _recorder.stop();
66+
isRecording = false;
67+
notifyListeners();
68+
}
69+
70+
Future<void> togglePause() async {
71+
if (isPaused) {
72+
await _recorder.resume();
73+
isPaused = false;
74+
} else {
75+
await _recorder.pause();
76+
isPaused = true;
77+
}
78+
notifyListeners();
79+
return;
80+
}
81+
}
82+
83+
/// An exception thrown when microphone permission is denied or not granted.
84+
class MicrophonePermissionDeniedException implements Exception {
85+
/// The optional message associated with the permission denial.
86+
final String? message;
87+
88+
/// Creates a new [MicrophonePermissionDeniedException] with an optional [message].
89+
MicrophonePermissionDeniedException([this.message]);
90+
91+
@override
92+
String toString() {
93+
return 'MicrophonePermissionDeniedException: $message';
94+
}
95+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'dart:typed_data';
16+
17+
import 'package:flutter_soloud/flutter_soloud.dart';
18+
19+
class AudioOutput {
20+
AudioSource? stream;
21+
SoundHandle? handle;
22+
23+
Future<void> init() async {
24+
// Initialize the player.
25+
await SoLoud.instance.init(sampleRate: 24000, channels: Channels.mono);
26+
await setupNewStream();
27+
}
28+
29+
Future<void> setupNewStream() async {
30+
if (SoLoud.instance.isInitialized) {
31+
// Stop and clear any previous playback handle if it's still valid
32+
await stopStream(); // Ensure previous sound is stopped
33+
34+
stream = SoLoud.instance.setBufferStream(
35+
maxBufferSizeBytes:
36+
1024 * 1024 * 10, // 10MB of max buffer (not allocated)
37+
bufferingType: BufferingType.released,
38+
bufferingTimeNeeds: 0,
39+
onBuffering: (isBuffering, handle, time) {},
40+
);
41+
// Reset handle to null until the stream is played again
42+
handle = null;
43+
}
44+
}
45+
46+
Future<AudioSource?> playStream() async {
47+
handle = await SoLoud.instance.play(stream!);
48+
return stream;
49+
}
50+
51+
Future<void> stopStream() async {
52+
if (stream != null &&
53+
handle != null &&
54+
SoLoud.instance.getIsValidVoiceHandle(handle!)) {
55+
SoLoud.instance.setDataIsEnded(stream!);
56+
await SoLoud.instance.stop(handle!);
57+
58+
// Clear old stream, set up new session for next time.
59+
await setupNewStream();
60+
}
61+
}
62+
63+
void addAudioStream(Uint8List audioChunk) {
64+
SoLoud.instance.addAudioDataStream(stream!, audioChunk);
65+
}
66+
}

0 commit comments

Comments
 (0)