Skip to content

Commit 81bf0d1

Browse files
committed
Migrate to the new structure
1 parent 4e03f14 commit 81bf0d1

File tree

17 files changed

+1316
-1
lines changed

17 files changed

+1316
-1
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "gemini-logo.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
235 KB
Loading

firebaseai/FirebaseAIExample/ContentView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ struct ContentView: View {
110110
FunctionCallingScreen(backendType: selectedBackend, sample: sample)
111111
case "GroundingScreen":
112112
GroundingScreen(backendType: selectedBackend, sample: sample)
113+
case "LiveScreen":
114+
LiveScreen(backendType: selectedBackend, sample: sample)
113115
default:
114116
EmptyView()
115117
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
#if canImport(FirebaseAILogic)
16+
import FirebaseAILogic
17+
#else
18+
import FirebaseAI
19+
#endif
20+
import SwiftUI
21+
import ConversationKit
22+
23+
struct LiveScreen: View {
24+
let backendType: BackendOption
25+
@StateObject var viewModel: LiveViewModel
26+
27+
init(backendType: BackendOption, sample: Sample? = nil) {
28+
self.backendType = backendType
29+
_viewModel =
30+
StateObject(wrappedValue: LiveViewModel(backendType: backendType,
31+
sample: sample))
32+
}
33+
34+
var body: some View {
35+
VStack(spacing: 20) {
36+
ModelAvatar(isConnected: viewModel.state == .connected)
37+
TranscriptView(typewriter: viewModel.transcriptTypewriter)
38+
39+
Spacer()
40+
if let error = viewModel.error {
41+
ErrorDetailsView(error: error)
42+
}
43+
if let tip = viewModel.tip, !viewModel.hasTranscripts {
44+
TipView(text: tip)
45+
}
46+
ConnectButton(
47+
state: viewModel.state,
48+
onConnect: viewModel.connect,
49+
onDisconnect: viewModel.disconnect
50+
)
51+
}
52+
.padding()
53+
.navigationTitle(viewModel.title)
54+
.navigationBarTitleDisplayMode(.inline)
55+
.background(viewModel.backgroundColor ?? .clear)
56+
}
57+
}
58+
59+
#Preview {
60+
LiveScreen(backendType: .googleAI)
61+
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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 FirebaseAILogic
16+
import Foundation
17+
import OSLog
18+
import AVFoundation
19+
import SwiftUI
20+
import AVKit
21+
import Combine
22+
23+
enum LiveViewModelState {
24+
case idle
25+
case connecting
26+
case connected
27+
}
28+
29+
@MainActor
30+
class LiveViewModel: ObservableObject {
31+
private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai")
32+
33+
@Published
34+
var error: Error?
35+
36+
@Published
37+
var state: LiveViewModelState = .idle
38+
39+
@Published
40+
var transcriptTypewriter: TypeWriterViewModel = TypeWriterViewModel()
41+
42+
@Published
43+
var backgroundColor: Color? = nil
44+
45+
@Published
46+
var hasTranscripts: Bool = false
47+
48+
@Published
49+
var title: String
50+
51+
@Published
52+
var tip: String?
53+
54+
private var model: LiveGenerativeModel?
55+
private var liveSession: LiveSession?
56+
57+
private var audioController: AudioController?
58+
private var microphoneTask = Task<Void, Never> {}
59+
60+
init(backendType: BackendOption, sample: Sample? = nil) {
61+
let firebaseService = backendType == .googleAI
62+
? FirebaseAI.firebaseAI(backend: .googleAI())
63+
: FirebaseAI.firebaseAI(backend: .vertexAI())
64+
65+
model = firebaseService.liveModel(
66+
modelName: (backendType == .googleAI) ? "gemini-2.5-flash-native-audio-preview-09-2025" : "gemini-live-2.5-flash-preview-native-audio-09-2025",
67+
generationConfig: sample?.liveGenerationConfig,
68+
tools: sample?.tools,
69+
systemInstruction: sample?.systemInstruction
70+
)
71+
title = sample?.title ?? ""
72+
tip = sample?.tip
73+
}
74+
75+
/// Start a connection to the model.
76+
///
77+
/// If a connection is already active, you'll need to call ``LiveViewModel/disconnect()`` first.
78+
func connect() async {
79+
guard let model, state == .idle else {
80+
return
81+
}
82+
83+
#if targetEnvironment(simulator)
84+
logger.warning("Playback audio is disabled on the simulator.")
85+
#endif
86+
87+
guard await requestRecordPermission() else {
88+
logger.warning("The user denied us permission to record the microphone.")
89+
return
90+
}
91+
92+
state = .connecting
93+
transcriptTypewriter.restart()
94+
hasTranscripts = false
95+
96+
do {
97+
liveSession = try await model.connect()
98+
audioController = try await AudioController()
99+
100+
try await startRecording()
101+
102+
state = .connected
103+
try await startProcessingResponses()
104+
} catch {
105+
logger.error("\(String(describing: error))")
106+
self.error = error
107+
await disconnect()
108+
}
109+
}
110+
111+
/// Disconnects the model.
112+
///
113+
/// Will stop any pending playback, and the recording of the mic.
114+
func disconnect() async {
115+
await audioController?.stop()
116+
await liveSession?.close()
117+
microphoneTask.cancel()
118+
state = .idle
119+
liveSession = nil
120+
transcriptTypewriter.clearPending()
121+
122+
withAnimation {
123+
backgroundColor = nil
124+
}
125+
}
126+
127+
/// Starts recording data from the user's microphone, and sends it to the model.
128+
private func startRecording() async throws {
129+
guard let audioController, let liveSession else { return }
130+
131+
let stream = try await audioController.listenToMic()
132+
microphoneTask = Task {
133+
do {
134+
for await audioBuffer in stream {
135+
await liveSession.sendAudioRealtime(try audioBuffer.int16Data())
136+
}
137+
} catch {
138+
logger.error("\(String(describing: error))")
139+
self.error = error
140+
await disconnect()
141+
}
142+
}
143+
}
144+
145+
/// Starts queuing responses from the model for parsing.
146+
private func startProcessingResponses() async throws {
147+
guard let liveSession else { return }
148+
149+
for try await response in liveSession.responses {
150+
try await processServerMessage(response)
151+
}
152+
}
153+
154+
/// Requests permission to record the user's microphone, returning the result.
155+
///
156+
/// This is a requirement on iOS devices, on top of needing the proper recording
157+
/// intents.
158+
private func requestRecordPermission() async -> Bool {
159+
await withCheckedContinuation { cont in
160+
if #available(iOS 17.0, *) {
161+
Task {
162+
let ok = await AVAudioApplication.requestRecordPermission()
163+
cont.resume(with: .success(ok))
164+
}
165+
} else {
166+
AVAudioSession.sharedInstance().requestRecordPermission { ok in
167+
cont.resume(with: .success(ok))
168+
}
169+
}
170+
}
171+
}
172+
173+
private func processServerMessage(_ message: LiveServerMessage) async throws {
174+
switch message.payload {
175+
case let .content(content):
176+
try await processServerContent(content)
177+
case let .toolCall(toolCall):
178+
try await processFunctionCalls(functionCalls: toolCall.functionCalls ?? [])
179+
case .toolCallCancellation:
180+
// we don't have any long running functions to cancel
181+
return
182+
case let .goingAwayNotice(goingAwayNotice):
183+
let time = goingAwayNotice.timeLeft?.description ?? "soon"
184+
logger.warning("Going away in: \(time)")
185+
}
186+
}
187+
188+
private func processServerContent(_ content: LiveServerContent) async throws {
189+
if let message = content.modelTurn {
190+
try await processAudioMessages(message)
191+
}
192+
193+
if content.isTurnComplete {
194+
// add a space, so the next time a transcript comes in, it's not squished with the previous one
195+
transcriptTypewriter.appendText(" ")
196+
}
197+
198+
if content.wasInterrupted {
199+
logger.warning("Model was interrupted")
200+
await audioController?.interrupt()
201+
transcriptTypewriter.clearPending()
202+
// adds an em dash to indiciate that the model was cutoff
203+
transcriptTypewriter.appendText("")
204+
} else if let transcript = content.outputAudioTranscription?.text {
205+
appendAudioTranscript(transcript)
206+
}
207+
}
208+
209+
private func processAudioMessages(_ content: ModelContent) async throws {
210+
for part in content.parts {
211+
if let part = part as? InlineDataPart {
212+
if part.mimeType.starts(with: "audio/pcm") {
213+
#if !targetEnvironment(simulator)
214+
try await audioController?.playAudio(audio: part.data)
215+
#endif
216+
} else {
217+
logger.warning("Received non audio inline data part: \(part.mimeType)")
218+
}
219+
}
220+
}
221+
}
222+
223+
private func processFunctionCalls(functionCalls: [FunctionCallPart]) async throws {
224+
let responses = try functionCalls.map { functionCall in
225+
switch functionCall.name {
226+
case "changeBackgroundColor":
227+
return try changeBackgroundColor(args: functionCall.args, id: functionCall.functionId)
228+
case "clearBackgroundColor":
229+
return clearBackgroundColor(id: functionCall.functionId)
230+
default:
231+
logger.debug("Function call: \(String(describing: functionCall))")
232+
throw ApplicationError("Unknown function named \"\(functionCall.name)\".")
233+
}
234+
}
235+
236+
await liveSession?.sendFunctionResponses(responses)
237+
}
238+
239+
private func appendAudioTranscript(_ transcript: String) {
240+
hasTranscripts = true
241+
transcriptTypewriter.appendText(transcript)
242+
}
243+
244+
private func changeBackgroundColor(args: JSONObject, id: String?) throws -> FunctionResponsePart {
245+
guard case let .string(color) = args["color"] else {
246+
logger.debug("Function arguments: \(String(describing: args))")
247+
throw ApplicationError("Missing `color` parameter.")
248+
}
249+
250+
withAnimation {
251+
backgroundColor = Color(hex: color)
252+
}
253+
254+
if backgroundColor == nil {
255+
logger.warning("The model sent us an invalid hex color: \(color)")
256+
}
257+
258+
return FunctionResponsePart(
259+
name: "changeBackgroundColor",
260+
response: JSONObject(),
261+
functionId: id
262+
)
263+
}
264+
265+
private func clearBackgroundColor(id: String?) -> FunctionResponsePart {
266+
withAnimation {
267+
backgroundColor = nil
268+
}
269+
270+
return FunctionResponsePart(
271+
name: "clearBackgroundColor",
272+
response: JSONObject(),
273+
functionId: id
274+
)
275+
}
276+
}

0 commit comments

Comments
 (0)