iOS SDK

更新时间:
复制 MD 格式

This document explains how to use the real-time multimodal interactive iOS SDK from the Alibaba Cloud Model Studio large model service, covering SDK download and installation, key interfaces, and code samples.

The MultiModalDialog SDK from the Alibaba Cloud Tongyi team enables end-to-end, multimodal, real-time interaction with audio and video. By integrating with the Qwen large model and various backend agents, the SDK offers features such as voice chat, weather, music, and news. It also supports using video and images in conversations with the large model.

Multimodal real-time interactive service architecture

多模态实时交互服务接入架构-通用-流程图

Prerequisites

Enable an Alibaba Cloud Model Studio real-time multimodal interaction application to get your workspace ID, app ID, and API key.

Interactive data links

The SDK enables multi-modal interaction with the server over WebSocket and RTC in two modes: AudioOnly and AudioAndVideo.

  • For audio interaction, a WebSocket connection is recommended because it provides faster connections and requires fewer resources.

    • Audio formats for the WebSocket link:

      • Upstream: Send pcm and opus audio formats for speech recognition.

      • Downstream: Receive pcm and mp3 audio streams.

  • For video interaction, use a WebSocket link. See Request LiveAI through a WebSocket link.

Interaction modes

The SDK supports three interaction modes: Push2Talk, Tap2Talk, and Duplex (full-duplex).

  • Push2Talk: Press and hold to speak, and release to stop (or tap to start and tap to stop).

  • Tap2Talk: Tap to start speaking; the SDK then automatically detects the end of speech.

  • Duplex: Enables full-duplex interaction, allowing you to speak at any time once connected. This mode supports barge-in and is recommended for video interaction.

Environment and dependencies

  • Import the SDK (multimodal-dialog-ios-1.0.7.zip)

    • multimodal_dialog.framework: The Model Studio multimodal dialog SDK.

    • MultimodalDialogTongyiMetathings.framework: SDK for license mode.

  • Connect to Model Studio

    • The multimodal dialog service is built on Model Studio, Alibaba Cloud's large model service platform. It uses a Model Studio API key for authentication. Create an API key in Model Studio. To enhance mobile client security, you can also generate a short-lived token on the server side and pass it to the client.

API

MultimodalDialog

Endpoint Types

1 MultiModalDialog

Initialize the service object with the conversation parameters and callback.

/// Initializes the multimodal conversation manager.
    /// - Parameters:
    ///   - url: The service URL.
    ///   - chainMode: The chain mode (RTC/WebSocket).
    ///   - workSpaceId: The workspace ID. Found in the Model Studio console.
    ///   - appId: The application ID. Configured in the multimodal dialog console.
    ///   - mode: The dialog mode (tap2talk/push2talk/duplex).
public init(url: String?, chainMode: ChainMode, workSpaceId: String, appId:String ,mode: DialogMode)

Step 2: Start

Starting the dialog service triggers the on_started callback, which receives the dialog_id.

/// Starts a dialog request by connecting to the dialog service.
    /// - Parameters:
    ///   - apiKey: The API key for authentication.
    ///   - params: The multimodal request parameters.
    ///   - completion: A completion callback that receives a boolean indicating success and an optional error.
    public func start(apiKey: String, params: MultiModalRequestParam, completion: @escaping (Bool, TYError?) -> Void ) 

3 startSpeech

Notifies the server to start an audio upload. This method applies only to push2talk and tap2talk modes and must be called in the Listening state.

/// Starts speech (Push-to-Talk).
    public func startSpeech()

4 sendAudioData

Instructs the server to upload the audio file.

/// Sends audio data.
    /// - Parameters:
    ///   - data: The pointer to the audio data.
    ///   - length: The length of the audio data.
    /// - Returns: A status code.
    public func sendAudioData(data: UnsafeMutablePointer<UInt8>, length: Int32) -> Int32? 

5 stopSpeech

Stops the server-side audio upload. This method should only be called in push-to-talk mode.

/// Stops speech in push-to-talk mode.
    public func stopSpeech() 

6 Interrupt

Notifies the server that the client will interrupt the current interaction to begin speaking. It returns RequestAccepted.

// Interrupts the current conversation.
    /// - Parameter completion: A completion handler called with a Boolean value indicating whether the interruption succeeded.
    public func interrupt(completion: ((Bool) -> Void)? = nil)

7 sendLocalRespondingStarted

Notifies the server that the client has started playing tts audio.

/// Sends a signal that a local response has started.
    public func sendLocalRespondingStarted()

8 sendLocalRespondingEnded

Notifies the server when the client finishes playing the TTS audio.

    /// Sends an end-of-response signal.
    public func sendLocalRespondingEnded()

9. Stop

Ends the dialogue turn and closes the server-side connection.

/// Disconnects from the service.
    public func stop()

10 Request to respond

The client sends text-to-speech (TTS) requests or other requests, such as for images, to the server.

/// Sends a request.
    /// - Parameters:
    ///   - type: The request type.
    ///   - text: The request text.
    ///   - params: Additional parameters.
    public func requestToRespond(type:String, text:String, params:[String : Any])

11 UpdateInfo

Update parameters and more.

/// Updates the information.
    /// - Parameter params: The update parameters.
    public func updateInfo(params:[String : Any])

Callback

This is the callback function for the user layer.

    /// Occurs when a connection to the server is established.
    public var onConnected: (() -> Void)?
    
    /// Occurs when the conversation is ready for you to start speaking.
    public var onConversationStarted: (() -> Void)?
    
    /// Occurs when a dialog event is triggered. Events include SpeechStarted, SpeechEnded, RespondingStarted, and RespondingEnded.
    public var onConversationEvent: ((DialogEvent) -> Void)?
    
    /// Occurs when the conversation state changes. States include Idle, Listening, Thinking, and Responding.
    public var onConversationStateChanged: ((DialogState) -> Void)?
    
    /// Occurs when a conversation message is received, containing the user's transcribed speech or the large model's response.
    public var onMessageReceived: (([String: Any]?, ResponsetMessageType) -> Void)?
    
    /// Occurs when an error is received.
    public var onErrorReceived: ((TYError) -> Void)?
    
    /// Occurs when the audio volume changes. Supported over the RTC link.
    public var onVolumeChanged: ((Float, TYVolumeSourceType) -> Void)?
    
    /// Occurs when the first video frame is sent in video mode. Supported over the RTC link.
    public var onFirstVideoPacketSent: (() -> Void)?
    
    /// Occurs when performance data is received.
    public var onPerformanceDataReceived: ((TYChatPerformace) -> Void)?
    
    /// Occurs when the connection status changes. Supported over the RTC link.
    public var onConnectionStatusChanged: ((RTCConnectionStatus) -> Void)?
    
    /// Occurs when recorded audio data is available. Supported over the RTC link.
    public var onRecorderData:  ((UnsafeMutablePointer<UInt8>, Int32) -> Void)?
    
    /// Occurs when synthesized audio data (Text-to-Speech) is available.
    public var onSynthesizedData:  ((UnsafeMutablePointer<UInt8>, Int32) -> Void)?

MultiModalRequestParam

request parameter class

You can set all request parameters using the builder pattern. The required and optional client parameters are described below.

Start connection parameters

Parameter level 1

Parameter level 2

Parameter level 3

Parameter level 4

Type

Required

Description

input

workspace_id

string

Yes

The workspace ID.

app_id

string

Yes

The application ID. It is created in the console and is used to determine which dialog system to use.

dialog_id

string

No

The dialog ID. If specified, this continues the existing conversation.

parameters

upstream

type

string

Yes

The upstream type:

AudioOnly: For audio-only calls.

AudioAndVideo: For calls that include video.

mode

string

No

The client interaction mode. Valid values are:

  • push2talk

  • tap2talk

  • duplex

The default is tap2talk.

audio_format

string

No

The audio format for the upstream data. Supported formats are pcm and opus. The default is pcm.

downstream

voice

string

No

The voice for the synthesized speech.

sample_rate

int

No

The sample rate of the synthesized speech. The default is 24,000 Hz.

intermediate_text

string

No

Controls which intermediate text results are returned to the client:

transcript: Returns the transcript of the end user's speech.

dialog: Returns intermediate results from the dialog system.

This parameter accepts multiple comma-separated values. The default is transcript.

audio_format

string

No

The audio format of the synthesized speech. Supported formats are pcm and mp3. The default is pcm.

client_info

user_id

string

Yes

The end user's ID, used for user-specific processing.

device

uuid

string

No

A universally unique identifier (UUID) for the client device. The client must generate this ID and pass it to the SDK.

network

ip

string

No

The public IP address of the client.

location

latitude

string

No

The client's latitude.

longitude

string

No

The client's longitude.

city_name

string

No

The city where the client is located.

biz_params

user_defined_params

object

No

Additional parameters to pass through to the agent.

user_defined_tokens

object

No

Authentication information to pass through to the agent.

tool_prompts

object

No

Prompts to pass through to the agent.

RequestToRespond parameters

Level 1 parameter

Level 2 parameter

Level 3 parameter

Type

Required

Description

input

type

string

Yes

Specifies the interaction type:

transcript: Converts the text directly to speech.

prompt: Sends the text to the large model for a response.

text

string

Yes

The text to process. It can be an empty string, but it cannot be null.

parameters

images

list[]

No

The images to analyze.

biz_params

object

No

Passes custom parameters to the dialogue system, similar to the biz_params parameter in the Start request. The biz_params parameter in RequestToRespond is valid only for the current request.

UpdateInfo parameters

Level 1 parameter

Level 2 parameter

Level 3 parameter

Type

Required

Description

parameters

images

list[]

No

A list of image data.

client_info

status

object

No

Current client status.

biz_params

object

No

Specifies custom parameters for the dialogue system. Same as biz_params in the Start message.

Dialog state (DialogState)

The voice chat service has three states: LISTENING, THINKING, and RESPONDING.

IDLE("Idle"), The bot is not connected.
LISTENING("Listening"), The bot is listening for audio input from the user.
THINKING("Thinking"), The bot is processing user input.
RESPONDING("Responding"), The bot is generating or playing an audio response.

LLM dialog response

The onMessageReceived callback returns the dialog result in the following format.

Parameter level 1

Parameter level 2

Parameter level 3

Type

Required

Description

output

event

string

Yes

The event name, such as RequestAccepted.

dialog_id

string

Yes

A unique identifier for the dialog.

round_id

string

Yes

A unique identifier for the current interaction.

llm_request_id

string

Yes

The request ID for the LLM call.

text

string

Yes

The user-facing text, provided as a full streaming output.

spoken

string

Yes

The text for speech synthesis, provided as a full streaming output.

finished

bool

Yes

Indicates whether the output is complete.

extra_info

object

No

Extended information. Supported fields include:

commands: A command string.

agent_info: Information about the agent.

tool_calls: Output from tool calls.

dialog_debug: Debugging information for the dialog.

timestamps: Timestamps for each node in the trace.

Sequence diagram

image

Example

Conversation parameters

Call the builder methods on the MultiModalRequestParam subclasses to build the parameters.

var params = MultiModalRequestParam{ multiBuilder in
            multiBuilder.upStream = MultiModalRequestParam.UpStream(builder: { upstreamBuilder in
                upstreamBuilder.mode = DialogMode.duplex.rawValue
                upstreamBuilder.type = "AudioOnly"
            })
            multiBuilder.clientInfo = MultiModalRequestParam.ClientInfo(builder: {
                clientInfoBuilder in
                clientInfoBuilder.userId = "test-ios-user"
                clientInfoBuilder.device = MultiModalRequestParam.ClientInfo.Device(uuid: "12345")
            })
            multiBuilder.downStream = MultiModalRequestParam.DownStream(builder: {
                downStreamBuilder in
                downStreamBuilder.sampleRate = 48000
            })
        }

Create MultiModalDialog

self.conversation = MultiModalDialog(url: self.HOST, chainMode: self.chain,  workSpaceId:self.WORKSPACE_ID ,appId: self.APP_ID, mode: DialogMode.duplex)

Complete example

//
//  ChatViewController.swift
//  ChatViewController
//
//  Created by songsong.sss on 2025/3/24.
//

import UIKit
import multimodal_dialog
import SnapKit
import AVFoundation



class ChatViewController: UIViewController {
    let videoView = UIView() // Video preview stream window
    var HOST = ""
    var API_KEY = ""
    var WORKSPACE_ID = ""
    var APP_ID = ""
    var chain = ChainMode.WebSocket
    var isconnected : Bool
    var conversation:MultiModalDialog?
    var isInDialog = false
    var audioRecorder: TYAudioRecorder?
    var audioPlayer: AudioPlayer?
    var image_url: String?
    
    
    
    lazy var titleLabel1: UILabel = createLabel(text: "User:")
    lazy var titleLabel2: UILabel = createLabel(text: "AI:")
    lazy var titleLabel3: UILabel = createLabel(text: "")
    
    lazy var textView1: UITextView = createTextField()
    lazy var textView2: UITextView = createTextField()
    lazy var textView3: UITextView = createTextField()
    
    private let buttonStack: UIStackView = {
            let stack = UIStackView()
            stack.axis = .horizontal
            stack.spacing = 20
            stack.distribution = .fillEqually
            return stack
        }()
        
    private let refreshButton: UIButton = {
            let btn = UIButton(type: .system)
            btn.setTitle("Refresh Content", for: .normal)
            btn.backgroundColor = .systemBlue
            btn.tintColor = .white
            btn.layer.cornerRadius = 8
            return btn
        }()
        
    private let submitButton: UIButton = {
            let btn = UIButton(type: .system)
            btn.setTitle("Submit Content", for: .normal)
            btn.backgroundColor = .systemGreen
            btn.tintColor = .white
            btn.layer.cornerRadius = 8
            return btn
        }()

    
    init(){
        self.isconnected = false
        super.init(nibName: nil, bundle: nil)
    }
    
    public func updateParam(url: String, apiKey: String , workSpaceId: String, appId: String, chain: String ,image_url:String){
        self.HOST = url
        self.API_KEY = apiKey
        self.image_url = image_url
        self.WORKSPACE_ID = workSpaceId
        self.APP_ID = appId
        if chain.lowercased() == "websocket" {
            self.chain = ChainMode.WebSocket
            self.audioRecorder = TYAudioRecorder()
            self.audioRecorder?.setup(sampleRate: 16000, numOfChannels: 1, bitsPerChannel: 16)
            self.audioRecorder?.delegate = self
            
            self.audioPlayer = AudioPlayer()
            self.audioPlayer?.delegate = self
        }else{
            self.chain = ChainMode.RTC
        }
        self.conversation = MultiModalDialog(url: self.HOST, chainMode: self.chain,  workSpaceId:self.WORKSPACE_ID ,
                                             appId: self.APP_ID, mode: DialogMode.duplex)
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        print("==================viewDidLoad")
        setupDialogCallbacks()
        
        // Configure the UI for video mode
        if self.chain == ChainMode.RTC {
            view.addSubview(videoView)
            videoView.backgroundColor = .white
            
            videoView.snp.makeConstraints { make in
                make.edges.equalToSuperview()
            }
            
            videoView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(videoViewTapped)))
        }else {
        // Configure the UI for audio mode
            setupUI()
        }
        // Start the connection
        connect()
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        TYLogger.shared.debug("==========viewWillDisappear")
        self.isInDialog = false
        self.audioPlayer?.stop()
        self.audioRecorder?.stopRecorder(shouldNotify: false)
        self.conversation?.stop()
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        TYLogger.shared.debug("==========viewDidDisappear")
    }
    
    func connect(){
        self.conversation?.stop()
        isInDialog = false
        
        var params = MultiModalRequestParam{ multiBuilder in
            multiBuilder.upStream = MultiModalRequestParam.UpStream(builder: { upstreamBuilder in
                upstreamBuilder.mode = DialogMode.duplex.rawValue
                upstreamBuilder.type = "AudioAndVideo"
            })
            multiBuilder.clientInfo = MultiModalRequestParam.ClientInfo(builder: {
                clientInfoBuilder in
                clientInfoBuilder.userId = "test-ios-user"
                clientInfoBuilder.device = MultiModalRequestParam.ClientInfo.Device(uuid: "12345")
            })
            multiBuilder.downStream = MultiModalRequestParam.DownStream(builder: {
                downStreamBuilder in
                downStreamBuilder.sampleRate = 48000
            })
        }
        
        self.conversation?.start(apiKey:self.API_KEY, params: params, completion: { success, error in
            if success {
                print("success")
                self.isconnected = true
            }else {
                self.isconnected = false
                if let e = error {
                    print("Connection failed. Error: \(String(describing: error))")
                } else {
                    print("Connection failed. No error message.")
                }
            }})
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    
    // The @objc attribute is required because this method must be exposed to the Objective-C runtime.
    @objc func videoViewTapped() {
        TYLogger.shared.debug("videoViewTapped")
    }
    
    deinit{
        self.conversation?.stop()
    }
    
    
    private func setupDialogCallbacks() {
        self.conversation?.onConnected = {
            print("callback: onConnectionReady ")
        }
        
        self.conversation?.onVolumeChanged = { volume , type in
        }
        
        self.conversation?.onConversationStarted = {
            TYLogger.shared.debug("callback: onConversationStarted")
            // Enter video mode for RTC.
            if self.chain == ChainMode.RTC {
                self.conversation?.requestToRespond(type: "prompt", text: "", params: self.createVideoChatParams())
            }
        }
        
        self.conversation?.onConversationEvent = { event in
            switch event{
            case .RespondingStarted:
                self.conversation?.sendLocalRespondingStarted()
                break
            case .RespondingEnded:
                // Stop sending TTS data.
                self.audioPlayer?.finishFeed(true)
                break
            case .SpeechStarted:
                break
            case .SpeechEnded:
                if self.chain != ChainMode.RTC {
                    self.audioRecorder?.stopRecorder(shouldNotify: false)
                }
                break
            default:
                break
            }
            print("callback: onConversationEvent ",event)
        }
        
        self.conversation?.onMessageReceived = { message, type in
            let payload = message?["payload"] as? [String: Any]
            let output = payload?["output"] as? [String: Any]
            
            
            let dialogId = output?["dialog_id"] as? String
            var debug_info = ("dialog_id:").appending(dialogId ?? "")
            if type == ResponsetMessageType.speaking {
                let text = output?["text"] as? String
                DispatchQueue.main.async {
                    self.textView1.text = text
                }
                
            }else{
                let spoken = output?["spoken"] as? String
                let llm_request_id = output?["llm_request_id"] as? String
                let round_id = output?["round_id"] as? String
                
                DispatchQueue.main.async {
                    self.textView2.text = spoken
                }
                self.handleCommand(output: output)
                debug_info = debug_info.appending("\n llm_request_id:").appending(llm_request_id ?? "")
                debug_info = debug_info.appending("\n round_id:").appending(round_id ?? "")
            }
            DispatchQueue.main.async {
                self.textView3.text = debug_info
            }
        }
        
        self.conversation?.onConversationStatechanged = {state in
            DispatchQueue.main.async {
                self.titleLabel3.text = state.rawValue
            }
            
            switch state {
            case .idle:
                break
            case .listening:
                print("camera::::", self.conversation?.getCurrentCameraDirection())
                if self.chain != ChainMode.RTC {
                    self.audioRecorder?.startRecorder()
                }
                break
            case .responding:
                break
            case .thinking:
                break
            }
            print("callback: onConversationStatechanged ",state)
        }
        self.conversation?.onErrorReceived = { err in
            print("callback: onErrorReceived: ",err.key, err.message )
            if(err.key.hasPrefix("RTCException.")) {
            }
        }
        
        self.conversation?.onFirstVideoPacketSent = {
            print("callback: onFirstVideoPacketSent")
        }
        
        self.conversation?.onConnectionStatusChanged = {
            state in
            print("onConnectionStatusChanged ::::::" ,state)
            switch state{
            case .failed :
                self.conversation?.stop()
                break
            case .inited:
                break
            case .disconnected:
                break
            case .connected:
                print("==================connected")
                self.isInDialog = true
                // Set up the video display.
                if self.chain == ChainMode.RTC {
                    DispatchQueue.main.async {
                        self.conversation?.setupLocalView(self.videoView, config: TYVideoConfig(fps: 24, width: 480, height: 640, bitrate: 0))
                        self.conversation?.publishLocalVideo()
                    }
                }
                
                break
            case .connecting:
                break
            case .reconnecting:
                break
            @unknown default:
                break
            }
            
        }
        
        self.conversation?.onSynthesizedData = { data, len in
            let audio = Data(bytes: UnsafeRawPointer(data), count: Int(len))
            TYLogger.shared.info("process \(len)")
            self.audioPlayer?.process(audioByte:data, length:Int(len))
        }
            
    }
    
    //RTC only
    private func createVideoChatParams() -> [String: Any]{
        var video:[String: Any] = [
            "action":"connect",
            "type" : "voicechat_video_channel"
        ]
        var videos = [video]
        
        var updateParam = MultiModalRequestParam{ multiBuilder in
            multiBuilder.bizParams = MultiModalRequestParam.BizParams(builder: {
                bizBuilder in
                bizBuilder.videos = videos
            })
        }
        return updateParam.parameters
        
    }
    
    // For VQA
    private func createImageParams() -> [String: Any]{
        var imageObject:[String: Any] = [
            "type":"url",
            "value":self.image_url ?? ""
        ]
        var images = [imageObject]
        
        var updateParam = MultiModalRequestParam{ multiBuilder in
            multiBuilder.clientInfo = MultiModalRequestParam.ClientInfo(builder: {
                clientInfoBuilder in
                clientInfoBuilder.userId = "test-ios-user"
                clientInfoBuilder.device = MultiModalRequestParam.ClientInfo.Device(uuid: "12345")
            })
            multiBuilder.images = images
        }
        return updateParam.parameters
        
    }
    
    // Handle command
    private func handleCommand(output: [String: Any]?)-> Void {
        
    }
    
    
    // Helper method for creating a label
    private func createLabel(text: String) -> UILabel {
        let label = UILabel()
        label.text = text
        label.font = UIFont.systemFont(ofSize: 16, weight: .medium)
        label.textAlignment = .right
        label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }
    
    // Helper method for creating a text view
    private func createTextField() -> UITextView {
        let tv = UITextView()
        tv.layer.cornerRadius = 8
        tv.layer.borderColor = UIColor.systemGray4.cgColor
        tv.layer.borderWidth = 1
        tv.font = UIFont.systemFont(ofSize: 14)
        tv.isScrollEnabled = false
        tv.autocorrectionType = .no
        return tv
    }
    
    private func setupUI() {
            view.backgroundColor = .white
            view.addSubview(titleLabel1)
            view.addSubview(textView1)
            view.addSubview(titleLabel2)
            view.addSubview(textView2)
            view.addSubview(textView3)
            view.addSubview(titleLabel3)
            view.addSubview(buttonStack)
            
            buttonStack.addArrangedSubview(refreshButton)
            buttonStack.addArrangedSubview(submitButton)
            
            // AutoLayout constraints
            let safeArea = view.safeAreaLayoutGuide
            let padding: CGFloat = 20
            
            titleLabel1.translatesAutoresizingMaskIntoConstraints = false
            textView1.translatesAutoresizingMaskIntoConstraints = false
            titleLabel2.translatesAutoresizingMaskIntoConstraints = false
            textView2.translatesAutoresizingMaskIntoConstraints = false
            textView3.translatesAutoresizingMaskIntoConstraints = false
            titleLabel3.translatesAutoresizingMaskIntoConstraints = false
            buttonStack.translatesAutoresizingMaskIntoConstraints = false
            
            NSLayoutConstraint.activate([
                titleLabel1.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: padding),
                titleLabel1.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                titleLabel1.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                
                textView1.topAnchor.constraint(equalTo: titleLabel1.bottomAnchor, constant: 8),
                textView1.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                textView1.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                textView1.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
                
                titleLabel2.topAnchor.constraint(equalTo: textView1.bottomAnchor, constant: padding),
                titleLabel2.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                titleLabel2.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                
                textView2.topAnchor.constraint(equalTo: titleLabel2.bottomAnchor, constant: 8),
                textView2.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                textView2.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                textView2.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
                
                textView3.topAnchor.constraint(equalTo: textView2.bottomAnchor, constant: 8),
                textView3.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                textView3.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                textView3.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
                
                titleLabel3.topAnchor.constraint(equalTo: textView3.bottomAnchor, constant: padding),
                titleLabel3.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                titleLabel3.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                
                
                buttonStack.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -padding),
                buttonStack.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: padding),
                buttonStack.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -padding),
                buttonStack.heightAnchor.constraint(equalToConstant: 50)
            ])
        }
    
}



extension [String: Any] {
    func toUTF8String() -> String? {
        do {
            let data = try JSONSerialization.data(withJSONObject: self, options: .prettyPrinted)
            return String(data: data, encoding: .utf8)
        } catch {
            return nil
        }
    }
}

extension ChatViewController: TYAudioRecorderDelegate {
    /// Callback for when the recorder starts. This is called on the main thread.
    func recorderDidStart(){
        TYLogger.shared.info("recorderDidStart")
    }
    
    /// Callback for when the recorder stops. This is called on the main thread.
    func recorderDidStop(){
        TYLogger.shared.info("recorderDidStop")
    }
    
    /// Called when the recorder captures audio data. This method is called on an AudioQueue thread to avoid blocking the main thread
    /// because it typically involves operations such as VAD and compression. Ensure thread safety.
    func voiceRecorded(_ buffer: UnsafeMutablePointer<UInt8>, length: Int32){
//        TYLogger.shared.debug("voiceRecorded \(length)")
        self.conversation?.sendAudioData(data: buffer, length: length)
        
    }
    
    /// This callback is triggered when the recorder fails to open or another error occurs.
    func recorderDidFail(_ error: Error?){
        TYLogger.shared.error("recorderDidFail")
    }
}

// Player callbacks
extension ChatViewController:AudioPlayerDelegate {
    func playDone() {
        self.conversation?.sendLocalRespondingEnded()
        
    }
}

Exception handling

  • Server-Side Errors

The error code is a string with the prefix "VoiceChat.", such as "VoiceChat.40000000".

Error code

Parameter

Description

40000000

ClientError

Indicates a general client-side error.

40000001

InvalidParameter

A required parameter is missing or a parameter value is invalid.

40000002

DirectiveNotSupported

The directive is not supported, for example, due to an incorrect name.

40000003

MessageInvalid

The message is invalid, for example, due to an incorrect format.

40000004

ConnectError

Indicates a connection error, for example, when the client or digital human disconnects from the RTC channel.

40010000

AccessDenied

Access to the requested resource is denied.

40010001

UNAUTHORIZED

The request is not authorized. Ensure that you have the required permissions.

40020000

DataInspectionFailed

The input or output text triggered Content Moderation.

50000000

InternalError

An internal server error occurred. Contact support for assistance.

50000001

UnknownError

An unknown internal server error occurred. Contact support for assistance.

50010000

InternalAsrError

An internal error occurred in the Automatic Speech Recognition (ASR) service.

50020000

InternalLLMError

An internal error occurred in the Large Language Model (LLM) service.

50030000

InternalSynthesizerError

An internal error occurred in the Text-to-Speech (TTS) service.

  • Client-Side Errors

Error code

Description

PermissionFailed

Could not obtain permissions for audio and video recording.

RTC.AuthFailed

RTC channel authentication failed.

RTC.JoinChannelFailed

Failed to join the RTC channel.

VoiceChat.ConversationFailed

The request to the conversation service failed.

Further SDK API usage

Duplex interaction

The mobile client iOS SDK supports duplex interaction mode. In this mode, the SDK accepts recorded audio data during speech synthesis playback. If a user speaks during playback, the service automatically interrupts the current playback stream (your application must clear the client-side player's buffer) and starts a new response.

Duplex interaction requires Acoustic Echo Cancellation (AEC). The iOS SDK has a built-in echo cancellation algorithm. However, you must configure it before using duplex interaction.

  • Send microphone audio

Use the following method to send real-time audio data from the microphone to the SDK.

self.conversation?.sendAudioData(data: data, length: length)
  • Send reference audio

Use the following method to send the real-time playback audio data to the SDK. Ensure that this data is identical to the currently playing audio.

self.conversation?.sendRefData(data: data, length: length)

Request LiveAI over the RTC link

LiveAI (video call) is the Model Studio agent for multimodal interaction. With the full-featured iOS SDK, you can add video calling to your app. This SDK includes a built-in RTC protocol for real-time, full-duplex audio and video interaction.

  • LiveAI call sequence

截屏2025-06-20 11

  • Key code examples

For the complete code, refer to the iOS demo available for download in the "Environment and dependencies" section.

When you start the interaction over the RTC link, the SDK enables its built-in video capture by default and establishes a video call with the server.

// 1. Set the interaction type to AudioAndVideo.
multiBuilder.upStream = MultiModalRequestParam.UpStream(builder: { upstreamBuilder in
                upstreamBuilder.mode = mode.rawValue
                upstreamBuilder.type = "AudioAndVideo"
            })
// 2. Initialize a MultiModalDialog instance and select the RTC link by setting chainMode to ChainMode.RTC.
self.conversation = MultiModalDialog(url: self.HOST, chainMode: self.chain,  workSpaceId:self.WORKSPACE_ID ,
                                             appId: self.APP_ID, mode: mode)

// 3. After the connection is established, send a voicechat_video_channel request.
private func createVideoChatParams() -> [String: Any]{
        var video:[String: Any] = [
            "action":"connect",
            "type" : "voicechat_video_channel"
        ]
        var videos = [video]
        
        var updateParam = MultiModalRequestParam{ multiBuilder in
            multiBuilder.bizParams = MultiModalRequestParam.BizParams(builder: {
                bizBuilder in
                bizBuilder.videos = videos
            })
        }
        return updateParam.parameters
}

// 4. Start the audio and video interaction. See the sample code for the complete flow, including special handling for ChainMode.RTC.

LiveAI over WebSocket

LiveAI is the official video call agent from Model Studio. The full-featured iOS SDK also allows you to implement the video call feature over a WebSocket link by capturing and sending video frames from your application.

Note: Images sent to LiveAI over a WebSocket link must be base64-encoded and smaller than 180 KB.

  • LiveAI call sequence

截屏2025-06-20 11

  • Key code examples

Follow the procedure below to call LiveAI over a WebSocket link.

// 1. Set the interaction type to "AudioAndVideo".
multiBuilder.upStream = MultiModalRequestParam.UpStream(builder: { upstreamBuilder in
                upstreamBuilder.mode = mode.rawValue
                upstreamBuilder.type = "AudioAndVideo"
            })
// 2. Initialize a MultiModalDialog instance. Select the WebSocket link by setting chainMode to ChainMode.WebSocket.
self.conversation = MultiModalDialog(url: self.HOST, chainMode: self.chain,  workSpaceId:self.WORKSPACE_ID ,
                                             appId: self.APP_ID, mode: mode)

// 3. After the connection is established, send a 'voicechat_video_channel' request. 
// Alternatively, use the 'open_videochat' command to trigger createVideoChatParams. The service returns this command in response to the "Open video call" voice command.
private func createVideoChatParams() -> [String: Any]{
        var video:[String: Any] = [
            "action":"connect",
            "type" : "voicechat_video_channel"
        ]
        var videos = [video]
        
        var updateParam = MultiModalRequestParam{ multiBuilder in
            multiBuilder.bizParams = MultiModalRequestParam.BizParams(builder: {
                bizBuilder in
                bizBuilder.videos = videos
            })
        }
        return updateParam.parameters
}

// 4. Submit a video frame every 500 ms. The image size must be less than 180 KB.
/**
 * This demo shows how to use LiveAI over a WebSocket link by sending a local image.
 * In your application, you must implement the logic to capture and send a video frame every 500 ms.
 */
// Start the background execution to send images.
func startPeriodicExecution() {
    timer = DispatchSource.makeTimerSource(queue: queue)
    timer?.schedule(deadline: .now(), repeating: .milliseconds(500))
    timer?.setEventHandler { [weak self] in
        self?.sendLocalImageBase64()
    }
    timer?.resume()
}

// Send a base64-encoded image to update the conversation.
private func sendLocalImageBase64() {
    print("send_local_image_base64() called : \(Thread.current)")
    var imageObjectBase64:[String: Any] = [
                "type":"base64",
                "value":getImageAsBase64FromAssets(imageName:"bridge") // Implement the base64 encoding for the image.
    ]
    var images = [imageObjectBase64]
    
    var updateParam = MultiModalRequestParam{ multiBuilder in
        multiBuilder.images = images
    }
    
    self.conversation?.updateInfo(params: updateParam.parameters)
    
}

VQA interaction

Visual Question Answering (VQA) enables multi-modal interaction, combining images with voice or text in a conversation.

The core process begins when a voice or text request triggers the "visual_qa" capture instruction.

After you receive the capture instruction, send an image URL or base64 data smaller than 180 KB.

Once a connection is established, send the capture request.

    // Create image request parameters.
    private func createImageParams() -> [String: Any]{
        var imageObject:[String: Any] = [
            "type":"url",
            "value":self.image_url ?? ""
        ]
        // Or, upload the image directly as base64 data (must be < 180 KB).
        var imageObjectBase64:[String: Any] = [
                    "type":"base64",
                    "value":"imagebase64"
        ]
        var images = [imageObject]
        
        var updateParam = MultiModalRequestParam{ multiBuilder in
            multiBuilder.images = images
        }
        return updateParam.parameters
    }
// Send the request.
self.conversation?.requestToRespond(type: "prompt", text: "", params: self.createImageParams())

Text-to-speech

self.conversation?.requestToRespond(type: "prompt", text: "Happiness is a skill: the inner peace that comes from letting go of excessive external desires.", params:[:])

Custom prompt variables and value passing

  • In the console, configure a custom variable in your project's "Prompt" section.

As shown in the following example, a user_name field is defined to represent the user nickname. The user_name variable is inserted into the Prompt as the placeholder ${user_name}.

image.png

  • Set the variable in your code.

For example, set "user_name" = "Jack".

// Pass biz_params.user_prompt_params when building the request parameters.
var promptParam = ["user_name" : "Rice"]
var params = MultiModalRequestParam{ multiBuilder in
            multiBuilder.bizParams = MultiModalRequestParam.BizParams(builder: {
                bizBuilder in
                bizBuilder.userPromptParams = promptParam
            })
}
  • Example request and response

image.png