diff --git a/packages/app/studio/README.md b/packages/app/studio/README.md index c5abc1fb7..390c7a359 100644 --- a/packages/app/studio/README.md +++ b/packages/app/studio/README.md @@ -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) diff --git a/packages/app/studio/src/project/ProjectSession.ts b/packages/app/studio/src/project/ProjectSession.ts index 846a8334f..81ee5099e 100644 --- a/packages/app/studio/src/project/ProjectSession.ts +++ b/packages/app/studio/src/project/ProjectSession.ts @@ -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, @@ -45,9 +55,13 @@ export class ProjectSession { this.#metaUpdated = new Notifier() } + /** 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 {return this.#cover} /** diff --git a/packages/app/studio/src/project/Projects.ts b/packages/app/studio/src/project/Projects.ts index 9eef45da9..7d82cbf19 100644 --- a/packages/app/studio/src/project/Projects.ts +++ b/packages/app/studio/src/project/Projects.ts @@ -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}` @@ -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> => { return WorkerAgents.Opfs.read(ProjectPaths.projectCover(uuid)) @@ -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 => { return WorkerAgents.Opfs.read(ProjectPaths.projectFile(uuid)) @@ -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)) diff --git a/packages/app/studio/src/service/SessionService.ts b/packages/app/studio/src/service/SessionService.ts index 2d52932d5..c8ebd9e84 100644 --- a/packages/app/studio/src/service/SessionService.ts +++ b/packages/app/studio/src/service/SessionService.ts @@ -213,6 +213,9 @@ export class SessionService implements MutableObservableValue { let blocks: Array = [] @@ -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 => { const totalLength = buffers.reduce((sum, buffer) => sum + buffer.byteLength, 0) const result = new Uint8Array(totalLength) diff --git a/packages/docs/docs-dev/architecture/sync-log.md b/packages/docs/docs-dev/architecture/sync-log.md new file mode 100644 index 000000000..1e7444e6b --- /dev/null +++ b/packages/docs/docs-dev/architecture/sync-log.md @@ -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. diff --git a/packages/docs/docs-user/troubleshooting.md b/packages/docs/docs-user/troubleshooting.md index 2896d9fdd..c8392074c 100644 --- a/packages/docs/docs-user/troubleshooting.md +++ b/packages/docs/docs-user/troubleshooting.md @@ -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. diff --git a/packages/docs/docs-user/workflows/history.md b/packages/docs/docs-user/workflows/history.md new file mode 100644 index 000000000..334e2ea3b --- /dev/null +++ b/packages/docs/docs-user/workflows/history.md @@ -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). diff --git a/packages/docs/sidebarsDev.js b/packages/docs/sidebarsDev.js index 98f08bad5..bff01e94a 100644 --- a/packages/docs/sidebarsDev.js +++ b/packages/docs/sidebarsDev.js @@ -59,6 +59,7 @@ module.exports = { "architecture/headless-vs-studio", "architecture/persistence", "architecture/opfs-samples", + "architecture/sync-log", { type: "category", label: "Capture", diff --git a/packages/docs/sidebarsUser.js b/packages/docs/sidebarsUser.js index a87b3d55f..d27a07064 100644 --- a/packages/docs/sidebarsUser.js +++ b/packages/docs/sidebarsUser.js @@ -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", + ], + }, ], }; diff --git a/packages/studio/core/src/Project.ts b/packages/studio/core/src/Project.ts index f415a9417..82794a7a0 100644 --- a/packages/studio/core/src/Project.ts +++ b/packages/studio/core/src/Project.ts @@ -171,12 +171,15 @@ export class Project ); } + /** Register a terminable to be disposed with the project. */ own(terminable: T): T { return this.#terminator.own(terminable); } + /** Register multiple terminables at once. */ ownAll(...terminables: Array): void { return this.#terminator.ownAll(...terminables); } + /** Create a child terminator bound to this project. */ spawn(): Terminator { return this.#terminator.spawn(); } @@ -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"); } diff --git a/packages/studio/core/src/sync-log/Commit.ts b/packages/studio/core/src/sync-log/Commit.ts index 78c55281b..50a3fc420 100644 --- a/packages/studio/core/src/sync-log/Commit.ts +++ b/packages/studio/core/src/sync-log/Commit.ts @@ -2,23 +2,51 @@ 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 { 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 { 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): Promise { const output = ByteArrayOutput.create() output.writeInt(updates.length) @@ -26,6 +54,9 @@ export class Commit { 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 { const date = Date.now() const output = ByteArrayOutput.create() @@ -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 @@ -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) @@ -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}` } diff --git a/packages/studio/core/src/sync-log/SyncLogReader.ts b/packages/studio/core/src/sync-log/SyncLogReader.ts index 721a291b3..cc853fb86 100644 --- a/packages/studio/core/src/sync-log/SyncLogReader.ts +++ b/packages/studio/core/src/sync-log/SyncLogReader.ts @@ -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, diff --git a/packages/studio/core/src/sync-log/SyncLogWriter.ts b/packages/studio/core/src/sync-log/SyncLogWriter.ts index be9bc01bf..8fe0d0a14 100644 --- a/packages/studio/core/src/sync-log/SyncLogWriter.ts +++ b/packages/studio/core/src/sync-log/SyncLogWriter.ts @@ -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, lastCommit?: Commit): SyncLogWriter { console.debug("SyncLogWriter.attach", project.rootBox.created.getValue(), isDefined(lastCommit) ? "append" : "new") return project.own(new SyncLogWriter(project, observer, lastCommit)) @@ -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>): Promise { return this.#lastPromise = this.#lastPromise.then(async (previous) => { const commit = await factory(previous) @@ -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 = [] return boxGraph.subscribeTransaction({