auto-delete samples on source deletion
This commit is contained in:
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user