fixed some bugs on ios
This commit is contained in:
@@ -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 */,
|
||||||
|
|||||||
Binary file not shown.
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal file
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
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)
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
let composition = AVMutableComposition()
|
let settings: [String: Any] = [
|
||||||
var currentTime = CMTime.zero
|
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 {
|
for url in urls {
|
||||||
let source = AVURLAsset(url: url)
|
let inputFile: AVAudioFile
|
||||||
let tracks = try await source.loadTracks(withMediaType: .audio)
|
do {
|
||||||
guard let track = tracks.first else {
|
inputFile = try AVAudioFile(forReading: url)
|
||||||
throw DubbingCompositorError.noAudioTracks
|
} catch {
|
||||||
|
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
let duration = try await source.load(.duration)
|
guard inputFile.length > 0 else {
|
||||||
guard duration.seconds.isFinite, duration.seconds > 0 else {
|
throw DubbingCompositorError.inputReadFailed("录音为空")
|
||||||
throw DubbingCompositorError.cannotReadDuration
|
|
||||||
}
|
}
|
||||||
let timeRange = CMTimeRange(start: .zero, duration: duration)
|
let inputFormat = inputFile.processingFormat
|
||||||
let compositionTrack = composition.addMutableTrack(
|
if inputFormat.sampleRate == targetFormat.sampleRate,
|
||||||
withMediaType: .audio,
|
inputFormat.channelCount == targetFormat.channelCount {
|
||||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
try appendStream(from: inputFile, to: outputFile)
|
||||||
)
|
} else {
|
||||||
guard let compositionTrack else {
|
try appendConverted(from: inputFile, to: outputFile, targetFormat: targetFormat)
|
||||||
throw DubbingCompositorError.noAudioTracks
|
|
||||||
}
|
}
|
||||||
try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime)
|
|
||||||
currentTime = currentTime + duration
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard let exporter = AVAssetExportSession(
|
private static func appendStream(from inputFile: AVAudioFile, to outputFile: AVAudioFile) throws {
|
||||||
asset: composition,
|
var remaining = inputFile.length
|
||||||
presetName: AVAssetExportPresetAppleM4A
|
while remaining > 0 {
|
||||||
) else {
|
let frames = AVAudioFrameCount(min(remaining, 8192))
|
||||||
throw DubbingCompositorError.noAudioTracks
|
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()
|
private static func appendConverted(
|
||||||
guard exporter.status == .completed else {
|
from inputFile: AVAudioFile,
|
||||||
throw exporter.error ?? DubbingCompositorError.noAudioTracks
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
let duration: TimeInterval
|
||||||
|
do {
|
||||||
|
try currentSession.stop()
|
||||||
|
duration = currentSession.recordDuration
|
||||||
|
} catch {
|
||||||
|
testStatus = "录音结束失败:\(error.localizedDescription)"
|
||||||
|
currentSession.cancel()
|
||||||
|
recordingSession = nil
|
||||||
|
isRecording = false
|
||||||
|
restorePlaybackAudioSession()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let duration = currentRecorder.currentTime
|
|
||||||
currentRecorder.stop()
|
|
||||||
restorePlaybackAudioSession()
|
restorePlaybackAudioSession()
|
||||||
recorder = nil
|
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
|
||||||
|
|||||||
@@ -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,与录音转码
|
||||||
|
同链路;如仍失败,提示中会附带底层错误详情,可按详情排查。
|
||||||
|
|||||||
Reference in New Issue
Block a user