From f93bb8d1ff1b012f5b616849cbfd59d7aef544ff Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 26 Aug 2026 18:56:04 +0800 Subject: [PATCH] fixed some bugs on ios --- ios/OralTrainer.xcodeproj/project.pbxproj | 4 + .../UserInterfaceState.xcuserstate | Bin 20828 -> 20828 bytes .../Services/AudioRecorderSession.swift | 159 +++++++++++++++++ .../Services/DubbingCompositor.swift | 164 ++++++++++++++---- ios/OralTrainer/Views/TestView.swift | 81 ++++++--- ios/README.md | 10 +- 6 files changed, 361 insertions(+), 57 deletions(-) create mode 100644 ios/OralTrainer/Services/AudioRecorderSession.swift diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index 8eb2b78..a7881f4 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; }; AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; }; AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; }; + AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -56,6 +57,7 @@ AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = ""; }; AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = ""; }; + AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -118,6 +120,7 @@ AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, + AA0000000000000000000081 /* AudioRecorderSession.swift */, ); path = Services; sourceTree = ""; @@ -237,6 +240,7 @@ AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */, AA000000000000000000004B /* ImitationAssessor.swift in Sources */, AA000000000000000000007D /* DubbingCompositor.swift in Sources */, + AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */, AA000000000000000000004C /* PlayerController.swift in Sources */, AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */, AA000000000000000000004E /* Theme.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index c308a3c97dcf66622ca05ac0d03637efa0077034..543bbeb0eba76d7096422f4ed5b5daac0592127b 100644 GIT binary patch delta 1086 zcmZXTZ){Ul7{eG62m3>MI|Q2M6+b1u*Av2oF)dp5E25CsNj1yiHY0~=iVoI z-{<+g@5!Aj;<+OJ>_fcVL6(vGh?BU<3bKl>qOa049j4OUPr4{> zG-ucqI~$uhbVxDsYPV6N4lk-rW?nt*c%Tl|qFVeTp2a`Gdk)pf52Pxc#-25gnEqfB zHooy}qo2rt@pZ^=Os}i0A(${!T_S`~qR5ZrQF*LHDu|8P<#GA3%o}GlX}ce(L?tfc zn(%pZWpn_Ik}9(N`Ht;7cgD6BNfnZ1vJz^lp=N~pVmbfj_&Bnj3&@GA?lpv?>LT$F zO%~*M5ls`X+$1MtU|0XXWFnS~Cx#P4-SN~*nSn$y{d6MPo9=Tr-!M+{09&jy&+{fs z-a2Yf?-OLuWj^VB!9gAW4N_0zv=0WM3ED$@bLu0T9&;SgmfX!B-KLFa4jy)EeCIe+U4dLe@{EG%( z1F&2Gd6BqwcWjSjGW+6tv+2apGcK3&%#m|l?{677m0vjN-Ikw~Ia7H!3PFk+f}V{u z2JhK`;#F#_l(3E)AV~2eRjGxbPa{4UTB!#a1bH#y!#VS2A2H!>9Q;?L(e?)@Rmi3UqJo}GTP!>d3SHhZ_4kBcdlHK1#_%r6yHn6*0L7MvtP#p zV9OpuDq9ae{#MJDwS(LUvZK|v>aH!@%(j3nd)&H?6ga=z`W<0e_f*>-=5reo7(QoD zxs5N|I#<-OOYAb6XFszA_z_ec7o=*2+N1Wsm~h=bq2q%omkaGcDHKP0P!`p|nTB&6 z+JM%g_2!YSGZddT`lD^JCapu;rR~+?T94MJrL;k9NXuw%XyaO0yRQAkE4juWl#&`2>-pkWG%SZSqf0Mt>-{J4^!@Oi(i;j|w--Z@KS3@^Kw?co0ZVM)C!XcbO q6^)`(92AGegg7fc7w5#3m=_D;s<v>i>1?z zxq0rB|1iYd1U9gCYXu_)Lv{&1i1MU~#st!h#KFRxM#UFHVlX~P@OLM~MDN47=bN1K z`F_8@oP`2jDBv^iVs|;IAP%yUtR~fD4OvS#@t7arubl&QkiJY`p~G~9j#&puH^oiX zWp=%sW#*24Helw|J!ZW+ysS=GIrX$-jUP22KmHES<16rAKn?P+bV}9itzBH;Bz%A<0kKp46xC*{L3uqU2=VJMkMWgknV_GMq} z8SGCb+EekwaN@w-w|rkt$!TzE6g_5% z`^swIbS@+LB=XDjJK)}x-M9dyONb!upw?z8Jq+9xLr%nEp%FnLN&nvr!i4N3YE zgrzB(ruz+CX5exIR}^W6xM|kF4moY$l>nRKose3uDA9vRrK5NAl;}9f*Z!%1^JZ1Z z52^03tzDrNm{IzBeH!+L^Cdb9 za*otJ)6p3j8tRYl9mysRJnnKiXO3LxKD2H0$`IJbliG!Qv$;_=RRki zZ|%C*&#tm-Y>|D>mf-2D2F^>TrEiXv2Qp{poC^kiJMD6zT_}y>s27c(dRVitM$snJ zgqp1*+b>f*V-Chz+e5sA_weWWULNQDe1Io;nve6h_&fX^fg)}uYE^=U~hqm62BXvejjbt5)LwtS&4>A&f}>o@g3^;`OFfdv&+!Xp}l nF2Z8Hcu;HPikCNjbl=f#(zBz_XVh#TTo$Yq_{Im7-22A?^p diff --git a/ios/OralTrainer/Services/AudioRecorderSession.swift b/ios/OralTrainer/Services/AudioRecorderSession.swift new file mode 100644 index 0000000..f1fb2b1 --- /dev/null +++ b/ios/OralTrainer/Services/AudioRecorderSession.swift @@ -0,0 +1,159 @@ +import AVFoundation +import Foundation + +enum AudioRecorderSessionError: LocalizedError { + case inputUnavailable + case startFailed + case conversionFailed + + var errorDescription: String? { + switch self { + case .inputUnavailable: + return "未检测到麦克风输入" + case .startFailed: + return "无法开始录制:请确认麦克风未被其他应用占用后重试" + case .conversionFailed: + return "录音文件转码失败" + } + } +} + +/// 基于 AVAudioEngine 的录音会话。 +/// +/// AVAudioRecorder 在 iOS 26 真机上存在 record() 恒返回 false 的已知问题 +/// (Apple 开发者论坛多个报告),因此这里改用 AVAudioEngine 采集 PCM, +/// 停止录音时再转码为 16kHz 单声道 AAC(m4a),与服务端评测接口保持一致。 +final class AudioRecorderSession { + private let engine = AVAudioEngine() + private let outputURL: URL + private let pcmURL: URL + private var inputFile: AVAudioFile? + private var tapInstalled = false + + private(set) var isRunning = false + private(set) var recordDuration: TimeInterval = 0 + + /// 本次录音的原始 PCM 文件(caf),停止录音后仍保留, + /// 供配音合成直接合并,避免在 iOS 26 上读回 AAC(AVAudioFile 存在 'fmt?' 问题)。 + var rawRecordingURL: URL { pcmURL } + + init(outputURL: URL) { + self.outputURL = outputURL + let stamp = Int(Date().timeIntervalSince1970 * 1000) + self.pcmURL = FileManager.default.temporaryDirectory + .appendingPathComponent("raw-\(stamp).caf") + } + + /// 激活音频会话并开始采集。失败时抛出错误,可在重新激活会话后重试。 + func start() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try session.setActive(true) + guard session.isInputAvailable else { + throw AudioRecorderSessionError.inputUnavailable + } + + let input = engine.inputNode + if !tapInstalled { + let inputFormat = input.outputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { + throw AudioRecorderSessionError.inputUnavailable + } + let file = try AVAudioFile(forWriting: pcmURL, settings: inputFormat.settings) + inputFile = file + input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in + guard let self = self else { return } + do { + try self.inputFile?.write(from: buffer) + } catch { + // 单个缓冲写入失败时忽略,继续录音 + } + } + tapInstalled = true + } + engine.prepare() + do { + try engine.start() + } catch { + throw AudioRecorderSessionError.startFailed + } + isRunning = true + } + + /// 停止录音,将 PCM 转码为 m4a 写入 outputURL。 + func stop() throws { + guard isRunning else { return } + engine.inputNode.removeTap(onBus: 0) + engine.stop() + isRunning = false + tapInstalled = false + inputFile = nil + try finalizeToAAC() + } + + /// 放弃本次录音并清理临时文件。 + func cancel() { + if isRunning { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + isRunning = false + tapInstalled = false + inputFile = nil + } + try? FileManager.default.removeItem(at: pcmURL) + try? FileManager.default.removeItem(at: outputURL) + } + + private func finalizeToAAC() throws { + let inputFile = try AVAudioFile(forReading: pcmURL) + recordDuration = Double(inputFile.length) / inputFile.processingFormat.sampleRate + let outputSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + ] + let outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings) + guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFile.processingFormat) else { + throw AudioRecorderSessionError.conversionFailed + } + let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in + if inputFile.framePosition >= inputFile.length { + outStatus.pointee = .endOfStream + return nil + } + let remaining = inputFile.length - inputFile.framePosition + let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining))) + guard frames > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else { + outStatus.pointee = .noDataNow + return nil + } + do { + try inputFile.read(into: buffer) + outStatus.pointee = .haveData + return buffer + } catch { + outStatus.pointee = .noDataNow + return nil + } + } + let capacity = AVAudioFrameCount(min(4096, max(1, Int(inputFile.length)))) + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFile.processingFormat, frameCapacity: capacity) else { + throw AudioRecorderSessionError.conversionFailed + } + while true { + var conversionError: NSError? + let status = converter.convert(to: outputBuffer, error: &conversionError, withInputFrom: inputBlock) + switch status { + case .haveData: + try outputFile.write(from: outputBuffer) + case .inputRanDry, .endOfStream: + return + case .error: + throw conversionError ?? AudioRecorderSessionError.conversionFailed + default: + return + } + } + } +} diff --git a/ios/OralTrainer/Services/DubbingCompositor.swift b/ios/OralTrainer/Services/DubbingCompositor.swift index 1e4fe3a..f11c313 100644 --- a/ios/OralTrainer/Services/DubbingCompositor.swift +++ b/ios/OralTrainer/Services/DubbingCompositor.swift @@ -2,61 +2,153 @@ import AVFoundation import Foundation enum DubbingCompositorError: LocalizedError { - case noAudioTracks - case cannotReadDuration + case inputReadFailed(String?) + case outputWriteFailed(String?) + case convertFailed(String?) var errorDescription: String? { switch self { - case .noAudioTracks: - return "录音没有音频轨道" - case .cannotReadDuration: - return "无法读取录音时长" + case .inputReadFailed(let detail): + return detail.map { "无法读取录音音频(\($0))" } ?? "无法读取录音音频" + case .outputWriteFailed(let detail): + return detail.map { "无法写入合成音频(\($0))" } ?? "无法写入合成音频" + case .convertFailed(let detail): + return detail.map { "无法转换音频格式(\($0))" } ?? "无法转换音频格式" } } } enum DubbingCompositor { + /// 合并多段录音为一段 16kHz 单声道 AAC(m4a)。 + /// + /// 不使用 AVMutableComposition + AVAssetExportSession:iOS 26 / macOS 26 上 + /// AVAssetExportSession 对纯音频资源导出会失败(AVError -11800,底层 'fmt?'), + /// AVAudioFile 读回 AAC 也会失败(CheckClientFormatSet / 'fmt?')。 + /// 因此传入录音时保留的原始 PCM(caf),解码拼接后直接编码 AAC, + /// 与录音转码走同一条已验证可用的链路。 static func merge(urls: [URL], outputURL: URL) async throws { - try await mergeSync(urls: urls, outputURL: outputURL) + do { + try mergeSync(urls: urls, outputURL: outputURL) + } catch { + try? FileManager.default.removeItem(at: outputURL) + throw error + } } - private static func mergeSync(urls: [URL], outputURL: URL) async throws { + private static func mergeSync(urls: [URL], outputURL: URL) throws { try? FileManager.default.removeItem(at: outputURL) - let composition = AVMutableComposition() - var currentTime = CMTime.zero + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + ] + let outputFile: AVAudioFile + do { + outputFile = try AVAudioFile(forWriting: outputURL, settings: settings) + } catch { + throw DubbingCompositorError.outputWriteFailed(error.localizedDescription) + } + let targetFormat = outputFile.processingFormat for url in urls { - let source = AVURLAsset(url: url) - let tracks = try await source.loadTracks(withMediaType: .audio) - guard let track = tracks.first else { - throw DubbingCompositorError.noAudioTracks + let inputFile: AVAudioFile + do { + inputFile = try AVAudioFile(forReading: url) + } catch { + throw DubbingCompositorError.inputReadFailed(error.localizedDescription) } - let duration = try await source.load(.duration) - guard duration.seconds.isFinite, duration.seconds > 0 else { - throw DubbingCompositorError.cannotReadDuration + guard inputFile.length > 0 else { + throw DubbingCompositorError.inputReadFailed("录音为空") } - let timeRange = CMTimeRange(start: .zero, duration: duration) - let compositionTrack = composition.addMutableTrack( - withMediaType: .audio, - preferredTrackID: kCMPersistentTrackID_Invalid - ) - guard let compositionTrack else { - throw DubbingCompositorError.noAudioTracks + let inputFormat = inputFile.processingFormat + if inputFormat.sampleRate == targetFormat.sampleRate, + inputFormat.channelCount == targetFormat.channelCount { + try appendStream(from: inputFile, to: outputFile) + } else { + try appendConverted(from: inputFile, to: outputFile, targetFormat: targetFormat) } - try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime) - currentTime = currentTime + duration } + } - guard let exporter = AVAssetExportSession( - asset: composition, - presetName: AVAssetExportPresetAppleM4A - ) else { - throw DubbingCompositorError.noAudioTracks + private static func appendStream(from inputFile: AVAudioFile, to outputFile: AVAudioFile) throws { + var remaining = inputFile.length + while remaining > 0 { + let frames = AVAudioFrameCount(min(remaining, 8192)) + guard let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else { + throw DubbingCompositorError.convertFailed("无法分配解码缓冲") + } + do { + try inputFile.read(into: buffer) + } catch { + throw DubbingCompositorError.inputReadFailed(error.localizedDescription) + } + guard buffer.frameLength > 0 else { + throw DubbingCompositorError.inputReadFailed("录音文件读取出错(读取到空帧)") + } + do { + try outputFile.write(from: buffer) + } catch { + throw DubbingCompositorError.outputWriteFailed(error.localizedDescription) + } + remaining -= AVAudioFramePosition(buffer.frameLength) } - exporter.outputURL = outputURL - exporter.outputFileType = .m4a - await exporter.export() - guard exporter.status == .completed else { - throw exporter.error ?? DubbingCompositorError.noAudioTracks + } + + private static func appendConverted( + from inputFile: AVAudioFile, + to outputFile: AVAudioFile, + targetFormat: AVAudioFormat + ) throws { + guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: targetFormat) else { + throw DubbingCompositorError.convertFailed("无法创建格式转换器") + } + let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in + if inputFile.framePosition >= inputFile.length { + outStatus.pointee = .endOfStream + return nil + } + let remaining = inputFile.length - inputFile.framePosition + let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining))) + guard frames > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else { + outStatus.pointee = .noDataNow + return nil + } + do { + try inputFile.read(into: buffer) + guard buffer.frameLength > 0 else { + outStatus.pointee = .endOfStream + return nil + } + outStatus.pointee = .haveData + return buffer + } catch { + outStatus.pointee = .noDataNow + return nil + } + } + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: 8192) else { + throw DubbingCompositorError.convertFailed("无法分配转码缓冲") + } + while true { + var error: NSError? + let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock) + switch status { + case .haveData: + do { + try outputFile.write(from: outputBuffer) + } catch { + throw DubbingCompositorError.outputWriteFailed(error.localizedDescription) + } + case .inputRanDry, .endOfStream: + return + case .error: + if let error { + throw DubbingCompositorError.convertFailed(error.localizedDescription) + } + throw DubbingCompositorError.convertFailed(nil) + default: + return + } } } } diff --git a/ios/OralTrainer/Views/TestView.swift b/ios/OralTrainer/Views/TestView.swift index acf702b..b12d4b5 100644 --- a/ios/OralTrainer/Views/TestView.swift +++ b/ios/OralTrainer/Views/TestView.swift @@ -5,7 +5,7 @@ struct TestView: View { @ObservedObject var controller: PlayerController let onFullscreen: () -> Void - @State private var recorder: AVAudioRecorder? + @State private var recordingSession: AudioRecorderSession? @State private var recordingURL: URL? @State private var isRecording = false @State private var isAssessing = false @@ -13,6 +13,7 @@ struct TestView: View { @State private var scoreSummary = "尚未评测" @State private var scoreDetail = "" @State private var dubSegments: [Int: URL] = [:] + @State private var dubRawSegments: [Int: URL] = [:] @State private var latestScores: [Int: ImitationAssessmentResult] = [:] @State private var mergedDubURL: URL? @State private var dubPlayer: AVPlayer? @@ -82,7 +83,7 @@ struct TestView: View { dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { toggleDubPlayback() } - dubButton("生成配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { + dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { generateDubShareAudio() } dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) { @@ -195,58 +196,99 @@ struct TestView: View { private func beginRecording() { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") - let settings: [String: Any] = [ - AVFormatIDKey: Int(kAudioFormatMPEG4AAC), - AVSampleRateKey: 16_000, - AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 96_000, - AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, - ] + let newSession = AudioRecorderSession(outputURL: fileURL) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) - let newRecorder = try AVAudioRecorder(url: fileURL, settings: settings) - guard newRecorder.record() else { - testStatus = "录音启动失败:无法开始录制" + guard session.isInputAvailable else { + testStatus = microphoneInputUnavailableMessage() return } - recorder = newRecorder + do { + try newSession.start() + } catch { + try session.setActive(false, options: .notifyOthersOnDeactivation) + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try session.setActive(true) + try newSession.start() + } + recordingSession = newSession recordingURL = fileURL isRecording = true testStatus = "正在录音…读完当前句子后点击“停止并评测”" + } catch AudioRecorderSessionError.inputUnavailable { + newSession.cancel() + testStatus = microphoneInputUnavailableMessage() + } catch AudioRecorderSessionError.startFailed { + newSession.cancel() + testStatus = recordingStartFailedMessage() } catch { + newSession.cancel() testStatus = "录音启动失败:\(error.localizedDescription)" } } + private func microphoneInputUnavailableMessage() -> String { + #if targetEnvironment(simulator) + return "录音启动失败:未检测到麦克风输入。请在模拟器菜单 I/O → Audio Input 中选择输入设备,并允许“模拟器”使用 Mac 麦克风后重试" + #else + return "录音启动失败:未检测到麦克风输入。请检查 设置 → 隐私与安全性 → 麦克风 是否允许本 App 使用麦克风" + #endif + } + + private func recordingStartFailedMessage() -> String { + #if targetEnvironment(simulator) + return "录音启动失败:无法开始录制。模拟器请确认 I/O → Audio Input 已选择输入设备,再重试" + #else + return "录音启动失败:无法开始录制。请确认麦克风未被其他应用占用后重试" + #endif + } + private func stopRecordingAndAssess() { - guard let currentRecorder = recorder else { + guard let currentSession = recordingSession else { + return + } + let duration: TimeInterval + do { + try currentSession.stop() + duration = currentSession.recordDuration + } catch { + testStatus = "录音结束失败:\(error.localizedDescription)" + currentSession.cancel() + recordingSession = nil + isRecording = false + restorePlaybackAudioSession() return } - let duration = currentRecorder.currentTime - currentRecorder.stop() restorePlaybackAudioSession() - recorder = nil + recordingSession = nil isRecording = false guard let url = recordingURL else { return } recordingURL = nil + let rawURL = currentSession.rawRecordingURL if duration < 1.0 { testStatus = "录音太短或无法保存" try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: rawURL) return } guard let sentence = controller.currentSentence else { testStatus = "没有可归属的句子,本次录音已丢弃" try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: rawURL) return } if let oldURL = dubSegments[sentence.index] { try? FileManager.default.removeItem(at: oldURL) } + if let oldRawURL = dubRawSegments[sentence.index] { + try? FileManager.default.removeItem(at: oldRawURL) + } dubSegments[sentence.index] = url + dubRawSegments[sentence.index] = rawURL mergedDubURL = nil submitAssessment(recordingURL: url) } @@ -292,7 +334,6 @@ struct TestView: View { scoreSummary = "评测失败" } isAssessing = false - try? FileManager.default.removeItem(at: url) } } @@ -399,8 +440,8 @@ struct TestView: View { } private func generateDubShareAudio() { - guard !dubSegments.isEmpty else { return } - let segments = dubSegments.sorted { $0.key < $1.key } + guard !dubRawSegments.isEmpty else { return } + let segments = dubRawSegments.sorted { $0.key < $1.key } let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") isGeneratingDub = true diff --git a/ios/README.md b/ios/README.md index b5b67c1..72c4b7c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -59,4 +59,12 @@ ios/ - “云端暂不可用:…”:检查手机能否访问服务端、地址是否填对、网关证书是否有效。 - 本地视频评测提示“仅云端课程支持朗读评测”:本地文件需先通过管理端上传到服务器。 -- 录音提示失败:在 设置 > 隐私 > 麦克风 中允许本 App 使用麦克风。 +- 录音提示“无法开始录制”:录音已改用 `AVAudioEngine` 采集并转码 AAC 16kHz + (`AVAudioRecorder` 在 iOS 26 真机上存在 `record()` 恒返回 `false` 的已知问题)。 + 若仍失败,真机请确认 设置 → 隐私与安全性 → 麦克风 已允许本 App 使用麦克风, + 且没有其他 App 正在占用麦克风;模拟器请先在 I/O → Audio Input 中选择输入设备 + (如 Mac 内置麦克风),并允许“模拟器”使用 Mac 麦克风。 +- 配音“合成失败”:iOS 26 / macOS 26 上 `AVAssetExportSession` 导出纯音频会失败 + (AVError -11800,底层 `'fmt?'`),`AVAudioFile` 读回 AAC 也会报 `'fmt?'`。 + 配音合成已改为合并录音时保留的原始 PCM(caf)后直接编码 AAC 16kHz,与录音转码 + 同链路;如仍失败,提示中会附带底层错误详情,可按详情排查。