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
4 changes: 4 additions & 0 deletions packages/app/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ For a guided overview of the interface, see the [UI tour](../../docs/docs-user/u
Guidance on saving and importing projects lives in the [file management guide](../../docs/docs-user/features/file-management.md). The [notepad feature](../../docs/docs-user/features/notepad.md) lets you store project notes using Markdown.
Developer details about project storage and sessions can be found in the [projects documentation](../../docs/docs-dev/projects/overview.md).

Project change history is captured in a SyncLog. Learn how to use it in the
[history workflow](../../docs/docs-user/workflows/history.md) and read about the
underlying [sync log architecture](../../docs/docs-dev/architecture/sync-log.md).

The capture subsystem for recording audio and MIDI is described in the
[capture architecture docs](../../docs/docs-dev/architecture/capture/overview.md).
Users can follow the [recording workflow guide](../../docs/docs-user/workflows/recording.md)
Expand Down
14 changes: 14 additions & 0 deletions packages/app/studio/src/project/ProjectSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export class ProjectSession {
#saved: boolean
#hasChanges: boolean = false

/**
* Create a new session wrapper.
*
* @param service - Owning studio service.
* @param uuid - Unique project identifier.
* @param project - Loaded project instance.
* @param meta - Associated metadata.
* @param cover - Optional cover image bytes.
* @param hasBeenSaved - Flag indicating persistence on disk.
*/
constructor(service: StudioService,
uuid: UUID.Format,
project: Project,
Expand All @@ -45,9 +55,13 @@ export class ProjectSession {
this.#metaUpdated = new Notifier<ProjectMeta>()
}

/** Unique identifier of the project on disk. */
get uuid(): UUID.Format {return this.#uuid}
/** Active project instance. */
get project(): Project {return this.#project}
/** Mutable project metadata. */
get meta(): ProjectMeta {return this.#meta}
/** Optional cover image for display in browsers. */
get cover(): Option<ArrayBuffer> {return this.#cover}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/app/studio/src/project/Projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ import {Project, SampleStorage, MainThreadSampleLoader, WorkerAgents} from "@ope
export namespace ProjectPaths {
/** Base folder for all projects. */
export const Folder = "projects/v1"
/** File name containing the serialized project graph. */
export const ProjectFile = "project.od"
/** File name storing project metadata. */
export const ProjectMetaFile = "meta.json"
/** File name for the optional project cover image. */
export const ProjectCoverFile = "image.bin"
/** Path to the serialized project file. */
export const projectFile = (uuid: UUID.Format): string => `${(projectFolder(uuid))}/${ProjectFile}`
Expand Down Expand Up @@ -59,6 +62,8 @@ export namespace Projects {

/**
* Load a project's cover image from storage.
*
* @param uuid - Identifier of the project to fetch the cover for.
*/
export const loadCover = async (uuid: UUID.Format): Promise<Option<ArrayBuffer>> => {
return WorkerAgents.Opfs.read(ProjectPaths.projectCover(uuid))
Expand All @@ -67,6 +72,9 @@ export namespace Projects {

/**
* Load and decode a project from disk.
*
* @param service - Studio service for environment configuration.
* @param uuid - Identifier of the project to load.
*/
export const loadProject = async (service: StudioService, uuid: UUID.Format): Promise<Project> => {
return WorkerAgents.Opfs.read(ProjectPaths.projectFile(uuid))
Expand Down Expand Up @@ -111,6 +119,8 @@ export namespace Projects {

/**
* Delete a project and all associated files.
*
* @param uuid - Identifier of the project to remove.
*/
export const deleteProject = async (uuid: UUID.Format) => WorkerAgents.Opfs.delete(ProjectPaths.projectFolder(uuid))

Expand Down
3 changes: 3 additions & 0 deletions packages/app/studio/src/service/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess

/**
* Start a new session using an existing project instance.
*
* @param project - Project to wrap in a session.
* @param name - Display name used for metadata.
*/
fromProject(project: Project, name: string): void {
this.#setSession(this.#service, UUID.generate(), project, ProjectMeta.init(name), Option.None)
Expand Down
8 changes: 7 additions & 1 deletion packages/app/studio/src/service/SyncLogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export namespace SyncLogService {
*
* @param handle - Destination file handle.
* @param callback - Invoked after each commit has been queued.
* @returns Observer function passed to {@link SyncLogWriter.attach}.
*/
const wrapBlockWriter = (handle: FileSystemFileHandle, callback: Exec) => {
let blocks: Array<Commit> = []
Expand All @@ -101,7 +102,12 @@ export namespace SyncLogService {
}
}

/** Concatenates array buffers into a single buffer. */
/**
* Concatenates multiple {@link ArrayBuffer} instances into one.
*
* @param buffers - Buffers in the order they should appear.
* @returns Combined buffer containing all input data.
*/
const appendArrayBuffers = (buffers: ReadonlyArray<ArrayBuffer>): ArrayBuffer => {
const totalLength = buffers.reduce((sum, buffer) => sum + buffer.byteLength, 0)
const result = new Uint8Array(totalLength)
Expand Down
22 changes: 22 additions & 0 deletions packages/docs/docs-dev/architecture/sync-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Sync Log

The sync log records immutable commits that describe how a project changes over time.
Each commit stores a hash of the previous commit, allowing logs to be validated when
replayed. Writers observe the project graph and append commits while readers rebuild
a project by streaming the log sequentially.

## Format

1. **Init** – Serialized project at the time the log was created.
2. **Open** – Marker written whenever the project is opened.
3. **Updates** – Batches of project mutations.

Every commit includes a timestamp and cryptographic hash to ensure order and integrity.

## Reading and Writing

`SyncLogWriter` attaches to a `Project` and emits commits whenever transactions
occur. Logs can later be rehydrated with `SyncLogReader.unwrap`, which applies all
recorded updates in sequence.

For integration details see the implementation in the Studio services.
5 changes: 5 additions & 0 deletions packages/docs/docs-user/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@

- Update the browser to the latest version or try a different one.
- If the message persists, report it to support with your browser details.

### How do I review past changes?

- Consult the [project history guide](workflows/history.md) for working with
SyncLog files.
21 changes: 21 additions & 0 deletions packages/docs/docs-user/workflows/history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Project History

openDAW can record a stream of changes made to a project. The log is stored in an
`.odsl` file and can be appended to or replayed later.

## Starting a Log

Use the **Start SyncLog** command to choose a destination file. From that point on,
all edits are written as commits.

## Appending to Existing Logs

Select **Append SyncLog** and pick an existing `.odsl` file. The project is restored
from the log and new commits are added to the end.

## Replaying

Logs can be opened directly through the append workflow. The reader rebuilds the
project by applying each commit sequentially.

For implementation details see the [sync log architecture](../../docs-dev/architecture/sync-log.md).
1 change: 1 addition & 0 deletions packages/docs/sidebarsDev.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ module.exports = {
"architecture/headless-vs-studio",
"architecture/persistence",
"architecture/opfs-samples",
"architecture/sync-log",
{
type: "category",
label: "Capture",
Expand Down
18 changes: 18 additions & 0 deletions packages/docs/sidebarsUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,23 @@ module.exports = {
"features/search",
],
},
{
type: "category",
label: "Workflows",
items: [
"workflows/automation-modulation",
"workflows/beat",
"workflows/collaboration",
"workflows/creating-projects",
"workflows/dawproject",
"workflows/exporting",
"workflows/exporting-and-sharing",
"workflows/headless-mode",
"workflows/history",
"workflows/mixing",
"workflows/record-and-fx",
"workflows/sample-management",
],
},
],
};
7 changes: 7 additions & 0 deletions packages/studio/core/src/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,15 @@ export class Project
);
}

/** Register a terminable to be disposed with the project. */
own<T extends Terminable>(terminable: T): T {
return this.#terminator.own<T>(terminable);
}
/** Register multiple terminables at once. */
ownAll<T extends Terminable>(...terminables: Array<T>): void {
return this.#terminator.ownAll<T>(...terminables);
}
/** Create a child terminator bound to this project. */
spawn(): Terminator {
return this.#terminator.spawn();
}
Expand All @@ -201,15 +204,19 @@ export class Project
get sampleManager(): SampleManager {
return this.#env.sampleManager;
}
/** Clip sequencing interface; only valid in audio contexts. */
get clipSequencing(): ClipSequencing {
return panic("Only available in audio context");
}
/** Whether this project runs inside an audio worklet. */
get isAudioContext(): boolean {
return false;
}
/** True when executing on the main browser thread. */
get isMainThread(): boolean {
return true;
}
/** Broadcaster for live-streaming audio data; audio context only. */
get liveStreamBroadcaster(): LiveStreamBroadcaster {
return panic("Only available in audio context");
}
Expand Down
36 changes: 36 additions & 0 deletions packages/studio/core/src/sync-log/Commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,61 @@ import {assert, ByteArrayInput, ByteArrayOutput, Hash} from "@opendaw/lib-std"
import {Update} from "@opendaw/lib-box"
import {Project} from "../Project"

/**
* Types of commits written to the project sync log.
*
* - {@link Init}: Initial project state.
* - {@link Open}: Session open event without updates.
* - {@link Updates}: One or more project mutations.
* - {@link NewVersion}: Placeholder for future versioning.
*/
export const enum CommitType { Init, Open, Updates, NewVersion }

/**
* Immutable block of project history written to a sync log.
*
* A commit stores the previous block hash, the current block hash and an
* optional payload such as serialized project updates. Commits are chained
* via hashes and can therefore be validated when replayed.
*/
export class Commit {
/** Current serialization format version. */
static readonly VERSION = 1 // For devs: walk your way to dynamic versioning from here

static readonly #NO_PAYLOAD = new Uint8Array(1).buffer
static readonly #EMPTY_HASH = new Uint8Array(32).buffer

/**
* Create the first commit containing the serialized project state.
*/
static createFirst(project: Project): Promise<Commit> {
const payload = project.toArrayBuffer() as ArrayBuffer
return this.#create(CommitType.Init, Commit.#EMPTY_HASH, payload)
}

/**
* Create a commit signalling a project being opened.
*/
static createOpen(prevHash: ArrayBuffer): Promise<Commit> {
return this.#create(CommitType.Open, prevHash, Commit.#NO_PAYLOAD)
}

/**
* Create a commit containing a list of project updates.
*
* @param prevHash - Hash of the previous commit in the chain.
* @param updates - Updates to serialize into the payload.
*/
static async createUpdate(prevHash: ArrayBuffer, updates: ReadonlyArray<Update>): Promise<Commit> {
const output = ByteArrayOutput.create()
output.writeInt(updates.length)
updates.forEach(update => update.write(output))
return this.#create(CommitType.Updates, prevHash, output.toArrayBuffer() as ArrayBuffer)
}

/**
* Internal factory creating a fully populated commit.
*/
static async #create(type: CommitType, prevHash: ArrayBuffer, payload: ArrayBuffer): Promise<Commit> {
const date = Date.now()
const output = ByteArrayOutput.create()
Expand All @@ -34,6 +65,9 @@ export class Commit {
return new Commit(type, prevHash, thisHash, payload, date)
}

/**
* Read a commit from the given {@link ByteArrayInput}.
*/
static deserialize(input: ByteArrayInput): Commit {
const type = input.readInt() as CommitType
assert(type === CommitType.Init
Expand All @@ -59,6 +93,7 @@ export class Commit {
readonly payload: ArrayBuffer,
readonly date: number) {}

/** Serialize the commit into an {@link ArrayBuffer}. */
serialize(): ArrayBuffer {
const output = ByteArrayOutput.create()
output.writeInt(this.type)
Expand All @@ -71,6 +106,7 @@ export class Commit {
return output.toArrayBuffer() as ArrayBuffer
}

/** Human readable representation for debugging. */
toString(): string {
return `{prevHash: ${Hash.toString(this.prevHash)}, thisHash: ${Hash.toString(this.thisHash)}, payload: ${this.payload.byteLength}bytes}`
}
Expand Down
10 changes: 10 additions & 0 deletions packages/studio/core/src/sync-log/SyncLogReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ import {Project} from "../Project"
import {Commit, CommitType} from "./Commit"
import {ProjectEnv} from "../ProjectEnv"

/**
* Reconstructs a project from a serialized sync log.
*/
export class SyncLogReader {
/**
* Rehydrate a project and final commit from the given log buffer.
*
* @param env - Project environment required to instantiate boxes.
* @param buffer - Concatenated sync log data.
* @returns The loaded project, the last commit and number of commits read.
*/
static async unwrap(env: ProjectEnv, buffer: ArrayBuffer): Promise<{
project: Project,
lastCommit: Commit,
Expand Down
13 changes: 13 additions & 0 deletions packages/studio/core/src/sync-log/SyncLogWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ import {BoxGraph, Update} from "@opendaw/lib-box"
import {Project} from "../Project"
import {Commit} from "./Commit"

/**
* Observes project changes and emits sync log commits.
*/
export class SyncLogWriter implements Terminable {
/**
* Attach a writer to the given project and emit an initial commit.
*
* @param project - Project to observe.
* @param observer - Callback receiving new commits.
* @param lastCommit - Optional last commit when appending to an existing log.
*/
static attach(project: Project, observer: Observer<Commit>, lastCommit?: Commit): SyncLogWriter {
console.debug("SyncLogWriter.attach", project.rootBox.created.getValue(), isDefined(lastCommit) ? "append" : "new")
return project.own(new SyncLogWriter(project, observer, lastCommit))
Expand All @@ -26,11 +36,13 @@ export class SyncLogWriter implements Terminable {
this.#subscription = this.#listen(project.boxGraph)
}

/** Stop observing the project. */
terminate(): void {
console.debug("SyncLogWriter.terminate")
this.#subscription.terminate()
}

/** Queue a commit factory to run after the previous commit has been written. */
#appendCommit(factory: Func<Commit, Promise<Commit>>): Promise<Commit> {
return this.#lastPromise = this.#lastPromise.then(async (previous) => {
const commit = await factory(previous)
Expand All @@ -39,6 +51,7 @@ export class SyncLogWriter implements Terminable {
})
}

/** Listen to box graph transactions and create update commits. */
#listen(boxGraph: BoxGraph): Subscription {
let updates: Array<Update> = []
return boxGraph.subscribeTransaction({
Expand Down