From be0cf09941053572841e3c19c50993a53bf93afa Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 25 Jun 2026 09:08:02 +0800 Subject: [PATCH] fix: HLS audio (playerItem.tracks + better tap callback) + window title sync with toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HLS录制音频: 改用playerItem.tracks查找trackID(fallback到asset.loadTracks) - tap回调: 始终先调GetSourceAudio保证管道畅通,加详细日志 - 窗口标题: onAppear初始隐藏,随toolbar一起30秒后消失/点击图标回来 --- Sources/MiniPlayerApp.swift | 10 +-- Sources/PlayerRecorder.swift | 166 ++++++++++++++++++++++------------- 2 files changed, 108 insertions(+), 68 deletions(-) diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 9d41cf3..5cd9111 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -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 - } } } diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index dc60ba1..64361f5 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -64,12 +64,7 @@ private func tapProcessCallback( _ numberFramesOut: UnsafeMutablePointer, _ flagsOut: UnsafeMutablePointer ) { - 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() {