From 5e3007b8bd9d188a21182f70440fb8d3905322c2 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sat, 7 Nov 2026 14:54:46 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates and enhancements --- KJamsClone/KJMixProApp.swift | 28 ++++++++++++------- KJamsClone/LibraryScanner.swift | 40 ++++++++++++++++++++++++++- KJamsClone/Player.swift | 26 +++++++++-------- KJamsCloneTests/KJamsCloneTests.swift | 31 ++++++++++++++++++++- 4 files changed, 101 insertions(+), 24 deletions(-) diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index e75f057..e336caa 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -993,10 +993,6 @@ struct LibraryView: View { if let progress = model.automaticWaveformProgress { BackgroundWaveformStatus(progress:progress,cancel:{model.automaticWaveforms.cancel()},dismiss:{model.automaticWaveformProgress = nil}).padding(.horizontal,18).padding(.vertical,8) } - if let progress = model.waveformJob { - WaveformProgressCard(progress:progress,running:model.generatingWaveforms,cancel:{model.cancelWaveformGeneration()},details:{waveformDetails = true},dismiss:{model.dismissWaveformProgress()}) - .padding(.horizontal,18).padding(.vertical,10) - } Divider() footer.padding(.horizontal,18).padding(.vertical,8) }.frame(minWidth:780) @@ -1053,12 +1049,24 @@ struct LibraryView: View { if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) } } } - .sheet(isPresented:$waveformDetails) { - VStack(alignment:.leading,spacing:14) { - Text("Waveform Analysis").font(.title2.bold()) - ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300) - HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) } - }.padding(24).frame(width:560) + .sheet(isPresented:Binding(get:{model.waveformJob != nil},set:{if !$0 { model.dismissWaveformProgress() }})) { + if let progress = model.waveformJob { + VStack(alignment:.leading,spacing:20) { + Label("Karaoke Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(.cyan) + Text(model.generatingWaveforms ? "Preparing saved waveforms in the background. Playback remains available." : progress.cancelled ? "Generation cancelled. Completed waveforms have been kept." : progress.issues > 0 ? "Some tracks could not be analysed. See the log below." : "Waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary) + WaveformProgressCard(progress:progress,running:model.generatingWaveforms,studio:"Karaoke Studio",cancel:{model.cancelWaveformGeneration()},details:{waveformDetails.toggle()},dismiss:{model.dismissWaveformProgress()}) + if waveformDetails || !progress.messages.isEmpty { + Text("Analysis Log").font(.headline) + ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180) + } + HStack { + if model.generatingWaveforms { ProgressView().controlSize(.small).tint(.cyan);Text("Cancel keeps completed waveforms.").font(.caption).foregroundStyle(.secondary) } + Spacer() + if !model.generatingWaveforms { Button("Done") { model.dismissWaveformProgress() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) } + } + }.padding(26).frame(width:660).background(Color(nsColor:.windowBackgroundColor)) + .interactiveDismissDisabled(model.generatingWaveforms) + } } .sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) } .sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) } diff --git a/KJamsClone/LibraryScanner.swift b/KJamsClone/LibraryScanner.swift index f3040f8..74ad5b0 100644 --- a/KJamsClone/LibraryScanner.swift +++ b/KJamsClone/LibraryScanner.swift @@ -135,6 +135,33 @@ enum WaveformBatch { } } +/// Blocking decoders and admission waits must not occupy Swift's cooperative executor. +/// Cancellation is captured explicitly because GCD blocks have no Swift task context. +enum WaveformWorker { + private final class Cancellation: @unchecked Sendable { + private let lock = NSLock() + private var value = false + var isCancelled: Bool { lock.lock();defer { lock.unlock() };return value } + func cancel() { lock.lock();value = true;lock.unlock() } + } + static func run(_ operation: @escaping @Sendable (@escaping @Sendable () -> Bool) throws -> T) async throws -> T { + let cancellation = Cancellation() + return try await withTaskCancellationHandler(operation: { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos:.utility).async { + do { + if cancellation.isCancelled { throw CancellationError() } + let result = try operation { cancellation.isCancelled } + if cancellation.isCancelled { throw CancellationError() } + continuation.resume(returning:result) + } catch { continuation.resume(throwing:error) } + } + } + },onCancel:{ cancellation.cancel() }) + } +} + /// Admission control is read on workers; starting playback reduces future admissions. final class WaveformWorkload: @unchecked Sendable { static let shared = WaveformWorkload() @@ -172,9 +199,20 @@ extension WaveformBatch { let tracks = groups[next];next += 1;active += 1 state.current = tracks.first?.title ?? "Preparing waveforms…" group.addTask(priority:.utility) { - run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled}) + do { + return try await WaveformWorker.run { cancelled in + run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:cancelled) + } + } catch { + var result = WaveformJobProgress(total:tracks.count) + result.cancelled = true;result.finished = true + return result + } } } + // Publish admission before awaiting a decoder: otherwise a long first track + // leaves the interface at “Preparing waveforms” with no visible activity. + if active > 0 { progress(state) } guard active > 0,let result = await group.next() else { break } active -= 1 state.processed += result.processed;state.generated += result.generated;state.cached += result.cached diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 6296ea5..9493a60 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -437,24 +437,26 @@ import CoreAudio let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false let directory = waveformCacheDirectory waveformTask = Task.detached(priority:.utility) { [weak self] in - let result: Result - var cached = false,saved = false + let result: Result<(AudioWaveform.Analysis,Bool,Bool),Error> do { - if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { - result = .success(analysis);cached = true;saved = true - } else { - let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled }) - if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil } - result = .success(analysis) - } + result = .success(try await WaveformWorker.run { cancelled in + if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { + return (analysis,true,true) + } + let analysis = try AudioWaveform.analyse(url:url,cancelled:cancelled) + if cancelled() { throw CancellationError() } + let saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil + return (analysis,false,saved) + }) } catch { result = .failure(error) } guard !Task.isCancelled else { return } - let loadedFromCache = cached,analysisSaved = saved await MainActor.run { [weak self] in guard let self,self.waveformGeneration == token else { return } - self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved + self.waveformLoading = false switch result { - case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands + case .success(let (analysis,cached,saved)): + self.waveformFromCache = cached;self.waveformSaved = saved + self.waveform = analysis.levels;self.waveformBands = analysis.bands case .failure:self.waveformUnavailable = true } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 2742725..3062dc5 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2781,11 +2781,40 @@ extension KJamsCloneTests { } var duplicate = songs[0];duplicate.id = 10;songs.append(duplicate) let cache = root.appendingPathComponent("Cache") - let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2}) + var updates = [WaveformJobProgress]() + let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2},progress:{updates.append($0)}) + XCTAssertEqual(updates.first?.processed,0,"Show the admitted track before waiting for its decoder") + XCTAssertEqual(updates.first?.current,"Track") + XCTAssertEqual(updates.first?.finished,false) XCTAssertEqual(result.generated,4);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.processed,5);XCTAssertEqual(result.issues,0) let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1}) XCTAssertEqual(reused.cached,5);XCTAssertEqual(reused.generated,0) } + @MainActor func testWaitingWaveformWorkerCancelsWithoutBlockingTaskExecutor() async throws { + let policy = WaveformWorkload.shared,id = UUID() + policy.setPlaying(true,id:id) + try policy.acquire(cancelled:{false}) + var held = true + defer { if held { policy.release() };policy.setPlaying(false,id:id) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav")) + let waiting = Task { + try await WaveformWorker.run { cancelled in + try AudioWaveform.analyse(url:fixture,cancelled:cancelled) + } + } + // This continuation must run even while decoding is waiting for admission. + try await Task.sleep(nanoseconds:50_000_000) + waiting.cancel() + do { _ = try await waiting.value;XCTFail("Cancelled analysis should not produce a waveform") } + catch { XCTAssertTrue(error is CancellationError) } + policy.release();held = false + let analysis = try await WaveformWorker.run { cancelled in + try AudioWaveform.analyse(url:fixture,cancelled:cancelled) + } + XCTAssertEqual(analysis.levels.count,4096) + XCTAssertGreaterThan(analysis.levels.max() ?? 0,0) + } + @MainActor func testWaveformAdmissionDropsToOneDuringPlayback() throws { let policy = WaveformWorkload.shared,id = UUID() policy.setPlaying(true,id:id)