diff --git a/packages/app/studio/src/service/SampleApi.ts b/packages/app/studio/src/service/SampleApi.ts index c20901170..44f7b78c8 100644 --- a/packages/app/studio/src/service/SampleApi.ts +++ b/packages/app/studio/src/service/SampleApi.ts @@ -133,7 +133,7 @@ export namespace SampleApi { ); }; - // Convert an AudioBuffer into the serialized AudioData format + /** Convert an {@link AudioBuffer} into serialized {@link AudioData}. */ const fromAudioBuffer = (buffer: AudioBuffer): AudioData => ({ frames: Arrays.create( (channel) => buffer.getChannelData(channel), diff --git a/packages/app/studio/src/service/SamplePlayback.ts b/packages/app/studio/src/service/SamplePlayback.ts index 510ed224e..64ce37fab 100644 --- a/packages/app/studio/src/service/SamplePlayback.ts +++ b/packages/app/studio/src/service/SamplePlayback.ts @@ -11,6 +11,7 @@ import { import { SampleApi } from "./SampleApi"; import { encodeWavFloat, SampleStorage } from "@opendaw/studio-core"; +/** Events emitted by {@link SamplePlayback}. */ export type PlaybackEvent = | { type: "idle"; @@ -127,10 +128,12 @@ export class SamplePlayback { return this.#linearVolume; } + /** Notify subscribers about a playback event. */ #notify(uuidAsString: string, event: PlaybackEvent): void { this.#notifiers.get(uuidAsString).forEach((procedure) => procedure(event)); } + /** Attach listeners to the underlying {@link HTMLAudioElement}. */ #watchAudio(uuidAsString: string): void { this.#audio.onended = () => this.#notify(uuidAsString, { type: "idle" }); this.#audio.ontimeupdate = () => { @@ -149,6 +152,7 @@ export class SamplePlayback { }); } + /** Remove previously attached audio event listeners. */ #unwatchAudio(): void { this.#audio.onended = null; this.#audio.onplay = null; diff --git a/packages/app/studio/src/service/SampleService.ts b/packages/app/studio/src/service/SampleService.ts new file mode 100644 index 000000000..709e3d337 --- /dev/null +++ b/packages/app/studio/src/service/SampleService.ts @@ -0,0 +1,8 @@ +/** + * Service level export for the sample management utilities. + * + * Re-exports {@link SampleService} so consumers can import from the service + * layer without referencing UI internals. + */ +export { SampleService } from "../ui/browse/SampleService"; + diff --git a/packages/app/studio/src/ui/browse/SampleBrowser.tsx b/packages/app/studio/src/ui/browse/SampleBrowser.tsx index be9b65818..1f135666c 100644 --- a/packages/app/studio/src/ui/browse/SampleBrowser.tsx +++ b/packages/app/studio/src/ui/browse/SampleBrowser.tsx @@ -34,7 +34,7 @@ import { RadioGroup } from "../components/RadioGroup"; import { Icon } from "../components/Icon"; import { SampleLocation } from "@/ui/browse/SampleLocation"; import { HTMLSelection } from "@/ui/HTMLSelection"; -import { SampleService } from "@/ui/browse/SampleService"; +import { SampleService } from "@/service/SampleService"; const className = Html.adoptStyleSheet(css, "Samples"); @@ -57,6 +57,9 @@ const location = new DefaultObservableValue(SampleLocation.Cloud); * {@link SampleLocation} to present a unified view of locally cached OPFS * samples and the remote library. Keyboard delete removes selected local * samples. + * + * @param lifecycle lifecycle controlling subscriptions + * @param service access to studio level services */ export const SampleBrowser = ({ lifecycle, service }: Construct) => { lifecycle.own({ terminate: () => service.samplePlayback.eject() }); diff --git a/packages/app/studio/src/ui/browse/SampleDialogs.tsx b/packages/app/studio/src/ui/browse/SampleDialogs.tsx index 5f3df0608..fa0526b22 100644 --- a/packages/app/studio/src/ui/browse/SampleDialogs.tsx +++ b/packages/app/studio/src/ui/browse/SampleDialogs.tsx @@ -39,6 +39,7 @@ export namespace SampleDialogs { * @param importer handler used to register the replacement sample. * @param uuid identifier of the missing sample. * @param name original name shown to the user. + * @returns the sample chosen by the user. */ export const missingSampleDialog = async ( importer: SampleImporter, diff --git a/packages/app/studio/src/ui/browse/SampleLocation.tsx b/packages/app/studio/src/ui/browse/SampleLocation.tsx index 1a9e65b71..304c565f5 100644 --- a/packages/app/studio/src/ui/browse/SampleLocation.tsx +++ b/packages/app/studio/src/ui/browse/SampleLocation.tsx @@ -1,8 +1,8 @@ /** * Indicates where a sample originates from. * - * Used by {@link SampleBrowser} to toggle between cloud and OPFS backed - * libraries. + * Used by {@link SampleBrowser} and {@link SampleService} to toggle between + * cloud and OPFS backed libraries. */ export const enum SampleLocation { /** Sample hosted on the server */ diff --git a/packages/app/studio/src/ui/browse/SampleService.ts b/packages/app/studio/src/ui/browse/SampleService.ts index fa081fd4f..ec0b4bdbd 100644 --- a/packages/app/studio/src/ui/browse/SampleService.ts +++ b/packages/app/studio/src/ui/browse/SampleService.ts @@ -138,7 +138,11 @@ export class SampleService { } } - /** Read the sample metadata from the current selection. */ + /** + * Collect metadata from all currently selected DOM elements. + * + * @returns parsed sample objects associated with the selection. + */ #samples(): ReadonlyArray { const selected = this.#selection.getSelected(); return selected.map( diff --git a/packages/docs/docs-dev/architecture/sample-storage.md b/packages/docs/docs-dev/architecture/sample-storage.md new file mode 100644 index 000000000..c501431ac --- /dev/null +++ b/packages/docs/docs-dev/architecture/sample-storage.md @@ -0,0 +1,27 @@ +# Sample Storage + +openDAW persists audio samples in the browser's [Origin Private File System](https://developer.mozilla.org/docs/Web/API/File_System_API/Origin_private_file_system) (OPFS). Each sample is stored in its own folder containing three files: + +- `audio.wav` – the original audio data encoded as a WAV file +- `peaks.bin` – precomputed peaks used for waveform rendering +- `meta.json` – descriptive metadata such as name and bpm + +``` +samples/ +└── v2/ + └── / + ├── audio.wav + ├── peaks.bin + └── meta.json +``` + +The [`SampleStorage` namespace](../../../packages/studio/core/src/samples/SampleStorage.ts) provides helper functions to read and write these files. `MainThreadSampleLoader` uses these helpers to cache downloads and serve subsequent requests directly from OPFS. + +## Lifecycle + +1. `SampleApi` downloads audio and metadata from the network. +2. `SampleStorage.store` writes the audio, peaks and metadata to OPFS. +3. `SampleStorage.load` reads the files back and decodes them into `AudioData` and peak information. +4. `SampleStorage.list` enumerates stored samples for the **Sample Browser**. + +Clients may call `SampleStorage.remove` to free space or `SampleStorage.updateMeta` to adjust metadata without touching the audio. diff --git a/packages/docs/docs-user/features/file-management.md b/packages/docs/docs-user/features/file-management.md index 60db6715d..230de4121 100644 --- a/packages/docs/docs-user/features/file-management.md +++ b/packages/docs/docs-user/features/file-management.md @@ -13,6 +13,12 @@ offline. Developers can dive deeper in the - Adjust preview volume with the slider in the browser footer. - Local samples are cached in OPFS and survive page reloads. +### Manage Local Storage + +- Delete unused entries from the **Sample Browser** to free space. +- Project data and samples reside in the browser; exporting bundles is the + safest backup. + ## Save Projects 1. **Write changes to the browser.** Press Ctrl+S or choose @@ -54,7 +60,10 @@ offline. Developers can dive deeper in the ## Collaborate and Share -Use project bundles to collaborate. Export a bundle and send it to another user who can open it and continue working. The [Collaboration workflow](../workflows/collaboration.md) covers best practices. +Use project bundles to collaborate. Export a bundle and send it to another +user who can open it and continue working. The +[Collaboration workflow](../workflows/collaboration.md) covers best +practices. Detailed steps for exporting audio or bundles are available in the [exporting and sharing workflow](../workflows/exporting-and-sharing.md). diff --git a/packages/studio/core/src/samples/MainThreadSampleLoader.ts b/packages/studio/core/src/samples/MainThreadSampleLoader.ts index 47bce28fa..1ac7be163 100644 --- a/packages/studio/core/src/samples/MainThreadSampleLoader.ts +++ b/packages/studio/core/src/samples/MainThreadSampleLoader.ts @@ -18,6 +18,13 @@ import {MainThreadSampleManager} from "./MainThreadSampleManager" import {WorkerAgents} from "../WorkerAgents" import {SampleStorage} from "./SampleStorage" +/** + * Loads sample data on the main thread and caches it in {@link SampleStorage}. + * + * Acts as a thin wrapper around the asynchronous fetch and decode pipeline, + * exposing the current {@link SampleLoaderState} to observers. Instances are + * created and tracked by {@link MainThreadSampleManager}. + */ export class MainThreadSampleLoader implements SampleLoader { readonly #manager: MainThreadSampleManager @@ -30,6 +37,12 @@ export class MainThreadSampleLoader implements SampleLoader { #state: SampleLoaderState = {type: "progress", progress: 0.0} #version: int = 0 + /** + * Create a new loader for the given sample. + * + * @param manager owning sample manager used for fetching data + * @param uuid identifier of the sample to load + */ constructor(manager: MainThreadSampleManager, uuid: UUID.Format) { this.#manager = manager this.#uuid = uuid @@ -38,6 +51,9 @@ export class MainThreadSampleLoader implements SampleLoader { this.#get() } + /** + * Drop any cached data and restart the loading process. + */ invalidate(): void { this.#state = {type: "progress", progress: 0.0} this.#meta = Option.None @@ -47,6 +63,11 @@ export class MainThreadSampleLoader implements SampleLoader { this.#get() } + /** + * Subscribe to state changes. + * + * @param observer callback receiving loader state updates + */ subscribe(observer: Observer): Subscription { if (this.#state.type === "loaded") { observer(this.#state) @@ -55,12 +76,23 @@ export class MainThreadSampleLoader implements SampleLoader { return this.#notifier.subscribe(observer) } + /** Identifier of the loaded sample. */ get uuid(): UUID.Format {return this.#uuid} + /** Loaded audio data, if available. */ get data(): Option {return this.#data} + /** Metadata describing the sample. */ get meta(): Option {return this.#meta} + /** Peak information used for waveform rendering. */ get peaks(): Option {return this.#peaks} + /** Current state of the loader. */ get state(): SampleLoaderState {return this.#state} + /** + * Append the sample files to a ZIP archive. + * + * If loading has not yet completed the promise resolves once data becomes + * available. + */ async pipeFilesInto(zip: JSZip): Promise { const exec: Exec = async () => { const path = `${SampleStorage.Folder}/${UUID.toString(this.#uuid)}` @@ -149,4 +181,4 @@ export class MainThreadSampleLoader implements SampleLoader { this.#setState({type: "error", reason: "N/A"}) } } -} \ No newline at end of file +} diff --git a/packages/studio/core/src/samples/MainThreadSampleManager.ts b/packages/studio/core/src/samples/MainThreadSampleManager.ts index 8ea454e38..86fe9aba6 100644 --- a/packages/studio/core/src/samples/MainThreadSampleManager.ts +++ b/packages/studio/core/src/samples/MainThreadSampleManager.ts @@ -3,11 +3,23 @@ import {AudioData, SampleLoader, SampleManager, SampleMetaData} from "@opendaw/s import {MainThreadSampleLoader} from "./MainThreadSampleLoader" import {SampleProvider} from "./SampleProvider" +/** + * Concrete {@link SampleManager} for the browser main thread. + * + * Keeps track of {@link MainThreadSampleLoader} instances and proxies fetch + * requests to a backing {@link SampleProvider} implementation. + */ export class MainThreadSampleManager implements SampleManager, SampleProvider { readonly #api: SampleProvider readonly #context: AudioContext readonly #loaders: SortedSet + /** + * Create a new manager. + * + * @param api provider used to fetch sample data from network or cache + * @param context audio context used for decoding + */ constructor(api: SampleProvider, context: AudioContext) { this.#api = api this.#context = context @@ -16,15 +28,27 @@ export class MainThreadSampleManager implements SampleManager, SampleProvider { get context(): AudioContext {return this.#context} + /** + * Fetch sample data from the backing provider. + */ fetch(uuid: UUID.Format, progress: Progress.Handler): Promise<[AudioData, SampleMetaData]> { return this.#api.fetch(uuid, progress) } + /** + * Invalidate the loader for a given sample. + */ invalidate(uuid: UUID.Format) {this.#loaders.opt(uuid).ifSome(loader => loader.invalidate())} + /** + * Register a loader with the manager so it can be invalidated later. + */ record(loader: SampleLoader): void {this.#loaders.add(loader)} + /** + * Retrieve an existing loader or create a new one. + */ getOrCreate(uuid: UUID.Format): SampleLoader { return this.#loaders.getOrCreate(uuid, uuid => new MainThreadSampleLoader(this, uuid)) } -} \ No newline at end of file +} diff --git a/packages/studio/core/src/samples/SampleProvider.ts b/packages/studio/core/src/samples/SampleProvider.ts index e860593c6..d417ff10f 100644 --- a/packages/studio/core/src/samples/SampleProvider.ts +++ b/packages/studio/core/src/samples/SampleProvider.ts @@ -1,6 +1,15 @@ import {Progress, UUID} from "@opendaw/lib-std" import {AudioData, SampleMetaData} from "@opendaw/studio-adapters" +/** + * Source of sample data for the {@link MainThreadSampleManager}. + */ export interface SampleProvider { + /** + * Retrieve a sample and associated metadata. + * + * @param uuid identifier of the requested sample + * @param progress callback receiving download progress between 0 and 1 + */ fetch(uuid: UUID.Format, progress: Progress.Handler): Promise<[AudioData, SampleMetaData]> -} \ No newline at end of file +} diff --git a/packages/studio/core/src/samples/SampleStorage.ts b/packages/studio/core/src/samples/SampleStorage.ts index 9e43a740c..b371cf053 100644 --- a/packages/studio/core/src/samples/SampleStorage.ts +++ b/packages/studio/core/src/samples/SampleStorage.ts @@ -4,11 +4,19 @@ import {AudioData, Sample, SampleMetaData} from "@opendaw/studio-adapters" import {WorkerAgents} from "../WorkerAgents" import {encodeWavFloat} from "../Wav" +/** + * Helpers for persisting samples in the browser's OPFS. + */ export namespace SampleStorage { + /** Remove legacy storage from earlier versions. */ export const clean = () => WorkerAgents.Opfs.delete("samples/v1").catch(EmptyExec) + /** Root folder within OPFS for stored samples. */ export const Folder = "samples/v2" + /** + * Write decoded audio, peaks and metadata to OPFS. + */ export const store = async (uuid: UUID.Format, audio: AudioData, peaks: ArrayBuffer, @@ -25,11 +33,17 @@ export namespace SampleStorage { ]).then(EmptyExec) } + /** + * Overwrite only the metadata file of a stored sample. + */ export const updateMeta = async (uuid: UUID.Format, meta: SampleMetaData): Promise => { const path = `${Folder}/${UUID.toString(uuid)}` return WorkerAgents.Opfs.write(`${path}/meta.json`, new TextEncoder().encode(JSON.stringify(meta))) } + /** + * Load a sample from OPFS and decode it into {@link AudioData} and peaks. + */ export const load = async (uuid: UUID.Format, context: AudioContext): Promise<[AudioData, Peaks, SampleMetaData]> => { const path = `${Folder}/${UUID.toString(uuid)}` return Promise.all([ @@ -47,11 +61,13 @@ export namespace SampleStorage { }, peaks, meta]) } + /** Delete a sample and all related files. */ export const remove = async (uuid: UUID.Format): Promise => { const path = `${Folder}/${UUID.toString(uuid)}` return WorkerAgents.Opfs.delete(`${path}`) } + /** List metadata for all stored samples. */ export const list = async (): Promise> => { return WorkerAgents.Opfs.list(Folder) .then(files => Promise.all(files.filter(file => file.kind === "directory") @@ -60,4 +76,4 @@ export namespace SampleStorage { return ({uuid: name, ...(JSON.parse(new TextDecoder().decode(array)) as SampleMetaData)}) })), () => []) } -} \ No newline at end of file +}