auto-delete samples on source deletion

This commit is contained in:
André Michelle
2026-02-02 17:19:54 +01:00
parent 02127f4507
commit b72a71dff1
10 changed files with 93 additions and 94 deletions
@@ -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()
@@ -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<StudioService> = ({lifecycle, service}: PageContext<StudioService>) => {
const pendingSamples = UUID.newSet<UUID.Bytes>(uuid => uuid)
const host = new ScriptHost({
openProject: (buffer: ArrayBufferLike, name?: string): void => {
const boxGraph = new BoxGraph<BoxIO.TypeMap>(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<StudioService> = ({lifecycle, service}:
})
})
},
addSample: (data: AudioData, name: string): Promise<Sample> => service.sampleService.importFile({
name, arrayBuffer: WavFile.encodeFloats(data)
})
addSample: async (data: AudioData, name: string): Promise<Sample> => {
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 (
<div className={className}>
@@ -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"
}
}
}
@@ -63,6 +63,7 @@ export abstract class TimelineDragAndDrop<T extends (ClipCaptureTarget | RegionC
console.warn(error)
return
}
this.project.trackUserCreatedSample(UUID.parse(value.uuid))
sample = value
} else if (data.type === "instrument") {
editing.modify(() => this.project.api.createAnyInstrument(InstrumentFactories[data.device]))
+11 -1
View File
@@ -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<Procedure<UUID.Bytes>> = 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<T extends Terminable>(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<UUID.Bytes>) {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
+1
View File
@@ -10,6 +10,7 @@ export abstract class Storage<ITEM extends { uuid: UUID.String } & META, META, N
async deleteItem(uuid: UUID.Bytes): Promise<void> {
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)
@@ -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
})
})
@@ -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))
@@ -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<unknown, Promise<unknown>>, load: Procedure<EngineWorklet> }
@@ -101,6 +103,7 @@ export class Project implements BoxAdaptersContext, Terminable, TerminableOwner
readonly #terminator = new Terminator()
readonly #sampleRegistrations: SortedSet<UUID.Bytes, { uuid: UUID.Bytes, terminable: Terminable }>
readonly #userCreatedSamples: SortedSet<UUID.Bytes, UUID.Bytes> = UUID.newSet(uuid => uuid)
readonly #env: ProjectEnv
readonly boxGraph: BoxGraph<BoxIO.TypeMap>
@@ -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<void> {
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))
}
}
+29 -89
View File
@@ -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<UUID.Bytes, UUID.Bytes>` 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<void> {
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.Bytes, UUID.Bytes> = 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<void> {
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)