mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-11 22:55:59 +05:45
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.
461 lines
13 KiB
Swift
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()
|
|
}
|
|
}
|