fixed some bugs on ios

This commit is contained in:
2026-08-26 18:56:04 +08:00
parent bb677ea8cf
commit f93bb8d1ff
6 changed files with 361 additions and 57 deletions

View File

@@ -29,6 +29,7 @@
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; }; AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; };
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; }; AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -56,6 +57,7 @@
AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = "<group>"; }; AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = "<group>"; };
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; }; AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -118,6 +120,7 @@
AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
AA000000000000000000002B /* ImitationAssessor.swift */, AA000000000000000000002B /* ImitationAssessor.swift */,
AA000000000000000000007E /* DubbingCompositor.swift */, AA000000000000000000007E /* DubbingCompositor.swift */,
AA0000000000000000000081 /* AudioRecorderSession.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -237,6 +240,7 @@
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */, AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */,
AA000000000000000000004B /* ImitationAssessor.swift in Sources */, AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
AA000000000000000000007D /* DubbingCompositor.swift in Sources */, AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
AA000000000000000000004C /* PlayerController.swift in Sources */, AA000000000000000000004C /* PlayerController.swift in Sources */,
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */, AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
AA000000000000000000004E /* Theme.swift in Sources */, AA000000000000000000004E /* Theme.swift in Sources */,

View File

@@ -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 AACm4a
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 AACAVAudioFile '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
}
}
}
}

View File

@@ -2,61 +2,153 @@ import AVFoundation
import Foundation import Foundation
enum DubbingCompositorError: LocalizedError { enum DubbingCompositorError: LocalizedError {
case noAudioTracks case inputReadFailed(String?)
case cannotReadDuration case outputWriteFailed(String?)
case convertFailed(String?)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .noAudioTracks: case .inputReadFailed(let detail):
return "录音没有音频轨道" return detail.map { "无法读取录音音频(\($0)" } ?? "无法读取录音音频"
case .cannotReadDuration: case .outputWriteFailed(let detail):
return "无法读取录音时长" return detail.map { "无法写入合成音频(\($0)" } ?? "无法写入合成音频"
case .convertFailed(let detail):
return detail.map { "无法转换音频格式(\($0)" } ?? "无法转换音频格式"
} }
} }
} }
enum DubbingCompositor { enum DubbingCompositor {
/// 16kHz AACm4a
///
/// 使 AVMutableComposition + AVAssetExportSessioniOS 26 / macOS 26
/// AVAssetExportSession AVError -11800 'fmt?'
/// AVAudioFile AAC CheckClientFormatSet / 'fmt?'
/// PCMcaf AAC
///
static func merge(urls: [URL], outputURL: URL) async throws { static func merge(urls: [URL], outputURL: URL) async throws {
try await mergeSync(urls: urls, outputURL: outputURL) do {
} try mergeSync(urls: urls, outputURL: outputURL)
} catch {
private static func mergeSync(urls: [URL], outputURL: URL) async throws {
try? FileManager.default.removeItem(at: outputURL) try? FileManager.default.removeItem(at: outputURL)
let composition = AVMutableComposition() throw error
var currentTime = CMTime.zero
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 duration = try await source.load(.duration)
guard duration.seconds.isFinite, duration.seconds > 0 else {
throw DubbingCompositorError.cannotReadDuration
}
let timeRange = CMTimeRange(start: .zero, duration: duration)
let compositionTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
)
guard let compositionTrack else {
throw DubbingCompositorError.noAudioTracks
}
try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime)
currentTime = currentTime + duration
} }
guard let exporter = AVAssetExportSession( private static func mergeSync(urls: [URL], outputURL: URL) throws {
asset: composition, try? FileManager.default.removeItem(at: outputURL)
presetName: AVAssetExportPresetAppleM4A let settings: [String: Any] = [
) else { AVFormatIDKey: kAudioFormatMPEG4AAC,
throw DubbingCompositorError.noAudioTracks 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 inputFile: AVAudioFile
do {
inputFile = try AVAudioFile(forReading: url)
} catch {
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
}
guard inputFile.length > 0 else {
throw DubbingCompositorError.inputReadFailed("录音为空")
}
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)
}
}
}
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)
}
}
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
} }
exporter.outputURL = outputURL
exporter.outputFileType = .m4a
await exporter.export()
guard exporter.status == .completed else {
throw exporter.error ?? DubbingCompositorError.noAudioTracks
} }
} }
} }

View File

@@ -5,7 +5,7 @@ struct TestView: View {
@ObservedObject var controller: PlayerController @ObservedObject var controller: PlayerController
let onFullscreen: () -> Void let onFullscreen: () -> Void
@State private var recorder: AVAudioRecorder? @State private var recordingSession: AudioRecorderSession?
@State private var recordingURL: URL? @State private var recordingURL: URL?
@State private var isRecording = false @State private var isRecording = false
@State private var isAssessing = false @State private var isAssessing = false
@@ -13,6 +13,7 @@ struct TestView: View {
@State private var scoreSummary = "尚未评测" @State private var scoreSummary = "尚未评测"
@State private var scoreDetail = "" @State private var scoreDetail = ""
@State private var dubSegments: [Int: URL] = [:] @State private var dubSegments: [Int: URL] = [:]
@State private var dubRawSegments: [Int: URL] = [:]
@State private var latestScores: [Int: ImitationAssessmentResult] = [:] @State private var latestScores: [Int: ImitationAssessmentResult] = [:]
@State private var mergedDubURL: URL? @State private var mergedDubURL: URL?
@State private var dubPlayer: AVPlayer? @State private var dubPlayer: AVPlayer?
@@ -82,7 +83,7 @@ struct TestView: View {
dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
toggleDubPlayback() toggleDubPlayback()
} }
dubButton("生成配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
generateDubShareAudio() generateDubShareAudio()
} }
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) { dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
@@ -195,58 +196,99 @@ struct TestView: View {
private func beginRecording() { private func beginRecording() {
let fileURL = FileManager.default.temporaryDirectory let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") .appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
let settings: [String: Any] = [ let newSession = AudioRecorderSession(outputURL: fileURL)
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 96_000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
do { do {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true) try session.setActive(true)
let newRecorder = try AVAudioRecorder(url: fileURL, settings: settings) guard session.isInputAvailable else {
guard newRecorder.record() else { testStatus = microphoneInputUnavailableMessage()
testStatus = "录音启动失败:无法开始录制"
return 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 recordingURL = fileURL
isRecording = true isRecording = true
testStatus = "正在录音…读完当前句子后点击“停止并评测”" testStatus = "正在录音…读完当前句子后点击“停止并评测”"
} catch AudioRecorderSessionError.inputUnavailable {
newSession.cancel()
testStatus = microphoneInputUnavailableMessage()
} catch AudioRecorderSessionError.startFailed {
newSession.cancel()
testStatus = recordingStartFailedMessage()
} catch { } catch {
newSession.cancel()
testStatus = "录音启动失败:\(error.localizedDescription)" 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() { private func stopRecordingAndAssess() {
guard let currentRecorder = recorder else { guard let currentSession = recordingSession else {
return return
} }
let duration = currentRecorder.currentTime let duration: TimeInterval
currentRecorder.stop() do {
try currentSession.stop()
duration = currentSession.recordDuration
} catch {
testStatus = "录音结束失败:\(error.localizedDescription)"
currentSession.cancel()
recordingSession = nil
isRecording = false
restorePlaybackAudioSession() restorePlaybackAudioSession()
recorder = nil return
}
restorePlaybackAudioSession()
recordingSession = nil
isRecording = false isRecording = false
guard let url = recordingURL else { guard let url = recordingURL else {
return return
} }
recordingURL = nil recordingURL = nil
let rawURL = currentSession.rawRecordingURL
if duration < 1.0 { if duration < 1.0 {
testStatus = "录音太短或无法保存" testStatus = "录音太短或无法保存"
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
try? FileManager.default.removeItem(at: rawURL)
return return
} }
guard let sentence = controller.currentSentence else { guard let sentence = controller.currentSentence else {
testStatus = "没有可归属的句子,本次录音已丢弃" testStatus = "没有可归属的句子,本次录音已丢弃"
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
try? FileManager.default.removeItem(at: rawURL)
return return
} }
if let oldURL = dubSegments[sentence.index] { if let oldURL = dubSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldURL) try? FileManager.default.removeItem(at: oldURL)
} }
if let oldRawURL = dubRawSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldRawURL)
}
dubSegments[sentence.index] = url dubSegments[sentence.index] = url
dubRawSegments[sentence.index] = rawURL
mergedDubURL = nil mergedDubURL = nil
submitAssessment(recordingURL: url) submitAssessment(recordingURL: url)
} }
@@ -292,7 +334,6 @@ struct TestView: View {
scoreSummary = "评测失败" scoreSummary = "评测失败"
} }
isAssessing = false isAssessing = false
try? FileManager.default.removeItem(at: url)
} }
} }
@@ -399,8 +440,8 @@ struct TestView: View {
} }
private func generateDubShareAudio() { private func generateDubShareAudio() {
guard !dubSegments.isEmpty else { return } guard !dubRawSegments.isEmpty else { return }
let segments = dubSegments.sorted { $0.key < $1.key } let segments = dubRawSegments.sorted { $0.key < $1.key }
let outputURL = FileManager.default.temporaryDirectory let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a") .appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
isGeneratingDub = true isGeneratingDub = true

View File

@@ -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?'`
配音合成已改为合并录音时保留的原始 PCMcaf后直接编码 AAC 16kHz与录音转码
同链路;如仍失败,提示中会附带底层错误详情,可按详情排查。