Compare commits

...

2 Commits

Author SHA1 Message Date
6de1d4a3d0 ajust playing time 2026-08-29 22:22:24 +08:00
f93bb8d1ff fixed some bugs on ios 2026-08-26 18:56:04 +08:00
8 changed files with 368 additions and 61 deletions

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.1" versionName = "0.1.2"
} }
compileOptions { compileOptions {

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 单声道 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
}
}
}
}

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

View File

@@ -43,6 +43,7 @@ dubAudio.preload = 'auto';
let segments = []; let segments = [];
let selectedIndex = 0; let selectedIndex = 0;
let mode = null; let mode = null;
const PLAY_EXTEND_MS = 500;
function stopAll() { function stopAll() {
video.pause(); video.pause();
@@ -63,13 +64,15 @@ function playOriginal() {
function syncDub() { function syncDub() {
const timeMs = video.currentTime * 1000; const timeMs = video.currentTime * 1000;
const sentence = segments[selectedIndex]; const sentence = segments[selectedIndex];
if (sentence && timeMs >= sentence.end_ms) { if (sentence && timeMs >= sentence.end_ms + PLAY_EXTEND_MS) {
video.pause(); video.pause();
dubAudio.pause(); dubAudio.pause();
video.currentTime = sentence.end_ms / 1000; video.currentTime = (sentence.end_ms + PLAY_EXTEND_MS) / 1000;
return; return;
} }
const active = segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms); const active = (sentence && timeMs >= sentence.start_ms && timeMs < sentence.end_ms + PLAY_EXTEND_MS)
? sentence
: segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms);
document.querySelectorAll('.sentence').forEach((node, index) => { document.querySelectorAll('.sentence').forEach((node, index) => {
node.classList.toggle('active-sentence', segments[index] === active); node.classList.toggle('active-sentence', segments[index] === active);
}); });