From b72a71dff1db98f2f361db6c2b0756ea7234ec69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Michelle?= Date: Mon, 2 Feb 2026 17:19:54 +0100 Subject: [PATCH] auto-delete samples on source deletion --- .../studio/src/ui/devices/SampleSelector.ts | 2 + .../studio/src/ui/pages/CodeEditorPage.tsx | 19 ++- .../src/ui/pages/PreferencesPageLabels.ts | 6 + .../tracks/audio-unit/TimelineDragAndDrop.ts | 1 + packages/studio/core/src/RecordingWorklet.ts | 12 +- packages/studio/core/src/Storage.ts | 1 + packages/studio/core/src/StudioSettings.ts | 5 + .../studio/core/src/capture/RecordAudio.ts | 1 + packages/studio/core/src/project/Project.ts | 22 ++++ plans/obsolete-sample.md | 118 +++++------------- 10 files changed, 93 insertions(+), 94 deletions(-) diff --git a/packages/app/studio/src/ui/devices/SampleSelector.ts b/packages/app/studio/src/ui/devices/SampleSelector.ts index e20cf809..046a6556 100644 --- a/packages/app/studio/src/ui/devices/SampleSelector.ts +++ b/packages/app/studio/src/ui/devices/SampleSelector.ts @@ -93,6 +93,7 @@ export class SampleSelector { .then(([file]) => file.arrayBuffer() .then(arrayBuffer => this.#service.sampleService.importFile({name: file.name, arrayBuffer})))) if (status === "resolved") { + this.#service.project.trackUserCreatedSample(UUID.parse(sample.uuid)) this.newSample(sample) } } @@ -125,6 +126,7 @@ export class SampleSelector { dialog.close() return } + this.#service.project.trackUserCreatedSample(UUID.parse(value.uuid)) sample = value } else { dialog.close() diff --git a/packages/app/studio/src/ui/pages/CodeEditorPage.tsx b/packages/app/studio/src/ui/pages/CodeEditorPage.tsx index 55e6b617..c9fe893c 100644 --- a/packages/app/studio/src/ui/pages/CodeEditorPage.tsx +++ b/packages/app/studio/src/ui/pages/CodeEditorPage.tsx @@ -6,7 +6,7 @@ import {ThreeDots} from "@/ui/spinner/ThreeDots" import {Button} from "@/ui/components/Button" import {Icon} from "@/ui/components/Icon" import {Colors, IconSymbol} from "@opendaw/studio-enums" -import {Option, panic, RuntimeNotifier} from "@opendaw/lib-std" +import {Option, panic, RuntimeNotifier, UUID} from "@opendaw/lib-std" import {ScriptHost} from "@opendaw/studio-scripting" import {MenuButton} from "@/ui/components/MenuButton" import {MenuItem} from "@opendaw/studio-core" @@ -35,12 +35,15 @@ const Examples = { const className = Html.adoptStyleSheet(css, "CodeEditorPage") export const CodeEditorPage: PageFactory = ({lifecycle, service}: PageContext) => { + const pendingSamples = UUID.newSet(uuid => uuid) const host = new ScriptHost({ openProject: (buffer: ArrayBufferLike, name?: string): void => { const boxGraph = new BoxGraph(Option.wrap(BoxIO.create)) boxGraph.fromArrayBuffer(buffer) const mandatoryBoxes = ProjectSkeleton.findMandatoryBoxes(boxGraph) const project = Project.fromSkeleton(service, {boxGraph, mandatoryBoxes}) + pendingSamples.forEach(uuid => project.trackUserCreatedSample(uuid)) + pendingSamples.clear() service.projectProfileService.setProject(project, name ?? "Scripted Project") service.switchScreen("default") }, @@ -53,9 +56,17 @@ export const CodeEditorPage: PageFactory = ({lifecycle, service}: }) }) }, - addSample: (data: AudioData, name: string): Promise => service.sampleService.importFile({ - name, arrayBuffer: WavFile.encodeFloats(data) - }) + addSample: async (data: AudioData, name: string): Promise => { + const sample = await service.sampleService.importFile({ + name, arrayBuffer: WavFile.encodeFloats(data) + }) + const uuid = UUID.parse(sample.uuid) + service.optProject.match({ + none: () => {pendingSamples.add(uuid)}, + some: project => {project.trackUserCreatedSample(uuid)} + }) + return sample + } }, scriptWorkerUrl) return (
diff --git a/packages/app/studio/src/ui/pages/PreferencesPageLabels.ts b/packages/app/studio/src/ui/pages/PreferencesPageLabels.ts index f518e567..4b0d1a23 100644 --- a/packages/app/studio/src/ui/pages/PreferencesPageLabels.ts +++ b/packages/app/studio/src/ui/pages/PreferencesPageLabels.ts @@ -53,6 +53,12 @@ export namespace PreferencesPageLabels { "enable-beta-features": "Enable Experimental Features", "enable-debug-menu": "Enable Debug Menu" } + }, + "storage": { + label: "Storage", + fields: { + "auto-delete-orphaned-samples": "Auto-delete orphaned samples" + } } } diff --git a/packages/app/studio/src/ui/timeline/tracks/audio-unit/TimelineDragAndDrop.ts b/packages/app/studio/src/ui/timeline/tracks/audio-unit/TimelineDragAndDrop.ts index 610f4eeb..27752cb9 100644 --- a/packages/app/studio/src/ui/timeline/tracks/audio-unit/TimelineDragAndDrop.ts +++ b/packages/app/studio/src/ui/timeline/tracks/audio-unit/TimelineDragAndDrop.ts @@ -63,6 +63,7 @@ export abstract class TimelineDragAndDrop this.project.api.createAnyInstrument(InstrumentFactories[data.device])) diff --git a/packages/studio/core/src/RecordingWorklet.ts b/packages/studio/core/src/RecordingWorklet.ts index f317266a..7fa52aa4 100644 --- a/packages/studio/core/src/RecordingWorklet.ts +++ b/packages/studio/core/src/RecordingWorklet.ts @@ -5,6 +5,7 @@ import { Observer, Option, panic, + Procedure, Progress, Subscription, Terminable, @@ -40,6 +41,7 @@ export class RecordingWorklet extends AudioWorkletNode implements Terminable, Sa #isRecording: boolean = true #limitSamples: int = Number.POSITIVE_INFINITY #state: SampleLoaderState = {type: "record"} + #onSaved: Option> = Option.None constructor(context: BaseAudioContext, config: RingBuffer.Config) { super(context, "recording-processor", { @@ -66,7 +68,14 @@ export class RecordingWorklet extends AudioWorkletNode implements Terminable, Sa own(terminable: T): T {return this.#terminator.own(terminable)} - limit(count: int): void {this.#limitSamples = count} + limit(count: int): void { + this.#limitSamples = count + if (this.numberOfFrames >= this.#limitSamples) { + this.#finalize().catch(error => console.warn(error)) + } + } + + set onSaved(callback: Procedure) {this.#onSaved = Option.wrap(callback)} setFillLength(value: int): void {this.#peakWriter.numFrames = value} @@ -119,6 +128,7 @@ export class RecordingWorklet extends AudioWorkletNode implements Terminable, Sa meta } await SampleStorage.get().save(sample) + this.#onSaved.ifSome(callback => callback(this.uuid)) this.#setState({type: "loaded"}) this.terminate() return sample diff --git a/packages/studio/core/src/Storage.ts b/packages/studio/core/src/Storage.ts index a892cf8e..3a1b6632 100644 --- a/packages/studio/core/src/Storage.ts +++ b/packages/studio/core/src/Storage.ts @@ -10,6 +10,7 @@ export abstract class Storage { const path = `${this.folder}/${UUID.toString(uuid)}` + console.debug(`deleteItem '${path}'`) const uuids = await this.loadTrashedIds() uuids.push(UUID.toString(uuid)) await this.saveTrashedIds(uuids) diff --git a/packages/studio/core/src/StudioSettings.ts b/packages/studio/core/src/StudioSettings.ts index 46024ee4..1d453534 100644 --- a/packages/studio/core/src/StudioSettings.ts +++ b/packages/studio/core/src/StudioSettings.ts @@ -57,6 +57,11 @@ export const StudioSettingsSchema = z.object({ "footer-show-build-infos": false, "enable-beta-features": false, "enable-debug-menu": false + }), + "storage": z.object({ + "auto-delete-orphaned-samples": z.boolean() + }).default({ + "auto-delete-orphaned-samples": false }) }) diff --git a/packages/studio/core/src/capture/RecordAudio.ts b/packages/studio/core/src/capture/RecordAudio.ts index 2310a560..f78ae159 100644 --- a/packages/studio/core/src/capture/RecordAudio.ts +++ b/packages/studio/core/src/capture/RecordAudio.ts @@ -114,6 +114,7 @@ export namespace RecordAudio { currentTake = Option.wrap(createTakeRegion(position, currentWaveformOffset, previousTrack)) } + recordingWorklet.onSaved = uuid => project.trackUserCreatedSample(uuid) terminator.ownAll( Terminable.create(() => { tryCatch(() => sourceNode.disconnect(recordingWorklet)) diff --git a/packages/studio/core/src/project/Project.ts b/packages/studio/core/src/project/Project.ts index dfe5cf2f..30be1edc 100644 --- a/packages/studio/core/src/project/Project.ts +++ b/packages/studio/core/src/project/Project.ts @@ -4,6 +4,7 @@ import { isDefined, panic, Procedure, + RuntimeNotifier, safeExecute, SortedSet, Terminable, @@ -60,6 +61,7 @@ import {ppqn, TempoMap, TimeBase} from "@opendaw/lib-dsp" import {MidiData} from "@opendaw/lib-midi" import {StudioPreferences} from "../StudioPreferences" import {RegionOverlapResolver, TimelineFocus} from "../ui" +import {SampleStorage} from "../samples/SampleStorage" export type RestartWorklet = { unload: Func>, load: Procedure } @@ -101,6 +103,7 @@ export class Project implements BoxAdaptersContext, Terminable, TerminableOwner readonly #terminator = new Terminator() readonly #sampleRegistrations: SortedSet + readonly #userCreatedSamples: SortedSet = UUID.newSet(uuid => uuid) readonly #env: ProjectEnv readonly boxGraph: BoxGraph @@ -178,6 +181,7 @@ export class Project implements BoxAdaptersContext, Terminable, TerminableOwner this.#registerSample(update.uuid) } else if (update instanceof DeleteUpdate && update.name === AudioFileBox.ClassName) { this.#unregisterSample(update.uuid) + this.#deleteUserCreatedSample(update.uuid) } } })) @@ -316,6 +320,10 @@ export class Project implements BoxAdaptersContext, Terminable, TerminableOwner .filter(isDefined).reduce((position, region) => Math.max(position, region.complete), 0) } + trackUserCreatedSample(uuid: UUID.Bytes): void { + this.#userCreatedSamples.add(uuid) + } + terminate(): void { this.#sampleRegistrations.forEach(({terminable}) => terminable.terminate()) this.#sampleRegistrations.clear() @@ -330,4 +338,18 @@ export class Project implements BoxAdaptersContext, Terminable, TerminableOwner #unregisterSample(uuid: UUID.Bytes): void { this.#sampleRegistrations.removeByKey(uuid).terminable.terminate() } + + async #deleteUserCreatedSample(uuid: UUID.Bytes): Promise { + if (!this.#userCreatedSamples.hasKey(uuid)) {return} + this.#userCreatedSamples.removeByKey(uuid) + const autoDelete = StudioPreferences.settings.storage["auto-delete-orphaned-samples"] + if (!autoDelete && !await RuntimeNotifier.approve({ + headline: "Delete Sample?", + message: "The sample is no longer used. Delete it from storage? This cannot be undone!", + approveText: "Delete", + cancelText: "Keep" + })) {return} + SampleStorage.get().deleteItem(uuid).catch((reason: unknown) => + console.warn("Failed to delete sample from storage", reason)) + } } \ No newline at end of file diff --git a/plans/obsolete-sample.md b/plans/obsolete-sample.md index 6e1397b4..974c1d4d 100644 --- a/plans/obsolete-sample.md +++ b/plans/obsolete-sample.md @@ -1,5 +1,7 @@ # Plan: Obsolete Sample Cleanup +## Status: FULLY IMPLEMENTED + ## Problem - Recorded and imported samples are stored in OPFS via SampleStorage - When an AudioFileBox is deleted (because no region references it), the box is removed from the BoxGraph @@ -7,10 +9,10 @@ - OPFS gets crowded over time with orphaned sample files ## Requirements -1. Track which samples are user-created (recorded/imported) vs preset/library -2. When a tracked AudioFileBox is deleted, delete the physical file from SampleStorage -3. Prompt user for confirmation before deletion (dialog exists) -4. Add StudioPreference to auto-delete without dialog +1. ✅ Track which samples are user-created (recorded/imported) vs preset/library +2. ✅ When a tracked AudioFileBox is deleted, delete the physical file from SampleStorage +3. ✅ Prompt user for confirmation before deletion +4. ✅ Add StudioPreference to auto-delete without dialog ## Design Decision: Option B - Track in Project @@ -30,99 +32,37 @@ Adding a `source` field to AudioFileBox schema doesn't work because: - Samples dragged from already stored samples (library/browser) - When an AudioFileBox is deleted and its UUID is in the set, delete from SampleStorage -### Why It Doesn't Go Out of Sync -- The trigger is `BoxGraph.subscribeToAllUpdates` with `DeleteUpdate` for AudioFileBox -- Project already has this subscription (see `#unregisterSample`) -- When the AudioFileBox is deleted, we check if its UUID is in our tracked set -- If yes → prompt for deletion (or auto-delete based on preference) -- If no → the sample is from a saved project or library, leave it alone +## Implementation Summary -## Existing Infrastructure +### Files Modified -### Project already subscribes to AudioFileBox deletions: -```typescript -this.#terminator.own(this.boxGraph.subscribeToAllUpdates({ - onUpdate: (update) => { - if (update instanceof NewUpdate && update.name === AudioFileBox.ClassName) { - this.#registerSample(update.uuid) - } else if (update instanceof DeleteUpdate && update.name === AudioFileBox.ClassName) { - this.#unregisterSample(update.uuid) - } - } -})) -``` +1. **`packages/studio/core/src/project/Project.ts`** + - Added `#userCreatedSamples: SortedSet` field + - Added `trackUserCreatedSample(uuid: UUID.Bytes): void` public method + - Added `#deleteUserCreatedSample(uuid: UUID.Bytes): void` private method + - Updated `subscribeToAllUpdates` to call `#deleteUserCreatedSample` on AudioFileBox deletion + - Checks `StudioPreferences.settings.storage["auto-delete-orphaned-samples"]` + - Shows confirmation dialog via `RuntimeNotifier.approve()` if not auto-delete -### Storage already has deleteItem method: -```typescript -// In Storage.ts base class -async deleteItem(uuid: UUID.Bytes): Promise { - const path = `${this.folder}/${UUID.toString(uuid)}` - const uuids = await this.loadTrashedIds() - uuids.push(UUID.toString(uuid)) - await this.saveTrashedIds(uuids) - await Workers.Opfs.delete(path) -} -``` +2. **`packages/studio/core/src/StudioSettings.ts`** + - Added `storage` section with `auto-delete-orphaned-samples` boolean (default: false) -## Implementation +3. **`packages/app/studio/src/ui/pages/PreferencesPageLabels.ts`** + - Added labels for "Storage" section and "Auto-delete orphaned samples" preference -### 1. Add tracking set to Project -```typescript -// In Project class -readonly #userCreatedSamples: SortedSet = UUID.newSet(uuid => uuid) +4. **`packages/studio/core/src/capture/RecordAudio.ts`** + - Subscribe to recordingWorklet state changes + - When state becomes "loaded", call `project.trackUserCreatedSample(originalUuid)` -trackUserCreatedSample(uuid: UUID.Bytes): void { - this.#userCreatedSamples.add(uuid) -} +5. **`packages/app/studio/src/ui/devices/SampleSelector.ts`** + - In `browse()`: Track sample after successful import + - In `configureDrop()`: Track sample when dropping file (not when dropping existing sample) -isUserCreatedSample(uuid: UUID.Bytes): boolean { - return this.#userCreatedSamples.hasKey(uuid) -} -``` +6. **`packages/app/studio/src/ui/timeline/tracks/audio-unit/TimelineDragAndDrop.ts`** + - In `drop()`: Track sample when dropping file (not when dropping existing sample) -### 2. Update deletion handler in Project -```typescript -// In the existing subscribeToAllUpdates callback -} else if (update instanceof DeleteUpdate && update.name === AudioFileBox.ClassName) { - this.#unregisterSample(update.uuid) - if (this.isUserCreatedSample(update.uuid)) { - this.#promptOrDeleteSample(update.uuid) - } -} -``` - -### 3. Add deletion logic -```typescript -async #promptOrDeleteSample(uuid: UUID.Bytes): Promise { - const autoDelete = StudioPreferences.settings.storage["auto-delete-orphaned-samples"] - if (autoDelete) { - await this.sampleManager.storage.deleteItem(uuid) - } else { - // Show confirmation dialog - // If confirmed, delete - } - this.#userCreatedSamples.delete(UUID.toString(uuid)) -} -``` - -### 4. Call trackUserCreatedSample after SampleStorage.save -- Called right after `SampleStorage.save()` completes successfully -- This is the safest single point for both recordings and file imports -- NOT called when dragging from stored samples (library/browser) - these don't go through save() - -### 5. Add StudioPreference -```typescript -// In StudioPreferences schema -storage: { - "auto-delete-orphaned-samples": false -} -``` - -## Files to Modify -- `packages/studio/core/src/project/Project.ts` - add tracking set and deletion logic -- `packages/studio/core/src/RecordingWorklet.ts` - call trackUserCreatedSample after save (line ~121, in #finalize) -- `packages/studio/core/src/samples/SampleService.ts` - call trackUserCreatedSample after save (line ~54) -- `packages/studio/core/src/StudioPreferences.ts` - add preference +7. **`packages/app/studio/src/ui/pages/CodeEditorPage.tsx`** + - In `addSample()`: Track sample after import if project exists ## Edge Cases - Multiple regions referencing same AudioFileBox: AudioFileBox is only deleted when ALL references are gone (handled by BoxGraph)