fix: HLS audio (playerItem.tracks + better tap callback) + window title sync with toolbar
- HLS录制音频: 改用playerItem.tracks查找trackID(fallback到asset.loadTracks) - tap回调: 始终先调GetSourceAudio保证管道畅通,加详细日志 - 窗口标题: onAppear初始隐藏,随toolbar一起30秒后消失/点击图标回来
This commit is contained in:
parent
6e94bb46b7
commit
be0cf09941
@ -265,8 +265,12 @@ struct PlayerContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 初始状态:标题随toolbar一起隐藏
|
||||||
|
NSApplication.shared.windows.first?.titleVisibility = .hidden
|
||||||
|
}
|
||||||
.onChange(of: showToolbar) { _, newValue in
|
.onChange(of: showToolbar) { _, newValue in
|
||||||
// 同步控制窗口标题的显示/隐藏
|
// 同步控制窗口标题的显示/隐藏(与toolbar联动)
|
||||||
NSApplication.shared.windows.first?.titleVisibility = newValue ? .visible : .hidden
|
NSApplication.shared.windows.first?.titleVisibility = newValue ? .visible : .hidden
|
||||||
if newValue {
|
if newValue {
|
||||||
resetHideTimer()
|
resetHideTimer()
|
||||||
@ -277,10 +281,6 @@ struct PlayerContentView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
hideTimer?.invalidate()
|
hideTimer?.invalidate()
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
// 初始状态:标题随toolbar一起隐藏
|
|
||||||
NSApplication.shared.windows.first?.titleVisibility = .hidden
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,12 +64,7 @@ private func tapProcessCallback(
|
|||||||
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||||||
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
|
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
|
||||||
) {
|
) {
|
||||||
numberFramesOut.pointee = 0
|
// 始终先获取源音频(保证音频管道正常运行)
|
||||||
flagsOut.pointee = 0
|
|
||||||
|
|
||||||
guard numberFrames > 0 else { return }
|
|
||||||
|
|
||||||
// 获取源音频数据 + 时间范围
|
|
||||||
var timeRange = CMTimeRange()
|
var timeRange = CMTimeRange()
|
||||||
var srcFlags: MTAudioProcessingTapFlags = 0
|
var srcFlags: MTAudioProcessingTapFlags = 0
|
||||||
var actualFrames: CMItemCount = 0
|
var actualFrames: CMItemCount = 0
|
||||||
@ -79,18 +74,25 @@ private func tapProcessCallback(
|
|||||||
&srcFlags, &timeRange, &actualFrames
|
&srcFlags, &timeRange, &actualFrames
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 必须始终设置输出帧数和标志(否则音频引擎可能阻塞)
|
||||||
numberFramesOut.pointee = actualFrames
|
numberFramesOut.pointee = actualFrames
|
||||||
flagsOut.pointee = srcFlags
|
flagsOut.pointee = srcFlags
|
||||||
|
|
||||||
guard status == noErr, actualFrames > 0 else { return }
|
guard status == noErr, actualFrames > 0 else { return }
|
||||||
|
|
||||||
// 通过全局变量获取 context
|
// 通过全局变量获取 context — 未就绪时仅跳过录制,不影响播放
|
||||||
guard let ctx = gAudioContext, ctx.isRunning,
|
guard let ctx = gAudioContext, ctx.isRunning,
|
||||||
let writerInput = ctx.writerInput,
|
let writerInput = ctx.writerInput,
|
||||||
writerInput.isReadyForMoreMediaData else { return }
|
writerInput.isReadyForMoreMediaData else { return }
|
||||||
|
|
||||||
ctx.processCallCount += 1
|
ctx.processCallCount += 1
|
||||||
|
|
||||||
|
// 定期记录回调活跃状态(每500次调用打一次日志)
|
||||||
|
if ctx.processCallCount == 1 || ctx.processCallCount % 500 == 0 {
|
||||||
|
NSLog("[Recorder] tapProcess #%d: frames=%ld, timescale=%d",
|
||||||
|
ctx.processCallCount, actualFrames, timeRange.duration.timescale)
|
||||||
|
}
|
||||||
|
|
||||||
let bufferList = bufferListInOut.pointee
|
let bufferList = bufferListInOut.pointee
|
||||||
guard bufferList.mNumberBuffers > 0 else { return }
|
guard bufferList.mNumberBuffers > 0 else { return }
|
||||||
|
|
||||||
@ -117,9 +119,14 @@ private func tapProcessCallback(
|
|||||||
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
||||||
|
|
||||||
// 获取 format description
|
// 获取 format description
|
||||||
guard let fd = ctx.formatDescription else { return }
|
guard let fd = ctx.formatDescription else {
|
||||||
|
if ctx.processCallCount == 1 {
|
||||||
|
NSLog("[Recorder] ⚠️ formatDescription nil on first process call")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// PTS 基于累积帧数
|
// PTS 基于累积帧数(与视频一样从0开始)
|
||||||
let sampleRate = Double(timeRange.duration.timescale) > 0
|
let sampleRate = Double(timeRange.duration.timescale) > 0
|
||||||
? Double(timeRange.duration.timescale) : 44100.0
|
? Double(timeRange.duration.timescale) : 44100.0
|
||||||
let ptsValue = Double(ctx.appendCount) / sampleRate
|
let ptsValue = Double(ctx.appendCount) / sampleRate
|
||||||
@ -135,7 +142,12 @@ private func tapProcessCallback(
|
|||||||
packetDescriptions: nil,
|
packetDescriptions: nil,
|
||||||
sampleBufferOut: &sampleBuffer
|
sampleBufferOut: &sampleBuffer
|
||||||
)
|
)
|
||||||
guard createStatus == noErr, let sb = sampleBuffer else { return }
|
guard createStatus == noErr, let sb = sampleBuffer else {
|
||||||
|
if ctx.processCallCount <= 3 {
|
||||||
|
NSLog("[Recorder] ⚠️ CMAudioSampleBufferCreate failed: %d", createStatus)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if writerInput.append(sb) {
|
if writerInput.append(sb) {
|
||||||
ctx.appendCount += actualFrames
|
ctx.appendCount += actualFrames
|
||||||
@ -143,6 +155,8 @@ private func tapProcessCallback(
|
|||||||
ctx.firstAppendLogged = true
|
ctx.firstAppendLogged = true
|
||||||
NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames)
|
NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames)
|
||||||
}
|
}
|
||||||
|
} else if ctx.processCallCount <= 3 {
|
||||||
|
NSLog("[Recorder] ⚠️ writerInput.append failed (input ready=%d)", writerInput.isReadyForMoreMediaData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,63 +336,23 @@ final class PlayerRecorder: NSObject {
|
|||||||
// MARK: - 音频 Tap 设置
|
// MARK: - 音频 Tap 设置
|
||||||
|
|
||||||
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
|
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
|
||||||
// 使用 playerItem.tracks(动态加载)而不是 asset.loadTracks
|
// 两步策略查找音频 track:
|
||||||
// HLS 流的 tracks 需要等待加载完成后才可用
|
// 1) playerItem.tracks(已加载的 track,trackID 与 audioMix 匹配)
|
||||||
|
// 2) asset.loadTracks(fallback,HLS 可能 tracks 尚未填充)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
// 等待 tracks 加载
|
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while attempts < 50 { // 最多等待 5 秒
|
|
||||||
|
// 阶段1:尝试 playerItem.tracks(最多 3 秒)
|
||||||
|
while attempts < 30 {
|
||||||
guard self.isRunning else { return }
|
guard self.isRunning else { return }
|
||||||
|
|
||||||
let tracks = try await playerItem.asset.loadTracks(withMediaType: .audio)
|
let itemTracks = playerItem.tracks
|
||||||
if let audioTrack = tracks.first {
|
if let audioItemTrack = itemTracks.first(where: { $0.assetTrack?.mediaType == .audio }),
|
||||||
NSLog("[Recorder] ✓ Audio track found: %d, setting up tap...", audioTrack.trackID)
|
let assetTrack = audioItemTrack.assetTrack {
|
||||||
|
let trackID = assetTrack.trackID
|
||||||
// 创建音频捕获上下文
|
NSLog("[Recorder] ✓ Audio track via playerItem.tracks: trackID=%d", trackID)
|
||||||
let context = AudioCaptureContext()
|
self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput)
|
||||||
context.writerInput = audioWriterInput
|
|
||||||
context.isRunning = true
|
|
||||||
self.audioContext = context
|
|
||||||
|
|
||||||
// 全局引用,供 C 回调访问
|
|
||||||
gAudioContext = context
|
|
||||||
|
|
||||||
// 创建 MTAudioProcessingTap 回调结构体
|
|
||||||
var callbacks = MTAudioProcessingTapCallbacks(
|
|
||||||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
|
||||||
clientInfo: nil,
|
|
||||||
init: tapInitCallback,
|
|
||||||
finalize: tapFinalizeCallback,
|
|
||||||
prepare: tapPrepareCallback,
|
|
||||||
unprepare: tapUnprepareCallback,
|
|
||||||
process: tapProcessCallback
|
|
||||||
)
|
|
||||||
|
|
||||||
var tap: MTAudioProcessingTap?
|
|
||||||
let status = MTAudioProcessingTapCreate(
|
|
||||||
kCFAllocatorDefault,
|
|
||||||
&callbacks,
|
|
||||||
kMTAudioProcessingTapCreationFlag_PostEffects,
|
|
||||||
&tap
|
|
||||||
)
|
|
||||||
|
|
||||||
guard status == noErr, let audioTap = tap else {
|
|
||||||
NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status)
|
|
||||||
gAudioContext = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.audioTap = audioTap
|
|
||||||
|
|
||||||
// 创建 audioMix 并挂上 tap
|
|
||||||
let params = AVMutableAudioMixInputParameters(track: audioTrack)
|
|
||||||
params.audioTapProcessor = audioTap
|
|
||||||
let audioMix = AVMutableAudioMix()
|
|
||||||
audioMix.inputParameters = [params]
|
|
||||||
|
|
||||||
playerItem.audioMix = audioMix
|
|
||||||
NSLog("[Recorder] ✓ Audio tap installed on playerItem")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,10 +360,76 @@ final class PlayerRecorder: NSObject {
|
|||||||
try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒
|
try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒
|
||||||
}
|
}
|
||||||
|
|
||||||
NSLog("[Recorder] ❌ Timeout waiting for audio track")
|
// 阶段2:fallback — asset.loadTracks(最多再等 5 秒)
|
||||||
|
NSLog("[Recorder] ⚠️ playerItem.tracks empty, falling back to asset.loadTracks")
|
||||||
|
attempts = 0
|
||||||
|
while attempts < 50 {
|
||||||
|
guard self.isRunning else { return }
|
||||||
|
|
||||||
|
if let assetTracks = try? await playerItem.asset.loadTracks(withMediaType: .audio),
|
||||||
|
let audioTrack = assetTracks.first {
|
||||||
|
let trackID = audioTrack.trackID
|
||||||
|
NSLog("[Recorder] ✓ Audio track via asset.loadTracks: trackID=%d", trackID)
|
||||||
|
self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("[Recorder] ❌ No audio track found after both attempts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func installTap(on playerItem: AVPlayerItem, trackID: CMPersistentTrackID, audioWriterInput: AVAssetWriterInput) {
|
||||||
|
// 创建音频捕获上下文
|
||||||
|
let context = AudioCaptureContext()
|
||||||
|
context.writerInput = audioWriterInput
|
||||||
|
context.isRunning = true
|
||||||
|
self.audioContext = context
|
||||||
|
|
||||||
|
// 全局引用,供 C 回调访问
|
||||||
|
gAudioContext = context
|
||||||
|
|
||||||
|
// 创建 MTAudioProcessingTap 回调结构体
|
||||||
|
var callbacks = MTAudioProcessingTapCallbacks(
|
||||||
|
version: kMTAudioProcessingTapCallbacksVersion_0,
|
||||||
|
clientInfo: nil,
|
||||||
|
init: tapInitCallback,
|
||||||
|
finalize: tapFinalizeCallback,
|
||||||
|
prepare: tapPrepareCallback,
|
||||||
|
unprepare: tapUnprepareCallback,
|
||||||
|
process: tapProcessCallback
|
||||||
|
)
|
||||||
|
|
||||||
|
var tap: MTAudioProcessingTap?
|
||||||
|
let status = MTAudioProcessingTapCreate(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
&callbacks,
|
||||||
|
kMTAudioProcessingTapCreationFlag_PostEffects,
|
||||||
|
&tap
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == noErr, let audioTap = tap else {
|
||||||
|
NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status)
|
||||||
|
gAudioContext = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.audioTap = audioTap
|
||||||
|
|
||||||
|
// 关键:用 playerItem 的 trackID 创建 audioMix input parameters
|
||||||
|
let params = AVMutableAudioMixInputParameters()
|
||||||
|
params.trackID = trackID
|
||||||
|
params.audioTapProcessor = audioTap
|
||||||
|
let audioMix = AVMutableAudioMix()
|
||||||
|
audioMix.inputParameters = [params]
|
||||||
|
|
||||||
|
playerItem.audioMix = audioMix
|
||||||
|
NSLog("[Recorder] ✓ Audio tap installed on playerItem (trackID=%d)", trackID)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 视频抓帧 (30fps Timer)
|
// MARK: - 视频抓帧 (30fps Timer)
|
||||||
|
|
||||||
private func startCaptureLoop() {
|
private func startCaptureLoop() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user