removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-07 14:54:46 +00:00
parent 9d0d1cccc8
commit 5e3007b8bd
4 changed files with 101 additions and 24 deletions
+18 -10
View File
@@ -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) }
+39 -1
View File
@@ -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<T: Sendable>(_ 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
+14 -12
View File
@@ -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<AudioWaveform.Analysis,Error>
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
}
}
+30 -1
View File
@@ -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)