Zoom iOS 转录例子

一、在zoom marketplace创建通用app,zoom-recall 详见Zoom会议机器人转写例子-CSDN博客

二、mac下按照Xcode,创建APP项目meetingbot4ios

三、本实用的SDK为MobileRTC,即Meeting SDK的iOS版本

四、依赖如下:

MobileRTC和CryptoSwift

五、所有代码如下(meetingbot4iosApp.swift):

复制代码
import SwiftUI
import MobileRTC
import CryptoSwift

@main
struct MeetingBot: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @StateObject private var meetingInfo = MeetingInfo()
    
    var body: some Scene {
        WindowGroup {
            ContentView(meetingInfo: meetingInfo, appDelegate: appDelegate)
        }
    }
}

struct ZoomMeetingView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = MobileRTC.shared().getMeetingService()?.meetingView() ?? UIView()
        if view.subviews.isEmpty {
            print("Meeting view is empty")
        } else {
            print("Meeting view has subviews")
        }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        // Update the view if needed
    }
}

class MeetingInfo: ObservableObject {
    @Published var meetingUrl: String = "https://us05web.zoom.us/j/81334539494?pwd=sf96p7am967Oc3GI39J1yLWSPa6WnS.1"
}

struct ContentView: View {
    @ObservedObject var meetingInfo: MeetingInfo
    @ObservedObject var appDelegate: AppDelegate
    
    init(meetingInfo: MeetingInfo, appDelegate: AppDelegate) {
        self.meetingInfo = meetingInfo
        self.appDelegate = appDelegate
        self.appDelegate.meetingInfo = meetingInfo
    }
    var body: some View {
        VStack {
            TextField("Enter Meeting URL", text: $meetingInfo.meetingUrl)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            // 显示会议状态
            Text("Meeting State: \(appDelegate.meetingState)")
                .padding()
            Button(action: {
                // Trigger initialization with the entered meeting URL
                self.appDelegate.initializeMobileRTCWithMeetingUrl(meetingInfo.meetingUrl)
                
            }) {
                Text("Join Meeting")
            }
            .padding()
            Button(action: {
                appDelegate.toggleRecording()
            }) {
                Text(appDelegate.recordingState == "stopped" ? "Start Recording" : "Stop Recording")
            }
            .padding()

            Text(appDelegate.transcriptText)
                .padding()

            ScrollView {
                Text(appDelegate.transcript.joined(separator: "\n"))
                    .padding()
            }
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, MobileRTCAuthDelegate, MobileRTCMeetingServiceDelegate {
    @Published var meetingNumber: String?
    @Published var password: String?
    @Published var recordingState: String = "stopped"
    @Published var transcript: [String] = []
    @Published var botId: String?
    @Published var meetingState: String = "Not Joined" // 新增会议状态属性
    @Published var transcriptText = "Loading..."
    var meetingInfo: MeetingInfo?
    var window: UIWindow?
    private var refreshTimer: Timer?
    private var ZM_CLIENT_ID = ""
    private var ZM_CLIENT_SECRET = ""
    private var recallApiKey = ""
    private var WEBHOOK_SECRET=""
    private var PUBLIC_URL=""

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        // Initialize MobileRTC SDK
        let context = MobileRTCSDKInitContext()
        context.domain = "zoom.us"
        context.enableLog = true

        let success = MobileRTC.shared().initialize(context)
        if success {
            print("MobileRTC SDK initialized successfully")
        } else {
            print("Failed to initialize MobileRTC SDK")
        }

        return true
    }

    func initializeMobileRTCWithMeetingUrl(_ meetingUrl: String) {
        // Parse meeting URL
        guard let (meetingNumber, password) = parseMeetingUrl(meetingUrl) else {
            print("Failed to parse meeting URL")
            return
        }

        self.meetingNumber = meetingNumber
        self.password = password

        // Generate JWT
        guard let jwt = generateSDKJWT(meetingNumber: meetingNumber, role: 0, expirationSeconds: 3600) else {
            print("Failed to generate JWT")
            return
        }

        // Set up the Zoom SDK with JWT
        let authService = MobileRTC.shared().getAuthService()
        authService?.delegate = self
        authService?.jwtToken = jwt
        authService?.sdkAuth()
    }

    private func parseMeetingUrl(_ link: String) -> (String, String)? {
        // 查找会议号和密码
        let meetingNumberPattern = "/j/(\\d+)"
        let passwordPattern = "pwd=([^&]+)"
        
        // 使用正则表达式来匹配会议号
        if let meetingNumberRange = link.range(of: meetingNumberPattern, options: .regularExpression) {
            let meetingNumberSubstring = link[meetingNumberRange]
            let meetingNumber = meetingNumberSubstring.replacingOccurrences(of: "/j/", with: "")
            
            // 使用正则表达式来匹配密码
            if let passwordRange = link.range(of: passwordPattern, options: .regularExpression) {
                let passwordSubstring = link[passwordRange]
                let password = passwordSubstring.replacingOccurrences(of: "pwd=", with: "")
                print(meetingNumber, password)
                return (meetingNumber, password)
            }
        }
        
        return nil
    }

    private func generateSDKJWT(meetingNumber: String, role: Int, expirationSeconds: Int?) -> String? {
        let iat = Int(Date().timeIntervalSince1970)
        let exp = expirationSeconds != nil ? iat + expirationSeconds! : iat + 60 * 60 * 2

        let header = ["alg": "HS256", "typ": "JWT"]
        let payload: [String: Any] = [
            "appKey": ZM_CLIENT_ID,
            "sdkKey": ZM_CLIENT_ID,
            "mn": meetingNumber,
            "role": role,
            "iat": iat,
            "exp": exp,
            "tokenExp": exp
        ]

        guard let headerData = try? JSONSerialization.data(withJSONObject: header, options: []),
              let payloadData = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
            return nil
        }

        let headerBase64 = headerData.base64EncodedString().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
        let payloadBase64 = payloadData.base64EncodedString().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")

        let toSign = "\(headerBase64).\(payloadBase64)"
        guard let hmac = try? HMAC(key: ZM_CLIENT_SECRET.bytes, variant: .sha256).authenticate(toSign.bytes) else {
            return nil
        }

        let signatureBase64 = hmac.toBase64().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
        let jwt = "\(toSign).\(signatureBase64)"

        return jwt
    }

    // MobileRTCAuthDelegate
    func onMobileRTCAuthReturn(_ returnValue: MobileRTCAuthError) {
        if returnValue == .success {
            print("Zoom SDK authentication successful")
            joinMeeting()
        } else {
            print("Zoom SDK authentication failed with error: \(returnValue)")
        }
    }

    // MobileRTCMeetingServiceDelegate
    func onMeetingStateChange(_ state: MobileRTCMeetingState) {
        let stateValue = state.rawValue
        print("Meeting state changed: \(stateValue)")
        
        switch stateValue {
        case 1:
            meetingState = "Connecting to Meeting Server"
        case 3:
            meetingState = "Promoting Participant to Host"
        case 4:
            meetingState = "Demoting Host to Participant"
        case 5:
            meetingState = "Disconnecting from Meeting"
        case 6:
            meetingState = "Reconnecting to Meeting"
        case 7:
            meetingState = "Connection Failed"
        case 10:
            meetingState = "Meeting Locked"
        case 12:
            meetingState = "Participant in Waiting Room"
        default:
            meetingState = "Other Meeting State"
        }
        // Update the ZoomMeetingView based on the meeting state
        DispatchQueue.main.async {
            self.objectWillChange.send()
        }
    }

    private func joinMeeting() {
        guard let meetingNumber = self.meetingNumber, let password = self.password else {
            print("Meeting number or password is missing")
            return
        }

        let meetingService = MobileRTC.shared().getMeetingService()
        meetingService?.delegate = self

        let joinParam = MobileRTCMeetingJoinParam()
        joinParam.meetingNumber = meetingNumber
        joinParam.password = password
        joinParam.userName = "iOS User"
        joinParam.noAudio = true
        joinParam.noVideo = false

        let result = meetingService?.joinMeeting(with: joinParam)
        if result == .success {
            print("Joining meeting...")
        } else {
            print("Failed to join meeting with error: \(result ?? .unknown)")
        }
    }

    func toggleRecording() {
        if recordingState == "stopped" {
            startRecording()
        } else {
            stopRecording()
        }
    }

    func startRecording() {
        recordingState = "starting"

        guard let meetingUrl = URL(string: meetingInfo?.meetingUrl ?? "") else {
            print("Invalid meeting URL.")
            return
        }

        let url = "https://us-west-2.recall.ai/api/v1/bot"
        var request = URLRequest(url: URL(string: url)!)
        request.httpMethod = "POST"
        request.addValue("Token \(recallApiKey)", forHTTPHeaderField: "Authorization")
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: Any] = [
            "bot_name": "meeetingbot",
            "meeting_url": meetingUrl.absoluteString,
            "transcription_options": [
                "provider": "assembly_ai"
            ],
            "real_time_transcription": [
                "destination_url": PUBLIC_URL+"/webhook/transcription?secret="+WEBHOOK_SECRET,
                "partial_results": true
            ],
            "zoom": [
                "request_recording_permission_on_host_join": true,
                "require_recording_permission": true
            ]
        ]

        request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted)

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard let data = data, error == nil else {
                print("Error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }

            if let httpResponse = response as? HTTPURLResponse {
                if httpResponse.statusCode <= 299 {
                    let bot = try? JSONDecoder().decode(BotResponse.self, from: data)
                    DispatchQueue.main.async {
                        self.botId = bot?.id
                        self.recordingState = "bot-joining"
                        print("startRecording:",self.botId)
                        print("stopRecording:",self.recordingState)
                        // 启动定时器
                        self.refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
                            self.fetchTranscript()
                        }
                    }
                } else {
                    DispatchQueue.main.async {
                        self.recordingState = "error"
                        print("startRecording:",self.recordingState)
                        print("startRecording:",httpResponse.statusCode)
                    }
                }
            }
        }

        task.resume()
    }

    func stopRecording() {
        guard let botId = botId else {
            print("No botId to stop recording")
            // 恢复到 startRecording 的状态或提供适当的错误处理
            DispatchQueue.main.async {
                self.recordingState = "stopped"
                print("stopRecording: No botId, resetting to stopped state")
            }
            return
        }

        recordingState = "stopping"

        let url = "https://us-west-2.recall.ai/api/v1/bot/\(botId)/leave_call"
        var request = URLRequest(url: URL(string: url)!)
        request.httpMethod = "POST"
        request.addValue("Token \(recallApiKey)", forHTTPHeaderField: "Authorization")
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard let _ = data, error == nil else {
                print("Error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }

            if let httpResponse = response as? HTTPURLResponse {
                if httpResponse.statusCode <= 299 {
                    DispatchQueue.main.async {
                        self.recordingState = "bot-leaving"
                        self.recordingState = "stopped"
                        print("stopRecording:",self.recordingState)
                        // 停止定时器
                        self.refreshTimer?.invalidate()
                        self.refreshTimer = nil
                    }
                } else {
                    DispatchQueue.main.async {
                        self.recordingState = "error"
                        print("stopRecording:",self.recordingState)
                        print("stopRecording:",httpResponse.statusCode)
                    }
                }
            }
        }

        task.resume()
    }

    func fetchTranscript() {
            guard let botId = botId else {
                print("No botId to fetchTranscript")
                return
            }
            let url = "https://us-west-2.recall.ai/api/v1/bot/\(botId)/transcript/?enhanced_diarization=true"
            var request = URLRequest(url: URL(string: url)!)
            request.httpMethod = "GET"
            request.addValue("Token \(recallApiKey)", forHTTPHeaderField: "Authorization")
            request.addValue("application/json", forHTTPHeaderField: "Content-Type")

            URLSession.shared.dataTask(with: request) { data, response, error in
                if let error = error {
                    self.updateTranscriptText(text: "Error: \(error.localizedDescription)")
                    return
                }

                guard let data = data else {
                    self.updateTranscriptText(text: "No data received")
                    return
                }

                do {
                    let transcripts = try JSONDecoder().decode([Transcript].self, from: data)
                    var transcriptText = ""
                    for transcript in transcripts {
                        transcriptText += "Speaker: \(transcript.speaker)\n"
                        for word in transcript.words {
                            transcriptText += "\(word.text) "
                        }
                        transcriptText += "\n"
                    }
                    self.updateTranscriptText(text: transcriptText)
                } catch {
                    self.updateTranscriptText(text: "Decoding error: \(error.localizedDescription)")
                }
            }.resume()
        }

        func updateTranscriptText(text: String) {
            DispatchQueue.main.async {
                self.transcriptText = text
            }
        }
    }

    // 定义 JSON 数据的结构
    struct Transcript: Codable {
        let words: [Word]
        let speaker: String
        let speaker_id: Int
        let language: String?
    }

    struct Word: Codable {
        let text: String
        let start_timestamp: Double
        let end_timestamp: Double
        let language: String?
        let confidence: Double?
    }

    struct BotResponse: Codable {
        let id: String
    }

六、在远程(海外)启动zoom客户端新建一个会议

七、复制会议地址

八、运行ios程序,输入会议地址,加入会议,开始转录(最终效果)

九、由于本机远程重定向了语音,所以iOS User的语音没有打开(会导致程序崩溃)。

相关推荐
用户0919 小时前
SwiftUI Charts 函数绘图完全指南
ios·swiftui·swift
YungFan19 小时前
iOS26适配指南之UIColor
ios·swift
权咚1 天前
阿权的开发经验小集
git·ios·xcode
用户091 天前
TipKit与CloudKit同步完全指南
ios·swift
法的空间2 天前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
2501_915918412 天前
iOS 上架全流程指南 iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传 ipa 与审核实战经验分享
android·ios·小程序·uni-app·cocoa·iphone·webview
00后程序员张2 天前
iOS App 混淆与加固对比 源码混淆与ipa文件混淆的区别、iOS代码保护与应用安全场景最佳实践
android·安全·ios·小程序·uni-app·iphone·webview
Magnetic_h2 天前
【iOS】设计模式复习
笔记·学习·ios·设计模式·objective-c·cocoa
00后程序员张2 天前
详细解析苹果iOS应用上架到App Store的完整步骤与指南
android·ios·小程序·https·uni-app·iphone·webview
前端小超超2 天前
capacitor配置ios应用图标不同尺寸
ios·蓝桥杯·cocoa