Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/studio/src/service/SampleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions packages/app/studio/src/service/SamplePlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -149,6 +152,7 @@ export class SamplePlayback {
});
}

/** Remove previously attached audio event listeners. */
#unwatchAudio(): void {
this.#audio.onended = null;
this.#audio.onplay = null;
Expand Down
8 changes: 8 additions & 0 deletions packages/app/studio/src/service/SampleService.ts
Original file line number Diff line number Diff line change
@@ -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";

5 changes: 4 additions & 1 deletion packages/app/studio/src/ui/browse/SampleBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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() });
Expand Down
1 change: 1 addition & 0 deletions packages/app/studio/src/ui/browse/SampleDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/app/studio/src/ui/browse/SampleLocation.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down
6 changes: 5 additions & 1 deletion packages/app/studio/src/ui/browse/SampleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Sample> {
const selected = this.#selection.getSelected();
return selected.map(
Expand Down
27 changes: 27 additions & 0 deletions packages/docs/docs-dev/architecture/sample-storage.md
Original file line number Diff line number Diff line change
@@ -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/
└── <uuid>/
├── 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.
11 changes: 10 additions & 1 deletion packages/docs/docs-user/features/file-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl</kbd>+<kbd>S</kbd> or choose
Expand Down Expand Up @@ -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).
34 changes: 33 additions & 1 deletion packages/studio/core/src/samples/MainThreadSampleLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<SampleLoaderState>): Subscription {
if (this.#state.type === "loaded") {
observer(this.#state)
Expand All @@ -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<AudioData> {return this.#data}
/** Metadata describing the sample. */
get meta(): Option<SampleMetaData> {return this.#meta}
/** Peak information used for waveform rendering. */
get peaks(): Option<Peaks> {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<void> {
const exec: Exec = async () => {
const path = `${SampleStorage.Folder}/${UUID.toString(this.#uuid)}`
Expand Down Expand Up @@ -149,4 +181,4 @@ export class MainThreadSampleLoader implements SampleLoader {
this.#setState({type: "error", reason: "N/A"})
}
}
}
}
26 changes: 25 additions & 1 deletion packages/studio/core/src/samples/MainThreadSampleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID.Format, SampleLoader>

/**
* 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
Expand All @@ -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))
}
}
}
11 changes: 10 additions & 1 deletion packages/studio/core/src/samples/SampleProvider.ts
Original file line number Diff line number Diff line change
@@ -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]>
}
}
18 changes: 17 additions & 1 deletion packages/studio/core/src/samples/SampleStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<void> => {
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([
Expand 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<void> => {
const path = `${Folder}/${UUID.toString(uuid)}`
return WorkerAgents.Opfs.delete(`${path}`)
}

/** List metadata for all stored samples. */
export const list = async (): Promise<ReadonlyArray<Sample>> => {
return WorkerAgents.Opfs.list(Folder)
.then(files => Promise.all(files.filter(file => file.kind === "directory")
Expand All @@ -60,4 +76,4 @@ export namespace SampleStorage {
return ({uuid: name, ...(JSON.parse(new TextDecoder().decode(array)) as SampleMetaData)})
})), () => [])
}
}
}