Files
firebase-ios-sdk/FirebaseAI/Tests/Unit/PartTests.swift
Andrew Heard 384fed9a83 feat(ai): support speech metadata on text parts (#16749)
Add SpeechMetadata (public preview) so requests to Gemini
text-to-speech models can attach turn-level instructions to a
TextPart:

- speaker assigns the turn to a voice configured in
  MultiSpeakerVoiceConfig. Multi-speaker requests require it on
  every part.
- style describes sustained delivery, such as emotion, pacing, or
  volume, so it isn't read aloud as part of the transcript.
  Momentary sounds and pauses stay inline as vocal tags, such as
  <laugh> or <short pause>.

Set metadata with the new TextPart.init(_:speechMetadata:). The
existing init(_:) is kept so references like
[String].map(TextPart.init) still compile. TextPart.speechMetadata
and the SpeechMetadata properties are internal while the API is in
preview.

InternalPart encodes and decodes speechMetadata on the wire. History
doesn't merge streamed text parts that carry speech metadata, so
speaker turns stay separate.

Document the multi-speaker requirements on SpeechConfig,
MultiSpeakerVoiceConfig, and SpeakerVoiceConfig.

Replace the gemini-3.1-flash-tts-preview integration tests with a
TextToSpeechTests suite covering gemini-3.8-flash-tts and
gemini-3.8-flash-lite-tts in unary and streaming, single- and
multi-speaker requests.
2026-10-14 18:56:25 -04:00

461 lines
13 KiB
Swift

// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import XCTest
@testable import FirebaseAILogic
final class PartTests: XCTestCase {
let decoder = JSONDecoder()
let encoder = JSONEncoder()
override func setUp() {
encoder.outputFormatting = .init(
arrayLiteral: .prettyPrinted, .sortedKeys, .withoutEscapingSlashes
)
}
// MARK: - Part Decoding
func testDecodeTextPart() throws {
let expectedText = "Hello, world!"
let json = """
{
"text" : "\(expectedText)"
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(TextPart.self, from: jsonData)
XCTAssertEqual(part.text, expectedText)
XCTAssertNil(part.speechMetadata)
}
func testDecodeTextPart_withSpeechMetadata() throws {
let expectedText = "Hello, world!"
let json = """
{
"speechMetadata" : {
"speaker" : "Joe",
"style" : "cheerful"
},
"text" : "\(expectedText)"
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(TextPart.self, from: jsonData)
XCTAssertEqual(part.text, expectedText)
XCTAssertEqual(part.speechMetadata, SpeechMetadata(speaker: "Joe", style: "cheerful"))
}
func testDecodeTextPart_withEmptySpeechMetadata() throws {
let expectedText = "Hello, world!"
let json = """
{
"speechMetadata" : {},
"text" : "\(expectedText)"
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(TextPart.self, from: jsonData)
XCTAssertEqual(part.text, expectedText)
XCTAssertEqual(part.speechMetadata, SpeechMetadata())
}
func testDecodeInlineDataPart() throws {
let imageBase64 = try PartTests.blueSquareImage()
let mimeType = "image/png"
let json = """
{
"inlineData" : {
"data" : "\(imageBase64)",
"mimeType" : "\(mimeType)"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(InlineDataPart.self, from: jsonData)
XCTAssertEqual(part.data, Data(base64Encoded: imageBase64))
XCTAssertEqual(part.mimeType, mimeType)
}
func testDecodeFunctionResponsePart() throws {
let functionName = "test-function-name"
let resultParameter = "test-result-parameter"
let resultValue = "test-result-value"
let json = """
{
"functionResponse" : {
"name" : "\(functionName)",
"response" : {
"\(resultParameter)" : "\(resultValue)"
}
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(FunctionResponsePart.self, from: jsonData)
let functionResponse = part.functionResponse
XCTAssertEqual(functionResponse.name, functionName)
XCTAssertEqual(functionResponse.response, [resultParameter: .string(resultValue)])
}
func testDecodeExecutableCodePart() throws {
let json = """
{
"executableCode": {
"language": "PYTHON",
"code": "print('hello')"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(ExecutableCodePart.self, from: jsonData)
XCTAssertEqual(part.language, .python)
XCTAssertEqual(part.code, "print('hello')")
}
func testDecodeExecutableCodePart_missingLanguage() throws {
let json = """
{
"executableCode": {
"code": "print('hello')"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(ExecutableCodePart.self, from: jsonData)
XCTAssertEqual(part.language.description, "LANGUAGE_UNSPECIFIED")
XCTAssertEqual(part.code, "print('hello')")
}
func testDecodeExecutableCodePart_missingCode() throws {
let json = """
{
"executableCode": {
"language": "PYTHON"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(ExecutableCodePart.self, from: jsonData)
XCTAssertEqual(part.language, .python)
XCTAssertEqual(part.code, "")
}
func testDecodeExecutableCodePart_missingLanguageAndCode() throws {
let json = """
{
"executableCode": {}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(ExecutableCodePart.self, from: jsonData)
XCTAssertEqual(part.language.description, "LANGUAGE_UNSPECIFIED")
XCTAssertEqual(part.code, "")
}
func testDecodeCodeExecutionResultPart() throws {
let json = """
{
"codeExecutionResult": {
"outcome": "OUTCOME_OK",
"output": "hello"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData)
XCTAssertEqual(part.outcome, .ok)
XCTAssertEqual(part.output, "hello")
}
func testDecodeCodeExecutionResultPart_missingOutcome() throws {
let json = """
{
"codeExecutionResult": {
"output": "hello"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData)
XCTAssertEqual(part.outcome.description, "OUTCOME_UNSPECIFIED")
XCTAssertEqual(part.output, "hello")
}
func testDecodeCodeExecutionResultPart_missingOutput() throws {
let json = """
{
"codeExecutionResult": {
"outcome": "OUTCOME_OK"
}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData)
XCTAssertEqual(part.outcome, .ok)
XCTAssertNil(part.output)
}
func testDecodeCodeExecutionResultPart_missingOutcomeAndOutput() throws {
let json = """
{
"codeExecutionResult": {}
}
"""
let jsonData = try XCTUnwrap(json.data(using: .utf8))
let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData)
XCTAssertEqual(part.outcome.description, "OUTCOME_UNSPECIFIED")
XCTAssertNil(part.output)
}
// MARK: - Part Encoding
func testEncodeTextPart() throws {
let expectedText = "Hello, world!"
let textPart = TextPart(expectedText)
let jsonData = try encoder.encode(textPart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"text" : "\(expectedText)"
}
""")
}
func testEncodeTextPart_withSpeechMetadata() throws {
let expectedText = "Hello, world!"
let speechMetadata = SpeechMetadata(speaker: "Joe", style: "cheerful")
let textPart = TextPart(expectedText, speechMetadata: speechMetadata)
let jsonData = try encoder.encode(textPart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"speechMetadata" : {
"speaker" : "Joe",
"style" : "cheerful"
},
"text" : "\(expectedText)"
}
""")
}
func testEncodeTextPart_withEmptySpeechMetadata() throws {
let expectedText = "Hello, world!"
let textPart = TextPart(expectedText, speechMetadata: SpeechMetadata())
let jsonData = try encoder.encode(textPart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"speechMetadata" : {
},
"text" : "\(expectedText)"
}
""")
}
func testEncodeInlineDataPart() throws {
let mimeType = "image/png"
let imageBase64 = try PartTests.blueSquareImage()
let imageBase64Data = Data(base64Encoded: imageBase64)
let inlineDataPart = InlineDataPart(data: imageBase64Data!, mimeType: mimeType)
let jsonData = try encoder.encode(inlineDataPart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"inlineData" : {
"data" : "\(imageBase64)",
"mimeType" : "\(mimeType)"
}
}
""")
}
func testEncodeFileDataPart() throws {
let mimeType = "image/jpeg"
let fileURI = "gs://test-bucket/image.jpg"
let fileDataPart = FileDataPart(uri: fileURI, mimeType: mimeType)
let jsonData = try encoder.encode(fileDataPart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"fileData" : {
"fileUri" : "\(fileURI)",
"mimeType" : "\(mimeType)"
}
}
""")
}
func testEncodeExecutableCodePart() throws {
let executableCodePart = ExecutableCodePart(language: .python, code: "print('hello')")
let jsonData = try encoder.encode(executableCodePart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"executableCode" : {
"code" : "print('hello')",
"language" : "PYTHON"
}
}
""")
}
func testEncodeCodeExecutionResultPart() throws {
let codeExecutionResultPart = CodeExecutionResultPart(outcome: .ok, output: "hello")
let jsonData = try encoder.encode(codeExecutionResultPart)
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
XCTAssertEqual(json, """
{
"codeExecutionResult" : {
"outcome" : "OUTCOME_OK",
"output" : "hello"
}
}
""")
}
// MARK: - ModelContent Conversion
func testModelContent_unsupportedPartType_isSkippedAndLogged() throws {
var loggedCodes: [AILog.MessageCode] = []
AILog.logInterceptor = { _, code, _ in
loggedCodes.append(code)
}
defer { AILog.logInterceptor = nil }
let content = ModelContent(parts: [TextPart("Hello"), CustomPart(), TextPart("world")])
XCTAssertEqual(content.parts.count, 2)
XCTAssertEqual(content.parts.compactMap { ($0 as? TextPart)?.text }, ["Hello", "world"])
XCTAssertEqual(loggedCodes, [.modelContentUnsupportedPartType])
}
func testModelContent_errorPart_isKeptAndThrowIfErrorThrows() throws {
let content = ModelContent(parts: [
TextPart("Hello"),
ErrorPart(ImageConversionError.couldNotConvertToJPEG),
])
// `Chat` and `countTokens` may rebuild content from its `parts`; the error must survive that.
let rebuiltContent = ModelContent(role: nil, parts: content.parts)
XCTAssertEqual(content.parts.count, 2)
XCTAssertTrue(content.parts.last is ErrorPart)
for contents in [[content], [rebuiltContent]] {
XCTAssertThrowsError(try contents.throwIfError()) { error in
guard case let GenerateContentError.internalError(underlying) = error else {
XCTFail("Expected an internal error, got \(error) instead.")
return
}
XCTAssertEqual(underlying as? ImageConversionError, .couldNotConvertToJPEG)
}
}
}
func testModelContent_errorPart_encodingThrows() throws {
let content = ModelContent(parts: [ErrorPart(ImageConversionError.couldNotConvertToJPEG)])
XCTAssertThrowsError(try encoder.encode(content)) { error in
guard case EncodingError.invalidValue = error else {
XCTFail("Expected an invalid value error, got \(error) instead.")
return
}
}
}
func testModelContent_errorPart_equality() throws {
let content = ModelContent(parts: [ErrorPart(ImageConversionError.couldNotConvertToJPEG)])
XCTAssertEqual(
content,
ModelContent(parts: [ErrorPart(ImageConversionError.couldNotConvertToJPEG)])
)
XCTAssertNotEqual(
content,
ModelContent(parts: [ErrorPart(ImageConversionError.invalidUnderlyingImage)])
)
XCTAssertNotEqual(content, ModelContent(parts: [TextPart("Hello")]))
}
func testErrorPart_encodingAndDecodingThrow() throws {
XCTAssertThrowsError(
try encoder.encode(ErrorPart(ImageConversionError.couldNotConvertToJPEG))
)
XCTAssertThrowsError(try decoder.decode(ErrorPart.self, from: Data("{}".utf8)))
}
// MARK: - Helpers
/// A `Part` conformance defined outside of the SDK.
private struct CustomPart: Part {
var isThought: Bool { false }
}
private static func bundle() -> Bundle {
#if SWIFT_PACKAGE
return Bundle.module
#else // SWIFT_PACKAGE
return Bundle(for: Self.self)
#endif // SWIFT_PACKAGE
}
private static func blueSquareImage() throws -> String {
let imageURL = bundle().url(forResource: "blue", withExtension: "png")!
let imageData = try Data(contentsOf: imageURL)
return imageData.base64EncodedString()
}
}