From f406cd82f1f740c67b198c93517a38d9767c1ce1 Mon Sep 17 00:00:00 2001 From: Sherlock Yin Date: Fri, 27 Feb 2026 11:32:15 +0800 Subject: [PATCH] feat(playback): optimize playback functionality and UI - Added @unchecked Sendable to AudioRecorder for concurrency safety. - Integrated playbackShouldStop notification to synchronize recording and playback. - Relocated playback controls below processing button in PipelineView. - Added audio timeline slider and error message display. Addresses playback UI/UX feedback and concurrency warnings. --- Sources/VoiceMemo/AudioRecorder.swift | 93 +++++++++++----------- Sources/VoiceMemo/Models/MeetingTask.swift | 1 + Sources/VoiceMemo/Views/PipelineView.swift | 9 +++ 3 files changed, 56 insertions(+), 47 deletions(-) diff --git a/Sources/VoiceMemo/AudioRecorder.swift b/Sources/VoiceMemo/AudioRecorder.swift index d4c3b40..7de4537 100644 --- a/Sources/VoiceMemo/AudioRecorder.swift +++ b/Sources/VoiceMemo/AudioRecorder.swift @@ -1,17 +1,16 @@ import Foundation -import ScreenCaptureKit +@preconcurrency import ScreenCaptureKit import AVFoundation import AppKit @available(macOS 13.0, *) -class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegate, AVCaptureAudioDataOutputSampleBufferDelegate, AVAudioPlayerDelegate { +class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegate, AVCaptureAudioDataOutputSampleBufferDelegate, @unchecked Sendable { @Published var isRecording = false @Published var statusMessage = "Ready to record" @Published var availableApps: [SCRunningApplication] = [] @Published var selectedApp: SCRunningApplication? @Published var latestTask: MeetingTask? @Published var recordingDuration: TimeInterval = 0 - @Published var isPlaying = false var lastUploadedURL: URL? { if let urlStr = latestTask?.ossUrl { @@ -22,7 +21,6 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat private var notificationObserver: NSObjectProtocol? private var timer: Timer? - private var audioPlayer: AVAudioPlayer? private var settings: SettingsStore private var recordingId: String? @@ -88,25 +86,31 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat @MainActor func startRecording() { - stopPlayback() + NotificationCenter.default.post(name: .playbackShouldStop, object: nil) guard let app = selectedApp else { statusMessage = "Please select an app first" return } + let appName = app.applicationName + let appProcessID = app.processID statusMessage = "Requesting permissions..." - settings.log("Start recording: app=\(app.applicationName)") + settings.log("Start recording: app=\(appName)") // Request Mic Permission first switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: - self.beginRecordingSession(app: app) + beginRecordingSession(appName: appName, appProcessID: appProcessID) case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { granted in if granted { - DispatchQueue.main.async { self.beginRecordingSession(app: app) } + Task { @MainActor in + self.beginRecordingSession(appName: appName, appProcessID: appProcessID) + } } else { - DispatchQueue.main.async { self.statusMessage = "Microphone permission denied" } + Task { @MainActor in + self.statusMessage = "Microphone permission denied" + } } } case .denied, .restricted: @@ -117,7 +121,7 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat } } - private func beginRecordingSession(app: SCRunningApplication) { + private func beginRecordingSession(appName: String, appProcessID: pid_t) { isFirstRemoteBuffer = true isFirstMicBuffer = true self.recordingStartTime = Date() @@ -153,7 +157,7 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat } // Start System Audio Capture (SCK) - startSystemAudioCapture(app: app) + startSystemAudioCapture(appName: appName, appProcessID: appProcessID) // Start Microphone Capture (AVCapture) startMicrophoneCapture() @@ -166,11 +170,14 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat // MARK: - System Audio (SCK) - private func startSystemAudioCapture(app: SCRunningApplication) { + private func startSystemAudioCapture(appName: String, appProcessID: pid_t) { Task { do { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - guard let matchedApp = content.applications.first(where: { $0.processID == app.processID }) else { return } + guard let matchedApp = content.applications.first(where: { $0.processID == appProcessID }) else { + settings.log("SCK start error: target app not found \(appName) \(appProcessID)") + return + } let filter = SCContentFilter(display: content.displays.first!, including: [matchedApp], exceptingWindows: []) let config = SCStreamConfiguration() @@ -338,7 +345,6 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat @MainActor func stopRecording() { Task { - stopPlayback() await MainActor.run { self.timer?.invalidate() self.timer = nil @@ -414,34 +420,6 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat } } - @MainActor - func playLatestRecording() { - guard let path = latestTask?.localFilePath else { - statusMessage = "No recording available" - return - } - let url = URL(fileURLWithPath: path) - do { - let player = try AVAudioPlayer(contentsOf: url) - player.delegate = self - player.prepareToPlay() - player.play() - audioPlayer = player - isPlaying = true - statusMessage = "Playing latest recording" - } catch { - isPlaying = false - statusMessage = "Play failed: \(error.localizedDescription)" - settings.log("Play failed: \(error.localizedDescription)") - } - } - - @MainActor - func stopPlayback() { - audioPlayer?.stop() - audioPlayer = nil - isPlaying = false - } private func mergeAudioFiles(audio1: URL, audio2: URL, output: URL) async throws { let composition = AVMutableComposition() @@ -531,11 +509,6 @@ class AudioRecorder: NSObject, ObservableObject, SCStreamOutput, SCStreamDelegat } } - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - Task { @MainActor in - isPlaying = false - } - } } @available(macOS 13.0, *) @@ -544,9 +517,32 @@ class AudioPlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate @Published var playingTaskId: UUID? @Published var currentTime: TimeInterval = 0 @Published var duration: TimeInterval = 0 + @Published var lastErrorMessage: String? private var audioPlayer: AVAudioPlayer? private var timer: Timer? + private var stopPlaybackObserver: NSObjectProtocol? + + override init() { + super.init() + stopPlaybackObserver = NotificationCenter.default.addObserver( + forName: .playbackShouldStop, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.stop() + } + } + } + + deinit { + if let observer = stopPlaybackObserver { + NotificationCenter.default.removeObserver(observer) + } + timer?.invalidate() + timer = nil + } @MainActor func toggle(task: MeetingTask) { @@ -564,6 +560,7 @@ class AudioPlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate @MainActor func play(filePath: String, taskId: UUID?) { guard FileManager.default.fileExists(atPath: filePath) else { + lastErrorMessage = "Audio file not found" stop() return } @@ -580,9 +577,11 @@ class AudioPlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate duration = player.duration isPlaying = true playingTaskId = taskId + lastErrorMessage = nil startTimer() } catch { + lastErrorMessage = error.localizedDescription stop() } } diff --git a/Sources/VoiceMemo/Models/MeetingTask.swift b/Sources/VoiceMemo/Models/MeetingTask.swift index 19ce01b..2b51510 100644 --- a/Sources/VoiceMemo/Models/MeetingTask.swift +++ b/Sources/VoiceMemo/Models/MeetingTask.swift @@ -2,6 +2,7 @@ import Foundation extension Notification.Name { static let meetingTaskDidUpdate = Notification.Name("meetingTaskDidUpdate") + static let playbackShouldStop = Notification.Name("playbackShouldStop") } enum MeetingTaskStatus: String, Codable, CaseIterable, Hashable { diff --git a/Sources/VoiceMemo/Views/PipelineView.swift b/Sources/VoiceMemo/Views/PipelineView.swift index a94869f..48baa60 100644 --- a/Sources/VoiceMemo/Views/PipelineView.swift +++ b/Sources/VoiceMemo/Views/PipelineView.swift @@ -118,6 +118,8 @@ struct PipelineView: View { let duration = isCurrentTask ? playback.duration : 0 let currentTime = isCurrentTask ? playback.currentTime : 0 + let errorMessage = isCurrentTask ? playback.lastErrorMessage : nil + VStack(spacing: 12) { // Timeline HStack(spacing: 8) { @@ -177,6 +179,13 @@ struct PipelineView: View { .buttonStyle(.plain) .disabled(!isCurrentTask) } + + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } } .padding() .background(Color(nsColor: .controlBackgroundColor).opacity(0.5))