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:
yumoqing 2026-06-25 09:08:02 +08:00
parent 6e94bb46b7
commit be0cf09941
2 changed files with 108 additions and 68 deletions

View File

@ -265,8 +265,12 @@ struct PlayerContentView: View {
}
}
}
.onAppear {
// 初始状态:标题随toolbar一起隐藏
NSApplication.shared.windows.first?.titleVisibility = .hidden
}
.onChange(of: showToolbar) { _, newValue in
// 同步控制窗口标题的显示/隐藏
// 同步控制窗口标题的显示/隐藏(与toolbar联动)
NSApplication.shared.windows.first?.titleVisibility = newValue ? .visible : .hidden
if newValue {
resetHideTimer()
@ -277,10 +281,6 @@ struct PlayerContentView: View {
.onDisappear {
hideTimer?.invalidate()
}
.onAppear {
// 初始状态:标题随toolbar一起隐藏
NSApplication.shared.windows.first?.titleVisibility = .hidden
}
}
}

View File

@ -64,12 +64,7 @@ private func tapProcessCallback(
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
numberFramesOut.pointee = 0
flagsOut.pointee = 0
guard numberFrames > 0 else { return }
// 获取源音频数据 + 时间范围
// 始终先获取源音频(保证音频管道正常运行)
var timeRange = CMTimeRange()
var srcFlags: MTAudioProcessingTapFlags = 0
var actualFrames: CMItemCount = 0
@ -79,18 +74,25 @@ private func tapProcessCallback(
&srcFlags, &timeRange, &actualFrames
)
// 必须始终设置输出帧数和标志(否则音频引擎可能阻塞)
numberFramesOut.pointee = actualFrames
flagsOut.pointee = srcFlags
guard status == noErr, actualFrames > 0 else { return }
// 通过全局变量获取 context
// 通过全局变量获取 context — 未就绪时仅跳过录制,不影响播放
guard let ctx = gAudioContext, ctx.isRunning,
let writerInput = ctx.writerInput,
writerInput.isReadyForMoreMediaData else { return }
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
guard bufferList.mNumberBuffers > 0 else { return }
@ -117,9 +119,14 @@ private func tapProcessCallback(
guard replaceStatus == kCMBlockBufferNoErr else { return }
// 获取 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
? Double(timeRange.duration.timescale) : 44100.0
let ptsValue = Double(ctx.appendCount) / sampleRate
@ -135,7 +142,12 @@ private func tapProcessCallback(
packetDescriptions: nil,
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) {
ctx.appendCount += actualFrames
@ -143,6 +155,8 @@ private func tapProcessCallback(
ctx.firstAppendLogged = true
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 设置
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
// 使用 playerItem.tracks(动态加载)而不是 asset.loadTracks
// HLS 流的 tracks 需要等待加载完成后才可用
// 两步策略查找音频 track:
// 1) playerItem.tracks(已加载的 track,trackID 与 audioMix 匹配)
// 2) asset.loadTracks(fallback,HLS 可能 tracks 尚未填充)
Task {
// 等待 tracks 加载
var attempts = 0
while attempts < 50 { // 最多等待 5 秒
// 阶段1:尝试 playerItem.tracks(最多 3 秒)
while attempts < 30 {
guard self.isRunning else { return }
let tracks = try await playerItem.asset.loadTracks(withMediaType: .audio)
if let audioTrack = tracks.first {
NSLog("[Recorder] ✓ Audio track found: %d, setting up tap...", audioTrack.trackID)
// 创建音频捕获上下文
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
// 创建 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")
let itemTracks = playerItem.tracks
if let audioItemTrack = itemTracks.first(where: { $0.assetTrack?.mediaType == .audio }),
let assetTrack = audioItemTrack.assetTrack {
let trackID = assetTrack.trackID
NSLog("[Recorder] ✓ Audio track via playerItem.tracks: trackID=%d", trackID)
self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput)
return
}
@ -386,10 +360,76 @@ final class PlayerRecorder: NSObject {
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)
private func startCaptureLoop() {