removed unwanted bar now with waveforms, major updates and enhancements
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user