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
5 changes: 5 additions & 0 deletions packages/app/studio/src/service/SampleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export namespace SampleApi {

/**
* Fetch metadata for all available samples.
*
* @returns array of sample metadata entries
*/
export const all = async (): Promise<ReadonlyArray<Sample>> => {
return await Promises.retry(() =>
Expand All @@ -63,6 +65,7 @@ export namespace SampleApi {
* Retrieve metadata for a single sample.
*
* @param uuid identifier of the sample to fetch.
* @returns metadata for the requested sample
*/
export const get = async (uuid: UUID.Format): Promise<Sample> => {
const url = `${ApiRoot}/get.php?uuid=${UUID.toString(uuid)}`;
Expand All @@ -86,6 +89,7 @@ export namespace SampleApi {
* @param context audio context used for decoding.
* @param uuid sample identifier.
* @param progress callback receiving loading progress between 0 and 1.
* @returns decoded audio data and metadata
*/
export const load = async (
context: AudioContext,
Expand Down Expand Up @@ -150,6 +154,7 @@ export namespace SampleApi {
*
* @param arrayBuffer raw WAV data.
* @param metaData description of the sample to accompany the upload.
* @returns void
*/
export const upload = async (
arrayBuffer: ArrayBuffer,
Expand Down
12 changes: 11 additions & 1 deletion packages/app/studio/src/service/SamplePlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export class SamplePlayback {
/**
* Toggle playback of a given sample. If it is currently playing it will
* stop, otherwise it will buffer and start playback.
*
* @param uuidAsString identifier of the sample to play
* @returns void
*/
toggle(uuidAsString: string): void {
if (this.#current.contains(uuidAsString)) {
Expand Down Expand Up @@ -104,7 +107,10 @@ export class SamplePlayback {
}
}

/** Stop playback and reset the internal state. */
/** Stop playback and reset the internal state.
*
* @returns void
*/
eject(): void {
this.#current.ifSome((uuid) => this.#notify(uuid, { type: "idle" }));
this.#current = Option.None;
Expand All @@ -114,6 +120,10 @@ export class SamplePlayback {

/**
* Subscribe to playback events for a particular sample.
*
* @param uuidAsString identifier of the sample to observe
* @param procedure callback invoked with playback events
* @returns subscription that can be terminated to stop updates
*/
subscribe(
uuidAsString: string,
Expand Down
2 changes: 2 additions & 0 deletions packages/app/studio/src/service/SampleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* Re-exports {@link SampleService} so consumers can import from the service
* layer without referencing UI internals.
*
* @packageDocumentation
*/
export { SampleService } from "../ui/browse/SampleService";

1 change: 1 addition & 0 deletions packages/app/studio/src/ui/browse/SampleBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const location = new DefaultObservableValue(SampleLocation.Cloud);
*
* @param lifecycle lifecycle controlling subscriptions
* @param service access to studio level services
* @returns rendered element for inclusion in the DOM
*/
export const SampleBrowser = ({ lifecycle, service }: Construct) => {
lifecycle.own({ terminate: () => service.samplePlayback.eject() });
Expand Down
4 changes: 2 additions & 2 deletions packages/app/studio/src/ui/browse/SampleDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export namespace SampleDialogs {
/**
* Open the browser's file picker for selecting sample files.
*
* @param multiple Allow selection of multiple files.
* @returns Result of the picker invocation.
* @param multiple allow selection of multiple files
* @returns promise resolving with the picker result
*/
export const nativeFileBrowser = async (multiple: boolean = true) =>
Promises.tryCatch(
Expand Down
2 changes: 2 additions & 0 deletions packages/app/studio/src/ui/browse/SampleLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* Used by {@link SampleBrowser} and {@link SampleService} to toggle between
* cloud and OPFS backed libraries.
*
* @enum
*/
export const enum SampleLocation {
/** Sample hosted on the server */
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/docs-dev/architecture/sample-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ The [`SampleStorage` namespace](../../../packages/studio/core/src/samples/Sample
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.

The `MainThreadSampleManager` uses a `SampleProvider` (such as `SampleApi`) to fetch audio when it cannot be found locally. Once retrieved, `MainThreadSampleLoader` persists the data back to OPFS via `SampleStorage` so subsequent requests are served from disk rather than the network.
1 change: 1 addition & 0 deletions packages/docs/docs-user/features/file-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ offline. Developers can dive deeper in the
- Search, preview and delete samples directly from the list.
- Adjust preview volume with the slider in the browser footer.
- Local samples are cached in OPFS and survive page reloads.
Once downloaded they can be reused offline until removed.

### Manage Local Storage

Expand Down
6 changes: 6 additions & 0 deletions packages/studio/core/src/samples/MainThreadSampleLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export class MainThreadSampleLoader implements SampleLoader {

/**
* Drop any cached data and restart the loading process.
*
* @returns void
*/
invalidate(): void {
this.#state = {type: "progress", progress: 0.0}
Expand All @@ -67,6 +69,7 @@ export class MainThreadSampleLoader implements SampleLoader {
* Subscribe to state changes.
*
* @param observer callback receiving loader state updates
* @returns subscription handle that can be terminated
*/
subscribe(observer: Observer<SampleLoaderState>): Subscription {
if (this.#state.type === "loaded") {
Expand All @@ -92,6 +95,9 @@ export class MainThreadSampleLoader implements SampleLoader {
*
* If loading has not yet completed the promise resolves once data becomes
* available.
*
* @param zip archive instance to receive the files
* @returns resolves once the files have been added
*/
async pipeFilesInto(zip: JSZip): Promise<void> {
const exec: Exec = async () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/studio/core/src/samples/MainThreadSampleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,36 @@ export class MainThreadSampleManager implements SampleManager, SampleProvider {

/**
* Fetch sample data from the backing provider.
*
* @param uuid identifier of the requested sample
* @param progress receives loading progress between 0 and 1
* @returns audio data and metadata for the sample
*/
fetch(uuid: UUID.Format, progress: Progress.Handler): Promise<[AudioData, SampleMetaData]> {
return this.#api.fetch(uuid, progress)
}

/**
* Invalidate the loader for a given sample.
*
* @param uuid sample to invalidate
* @returns void
*/
invalidate(uuid: UUID.Format) {this.#loaders.opt(uuid).ifSome(loader => loader.invalidate())}

/**
* Register a loader with the manager so it can be invalidated later.
*
* @param loader loader instance to track
* @returns void
*/
record(loader: SampleLoader): void {this.#loaders.add(loader)}

/**
* Retrieve an existing loader or create a new one.
*
* @param uuid identifier of the desired sample
* @returns corresponding loader instance
*/
getOrCreate(uuid: UUID.Format): SampleLoader {
return this.#loaders.getOrCreate(uuid, uuid => new MainThreadSampleLoader(this, uuid))
Expand Down
1 change: 1 addition & 0 deletions packages/studio/core/src/samples/SampleProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface SampleProvider {
*
* @param uuid identifier of the requested sample
* @param progress callback receiving download progress between 0 and 1
* @returns audio data and metadata for the sample
*/
fetch(uuid: UUID.Format, progress: Progress.Handler): Promise<[AudioData, SampleMetaData]>
}
25 changes: 23 additions & 2 deletions packages/studio/core/src/samples/SampleStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export namespace SampleStorage {

/**
* Write decoded audio, peaks and metadata to OPFS.
*
* @param uuid identifier of the sample to store
* @param audio decoded audio frames
* @param peaks precomputed peak data
* @param meta additional sample metadata
* @returns resolves when the files have been written
*/
export const store = async (uuid: UUID.Format,
audio: AudioData,
Expand All @@ -35,6 +41,10 @@ export namespace SampleStorage {

/**
* Overwrite only the metadata file of a stored sample.
*
* @param uuid identifier of the sample to update
* @param meta new metadata to persist
* @returns resolves once the metadata file was written
*/
export const updateMeta = async (uuid: UUID.Format, meta: SampleMetaData): Promise<void> => {
const path = `${Folder}/${UUID.toString(uuid)}`
Expand All @@ -43,6 +53,10 @@ export namespace SampleStorage {

/**
* Load a sample from OPFS and decode it into {@link AudioData} and peaks.
*
* @param uuid identifier of the sample to load
* @param context audio context used for decoding
* @returns audio data, peak information and metadata
*/
export const load = async (uuid: UUID.Format, context: AudioContext): Promise<[AudioData, Peaks, SampleMetaData]> => {
const path = `${Folder}/${UUID.toString(uuid)}`
Expand All @@ -61,13 +75,20 @@ export namespace SampleStorage {
}, peaks, meta])
}

/** Delete a sample and all related files. */
/** Delete a sample and all related files.
*
* @param uuid identifier of the sample to delete
* @returns resolves when the files have been removed
*/
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. */
/** List metadata for all stored samples.
*
* @returns metadata for each sample stored in OPFS
*/
export const list = async (): Promise<ReadonlyArray<Sample>> => {
return WorkerAgents.Opfs.list(Folder)
.then(files => Promise.all(files.filter(file => file.kind === "directory")
Expand Down