Product10/13/2026·5 min read

When WaveCraft's Timeline Became a Signal Graph

WaveCraft had tracks that looked real but were not signal paths. We made every edit, effect, pan and export share one graph, then made the project survive a restart.

The row that was pretending to be a track

WaveCraft had a timeline before it had a real multi-track engine. The screen could draw rows, clips could move, and a waveform could be sampled into the right rectangle. That was enough to make the editor look like a multitrack editor. It was not enough to make the rows mean anything to the audio engine.

The gap showed up as soon as a user asked for the operations that make a track a track: pan it, mount an effect, move it without changing the other tracks, preview the result, and export exactly what was heard. The UI had a row. The graph had a collection of clips. The exporter had its own traversal. Each was locally reasonable, and they did not share an invariant.

We wrote the invariant down in the shape of the system:

Every track is one signal path from source to mix.

The path has an order. It reads the source clips, applies the track's effect chain, applies the track's pan law, and reaches the mixer. Preview and export consume that same plan. The timeline is a projection of the path; it is not a second audio model.

Make the graph own the sound

The first implementation had a tempting shortcut: let the editor decide which effects are visible, then let the audio graph inspect the model again and build whatever it can. That makes the graph forgiving, and it makes the editor's promise meaningless. A row can show an effect that preview silently skipped, or export can include a node the live player did not know how to mount.

The graph builder now receives a track and produces one AudioGraphPlan. The plan contains the source, the ordered effect nodes, the pan unit and the connection to the mix bus. A track effect enters the plan only if isTrackMountable accepts it. That predicate is the admission rule for the path, and the editor uses the same rule when it presents the effect panel.

This is deliberately a little stricter than a list of supported effects. The question is not "does the product know this effect?" It is "can this effect be mounted on this track with the inputs and channel shape this track actually has?" The distinction matters for a stereo track, a mono recording, a preview graph and an export graph. A capability that exists in a catalog but cannot be carried by the path is not a capability.

The payoff is that the three consumers stop disagreeing:

  • the editor can refuse an effect before it creates an impossible command;
  • the preview player renders the graph the user is looking at;
  • the exporter serialises the same graph rather than rebuilding it from UI state.

The tests follow the same boundary. Track-effect tests assert admission and ordering. Audio graph tests assert node connections. Export tests assert that the rendered path contains the track effects. No single snapshot is asked to infer the entire system from pixels.

Panning is a model, not a knob

Pan looked like a small UI feature: add a slider, store a number, pass it to the player. That shape hides a decision about energy. A linear left/right split makes the centre quieter than the ends, so a track appears to dip whenever the user drags through the middle. WaveCraft uses an equal-power pan law instead.

The law lives in PanLaw, beside the timeline model, rather than in the view or the audio unit. The model owns the mapping from a normalised position to left and right gains. The audio unit applies the mapping. The editor displays the same position. Codable round-trip tests ensure that a saved track comes back with the same position and therefore the same gains.

That placement is what makes the behaviour durable. If the pan curve were a constant in the view, an export path could use a different curve and still compile. If it were hidden in the audio unit, the timeline would not know how to describe the value it saves. One model, one owner, three consumers.

Edits that survive the next operation

Once tracks became real paths, editing them exposed a second problem. A command that inserts a clip, deletes a region or moves a track changes more than one projection: it changes the timeline, the graph plan and the serialised project. The old commands were grouped by screen interaction, which made an operation look complete before the project had a durable representation of it.

WaveCraft's edit commands now describe the domain operation. Ripple delete moves later content to close the gap. Track commands move or duplicate a track while preserving its effects and pan. Track-effect commands mount, replace or remove a node through the same admission rule the graph uses. Each command has an inverse, and the undo stack owns the sequence rather than the page.

That last clause matters on mobile. If undo belongs to the editor view controller, leaving the page destroys the history. Project persistence moved the state into a package with a writer, reader and installer. The package records the project model and the edit history needed to reopen it. A relaunch is no longer a hidden reset of what the user just did.

The package boundary also gives failures a place to land. A corrupt project can fail to install with a named reason. A missing asset does not become an empty clip. The writer can commit a complete package before the UI says the project is safe to close. The editor can then treat the package as its durable state instead of relying on a view's current memory.

Inputs and tools join the same contract

The same boundary shaped two later features.

The share extension does not try to edit audio inside the extension process. It receives an external audio item, preserves its source name, and places it in the app group's staging area. ExternalAudioIngest becomes the one owner that turns that staged item into a project asset. The extension hands off; the app imports through the same model and persistence path as an in-app file.

The AI tools page also stopped being a collection of buttons that mutate the editor directly. Each tool creates a job, the job reports progress and a result, and the editor applies the result through an edit command. A vocal separation or mastering pass is therefore another durable operation on the project, with a place to retry and a result the user can undo.

This is slower to build than wiring a button to a callback. It is also the difference between a feature that works in a demo and one that remains intelligible after a share handoff, a background job, a relaunch or an export.

What the graph changed

The visible result is a better editor: tracks can be moved, panned, processed and exported without the screen and the sound disagreeing. The deeper result is that WaveCraft has one owner for the meaning of a track. The timeline, preview, export, share import and AI tools all meet at that owner.

When you open WaveCraft and drag a clip, you are manipulating a model that can be saved, replayed and rendered. The waveform is the view. The signal graph is the product's promise.

Takeaways

Give a visible editing object one domain meaning. A timeline row that is not also a signal path will eventually produce different answers in preview and export.

Use one admission rule for every consumer. isTrackMountable is valuable because the editor, graph builder, preview player and exporter all ask the same question before they accept an effect.

Keep sound decisions in the model. An equal-power pan law belongs beside the value that is saved, with tests that prove its centre, endpoints and round-trip behaviour.

Make persistence part of the feature contract. A share extension, a background AI job and an undo action all become ordinary project edits when they enter through one package and one command owner.

Products in this post

Keep reading