1
0
Fork 0
forked from hanez/xtc

Phase 8: Add stereo track mixer, automation and undo/redo

This commit is contained in:
Johannes Findeisen 2026-10-27 01:06:57 +01:00
commit 7db5fb7738
45 changed files with 1611 additions and 130 deletions

View file

@ -13,7 +13,7 @@ CXXFLAGS ?= -O2 -g
CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude
LDFLAGS ?=
CORE_SOURCES := src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
CORE_SOURCES := src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes)
ifeq ($(JACK_AVAILABLE),yes)
JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack)
@ -80,8 +80,12 @@ $(BUILD_DIR)/test_audio: $(BUILD_DIR)/tests/unit/test_audio.o $(BUILD_DIR)/libxt
$(BUILD_DIR)/test_midi: $(BUILD_DIR)/tests/unit/test_midi.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm
test: $(BUILD_DIR)/test_midi $(BUILD_DIR)/test_audio $(BUILD_DIR)/test_project $(BUILD_DIR)/test_graph_edits $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check
$(BUILD_DIR)/test_mixer: $(BUILD_DIR)/tests/unit/test_mixer.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm
test: $(BUILD_DIR)/test_mixer $(BUILD_DIR)/test_midi $(BUILD_DIR)/test_audio $(BUILD_DIR)/test_project $(BUILD_DIR)/test_graph_edits $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check
$(if $(BACKEND_TEST),./$(BACKEND_TEST),@echo "JACK fault tests skipped: JACK development files unavailable")
./$(BUILD_DIR)/test_mixer
./$(BUILD_DIR)/test_midi
./$(BUILD_DIR)/test_audio
./$(BUILD_DIR)/test_project
@ -124,14 +128,14 @@ endif
ifeq ($(GUI_AVAILABLE),yes)
gui: $(BUILD_DIR)/xtc
$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/libxtc.a
$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/src/gui/mixer_widget.o $(BUILD_DIR)/libxtc.a
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm
test-gui: $(BUILD_DIR)/test_gui
xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui
ifeq ($(JACK_AVAILABLE),yes)
$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/libxtc.a
$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/src/gui/mixer_widget.o $(BUILD_DIR)/libxtc.a
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
test-gui-jack: $(BUILD_DIR)/test_gui_jack
@ -142,7 +146,7 @@ test-gui-jack:
@exit 1
endif
$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/libxtc.a
$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/src/gui/mixer_widget.o $(BUILD_DIR)/libxtc.a
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm
else
test-gui-jack test-gui:
@ -174,3 +178,7 @@ clean:
-include $(BUILD_DIR)/tests/unit/test_midi.d
-include $(BUILD_DIR)/tests/integration/test_jack_midi.d
-include $(BUILD_DIR)/src/gui/mixer_widget.d
-include $(BUILD_DIR)/tests/unit/test_mixer.d

View file

@ -17,7 +17,7 @@ This repository is a starter scaffold, not a production DAW. It currently provid
- a mono graph model with port validation, cycle rejection and topological ordering;
- compiled offline plans with preallocated buffers, sine oscillator, audio source, gain, meter and output nodes;
- C tests for deterministic rendering, routing, validation and lifecycle;
- an optional JACK-compatible mono backend with start/stop, routing and status;
- an optional JACK-compatible backend with mono input and mono/stereo output with start/stop, routing and status;
- safe control/audio plan handoff and concurrent replacement tests;
- an optional FLTK shell with engine controls, view switching and backend status;
- a modular graph editor with transactional edits and live plan adoption status;
@ -25,6 +25,8 @@ This repository is a starter scaffold, not a production DAW. It currently provid
- sample-counted transport, selection, move/trim, timeline zoom and scrolling;
- WAV import/graph playback, bounded recording and missing-asset relinking;
- MIDI clips, a sine instrument, JACK MIDI input and a basic piano roll;
- stereo track gain/pan/mute/solo, output meters and gain/pan automation lanes;
- bounded undo/redo with keyboard shortcuts;
- architecture, format, GUI, roadmap, and decision documents.
The JACK backend has passed software-loopback testing; physical-device playback
@ -65,7 +67,11 @@ select a missing clip and use File/Relink selected audio to repair it.
Add MIDI clip on an existing track, then open Piano roll (Ctrl+4) to draw notes.
Connect a MIDI source to the JACK `midi_in` port for live playing; Panic/F9
silences held notes. See [MIDI workflow and limits](docs/MIDI.md).
See [audio workflow and limits](docs/AUDIO_FILES.md). The mixer remains a placeholder.
See [audio workflow and limits](docs/AUDIO_FILES.md).
Mixer (Ctrl+2) selects a track for gain/pan/mute/solo and automation editing.
Ctrl+Z undoes; Ctrl+Shift+Z or Ctrl+Y redoes up to 32 edits.
The GUI now exposes left `output` and `output_right` JACK ports; route both
explicitly for stereo. See [mixer workflow and limits](docs/MIXER.md).
Use Add track/Add region, drag regions or their edges, and Zoom +/-; scrollbars pan.
Ctrl+N/O/S creates, opens or saves a project; Save As chooses another path.
Arrangement edits stop playback; graph edits remain live.

View file

@ -118,3 +118,15 @@ prepared MIDI runtime state exclusively while active, with lock-free panic
requests and no callback allocation/I/O. Stop/seek clear live voices; scheduled
notes chase their absolute phase. See MIDI.md for event ordering, panic semantics,
capacities and test coverage. No new dependency is required.
## Phase 8 mix snapshots and history
`src/mixer` owns validated per-track settings and bounded gain/pan automation.
Audio/MIDI preparation copies and indexes these into source snapshots; paired
source buffers feed the same graph independently per stereo side. Atomic backend
output-peak snapshots cross to the GUI without exposing DSP state. Mixer edits
stop transport before subsequent preparation. `src/project/history.c` provides
32-edit C project snapshots; checkout clones a candidate and accept moves the
cursor only after successful GUI/backend installation. No samples or FLTK state
are stored in history. `mixer_widget.cpp` is only controls/drawing/gestures.
See MIXER.md and PROJECT_FORMAT.md for ownership, limits and v4 migration.

View file

@ -42,8 +42,8 @@ without normalization. Failed additions leave IDs and graph contents unchanged.
thread. Empty graphs compile to silence. Sample rate must be finite and positive,
oscillators must be at or below Nyquist, and max_frames is 1..65536. Compilation
allocates and copies topology/parameters, builds a topological schedule and
input adjacency, and allocates one max_frames float buffer per node. The
maximum buffer storage is 64 MiB per plan. Compilation may fail with an
input adjacency, and allocates paired max_frames float buffers per node for stereo. The
maximum buffer storage is 128 MiB per plan (32 MiB for the GUI 64-node limit). Compilation may fail with an
allocation error; output plan is then NULL. Destroy a plan on the control thread.
`xtc_plan_process` uses only prepared state: no allocation/free, sorting,
@ -86,7 +86,7 @@ publication and JACK processing; offline tests alone do not qualify hardware.
Use `xtc_backend.h` on one serialized control thread:
1. Open a config with `XTC_BACKEND_JACK`, a client name, channel_count 1, and
1. Open a config with `XTC_BACKEND_JACK`, a client name, channel_count 1 (or 2 for stereo output), and
max_frames 1..65536. It must accommodate the server's current buffer size.
The actual rate and buffer size are returned through get_status. Open does
not start the server or acquire devices directly. Invalid config returns
@ -134,8 +134,7 @@ JACK notification callbacks were audited for prohibited operations; JACK's own
internal realtime guarantees depend on the installed implementation/server.
Validation commands and results are recorded in BUILD.md and the integration
test README. Current limitations: mono input/output, no parameter ramping or
crossfaded graph changes, no realtime meter bridge, no automatic reconnect,
test README. Current limitations: mono input, no crossfaded graph changes, no automatic reconnect,
no physical-device or long-duration endurance qualification. Phase 3 connects
the FLTK shell to backend controls and status; see GUI.md. Its editors remain
placeholders except the Phase 4 modular editor described below.
@ -250,3 +249,17 @@ prepared MIDI runtime state exclusively while active, with lock-free panic
requests and no callback allocation/I/O. Stop/seek clear live voices; scheduled
notes chase their absolute phase. See MIDI.md for event ordering, panic semantics,
capacities and test coverage. No new dependency is required.
## Phase 8 stereo mixing and automation
Track settings and automation are copied into immutable source-plan snapshots.
`xtc_audio_plan_render_stereo` and `xtc_midi_plan_render_stereo` pan track audio
and scheduled notes before summing into paired source buffers. Mono wrappers
retain gain/mute/solo/automation but ignore pan. `xtc_plan_process_stereo` and
`xtc_realtime_process_stereo` process both buses through the same graph topology
with one oscillator phase advance, preserving mono APIs and graph publication.
The backend preallocates paired buffers, registers an optional output_right port
and publishes independently atomic output peak snapshots. Callback work remains
bounded and allocation/I/O/lock free. Meter UI reads no active plan memory.
See MIXER.md for exact pan law, automation interpolation, event/source exceptions,
memory/history bounds and verification. Input/recording remain mono.

View file

@ -23,13 +23,15 @@ can briefly block the GUI. Candidate compilation retains the old plan until
success; temporary build memory can include both old and candidate buffers.
The callback renders only overlapping clip ranges from memory. Clips on all
tracks are summed at unity gain into the graph's **Audio clips** source node.
tracks are summed through their mix/automation settings into the graph's
**Audio clips** source node.
Import creates that source and connects it to an existing/new output if no
audio source exists. Subsequent routing is explicit: disconnect it or route it
through gain/meter nodes in Modular Graph. Removing the output silences editor
graphs. This source does not monitor JACK input; unconnected ordinary graph
inputs retain their earlier external-input behavior. Track faders/pan/mute
and individual track routing belong to the mixer phase.
are implemented by the Phase 8 track mixer (see MIXER.md); individual
track graph routing remains deferred.
Linear interpolation converts source sample rate to backend rate. There is no
antialiasing filter, time stretching, looping or fades. Musical durations round

View file

@ -175,3 +175,24 @@ A fresh `build/verify` build passed all targets above with no compiler warnings.
The no-JACK core/GUI suite and Clang ASan/UBSan core/GUI suite also passed; GUI
leak checking uses only the existing fontconfig suppression documented in the
integration README. Physical MIDI and audio devices remain unverified.
## Phase 8 verification
```sh
make all test gui debug test-gui test-gui-jack test-jack-audio test-jack-midi
make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui
```
`test_mixer` covers stereo gain/pan, mute/solo, sample automation, block
independence, WAV and graph routing, v4 persistence failures and bounded history.
GUI tests add automation mouse edits, mixer keys, history rollback/branching and
save/reopen. The existing live MIDI test now uses two software capture ports and
checks hard-right saved-note playback and output meter snapshots; mono audio
record/play acceptance remains covered. A native Xvfb mouse smoke check confirms
sliders and automation drawing. See MIXER.md for capacities and deferred features.
The fresh Phase 8 verification build, no-JACK core/GUI suite and Clang
ASan/UBSan core/GUI suite passed without compiler warnings. GUI leak checking
retains only the existing fontconfig suppression. ThreadSanitizer also passed
the recorder concurrency and realtime publication tests. Physical devices and
long-running stereo performance remain unverified.

View file

@ -31,7 +31,7 @@ by the GUI. The initially empty graph produces silence.
Use this after server loss or sample-rate changes. A failed close preserves
the handle and reports the failure instead of opening another client.
- **View menu / Ctrl+1, Ctrl+2, Ctrl+3:** switch between sequencer, mixer and
modular graph. Tabs provide the same view switching. The mixer remains a placeholder; graph and sequencer editors are described below.
modular graph. Tabs provide the same view switching. Mixer, graph and sequencer editors are described below.
- **File / Quit / Ctrl+Q** or the window close button: close the backend and
synchronize audio callbacks before hiding/destroying widgets. A failed close
leaves the window open for a retry. New/Open/Save actions are described below. About identifies the development milestone and version.
@ -154,7 +154,8 @@ can block the GUI while reading, but never the audio callback.
See AUDIO_FILES.md for supported WAV formats, memory limits, musical placement,
recording overflow/partial-take recovery, external asset portability and the
software-only acceptance tests. Waveform drawing, input monitoring, recording
latency compensation and per-track mixing are not implemented.
latency compensation and per-track graph inserts are not implemented.
Per-track gain/pan/mute/solo is available in Phase 8 below.
## Phase 7 piano roll
@ -166,3 +167,13 @@ content is retained. Edits stop transport and are blocked during audio recording
Panic/F9 silences held notes; Record continues to mean WAV audio capture.
Modular Graph offers Add instrument for explicit routing. Status includes MIDI
drops. See MIDI.md for voice limits, unsupported messages and live input routing.
## Phase 8 mixer and editing
Mixer (Ctrl+2) now has a selectable track strip, gain/pan controls, mute/solo,
Output L/R peaks and editable gain/pan automation lanes. Mixer edits stop
playback. Click the lane to add/replace points, right-click near a point to
remove, and change First tick to navigate. Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y undo
and redo up to 32 project edits; New/Open resets history. M/S and arrow keys
provide mixer shortcuts. See MIXER.md for exact controls, history rollback,
stereo port routing, automation semantics and prototype limits.

View file

@ -8,7 +8,8 @@ captures audio to WAV: MIDI capture and Standard MIDI File import are deferred.
Add/select a track in Sequencer, choose Add MIDI clip, then Piano roll (Ctrl+4).
Creation adds an instrument-to-output route if no instrument exists. Existing
instruments retain their routes; use Modular Graph to connect them as needed.
All MIDI clips and live input share one mono instrument mix. No hardware ports
Scheduled MIDI clips now apply their track mix/automation before summing to
paired stereo instrument buffers; live input stays centered and unassigned. No hardware ports
are connected automatically. Connect a MIDI source to the backend's `midi_in`
JACK port, and route audio output explicitly to hear it while Play is running.
@ -71,3 +72,5 @@ panic, disconnection and saved/reopened scheduled playback. JACK port discovery
may lag activation under PipeWire; the test waits before connecting.
Physical MIDI devices, bridge/device loss behind an unchanged port, latency and
long-running performance remain unverified. Panic/Stop provides manual recovery.
Phase 8 adds track mixing, automation and project undo/redo; see MIXER.md.

98
docs/MIXER.md Normal file
View file

@ -0,0 +1,98 @@
# Mixer, automation and undo
Phase 8 provides one selectable channel strip per project track. Open Mixer
(Ctrl+2), select a track, and set Gain, Pan, Mute and Solo. Changes stop transport;
Play prepares independent audio/MIDI snapshots with the accepted settings.
No edit changes a plan that the callback is using. Editing is blocked while
recording. Graph edits retain their existing live publication behavior.
Gain is a linear multiplier from 0 to 4. Pan ranges from -1 (left) to +1 (right):
center has unity on both sides and moving away attenuates the opposite side
linearly. This preserves existing centered mono levels; it is not an equal-power
pan law. Mute wins over Solo. If any track is soloed, only soloed, unmuted tracks
play. WAV clips and scheduled MIDI use their containing track's settings before
summing into the audio/instrument graph sources. Live MIDI input and independent
graph oscillators/external input are unassigned sources and bypass track mixing.
Stereo WAV files are still downmixed at import; stereo output comes from panning
those mono sources. Separate per-track graph inserts/sends are deferred.
## Automation lanes
Choose Gain or Pan under Automation. The lane shows four bars (15360 ticks);
First tick moves its viewport across the project. Grid labels are absolute ticks.
Click to add a point or replace the point at that snapped time (1/16 note, 240
ticks). Vertical position maps to gain 0..4 or pan -1..1, top to bottom descending.
Right-click near a point's time to remove it. Removing every point restores the
static control. There are at most 1024 points globally, with one per
track/parameter/tick. Track deletion cascades to its automation.
A nonempty lane replaces that parameter's static value. Between points the C
engine interpolates linearly at each sample, holding the first/last value outside
their range. Preparation converts ticks to integer sample endpoints, sorts and
indexes lanes. When several ticks quantize to one sample at very low sample
rates, the last point wins at that sample. Rendering searches prepared bounded
arrays; it never sorts, allocates, accesses project data, or calls the GUI.
Seeking and block partitioning give the same timeline values. Mute/solo are
static; automation capture, modes, curves and tempo automation are deferred.
## Stereo output and meters
The GUI opens the JACK backend with two audio output ports: `output` (left) and
`output_right`. Input and raw WAV recording remain mono. Connect both outputs
explicitly using an external JACK routing tool. Nothing connects hardware
automatically. Legacy configurations with channel_count=1 and the original
port/route APIs remain supported; output_name(channel) queries either stereo
port. Old mono audio/MIDI render functions apply gain/mute/solo and gain
automation while ignoring pan; the stereo variants apply both lanes.
The graph executes the same topology separately on left and right buses;
oscillators advance once per frame and are centered on both sides. Existing
port metadata and validation describe the mono graph topology. Stereo processing
uses explicit paired buffers, not new port IDs. Output buffers must be distinct;
stereo callers must not alias them with source buffers. Graph-node meter reads
report the maximum of both sides on the processing owner only.
Mixer Output L/R bars show last-block post-graph absolute peaks, polled at 100 ms.
Peaks above unity turn red. Independent lock-free integer snapshots avoid sharing
DSP pointers or float state between callback and GUI; stopped meters show zero.
They are output meters, without per-track RMS, peak hold or loudness analysis.
There is no limiter: finite summed output can exceed unity. GUI polling may miss
brief peaks between refreshes; these bars are not overload history.
## Undo and keyboard workflow
Ctrl+Z undoes; Ctrl+Shift+Z or Ctrl+Y redoes. The C history keeps 32 project edits
plus a baseline, cloning metadata and graph, never audio samples. Common graph,
track, clip, note, mixer, import/relink and automation edits are included. One
accepted gesture is one step; selection, scrolling and graph-node placement are
GUI state and are not undoable. New/Open resets history; Save retains it.
Undo/redo stops and resets transport and resets editor selections/layout. The
candidate is installed before moving the history cursor: validation, asset-plan
or backend failure retains the current document and history position. Missing
assets retain the existing silent/reporting behavior. A new edit after Undo
removes the redo branch; the oldest state is evicted at capacity. Allocation
ID high-water marks survive Undo/branching so abandoned IDs are not reused. History is
session-only and Undo marks the document dirty even if it matches a saved state.
External WAV files are never deleted or restored by Undo; recording itself cannot
be undone while active. An allocation failure retaining a new history snapshot
reports an error; the accepted edit remains in the document.
With mixer focus, M/S toggles mute/solo, Left/Right changes gain by 0.05,
Shift+Left/Right changes pan, and Up/Down selects a track. Native sliders also
support keyboard navigation; Ctrl+1/2/3 switches views, Ctrl+4 opens Piano roll,
F5/F8/Space controls transport and F9 panics, as before. There is no multiselection,
clipboard editing, track rename or automation-point dragging yet.
## Verification and limits
`test_mixer` verifies exact stereo gain/pan, mute/solo precedence, automation
samples and block equivalence, stereo graph phase/routing, WAV mixing, v4 exact
roundtrip/truncations/malformed records, lane capacities and history eviction and
branching. Backend fault injection verifies stereo registration failure, output
names, output separation and atomic meters. GUI tests edit lanes with mouse
events, use mixer keys and exercise undo/redo, failed installation rollback,
branching, save/reopen and history reset. Live JACK MIDI acceptance verifies
saved/reopened hard-right playback and stereo peaks using only software clients.
A native Xvfb mouse smoke check covers sliders and automation drawing.
Physical stereo devices, listening quality, realtime endurance and high-density
project performance remain unverified; no new dependency was introduced.

View file

@ -1,6 +1,6 @@
# Project format v3 (reads v1/v2)
# Project format v4 (reads v1/v2/v3)
Phases 5–7 use a bounded text format implemented in C99 without a parser library.
Phases 5–8 use a bounded text format implemented in C99 without a parser library.
The extension is `.xtc`. File operations run exclusively on the control thread.
The project owns its graph, tracks and silent/audio arrangement regions. Audio samples remain in external WAV
files; MIDI notes are stored inline.
@ -12,7 +12,7 @@ Fields use ASCII spaces. Integers are unsigned decimal, without signs.
Counts determine the number of subsequent records:
```text
XTC_PROJECT 3
XTC_PROJECT 4
APP 0 1 0
TEMPO 120000
IDS 1 1
@ -22,11 +22,13 @@ NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 547261636b
MIX 3f800000 00000000 0 0
CLIPS 1
CLIP 0 0 3840 2880 960 3840 526567696f6e
ASSET -
MIDI 0
NOTES 0 0
AUTOMATION 0
END
```
@ -40,30 +42,37 @@ END
- `NODE`: ID, type (1 gain, 2 oscillator, 3 meter, 4 output, 5 audio source, 6 instrument), and exactly eight
hexadecimal digits encoding the IEEE binary32 parameter bits, independent
of locale and host byte order. Nonfinite values are rejected.
- `EDGE`: source and destination node IDs. Mono port directions are implied
- `EDGE`: source and destination node IDs. Port directions are implied
by node type; endpoints, duplicates and cycles are validated by the graph API.
- `TRACK`: ID and hex-encoded name bytes.
- `TRACK`: ID and hex-encoded name bytes. In v4 each is immediately followed
by `MIX gain_bits pan_bits mute solo`: finite binary32 gain 0..4, pan -1..1
encoded as eight hex digits, mute and solo unsigned 0/1.
- `CLIP`: ID, track ID, start tick, length tick, source offset tick, source
length tick, hex-encoded name bytes. Trimming
preserves source bounds, so a region can re-extend only within its original
length. Overlaps are allowed and audio clips mix through the audio source node.
- `ASSET`: required after each CLIP in v2/v3; "-" means no audio asset, otherwise
- `ASSET`: required after each CLIP in v2/v3/v4; "-" means no audio asset, otherwise
hex-encoded absolute path bytes, <=511 bytes after decoding, no embedded NUL
or ASCII control characters. Each clip directly references a WAV; no sample
data, pointers or DSP state are embedded. Paths can contain spaces.
- `MIDI`: required after each ASSET in v3; 0 for silent/audio regions, 1 for
- `MIDI`: required after each ASSET in v3/v4; 0 for silent/audio regions, 1 for
MIDI clips. MIDI clips must have no asset.
- `NOTES`: next stable note ID and global note count, followed by that many
`NOTE id clip_id start_tick length_tick pitch velocity channel` records.
Notes reference MIDI clips; start/length are source-relative, positive length
within source bounds. Pitch is 0..127, velocity 1..127, channel 0..15.
Note IDs are unique, monotonic uint64 IDs with UINT64_MAX reserved.
- `AUTOMATION`: global point count, after the last NOTE and before END in v4.
Follow with `POINT track_id parameter tick value_bits` for each point.
Parameter 0 is gain (0..4), 1 pan (-1..1); values use finite binary32 hex.
Track must exist; tick <= MAX_TICK; duplicate track/parameter/tick is invalid.
Record order is preserved; rendering sorts a separate prepared copy.
- Names decode to nonempty valid UTF-8 of at most 63 bytes; ASCII control
characters and embedded NULs are forbidden. Hex avoids quoting ambiguity.
Ticks use 960 per quarter note, 3840 per bar. All region endpoints are bounded
by 38400000 ticks (10000 bars). Limits: 64 tracks, 1024 clips, 64 graph nodes,
4096 edges, 4096 notes, 4 MiB input, 1535 bytes per record, 12 tokens per record. The reader
4096 edges, 4096 notes, 1024 automation points, 4 MiB input, 1535 bytes per record, 12 tokens per record. The reader
rejects invalid references, duplicate IDs, inconsistent next IDs, ranges,
truncated records, raw control/non-ASCII bytes, and trailing data.
@ -71,8 +80,10 @@ Unsupported format versions return UNSUPPORTED. Other malformed data returns
FORMAT. The reader explicitly accepts v1 records without ASSET lines, with
node types 1..4, migrating their clips to empty asset paths. Version 2 accepts
node types 1..5 and ASSET records. Both migrate to MIDI=0 and no notes.
Saving always writes v3. Unknown versions are rejected without replacing the destination; older
applications cannot read v3. Migration and maximum-path roundtrips are tested.
Version 3 accepts instrument nodes and MIDI/NOTES records. Versions 1–3 migrate
all tracks to unity gain, centered pan, mute/solo off and no automation.
Saving always writes v4. Unknown versions are rejected without replacing the destination; older
applications cannot read v4. Migration and maximum-path roundtrips are tested.
## Ownership and failures
@ -86,7 +97,8 @@ loss is not guaranteed. Save replaces symlinks themselves and does not preserve
existing file permissions. POSIX I/O is private to the Linux-first persistence
module; public headers contain no POSIX types.
Saved data reproduces the arrangement and graph, including ID allocation state.
Saved data reproduces the arrangement, graph, track mix and automation, including
ID allocation state. Undo history is not saved. See MIXER.md for pan/interpolation.
Graph node positions, timeline zoom/scroll/selection, transport position and DSP
oscillator phase are GUI/session state and are not saved. Asset references are
external absolute paths, not copied on Save As. Metadata loading preserves

View file

@ -91,6 +91,16 @@ See MIDI.md for capacities, immediate note-off, routing and hardware limits.
## Phase 8 — Mixer and editing
Gain/pan, meters, mute/solo, automation, undo/redo, keyboard workflow.
Implemented: selectable per-track channel strips with gain/pan and mute/solo;
stereo graph/JACK output and output peak meters; editable gain/pan automation
lanes with sample interpolation; bounded 32-edit C snapshot undo/redo and mixer
keyboard controls. Format v4 persists mixing/automation and reads v1–v3.
Core, GUI rollback/gesture/history and live stereo software playback tests pass.
See MIXER.md for routing, mono compatibility, pan law, history behavior and limits.
Track inserts/sends, live fader adoption, MIDI capture, per-track meters and
advanced automation remain deferred. No project-file editing is needed for the
implemented composition/mixing workflow.
## Phase 9 — Extensibility and release
Native node API, evaluate plugin hosting, licensing review, packaging,
installation tests, performance and recovery tests.

View file

@ -0,0 +1,17 @@
# 0007 — Track mixing, paired stereo buses and snapshot history
Status: accepted, Phase 8.
Apply gain/pan/mute/solo and gain/pan automation in prepared track sources, then
run paired stereo buses through the existing modular topology. This preserves
mono APIs and graph edits without inventing new graph-port IDs. Center-unity
linear pan preserves previous centered levels. Add optional stereo JACK output
and atomic output peak snapshots; keep input/recording mono.
Use 1024 global automation points, linear sample interpolation, and stopped
preparation for mixer edits. Store settings/points in v4 and migrate v1–v3 to
unity/center/no automation. Use 32 bounded C project snapshots for common undo
edits; commit history movement only after candidate installation succeeds.
Samples and GUI-only state stay outside history. Track graph inserts/sends,
per-track meters, live fader publication, automation modes/capture, nonlinear
curves and equal-power pan are future extensions. See ../MIXER.md.

View file

@ -24,6 +24,8 @@ void xtc_audio_plan_destroy(xtc_audio_plan *plan);
size_t xtc_audio_plan_issues(const xtc_audio_plan *plan, const char **out_first_path);
/* Single consumer; output replaced with overlap-summed mono at unity gain.
* Linear rate conversion, no time stretching. Output must have frames floats. */
/* Stereo outputs must be distinct; NULL right selects mono gain-only rendering. */
xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *, uint64_t, float *left, float *right, size_t);
xtc_result xtc_audio_plan_render(const xtc_audio_plan *plan, uint64_t position,
float *output, size_t frames);
/* Control-thread creation opens a same-directory temporary float32 WAV.

View file

@ -13,9 +13,10 @@ typedef struct {
xtc_backend_kind kind;
const char *client_name; /* Borrowed for open only; nonempty. */
size_t max_frames; /* 1..65536, at least the server's current block. */
size_t channel_count; /* Currently must be 1. */
size_t channel_count; /* 1 mono or 2 stereo output; input/recording remain mono. */
} xtc_backend_config;
typedef struct {
float peak_left, peak_right; /* Last-block output peaks, zero while stopped. */
unsigned int sample_rate;
unsigned int buffer_frames;
unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */
@ -63,6 +64,8 @@ void xtc_backend_collect(xtc_backend *backend);
/* Snapshot fields are individually coherent, not a transactional snapshot. */
xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *out_status);
/* Borrowed names valid until close. Direction is the client's own port. */
/* Output channel 0 is legacy output, channel 1 is output_right in stereo. */
xtc_result xtc_backend_output_name(const xtc_backend *, size_t channel, const char **out_name);
xtc_result xtc_backend_port_name(const xtc_backend *backend, xtc_port_direction direction,
const char **out_name);
/* Enumerates available mono audio ports (devices and software routes). Visitor

View file

@ -109,6 +109,12 @@ void xtc_plan_destroy(xtc_plan *plan);
* unless require_output_nodes is set. Empty graphs produce silence.
* Unconnected gain/meter/output inputs receive external input. */
/* Separate mono source buffers; NULL is silence. Existing wrappers omit MIDI. */
/* Stereo graph with paired source buffers and a mono external input duplicated
* to both sides. Output buffers must be distinct and must not alias sources.
* Stereo oscillator phase advances once per frame. NULL right selects mono. */
xtc_result xtc_plan_process_stereo(xtc_plan *, const float *input,
const float *audio_left, const float *audio_right, const float *midi_left, const float *midi_right,
float *left, float *right, size_t frames);
xtc_result xtc_plan_process_sources(xtc_plan *plan, const float *input, const float *timeline,
const float *instrument, float *output, size_t frames);
/* timeline is a separate mono input for XTC_NODE_AUDIO, NULL means silence. */

View file

@ -49,6 +49,8 @@ void xtc_midi_plan_panic(xtc_midi_plan *plan, uint64_t position);
* caller order, count<=256, frames<=65536. Invalid input leaves state/output alone.
* Renders [position,position+frames); discontinuity clears live voices, chases
* scheduled notes at their correct phase. Note-off is immediate (no envelope). */
/* Distinct stereo outputs; NULL right retains mono (gain only). */
xtc_result xtc_midi_plan_render_stereo(xtc_midi_plan *, uint64_t, const xtc_midi_event *, size_t, float *left, float *right, size_t);
xtc_result xtc_midi_plan_render(xtc_midi_plan *plan, uint64_t position,
const xtc_midi_event *events, size_t count, float *output, size_t frames);
#ifdef __cplusplus

40
include/xtc/xtc_mixer.h Normal file
View file

@ -0,0 +1,40 @@
#ifndef XTC_MIXER_H
#define XTC_MIXER_H
#include "xtc_project.h"
#ifdef __cplusplus
extern "C" {
#endif
#define XTC_MAX_AUTOMATION_POINTS 1024U
#define XTC_HISTORY_EDITS 32U
typedef enum { XTC_AUTOMATION_GAIN = 0, XTC_AUTOMATION_PAN = 1 } xtc_automation_parameter;
typedef struct {
uint64_t track_id, tick;
xtc_automation_parameter parameter;
float value;
} xtc_automation_point;
/* Gain 0..4, pan -1..1, mute/solo 0 or 1. Track audio and scheduled MIDI only.
* Linear pan: center unity on both outputs; hard pan attenuates opposite side.
* Automation replaces the corresponding static value, linear between points,
* holds first/last outside the lane. Duplicate track/parameter/tick replaces. */
xtc_result xtc_project_set_mix(xtc_project *, uint64_t track, float gain, float pan, int mute,
int solo);
xtc_result xtc_project_set_automation(xtc_project *, const xtc_automation_point *);
xtc_result xtc_project_remove_automation(xtc_project *, uint64_t track, xtc_automation_parameter,
uint64_t tick);
xtc_result xtc_project_automation_count(const xtc_project *, size_t *);
xtc_result xtc_project_get_automation(const xtc_project *, size_t, xtc_automation_point *);
/* Control-thread snapshot history; 32 edits plus baseline, no assets/DSP/GUI.
* record clones before changing history; failure retains all history. checkout
* clones adjacent state without moving cursor; accept only after installation.
* Allocation ID high-water marks survive checkout/branching.
* Reset by creating a new history for New/Open. */
typedef struct xtc_history xtc_history;
xtc_result xtc_history_create(const xtc_project *, xtc_history **);
void xtc_history_destroy(xtc_history *);
xtc_result xtc_history_record(xtc_history *, const xtc_project *);
xtc_result xtc_history_checkout(const xtc_history *, int redo, xtc_project **);
xtc_result xtc_history_accept(xtc_history *, int redo);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -11,7 +11,9 @@ extern "C" {
#define XTC_TICKS_PER_QUARTER 960U
#define XTC_PROJECT_MAX_TICK UINT64_C(38400000)
typedef struct xtc_project xtc_project;
typedef struct { uint64_t id; char name[XTC_PROJECT_NAME_BYTES]; } xtc_track_info;
typedef struct { uint64_t id; char name[XTC_PROJECT_NAME_BYTES];
float gain, pan; int mute, solo;
} xtc_track_info;
/* Arrangement region: empty asset_path means silent metadata.
* All musical positions are integer ticks, constant tempo and 4/4 meter. */
typedef struct {

View file

@ -31,6 +31,9 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine,
* frame_count must be <= configured max_frames and output must hold it.
* Oversized buffers are silenced too; callers must bound their own frame count.
* NULL input means silence. Zero frames is a no-op. */
xtc_result xtc_realtime_process_stereo(xtc_realtime *, const float *input,
const float *audio_left, const float *audio_right, const float *midi_left, const float *midi_right,
float *left, float *right, size_t frames);
xtc_result xtc_realtime_process_sources(xtc_realtime *engine, const float *input, const float *timeline,
const float *instrument, float *output, size_t frames);
xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline,

View file

@ -2,9 +2,12 @@
C99/POSIX control code loads bounded WAV files and prepares immutable clip
plans. Callback rendering reads only preloaded samples. The audio source node
passes the mono timeline mix through the ordinary modular graph.
passes the mono or paired stereo timeline mix through the ordinary modular graph.
The recorder uses a lock-free SPSC ring: callback push, control-thread drain,
and finish/destroy only after callback quiescence. WAV headers, file writing
and final rename occur on the control thread. See docs/AUDIO_FILES.md and
include/xtc/xtc_audio.h for ownership, limits and recovery behavior.
Phase 8 track gain/pan/mute/solo and automation are prepared with each region;
see ../../docs/MIXER.md. Raw recording remains mono and bypasses track settings.

View file

@ -1,12 +1,13 @@
#define _XOPEN_SOURCE 700
#include "wave_internal.h"
#include "../project/project_internal.h"
#include "../mixer/mix_internal.h"
#include "../graph/graph_internal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
typedef struct { uint64_t start, end, offset; size_t asset; } region;
typedef struct { uint64_t start, end, offset; size_t asset, track; } region;
struct xtc_audio_plan {
xtc_mix_snapshot mix;
xtc_wave waves[64];
char paths[64][XTC_ASSET_PATH_BYTES];
region regions[XTC_PROJECT_MAX_CLIPS];
@ -73,6 +74,8 @@ xtc_result xtc_audio_plan_create(const xtc_project *project, unsigned int rate,
plan = calloc(1, sizeof(*plan));
if (!plan) return XTC_ERR_OUT_OF_MEMORY;
plan->rate = rate;
result = xtc_mix_prepare(project, rate, &plan->mix);
if (result != XTC_OK) { free(plan); return result; }
for (i = 0; i < project->clip_count; ++i) {
const xtc_clip_info *clip = project->clips + i;
size_t a; region *r;
@ -92,7 +95,7 @@ xtc_result xtc_audio_plan_create(const xtc_project *project, unsigned int rate,
if (total > 67108864U) { result = XTC_ERR_CAPACITY; break; }
}
r = plan->regions + plan->count++;
r->asset = a;
r->asset = a; r->track = xtc_mix_track(&plan->mix, clip->track_id);
result = xtc_project_tick_to_sample(project, clip->start_tick, rate, &r->start);
if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->start_tick + clip->length_tick, rate, &r->end);
if (result == XTC_OK) result = xtc_project_tick_to_sample(project, clip->offset_tick, rate, &r->offset);
@ -106,11 +109,12 @@ size_t xtc_audio_plan_issues(const xtc_audio_plan *plan, const char **path)
if (path) *path = plan ? plan->first_issue : "";
return plan ? plan->issues : 0;
}
xtc_result xtc_audio_plan_render(const xtc_audio_plan *plan, uint64_t position, float *output, size_t frames)
xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *plan, uint64_t position, float *output, float *right, size_t frames)
{
size_t i, j;
if (!plan || !output || frames > 65536U || position > UINT64_MAX - frames) return XTC_ERR_INVALID_ARGUMENT;
if (!plan || !output || (right && right == output) || frames > 65536U || position > UINT64_MAX - frames) return XTC_ERR_INVALID_ARGUMENT;
memset(output, 0, frames * sizeof(*output));
if (right) memset(right, 0, frames * sizeof(*right));
for (i = 0; i < plan->count; ++i) {
const region *r = plan->regions + i;
const xtc_wave *wave = plan->waves + r->asset;
@ -123,10 +127,18 @@ xtc_result xtc_audio_plan_render(const xtc_audio_plan *plan, uint64_t position,
double fraction;
if (source >= (double)wave->frames) continue;
index = (size_t)source; fraction = source - (double)index;
output[j] += (float)(wave->samples[index] * (1.0 - fraction) +
float sample = (float)(wave->samples[index] * (1.0 - fraction) +
(index + 1 < wave->frames ? wave->samples[index + 1] : wave->samples[index]) * fraction);
output[j] += sample * xtc_mix_level(&plan->mix, r->track, position + j, right ? 0U : 2U);
if (right) right[j] += sample * xtc_mix_level(&plan->mix, r->track, position + j, 1);
}
}
for (j = 0; j < frames; ++j) if (!isfinite(output[j])) output[j] = 0;
for (j = 0; j < frames; ++j) {
if (!isfinite(output[j])) output[j] = 0;
if (right && !isfinite(right[j])) right[j] = 0;
}
return XTC_OK;
}
xtc_result xtc_audio_plan_render(const xtc_audio_plan *p, uint64_t position, float *out, size_t frames)
{ return xtc_audio_plan_render_stereo(p, position, out, NULL, frames); }

View file

@ -7,14 +7,15 @@
#include <jack/jack.h>
#include <jack/midiport.h>
#include <errno.h>
#include <math.h>
struct xtc_backend {
jack_client_t *client;
jack_port_t *input, *output, *midi_input;
jack_port_t *input, *output, *midi_input, *output_right;
xtc_realtime *engine;
xtc_audio_plan *audio;
xtc_midi_plan *midi;
unsigned int panic_requested, panic_seen, midi_dropped;
unsigned int panic_requested, panic_seen, midi_dropped, peak_left, peak_right;
xtc_recorder *recorder; /* Borrowed, installed while callbacks quiescent. */
float *timeline, *instrument;
size_t max_frames;
@ -56,14 +57,20 @@ static xtc_result render_midi(xtc_backend *backend, uint64_t position, jack_nfra
}
++count;
}
return xtc_midi_plan_render(backend->midi, position, events, count, backend->instrument, (size_t)frames);
return xtc_midi_plan_render_stereo(backend->midi, position, events, count, backend->instrument,
backend->output_right ? backend->instrument + backend->max_frames : NULL, (size_t)frames);
}
static int process(jack_nframes_t frames, void *context)
{
xtc_backend *backend = context;
float *output = jack_port_get_buffer(backend->output, frames);
float *right = backend->output_right ? jack_port_get_buffer(backend->output_right, frames) : NULL;
xtc_result result = XTC_OK;
if (output == NULL) {
if (output == NULL || (backend->output_right && !right)) {
if (output) memset(output, 0, (size_t)frames * sizeof(*output));
if (right) memset(right, 0, (size_t)frames * sizeof(*right));
__atomic_store_n(&backend->peak_left, 0U, __ATOMIC_RELAXED);
__atomic_store_n(&backend->peak_right, 0U, __ATOMIC_RELAXED);
(void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE);
if (backend->recorder) xtc_recorder_push(backend->recorder, NULL, 1U);
__atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED);
@ -79,12 +86,16 @@ static int process(jack_nframes_t frames, void *context)
uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED);
if (backend->recorder) xtc_recorder_push(backend->recorder, input, (size_t)frames);
if (backend->audio)
result = xtc_audio_plan_render(backend->audio, position, backend->timeline, (size_t)frames);
result = xtc_audio_plan_render_stereo(backend->audio, position, backend->timeline,
right ? backend->timeline + backend->max_frames : NULL, (size_t)frames);
if (result == XTC_OK) result = render_midi(backend, position, frames);
if (result == XTC_OK)
result = xtc_realtime_process_sources(backend->engine, input,
result = xtc_realtime_process_stereo(backend->engine, input,
backend->audio ? backend->timeline : NULL,
backend->midi ? backend->instrument : NULL, output, (size_t)frames);
backend->audio && right ? backend->timeline + backend->max_frames : NULL,
backend->midi ? backend->instrument : NULL,
backend->midi && right ? backend->instrument + backend->max_frames : NULL,
output, right, (size_t)frames);
}
if (result == XTC_OK) {
uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED);
@ -95,6 +106,17 @@ static int process(jack_nframes_t frames, void *context)
(void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE);
if (backend->recorder) xtc_recorder_push(backend->recorder, NULL, 1U);
memset(output, 0, (size_t)frames * sizeof(*output));
if (right) memset(right, 0, (size_t)frames * sizeof(*right));
}
{
size_t i; float left_peak = 0, right_peak = 0;
for (i = 0; i < frames; ++i) {
float l = fabsf(output[i]), r = right ? fabsf(right[i]) : l;
if (l > left_peak) left_peak = l;
if (r > right_peak) right_peak = r;
}
__atomic_store_n(&backend->peak_left, (unsigned int)(fminf(left_peak, 1000) * 1000000), __ATOMIC_RELAXED);
__atomic_store_n(&backend->peak_right, (unsigned int)(fminf(right_peak, 1000) * 1000000), __ATOMIC_RELAXED);
}
__atomic_store_n(&backend->process_result, (unsigned int)result, __ATOMIC_RELAXED);
return 0; /* Keep connected and silent on recoverable processing errors. */
@ -141,15 +163,15 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_
if (out_backend == NULL) return XTC_ERR_INVALID_ARGUMENT;
*out_backend = NULL;
if (config == NULL || config->client_name == NULL || config->client_name[0] == '\0' ||
config->max_frames == 0U || config->max_frames > 65536U || config->channel_count != 1U)
config->max_frames == 0U || config->max_frames > 65536U || (config->channel_count != 1U && config->channel_count != 2U))
return XTC_ERR_INVALID_ARGUMENT;
if (!__atomic_always_lock_free(sizeof(uint64_t), 0)) return XTC_ERR_UNSUPPORTED;
if (config->kind != XTC_BACKEND_JACK) return XTC_ERR_UNSUPPORTED;
if (strlen(config->client_name) >= (size_t)jack_client_name_size()) return XTC_ERR_INVALID_ARGUMENT;
backend = calloc(1U, sizeof(*backend));
if (backend == NULL) return XTC_ERR_OUT_OF_MEMORY;
backend->timeline = calloc(config->max_frames, sizeof(float));
backend->instrument = calloc(config->max_frames, sizeof(float));
backend->timeline = calloc(config->max_frames * config->channel_count, sizeof(float));
backend->instrument = calloc(config->max_frames * config->channel_count, sizeof(float));
if (!backend->timeline || !backend->instrument) {
free(backend->timeline); free(backend->instrument); free(backend); return XTC_ERR_OUT_OF_MEMORY;
}
@ -165,7 +187,9 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_
backend->input = jack_port_register(backend->client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0U);
backend->output = jack_port_register(backend->client, "output", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0U);
backend->midi_input = jack_port_register(backend->client, "midi_in", JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0U);
if (backend->input == NULL || backend->output == NULL || backend->midi_input == NULL) { result = XTC_ERR_IO; goto fail; }
if (config->channel_count == 2U)
backend->output_right = jack_port_register(backend->client, "output_right", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0U);
if ((config->channel_count == 2U && !backend->output_right) || backend->input == NULL || backend->output == NULL || backend->midi_input == NULL) { result = XTC_ERR_IO; goto fail; }
if (jack_set_port_connect_callback(backend->client, port_connection, backend) != 0 ||
jack_set_process_callback(backend->client, process, backend) != 0 ||
jack_set_sample_rate_callback(backend->client, rate_callback, backend) != 0 ||
@ -314,6 +338,8 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status
if (backend == NULL || out_status == NULL) return XTC_ERR_INVALID_ARGUMENT;
if (!backend->ready) return XTC_ERR_INVALID_STATE;
(void)xtc_realtime_get_status(backend->engine, &engine_status);
out_status->peak_left = backend->running ? (float)__atomic_load_n(&backend->peak_left, __ATOMIC_RELAXED) / 1000000.0f : 0;
out_status->peak_right = backend->running ? (float)__atomic_load_n(&backend->peak_right, __ATOMIC_RELAXED) / 1000000.0f : 0;
out_status->midi_dropped = __atomic_load_n(&backend->midi_dropped, __ATOMIC_RELAXED);
out_status->asset_issues = (unsigned int)xtc_audio_plan_issues(backend->audio, NULL);
out_status->sample_rate = __atomic_load_n(&backend->rate, __ATOMIC_RELAXED);
@ -338,6 +364,14 @@ xtc_result xtc_backend_port_name(const xtc_backend *backend, xtc_port_direction
*out_name = jack_port_name(direction == XTC_PORT_INPUT ? backend->input : backend->output);
return *out_name == NULL ? XTC_ERR_IO : XTC_OK;
}
xtc_result xtc_backend_output_name(const xtc_backend *backend, size_t channel, const char **out)
{
if (!backend || !out || channel > 1) return XTC_ERR_INVALID_ARGUMENT;
if (!backend->ready || __atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE;
if (channel && !backend->output_right) return XTC_ERR_NOT_FOUND;
*out = jack_port_name(channel ? backend->output_right : backend->output);
return *out ? XTC_OK : XTC_ERR_IO;
}
xtc_result xtc_backend_visit_ports(xtc_backend *backend, xtc_port_direction direction,
xtc_backend_port_visitor visitor, void *context)
{
@ -376,6 +410,8 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc
{ return route(backend, direction, remote, 1); }
#else
xtc_result xtc_backend_output_name(const xtc_backend *b, size_t c, const char **out)
{ (void)b; (void)c; (void)out; return XTC_ERR_UNSUPPORTED; }
xtc_result xtc_backend_panic(xtc_backend *backend)
{ (void)backend; return XTC_ERR_UNSUPPORTED; }
xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **out)

View file

@ -60,7 +60,7 @@ xtc_result xtc_graph_compile(const xtc_graph *graph, double sample_rate,
plan->offsets = calloc(plan->node_count + 1U, sizeof(*plan->offsets));
plan->sources = calloc(graph->edge_count + 1U, sizeof(*plan->sources));
plan->render = calloc(storage_nodes, sizeof(*plan->render));
plan->buffers = calloc(storage_nodes * max_frames, sizeof(*plan->buffers));
plan->buffers = calloc(storage_nodes * max_frames * 2U, sizeof(*plan->buffers));
plan->peaks = calloc(storage_nodes, sizeof(*plan->peaks));
plan->phases = calloc(storage_nodes, sizeof(*plan->phases));
if (plan->nodes == NULL || plan->order == NULL || plan->offsets == NULL ||
@ -109,50 +109,56 @@ xtc_result xtc_plan_meter_peak(const xtc_plan *plan, size_t node_id, float *out_
return XTC_OK;
}
xtc_result xtc_plan_process_sources(xtc_plan *plan, const float *input, const float *timeline, const float *instrument, float *output,
size_t frame_count)
xtc_result xtc_plan_process_stereo(xtc_plan *plan, const float *input,
const float *timeline, const float *timeline_right, const float *instrument, const float *instrument_right,
float *output, float *right, size_t frame_count)
{
size_t index, frame, edge;
if (plan == NULL || output == NULL) return XTC_ERR_INVALID_ARGUMENT;
size_t index, frame, edge, channel;
if (plan == NULL || output == NULL || (right && right == output)) return XTC_ERR_INVALID_ARGUMENT;
if (frame_count > plan->max_frames) return XTC_ERR_CAPACITY;
if (frame_count == 0U) return XTC_OK;
/* All external input reads finish before output is written, allowing aliasing.
* Nonfinite input/arithmetic becomes silence per sample, never persistent DSP state. */
for (index = 0U; index < plan->node_count; ++index) {
size_t node = plan->order[index];
xtc_node spec = plan->nodes[node];
float *buffer = plan->buffers + node * plan->max_frames;
float peak = 0.0f;
double phase = plan->phases[node];
for (channel = 0; channel < (right ? 2U : 1U); ++channel) {
const float *audio_source = channel ? timeline_right : timeline;
const float *midi_source = channel ? instrument_right : instrument;
float *destination = channel ? right : output;
for (index = 0U; index < plan->node_count; ++index) {
size_t node = plan->order[index];
xtc_node spec = plan->nodes[node];
float *buffer = plan->buffers + (channel * plan->node_count + node) * plan->max_frames;
float peak = 0.0f;
double phase = plan->phases[node];
for (frame = 0U; frame < frame_count; ++frame) {
float sample = 0.0f;
if (spec.type == XTC_NODE_INSTRUMENT) {
sample = midi_source ? midi_source[frame] : 0.0f;
} else if (spec.type == XTC_NODE_AUDIO) {
sample = audio_source ? audio_source[frame] : 0.0f;
} else if (spec.type == XTC_NODE_OSCILLATOR) {
sample = (float)sin(XTC_TAU * phase);
phase += (double)spec.value / plan->sample_rate;
if (phase >= 1.0) phase -= 1.0;
} else {
if (plan->offsets[node] == plan->offsets[node + 1U])
sample = input == NULL ? 0.0f : input[frame] * plan->input_gain;
for (edge = plan->offsets[node]; edge < plan->offsets[node + 1U]; ++edge)
sample += plan->buffers[(channel * plan->node_count + plan->sources[edge]) * plan->max_frames + frame];
if (spec.type == XTC_NODE_GAIN) sample *= spec.value;
}
if (!isfinite(sample)) sample = 0.0f;
buffer[frame] = sample;
if (fabsf(sample) > peak) peak = fabsf(sample);
}
if (!right || channel == 1) plan->phases[node] = phase;
if (spec.type == XTC_NODE_METER) plan->peaks[node] = channel && plan->peaks[node] > peak ? plan->peaks[node] : peak;
}
for (frame = 0U; frame < frame_count; ++frame) {
float sample = 0.0f;
if (spec.type == XTC_NODE_INSTRUMENT) {
sample = instrument ? instrument[frame] : 0.0f;
} else if (spec.type == XTC_NODE_AUDIO) {
sample = timeline ? timeline[frame] : 0.0f;
} else if (spec.type == XTC_NODE_OSCILLATOR) {
sample = (float)sin(XTC_TAU * phase);
phase += (double)spec.value / plan->sample_rate;
if (phase >= 1.0) phase -= 1.0;
} else {
if (plan->offsets[node] == plan->offsets[node + 1U])
sample = input == NULL ? 0.0f : input[frame] * plan->input_gain;
for (edge = plan->offsets[node]; edge < plan->offsets[node + 1U]; ++edge)
sample += plan->buffers[plan->sources[edge] * plan->max_frames + frame];
if (spec.type == XTC_NODE_GAIN) sample *= spec.value;
}
if (!isfinite(sample)) sample = 0.0f;
buffer[frame] = sample;
if (fabsf(sample) > peak) peak = fabsf(sample);
for (index = 0U; index < plan->node_count; ++index)
if (plan->render[index]) sample += plan->buffers[(channel * plan->node_count + index) * plan->max_frames + frame];
destination[frame] = isfinite(sample) ? sample : 0.0f;
}
plan->phases[node] = phase;
if (spec.type == XTC_NODE_METER) plan->peaks[node] = peak;
}
for (frame = 0U; frame < frame_count; ++frame) {
float sample = 0.0f;
for (index = 0U; index < plan->node_count; ++index)
if (plan->render[index]) sample += plan->buffers[index * plan->max_frames + frame];
output[frame] = isfinite(sample) ? sample : 0.0f;
}
return XTC_OK;
}
@ -177,3 +183,6 @@ xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, s
xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const float *timeline, float *output, size_t frames)
{ return xtc_plan_process_sources(plan, input, timeline, NULL, output, frames); }
xtc_result xtc_plan_process_sources(xtc_plan *p, const float *in, const float *audio, const float *midi, float *out, size_t frames)
{ return xtc_plan_process_stereo(p, in, audio, NULL, midi, NULL, out, NULL, frames); }

View file

@ -80,11 +80,12 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine,
return XTC_OK;
}
xtc_result xtc_realtime_process_sources(xtc_realtime *engine, const float *input, const float *timeline, const float *instrument,
float *output, size_t frame_count)
xtc_result xtc_realtime_process_stereo(xtc_realtime *engine, const float *input,
const float *timeline, const float *timeline_right, const float *instrument, const float *instrument_right,
float *output, float *right, size_t frame_count)
{
xtc_result result = XTC_OK;
if (engine == NULL || output == NULL || frame_count > SIZE_MAX / sizeof(*output))
if (engine == NULL || output == NULL || (right && right == output) || frame_count > SIZE_MAX / sizeof(*output))
return XTC_ERR_INVALID_ARGUMENT;
if (frame_count == 0U) return XTC_OK;
if (frame_count > engine->max_frames) {
@ -107,11 +108,16 @@ xtc_result xtc_realtime_process_sources(xtc_realtime *engine, const float *input
}
}
if (engine->active != NULL)
result = xtc_plan_process_sources(engine->active, input, timeline, instrument, output, frame_count);
else
result = xtc_plan_process_stereo(engine->active, input, timeline, timeline_right, instrument, instrument_right, output, right, frame_count);
else {
memset(output, 0, frame_count * sizeof(*output));
if (right) memset(right, 0, frame_count * sizeof(*right));
}
}
if (result != XTC_OK) {
memset(output, 0, frame_count * sizeof(*output));
if (right) memset(right, 0, frame_count * sizeof(*right));
}
if (result != XTC_OK) memset(output, 0, frame_count * sizeof(*output));
__atomic_store_n(&engine->result, (unsigned int)result, __ATOMIC_RELAXED);
__atomic_store_n(&engine->blocks,
__atomic_load_n(&engine->blocks, __ATOMIC_RELAXED) + 1U, __ATOMIC_RELAXED);
@ -123,3 +129,6 @@ xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, float
xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline, float *output, size_t frames)
{ return xtc_realtime_process_sources(engine, input, timeline, NULL, output, frames); }
xtc_result xtc_realtime_process_sources(xtc_realtime *p, const float *in, const float *audio, const float *midi, float *out, size_t frames)
{ return xtc_realtime_process_stereo(p, in, audio, NULL, midi, NULL, out, NULL, frames); }

View file

@ -2,6 +2,7 @@
#include "graph_widget.hpp"
#include "sequencer_widget.hpp"
#include "piano_roll.hpp"
#include "mixer_widget.hpp"
#include <FL/Fl_Native_File_Chooser.H>
#include "xtc/xtc_version.h"
@ -33,8 +34,15 @@ MainWindow::MainWindow(bool prompt_unsaved)
xtc_result result = xtc_project_create(&initial);
if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result));
project_.reset(initial);
xtc_history *history = nullptr;
result = xtc_history_create(initial, &history);
if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result));
history_.reset(history);
begin();
auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
menu->add("Edit/Undo", FL_CTRL + 'z', history_callback, this);
menu->add("Edit/Redo", FL_CTRL + FL_SHIFT + 'z', history_callback, this);
menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this);
menu->add("File/New", FL_CTRL + 'n', file_callback, this);
menu->add("File/Open", FL_CTRL + 'o', file_callback, this);
menu->add("File/Save", FL_CTRL + 's', file_callback, this);
@ -102,7 +110,9 @@ MainWindow::MainWindow(bool prompt_unsaved)
sequencer->end();
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
mixer->begin();
new Fl_Box(24, 164, 940, 40, "Mixer placeholder — channels and meters are not implemented yet.");
mixer_ = new MixerWidget(20, 150, 956, 420, project_.get(), before_arrangement_edit, this);
mixer_->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->mark_dirty(); }, this);
mixer->resizable(mixer_);
mixer->end();
auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph");
graph->begin();
@ -216,7 +226,7 @@ xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, siz
void MainWindow::open_backend()
{
xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 1U};
xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 2U};
xtc_result result = xtc_backend_open(&config, &backend_);
ready_ = result == XTC_OK;
running_ = false;
@ -280,6 +290,7 @@ void MainWindow::reconnect_engine()
void MainWindow::refresh_status()
{
if (mixer_) mixer_->peaks(0, 0);
bool can_play = false, can_record = false;
if (!ready_) {
set_label(indicator_, "Audio: disconnected");
@ -310,8 +321,9 @@ void MainWindow::refresh_status()
set_label(indicator_, state);
indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) :
running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215));
mixer_->peaks(status.peak_left, status.peak_right);
char text[256];
std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | mono | %u blocks | %u xruns | %s | %u asset issues | %u MIDI drops",
std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | stereo | %u blocks | %u xruns | %s | %u asset issues | %u MIDI drops",
status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns,
xtc_result_string(status.process_result), status.asset_issues, status.midi_dropped);
set_label(status_, text);
@ -372,7 +384,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view)
}
void MainWindow::about_callback(Fl_Widget *, void *)
{
fl_message("%s %s\nModular audio workstation\nPhase 7: MIDI input, sine instrument, persisted notes and piano roll.",
fl_message("%s %s\nModular audio workstation\nPhase 8: stereo track mixer, automation, meters and undo/redo.",
XTC_PROJECT_NAME, XTC_VERSION_STRING);
}
void MainWindow::timer_callback(void *context)
@ -392,6 +404,9 @@ void MainWindow::timer_callback(void *context)
void MainWindow::mark_dirty()
{
xtc_result history_result = xtc_history_record(history_.get(), project_.get());
if (history_result != XTC_OK) report_error("Undo history could not retain edit", history_result);
mixer_->set_project(project_.get());
graph_widget_->set_graph(xtc_project_graph(project_.get()));
if (piano_) piano_->set_project(project_.get(), piano_->clip());
dirty_ = true; copy_label("xtc * — unsaved changes");
@ -420,9 +435,16 @@ xtc_result MainWindow::save_project(const char *path)
if (r == XTC_OK) { path_ = saved_path; dirty_ = false; copy_label(path_.c_str()); }
return r;
}
xtc_result MainWindow::install_project(xtc_project *candidate)
xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_history)
{
if (recorder_) return XTC_ERR_INVALID_STATE;
std::unique_ptr<xtc_history, decltype(&xtc_history_destroy)> new_history(nullptr, xtc_history_destroy);
if (reset_history) {
xtc_history *h = nullptr;
xtc_result result = xtc_history_create(candidate, &h);
if (result != XTC_OK) return result;
new_history.reset(h);
}
xtc_result r = XTC_OK;
if (ready_) {
r = xtc_backend_stop(backend_);
@ -444,6 +466,8 @@ xtc_result MainWindow::install_project(xtc_project *candidate)
if (piano_window_) piano_window_->hide();
if (piano_) piano_->set_project(candidate, UINT64_MAX);
project_.reset(candidate);
mixer_->set_project(candidate);
if (reset_history) history_.swap(new_history);
graph_widget_->reset_layout();
graph_widget_->set_graph(xtc_project_graph(candidate));
sequencer_->set_project(candidate);
@ -634,3 +658,23 @@ void MainWindow::panic_callback(Fl_Widget *, void *context)
xtc_result r = xtc_backend_panic(window->backend_);
if (r != XTC_OK) window->report_error("Panic failed", r);
}
xtc_result MainWindow::undo(bool redo)
{
if (recorder_) return XTC_ERR_INVALID_STATE;
xtc_project *candidate = nullptr;
xtc_result r = xtc_history_checkout(history_.get(), redo ? 1 : 0, &candidate);
if (r == XTC_OK) r = install_project(candidate, false);
if (r != XTC_OK) { xtc_project_destroy(candidate); return r; }
r = xtc_history_accept(history_.get(), redo ? 1 : 0);
dirty_ = true; copy_label("xtc * — unsaved changes");
return r;
}
void MainWindow::history_callback(Fl_Widget *widget, void *context)
{
auto *window = static_cast<MainWindow *>(context);
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
bool redo = std::strncmp(menu->mvalue()->label(), "Redo", 4) == 0;
xtc_result r = window->undo(redo);
if (r != XTC_OK && r != XTC_ERR_NOT_FOUND) window->report_error("Undo/redo failed", r);
}

View file

@ -6,8 +6,10 @@
#include <string>
#include "xtc/xtc_project.h"
#include "xtc/xtc_backend.h"
#include "xtc/xtc_mixer.h"
class GraphWidget;
class MixerWidget;
class PianoRollWidget;
class SequencerWidget;
class Fl_Box;
@ -23,6 +25,8 @@ public:
using Fl_Double_Window::show;
int handle(int event) override;
xtc_result undo(bool redo = false);
MixerWidget *mixer() const { return mixer_; }
xtc_result open_piano_roll();
PianoRollWidget *piano_roll() const { return piano_; }
xtc_result import_audio(const char *path);
@ -30,6 +34,9 @@ public:
xtc_result save_project(const char *path);
xtc_result open_project(const char *path);
private:
MixerWidget *mixer_ = nullptr;
std::unique_ptr<xtc_history, decltype(&xtc_history_destroy)> history_{nullptr, xtc_history_destroy};
static void history_callback(Fl_Widget *, void *);
std::unique_ptr<xtc_project, decltype(&xtc_project_destroy)> project_{nullptr, xtc_project_destroy};
std::unique_ptr<Fl_Double_Window> piano_window_;
PianoRollWidget *piano_ = nullptr;
@ -46,7 +53,7 @@ private:
std::string path_;
bool discard_changes();
void mark_dirty();
xtc_result install_project(xtc_project *candidate);
xtc_result install_project(xtc_project *candidate, bool reset_history = true);
static xtc_result seek(void *context, uint64_t tick);
static void file_callback(Fl_Widget *, void *context);
GraphWidget *graph_widget_ = nullptr;

314
src/gui/mixer_widget.cpp Normal file
View file

@ -0,0 +1,314 @@
#include "mixer_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Check_Button.H>
#include <FL/Fl_Choice.H>
#include <FL/Fl_Progress.H>
#include <FL/Fl_Value_Input.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/fl_ask.H>
#include <FL/fl_draw.H>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
namespace
{
constexpr uint64_t lane_ticks = 15360;
}
class AutomationLane : public Fl_Widget
{
MixerWidget &mixer_;
public:
AutomationLane(int x, int y, int w, int h, MixerWidget &m) : Fl_Widget(x, y, w, h), mixer_(m) {}
void draw() override
{
fl_push_clip(x(), y(), w(), h());
fl_color(fl_rgb_color(32, 35, 40));
fl_rectf(x(), y(), w(), h());
fl_font(FL_HELVETICA, 11);
for (int i = 0; i <= 16; ++i) {
int px = x() + i * (w() - 1) / 16;
fl_color(fl_rgb_color(i % 4 ? 55 : 95, 60, 65));
fl_line(px, y(), px, y() + h());
if (i % 4 == 0) {
char label[32];
std::snprintf(label, sizeof(label), "%llu",
static_cast<unsigned long long>(mixer_.lane_start() +
static_cast<uint64_t>(i) * 960));
fl_color(FL_WHITE);
fl_draw(label, px + 3, y() + 14);
}
}
size_t count = 0;
xtc_project_automation_count(mixer_.project(), &count);
std::vector<xtc_automation_point> points;
for (size_t i = 0; i < count; ++i) {
xtc_automation_point a{};
xtc_project_get_automation(mixer_.project(), i, &a);
if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter())
points.push_back(a);
}
std::sort(points.begin(), points.end(),
[](const auto &a, const auto &b) { return a.tick < b.tick; });
float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f;
float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f;
auto py = [&](float v) {
return y() + h() - 6 - static_cast<int>((v - min) / range * (h() - 28));
};
auto value = [&](uint64_t tick) {
if (points.empty())
return min;
auto it = std::upper_bound(points.begin(), points.end(), tick,
[](uint64_t t, const auto &a) { return t < a.tick; });
if (it == points.begin())
return it->value;
if (it == points.end())
return points.back().value;
const auto &a = *(it - 1);
return a.value + static_cast<float>(static_cast<double>(tick - a.tick) /
static_cast<double>(it->tick - a.tick)) *
(it->value - a.value);
};
fl_color(fl_rgb_color(105, 200, 150));
if (!points.empty())
for (int px = 1; px < w(); ++px) {
uint64_t a = mixer_.lane_start() + static_cast<uint64_t>(px - 1) * lane_ticks /
static_cast<uint64_t>(w() - 1);
uint64_t b = mixer_.lane_start() + static_cast<uint64_t>(px) * lane_ticks /
static_cast<uint64_t>(w() - 1);
fl_line(x() + px - 1, py(value(a)), x() + px, py(value(b)));
}
for (const auto &a : points)
if (a.tick >= mixer_.lane_start() && a.tick <= mixer_.lane_start() + lane_ticks) {
int px = x() + static_cast<int>((a.tick - mixer_.lane_start()) *
static_cast<uint64_t>(w() - 1) / lane_ticks);
fl_rectf(px - 4, py(a.value) - 4, 8, 8);
}
if (points.empty()) {
fl_color(FL_WHITE);
fl_draw("Click to add automation; right-click a point to remove. Values replace the "
"static control.",
x() + 12, y() + h() / 2);
}
fl_pop_clip();
}
int handle(int event) override
{
if (event != FL_PUSH)
return Fl_Widget::handle(event);
if (Fl::event_button() != FL_LEFT_MOUSE && Fl::event_button() != FL_RIGHT_MOUSE)
return 0;
uint64_t tick = mixer_.lane_start() +
static_cast<uint64_t>(std::clamp(Fl::event_x() - x(), 0, w() - 1)) *
lane_ticks / static_cast<uint64_t>(w() - 1);
tick = (tick + 120) / 240 * 240;
tick = std::min(tick, XTC_PROJECT_MAX_TICK);
float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f;
float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f;
float value =
min + static_cast<float>(std::clamp(y() + h() - 6 - Fl::event_y(), 0, h() - 28)) /
static_cast<float>(h() - 28) * range;
if (Fl::event_button() == FL_RIGHT_MOUSE) {
size_t count = 0;
xtc_project_automation_count(mixer_.project(), &count);
uint64_t distance =
static_cast<uint64_t>(8) * lane_ticks / static_cast<uint64_t>(w() - 1) + 1,
closest = UINT64_MAX;
for (size_t i = 0; i < count; ++i) {
xtc_automation_point a{};
xtc_project_get_automation(mixer_.project(), i, &a);
uint64_t d = a.tick > tick ? a.tick - tick : tick - a.tick;
if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter() &&
d < distance) {
distance = d;
closest = a.tick;
}
}
if (closest != UINT64_MAX)
mixer_.point(closest, 0, true);
} else
mixer_.point(tick, value);
return 1;
}
};
MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit before,
void *context)
: Fl_Group(x, y, w, h), project_(p), before_(before), context_(context)
{
begin();
tracks_ = new Fl_Choice(x + 70, y + 8, 240, 26, "Track");
tracks_->callback(select, this);
gain_ = new Fl_Value_Slider(x + 70, y + 48, 320, 28, "Gain");
gain_->type(FL_HOR_NICE_SLIDER);
gain_->bounds(0, 4);
gain_->step(0.01);
gain_->when(FL_WHEN_RELEASE);
gain_->callback(control, this);
pan_ = new Fl_Value_Slider(x + 480, y + 48, 320, 28, "Pan");
pan_->type(FL_HOR_NICE_SLIDER);
pan_->bounds(-1, 1);
pan_->step(0.01);
pan_->when(FL_WHEN_RELEASE);
pan_->callback(control, this);
mute_ = new Fl_Check_Button(x + 340, y + 8, 90, 26, "Mute");
solo_ = new Fl_Check_Button(x + 440, y + 8, 90, 26, "Solo");
mute_->callback(control, this);
solo_->callback(control, this);
left_ = new Fl_Progress(x + 70, y + 90, 320, 20, "Output L");
right_ = new Fl_Progress(x + 480, y + 90, 320, 20, "Output R");
for (auto *meter : {left_, right_}) {
meter->minimum(0);
meter->maximum(1);
meter->selection_color(fl_rgb_color(80, 180, 120));
}
parameter_ = new Fl_Choice(x + 110, y + 126, 150, 26, "Automation");
parameter_->add("Gain|Pan");
parameter_->value(0);
parameter_->callback(select, this);
start_ = new Fl_Value_Input(x + 420, y + 126, 140, 26, "First tick");
start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks);
start_->step(3840);
start_->callback(select, this);
auto *hint = new Fl_Box(x + 10, y + 160, w - 20, 24,
"Gain 0..4; pan -1 left / +1 right. M/S toggles; arrows change gain, "
"Shift+arrows pan; Up/Down selects track.");
hint->labelsize(12);
lane_ = new AutomationLane(x + 10, y + 192, w - 20, h - 222, *this);
end();
set_project(p);
}
uint64_t MixerWidget::selected_track() const
{
xtc_track_info t{};
return xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK
? t.id
: UINT64_MAX;
}
xtc_automation_parameter MixerWidget::parameter() const
{
return parameter_->value() == 1 ? XTC_AUTOMATION_PAN : XTC_AUTOMATION_GAIN;
}
uint64_t MixerWidget::lane_start() const
{
if (!std::isfinite(start_->value())) return 0;
return static_cast<uint64_t>(
std::clamp(start_->value(), 0.0, static_cast<double>(XTC_PROJECT_MAX_TICK - lane_ticks)));
}
void MixerWidget::set_project(xtc_project *p)
{
uint64_t selected = project_ == p ? selected_track() : UINT64_MAX;
project_ = p;
tracks_->clear();
size_t n = 0, c = 0;
xtc_project_get_counts(p, &n, &c);
int index = 0;
for (size_t i = 0; i < n; ++i) {
xtc_track_info t{};
xtc_project_get_track(p, i, &t);
std::string label;
for (char ch : std::string(t.name)) {
if (ch == '/' || ch == '\\' || ch == '_' || ch == '&')
label += '\\';
label += ch;
}
tracks_->add(label.c_str());
if (t.id == selected)
index = static_cast<int>(i);
}
tracks_->value(n ? index : -1);
selection();
redraw();
}
void MixerWidget::selection()
{
xtc_track_info t{};
bool valid =
xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK;
for (Fl_Widget *w : {static_cast<Fl_Widget *>(gain_), static_cast<Fl_Widget *>(pan_),
static_cast<Fl_Widget *>(mute_), static_cast<Fl_Widget *>(solo_),
static_cast<Fl_Widget *>(lane_)}) {
if (valid)
w->activate();
else
w->deactivate();
}
gain_->value(valid ? t.gain : 1);
pan_->value(t.pan);
mute_->value(t.mute);
solo_->value(t.solo);
lane_->redraw();
}
void MixerWidget::peaks(float left, float right)
{
left_->value(std::min(left, 1.0f));
right_->value(std::min(right, 1.0f));
left_->selection_color(left > 1 ? FL_RED : FL_GREEN);
right_->selection_color(right > 1 ? FL_RED : FL_GREEN);
}
xtc_result MixerWidget::changed(xtc_result r)
{
if (r == XTC_OK)
do_callback();
else
fl_alert("Mixer edit failed: %s", xtc_result_string(r));
selection();
return r;
}
xtc_result MixerWidget::mix(float gain, float pan, int mute, int solo)
{
xtc_result r = before_ ? before_(context_) : XTC_OK;
if (r == XTC_OK)
r = xtc_project_set_mix(project_, selected_track(), gain, pan, mute, solo);
return changed(r);
}
xtc_result MixerWidget::point(uint64_t tick, float value, bool remove)
{
xtc_result r = before_ ? before_(context_) : XTC_OK;
xtc_automation_point a{selected_track(), tick, parameter(), value};
if (r == XTC_OK)
r = remove ? xtc_project_remove_automation(project_, a.track_id, a.parameter, tick)
: xtc_project_set_automation(project_, &a);
return changed(r);
}
void MixerWidget::control(Fl_Widget *, void *c)
{
auto *m = static_cast<MixerWidget *>(c);
m->mix(static_cast<float>(m->gain_->value()), static_cast<float>(m->pan_->value()),
m->mute_->value(), m->solo_->value());
}
void MixerWidget::select(Fl_Widget *, void *c)
{
static_cast<MixerWidget *>(c)->selection();
}
int MixerWidget::handle(int e)
{
if (e == FL_KEYDOWN && !(Fl::event_state() & (FL_CTRL | FL_ALT | FL_META))) {
int key = Fl::event_key();
xtc_track_info t{};
if (xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK) {
if (key == 'm' || key == 's') {
mix(t.gain, t.pan, key == 'm' ? !t.mute : t.mute, key == 's' ? !t.solo : t.solo);
return 1;
}
if (key == FL_Left || key == FL_Right) {
float d = key == FL_Left ? -0.05f : 0.05f;
bool pan = (Fl::event_state() & FL_SHIFT) != 0;
mix(pan ? t.gain : std::clamp(t.gain + d, 0.0f, 4.0f),
pan ? std::clamp(t.pan + d, -1.0f, 1.0f) : t.pan, t.mute, t.solo);
return 1;
}
if (key == FL_Up || key == FL_Down) {
size_t n = 0, c = 0;
xtc_project_get_counts(project_, &n, &c);
tracks_->value(std::clamp(tracks_->value() + (key == FL_Up ? -1 : 1), 0,
static_cast<int>(n) - 1));
selection();
return 1;
}
}
}
return Fl_Group::handle(e);
}

44
src/gui/mixer_widget.hpp Normal file
View file

@ -0,0 +1,44 @@
#ifndef XTC_MIXER_WIDGET_HPP
#define XTC_MIXER_WIDGET_HPP
#include "xtc/xtc_mixer.h"
#include <FL/Fl_Group.H>
class Fl_Choice;
class Fl_Value_Slider;
class Fl_Check_Button;
class Fl_Progress;
class Fl_Value_Input;
class AutomationLane;
class MixerWidget : public Fl_Group
{
public:
using BeforeEdit = xtc_result (*)(void *);
MixerWidget(int, int, int, int, xtc_project *, BeforeEdit, void *);
void set_project(xtc_project *);
void peaks(float left, float right);
uint64_t selected_track() const;
xtc_project *project() const
{
return project_;
}
xtc_automation_parameter parameter() const;
uint64_t lane_start() const;
xtc_result point(uint64_t tick, float value, bool remove = false);
xtc_result mix(float gain, float pan, int mute, int solo);
int handle(int) override;
private:
xtc_project *project_;
BeforeEdit before_;
void *context_;
Fl_Choice *tracks_, *parameter_;
Fl_Value_Slider *gain_, *pan_;
Fl_Check_Button *mute_, *solo_;
Fl_Progress *left_, *right_;
Fl_Value_Input *start_;
AutomationLane *lane_;
xtc_result changed(xtc_result);
void selection();
static void control(Fl_Widget *, void *);
static void select(Fl_Widget *, void *);
};
#endif

View file

@ -1,13 +1,14 @@
#include "xtc/xtc_midi.h"
#include "../project/project_internal.h"
#include "../mixer/mix_internal.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#define TAU 6.28318530717958647692
typedef struct { uint64_t start, end, age; unsigned int pitch, velocity, channel; } span;
typedef struct { uint64_t start, end, age; unsigned int pitch, velocity, channel; size_t track; } span;
typedef struct { uint64_t time; int change; } endpoint;
typedef struct { unsigned int active, channel, pitch, velocity; uint64_t age; double phase; } voice;
struct xtc_midi_plan {
xtc_mix_snapshot mix;
span notes[XTC_PROJECT_MAX_NOTES];
size_t count;
voice voices[XTC_MIDI_VOICES];
@ -51,6 +52,10 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, x
if (!project || !rate || rate > 768000) return XTC_ERR_INVALID_ARGUMENT;
p = calloc(1, sizeof(*p)); points = calloc(XTC_PROJECT_MAX_NOTES * 2U, sizeof(*points));
if (!p || !points) { free(p); free(points); return XTC_ERR_OUT_OF_MEMORY; }
{
xtc_result r = xtc_mix_prepare(project, rate, &p->mix);
if (r != XTC_OK) { free(points); free(p); return r; }
}
for (i = 0; i < 128; ++i) {
double frequency = 440.0 * pow(2.0, ((double)i - 69.0) / 12.0);
p->step[i] = frequency < rate * 0.5 ? frequency / rate : 0;
@ -71,6 +76,7 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, x
if (r == XTC_OK) r = xtc_project_tick_to_sample(project, begin - n->start_tick, rate, &s->age);
if (r != XTC_OK) { free(points); free(p); return r; }
if (s->end <= s->start) continue;
s->track = xtc_mix_track(&p->mix, c.track_id);
s->pitch = n->pitch; s->velocity = n->velocity; s->channel = n->channel;
points[p->count * 2].time = s->start; points[p->count * 2].change = 1;
points[p->count * 2 + 1].time = s->end; points[p->count * 2 + 1].change = -1;
@ -122,7 +128,7 @@ static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position)
p->voices[slot].pitch = e->pitch; p->voices[slot].velocity = e->velocity;
p->voices[slot].phase = 0; p->voices[slot].age = p->age++;
}
static void segment(xtc_midi_plan *p, uint64_t position, float *out, size_t frames)
static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *right, size_t frames)
{
size_t i, j;
for (i = 0; i < p->count; ++i) {
@ -130,27 +136,30 @@ static void segment(xtc_midi_plan *p, uint64_t position, float *out, size_t fram
uint64_t begin = position > s->start ? position : s->start;
uint64_t end = position + frames < s->end ? position + frames : s->end;
double step = p->step[s->pitch];
if (!step || s->start < p->cutoff[s->channel] || end <= begin) continue;
if (step == 0.0 || s->start < p->cutoff[s->channel] || end <= begin) continue;
for (j = (size_t)(begin - position); j < (size_t)(end - position); ++j) {
double phase = fmod((double)(position + j - s->start + s->age) * step, 1.0);
out[j] += (float)(sin(TAU * phase) * 0.1 * s->velocity / 127.0);
float sample = (float)(sin(TAU * phase) * 0.1 * s->velocity / 127.0);
out[j] += sample * xtc_mix_level(&p->mix, s->track, position + j, right ? 0U : 2U);
if (right) right[j] += sample * xtc_mix_level(&p->mix, s->track, position + j, 1);
}
}
for (i = 0; i < XTC_MIDI_VOICES; ++i) {
voice *v = p->voices + i;
double step = p->step[v->pitch];
if (!v->active || !step) continue;
if (!v->active || step == 0.0) continue;
for (j = 0; j < frames; ++j) {
out[j] += (float)(sin(TAU * v->phase) * 0.1 * v->velocity / 127.0);
float sample = (float)(sin(TAU * v->phase) * 0.1 * v->velocity / 127.0);
out[j] += sample; if (right) right[j] += sample;
v->phase += step; if (v->phase >= 1) v->phase -= 1;
}
}
}
xtc_result xtc_midi_plan_render(xtc_midi_plan *p, uint64_t position,
const xtc_midi_event *events, size_t count, float *out, size_t frames)
xtc_result xtc_midi_plan_render_stereo(xtc_midi_plan *p, uint64_t position,
const xtc_midi_event *events, size_t count, float *out, float *right, size_t frames)
{
size_t i, cursor = 0;
if (!p || !out || count > XTC_MIDI_MAX_EVENTS || (count && !events) ||
if (!p || !out || (right && right == out) || count > XTC_MIDI_MAX_EVENTS || (count && !events) ||
frames > 65536U || position > UINT64_MAX - frames) return XTC_ERR_INVALID_ARGUMENT;
for (i = 0; i < count; ++i) {
const xtc_midi_event *e = events + i;
@ -162,12 +171,16 @@ xtc_result xtc_midi_plan_render(xtc_midi_plan *p, uint64_t position,
if (!frames) return XTC_OK;
if (p->next_position != position) xtc_midi_plan_reset(p);
memset(out, 0, frames * sizeof(*out));
if (right) memset(right, 0, frames * sizeof(*right));
for (i = 0; i < count; ++i) {
size_t next = events[i].offset;
segment(p, position + cursor, out + cursor, next - cursor);
segment(p, position + cursor, out + cursor, right ? right + cursor : NULL, next - cursor);
event(p, events + i, position + next); cursor = next;
}
segment(p, position + cursor, out + cursor, frames - cursor);
segment(p, position + cursor, out + cursor, right ? right + cursor : NULL, frames - cursor);
p->next_position = position + frames;
return XTC_OK;
}
xtc_result xtc_midi_plan_render(xtc_midi_plan *p, uint64_t position, const xtc_midi_event *events, size_t count, float *out, size_t frames)
{ return xtc_midi_plan_render_stereo(p, position, events, count, out, NULL, frames); }

6
src/mixer/README.md Normal file
View file

@ -0,0 +1,6 @@
# Mixer core
`model.c` validates track settings and stable track-relative automation references.
`render.c` prepares sorted sample endpoints and evaluates linear gain/pan lanes
without allocation. `mix_internal.h` is private snapshot storage for audio/MIDI
source plans. See ../../docs/MIXER.md for the stereo pan law and ownership.

20
src/mixer/mix_internal.h Normal file
View file

@ -0,0 +1,20 @@
#ifndef XTC_MIX_INTERNAL_H
#define XTC_MIX_INTERNAL_H
#include "../project/project_internal.h"
typedef struct {
uint64_t sample, tick;
size_t track;
unsigned int parameter;
float value;
} xtc_mix_point;
typedef struct {
xtc_track_info tracks[XTC_PROJECT_MAX_TRACKS];
size_t count, offsets[XTC_PROJECT_MAX_TRACKS][2], lengths[XTC_PROJECT_MAX_TRACKS][2];
xtc_mix_point points[XTC_MAX_AUTOMATION_POINTS];
int any_solo;
} xtc_mix_snapshot;
xtc_result xtc_mix_prepare(const xtc_project *, unsigned int rate, xtc_mix_snapshot *);
size_t xtc_mix_track(const xtc_mix_snapshot *, uint64_t id);
/* side 0/1 stereo, 2 mono (gain only). */
float xtc_mix_level(const xtc_mix_snapshot *, size_t track, uint64_t sample, unsigned int side);
#endif

81
src/mixer/model.c Normal file
View file

@ -0,0 +1,81 @@
#include "../project/project_internal.h"
#include <math.h>
#include <string.h>
static size_t find_track(const xtc_project *p, uint64_t id)
{
size_t i;
for (i = 0; i < p->track_count; ++i)
if (p->tracks[i].id == id)
return i;
return SIZE_MAX;
}
xtc_result xtc_project_set_mix(xtc_project *p, uint64_t id, float gain, float pan, int mute,
int solo)
{
size_t i;
if (!p || !isfinite(gain) || gain < 0 || gain > 4 || !isfinite(pan) || pan < -1 || pan > 1 ||
(mute != 0 && mute != 1) || (solo != 0 && solo != 1))
return XTC_ERR_INVALID_ARGUMENT;
i = find_track(p, id);
if (i == SIZE_MAX)
return XTC_ERR_NOT_FOUND;
p->tracks[i].gain = gain;
p->tracks[i].pan = pan;
p->tracks[i].mute = mute;
p->tracks[i].solo = solo;
return XTC_OK;
}
xtc_result xtc_project_set_automation(xtc_project *p, const xtc_automation_point *a)
{
size_t i;
if (!p || !a || !isfinite(a->value) || a->tick > XTC_PROJECT_MAX_TICK ||
(a->parameter != XTC_AUTOMATION_GAIN && a->parameter != XTC_AUTOMATION_PAN) ||
a->value < (a->parameter == XTC_AUTOMATION_GAIN ? 0 : -1) ||
a->value > (a->parameter == XTC_AUTOMATION_GAIN ? 4 : 1))
return XTC_ERR_INVALID_ARGUMENT;
if (find_track(p, a->track_id) == SIZE_MAX)
return XTC_ERR_NOT_FOUND;
for (i = 0; i < p->automation_count; ++i)
if (p->automation[i].track_id == a->track_id &&
p->automation[i].parameter == a->parameter && p->automation[i].tick == a->tick)
break;
if (i == XTC_MAX_AUTOMATION_POINTS)
return XTC_ERR_CAPACITY;
if (i == p->automation_count)
++p->automation_count;
p->automation[i] = *a;
return XTC_OK;
}
xtc_result xtc_project_remove_automation(xtc_project *p, uint64_t id,
xtc_automation_parameter param, uint64_t tick)
{
size_t i;
if (!p || (param != XTC_AUTOMATION_GAIN && param != XTC_AUTOMATION_PAN) ||
tick > XTC_PROJECT_MAX_TICK)
return XTC_ERR_INVALID_ARGUMENT;
for (i = 0; i < p->automation_count; ++i)
if (p->automation[i].track_id == id && p->automation[i].parameter == param &&
p->automation[i].tick == tick)
break;
if (i == p->automation_count)
return XTC_ERR_NOT_FOUND;
memmove(p->automation + i, p->automation + i + 1,
(--p->automation_count - i) * sizeof(*p->automation));
return XTC_OK;
}
xtc_result xtc_project_automation_count(const xtc_project *p, size_t *n)
{
if (!p || !n)
return XTC_ERR_INVALID_ARGUMENT;
*n = p->automation_count;
return XTC_OK;
}
xtc_result xtc_project_get_automation(const xtc_project *p, size_t i, xtc_automation_point *a)
{
if (!p || !a)
return XTC_ERR_INVALID_ARGUMENT;
if (i >= p->automation_count)
return XTC_ERR_NOT_FOUND;
*a = p->automation[i];
return XTC_OK;
}

87
src/mixer/render.c Normal file
View file

@ -0,0 +1,87 @@
#include "mix_internal.h"
#include <stdlib.h>
#include <string.h>
size_t xtc_mix_track(const xtc_mix_snapshot *m, uint64_t id)
{
size_t i;
for (i = 0; i < m->count; ++i)
if (m->tracks[i].id == id)
return i;
return SIZE_MAX;
}
static int compare(const void *left, const void *right)
{
const xtc_mix_point *a = left, *b = right;
if (a->track != b->track)
return a->track < b->track ? -1 : 1;
if (a->parameter != b->parameter)
return a->parameter < b->parameter ? -1 : 1;
return a->tick < b->tick ? -1 : a->tick != b->tick;
}
xtc_result xtc_mix_prepare(const xtc_project *p, unsigned int rate, xtc_mix_snapshot *m)
{
size_t i;
xtc_result r;
memset(m, 0, sizeof(*m));
m->count = p->track_count;
memcpy(m->tracks, p->tracks, p->track_count * sizeof(*m->tracks));
for (i = 0; i < m->count; ++i)
if (m->tracks[i].solo)
m->any_solo = 1;
for (i = 0; i < p->automation_count; ++i) {
const xtc_automation_point *a = p->automation + i;
xtc_mix_point *b = m->points + i;
b->track = xtc_mix_track(m, a->track_id);
b->parameter = (unsigned int)a->parameter;
if (b->track == SIZE_MAX || b->parameter > 1)
return XTC_ERR_INVALID_STATE;
b->tick = a->tick;
b->value = a->value;
r = xtc_project_tick_to_sample(p, a->tick, rate, &b->sample);
if (r != XTC_OK)
return r;
}
qsort(m->points, p->automation_count, sizeof(*m->points), compare);
for (i = 0; i < p->automation_count; ++i) {
xtc_mix_point *a = m->points + i;
size_t *n = &m->lengths[a->track][a->parameter];
if (!*n)
m->offsets[a->track][a->parameter] = i;
++*n;
}
return XTC_OK;
}
static float value(const xtc_mix_snapshot *m, size_t track, unsigned int param, uint64_t sample)
{
size_t low = 0, high = m->lengths[track][param], count = high;
const xtc_mix_point *p = m->points + m->offsets[track][param];
if (!count)
return param ? m->tracks[track].pan : m->tracks[track].gain;
while (low < high) {
size_t mid = low + (high - low) / 2;
if (p[mid].sample <= sample)
low = mid + 1;
else
high = mid;
}
if (!low)
return p[0].value;
if (low == count)
return p[count - 1].value;
return (float)(p[low - 1].value + ((double)sample - (double)p[low - 1].sample) /
(double)(p[low].sample - p[low - 1].sample) *
(p[low].value - p[low - 1].value));
}
float xtc_mix_level(const xtc_mix_snapshot *m, size_t track, uint64_t sample, unsigned int side)
{
float gain, pan;
if (track >= m->count)
return 0;
if (m->tracks[track].mute || (m->any_solo && !m->tracks[track].solo))
return 0;
gain = value(m, track, 0, sample);
if (side == 2)
return gain;
pan = value(m, track, 1, sample);
return gain * (side == 0 ? (pan > 0 ? 1 - pan : 1) : (pan < 0 ? 1 + pan : 1));
}

View file

@ -4,3 +4,7 @@ project_file.c implements bounded version-2 project metadata loading (including
temporary-file/fsync/rename saving in C. Loading validates a candidate before
replacing the existing model. All I/O is control-thread only.
See docs/PROJECT_FORMAT.md for the schema, limits and durability guarantees.
Phase 8 writes format v4 with per-track MIX records and bounded AUTOMATION/POINT
records, reading v1–v3 with unity/center/no-automation defaults. History stays
session-only. See ../../docs/PROJECT_FORMAT.md.

View file

@ -89,7 +89,7 @@ static xtc_result read_project(reader *input, xtc_project *project)
size_t i, j;
xtc_graph *graph = project->graph;
READ("XTC_PROJECT", 2U); NUM(1U, version);
if (version < 1U || version > 3U) return XTC_ERR_UNSUPPORTED;
if (version < 1U || version > 4U) return XTC_ERR_UNSUPPORTED;
format_version = version;
READ("APP", 4U);
for (i = 1U; i < 4U; ++i) { NUM(i, value); if (value > UINT_MAX) return XTC_ERR_FORMAT; }
@ -124,6 +124,14 @@ static xtc_result read_project(reader *input, xtc_project *project)
for (i = 0U; i < project->track_count; ++i) {
READ("TRACK", 3U); NUM(1U, project->tracks[i].id);
if (!decode_name(input->tokens[2], project->tracks[i].name)) return XTC_ERR_FORMAT;
project->tracks[i].gain = 1;
if (format_version >= 4U) {
READ("MIX", 5U);
if (!decode_float(input->tokens[1], &project->tracks[i].gain) ||
!decode_float(input->tokens[2], &project->tracks[i].pan)) return XTC_ERR_FORMAT;
NUM(3U, value); if (value > 1) return XTC_ERR_FORMAT; project->tracks[i].mute = (int)value;
NUM(4U, value); if (value > 1) return XTC_ERR_FORMAT; project->tracks[i].solo = (int)value;
}
}
READ("CLIPS", 2U); NUM(1U, value);
if (value > XTC_PROJECT_MAX_CLIPS) return XTC_ERR_FORMAT;
@ -167,6 +175,19 @@ static xtc_result read_project(reader *input, xtc_project *project)
NUM(7U, value); if (value > 15) return XTC_ERR_FORMAT; n->channel = (unsigned int)value;
}
}
if (format_version >= 4U) {
READ("AUTOMATION", 2U); NUM(1U, value);
if (value > XTC_MAX_AUTOMATION_POINTS) return XTC_ERR_FORMAT;
project->automation_count = (size_t)value;
for (i = 0; i < project->automation_count; ++i) {
xtc_automation_point *a = project->automation + i;
READ("POINT", 5U); NUM(1U, a->track_id); NUM(2U, value);
if (value > 1) return XTC_ERR_FORMAT;
a->parameter = (xtc_automation_parameter)value;
NUM(3U, a->tick);
if (!decode_float(input->tokens[4], &a->value)) return XTC_ERR_FORMAT;
}
}
READ("END", 1U);
if (fgetc(input->file) != EOF) return XTC_ERR_FORMAT;
if (ferror(input->file)) return XTC_ERR_IO;
@ -183,7 +204,7 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
const xtc_graph *graph = project->graph;
size_t i;
if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) return XTC_ERR_UNSUPPORTED;
if (fprintf(file, "XTC_PROJECT 3\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
if (fprintf(file, "XTC_PROJECT 4\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
XTC_VERSION_MAJOR, XTC_VERSION_MINOR, XTC_VERSION_PATCH, project->tempo,
project->next_track_id, project->next_clip_id, graph->require_output_nodes,
graph->next_id, graph->node_count, graph->edge_count) < 0) return XTC_ERR_IO;
@ -197,8 +218,13 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
if (fprintf(file, "EDGE %zu %zu\n", graph->nodes[graph->edges[i].source].id,
graph->nodes[graph->edges[i].destination].id) < 0) return XTC_ERR_IO;
if (fprintf(file, "TRACKS %zu\n", project->track_count) < 0) return XTC_ERR_IO;
for (i = 0U; i < project->track_count; ++i)
if (fprintf(file, "TRACK %" PRIu64 " ", project->tracks[i].id) < 0 || !write_name(file, project->tracks[i].name)) return XTC_ERR_IO;
for (i = 0U; i < project->track_count; ++i) {
uint32_t gain, pan;
const xtc_track_info *t = project->tracks + i;
memcpy(&gain, &t->gain, sizeof(gain)); memcpy(&pan, &t->pan, sizeof(pan));
if (fprintf(file, "TRACK %" PRIu64 " ", t->id) < 0 || !write_name(file, t->name) ||
fprintf(file, "MIX %08" PRIx32 " %08" PRIx32 " %d %d\n", gain, pan, t->mute, t->solo) < 0) return XTC_ERR_IO;
}
if (fprintf(file, "CLIPS %zu\n", project->clip_count) < 0) return XTC_ERR_IO;
for (i = 0U; i < project->clip_count; ++i) {
const xtc_clip_info *clip = project->clips + i;
@ -217,6 +243,13 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
if (fprintf(file, "NOTE %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " %u %u %u\n",
n->id, n->clip_id, n->start_tick, n->length_tick, n->pitch, n->velocity, n->channel) < 0) return XTC_ERR_IO;
}
if (fprintf(file, "AUTOMATION %zu\n", project->automation_count) < 0) return XTC_ERR_IO;
for (i = 0; i < project->automation_count; ++i) {
uint32_t bits; const xtc_automation_point *a = project->automation + i;
memcpy(&bits, &a->value, sizeof(bits));
if (fprintf(file, "POINT %" PRIu64 " %u %" PRIu64 " %08" PRIx32 "\n",
a->track_id, (unsigned int)a->parameter, a->tick, bits) < 0) return XTC_ERR_IO;
}
return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK;
}
static size_t path_length(const char *path)

View file

@ -4,3 +4,8 @@ project.c owns the opaque C project: graph, bounded tracks, silent/audio arrange
regions and external asset references, tempo and stable ID allocation. The GUI borrows handles and edits through
xtc_project.h. Tick/sample conversion and source-preserving trim validation live
here; no FLTK, file I/O or callback scheduling belongs in this module.
Phase 8 adds track mix settings, bounded automation and 32-edit C snapshot
history in history.c. Checkout/accept separates candidate installation from
history cursor movement; allocation IDs stay monotonic across history branches.
See ../../docs/MIXER.md.

111
src/project/history.c Normal file
View file

@ -0,0 +1,111 @@
#include "../graph/graph_internal.h"
#include "project_internal.h"
#include <stdlib.h>
#include <string.h>
struct xtc_history {
xtc_project *states[XTC_HISTORY_EDITS + 1];
size_t count, cursor;
uint64_t next_track, next_clip, next_note;
size_t next_node;
};
static void remember_ids(xtc_history *h, const xtc_project *p)
{
if (p->next_track_id > h->next_track)
h->next_track = p->next_track_id;
if (p->next_clip_id > h->next_clip)
h->next_clip = p->next_clip_id;
if (p->next_note_id > h->next_note)
h->next_note = p->next_note_id;
if (p->graph->next_id > h->next_node)
h->next_node = p->graph->next_id;
}
void xtc_history_destroy(xtc_history *h)
{
size_t i;
if (!h)
return;
for (i = 0; i < h->count; ++i)
xtc_project_destroy(h->states[i]);
free(h);
}
xtc_result xtc_history_create(const xtc_project *p, xtc_history **out)
{
xtc_history *h;
xtc_result r;
if (!out)
return XTC_ERR_INVALID_ARGUMENT;
*out = NULL;
if (!p)
return XTC_ERR_INVALID_ARGUMENT;
h = calloc(1, sizeof(*h));
if (!h)
return XTC_ERR_OUT_OF_MEMORY;
r = xtc_project_clone(p, h->states);
if (r != XTC_OK) {
free(h);
return r;
}
remember_ids(h, p);
h->count = 1;
*out = h;
return XTC_OK;
}
xtc_result xtc_history_record(xtc_history *h, const xtc_project *p)
{
xtc_project *copy;
size_t i;
xtc_result r;
if (!h || !p)
return XTC_ERR_INVALID_ARGUMENT;
r = xtc_project_clone(p, &copy);
if (r != XTC_OK)
return r;
remember_ids(h, p);
for (i = h->cursor + 1; i < h->count; ++i)
xtc_project_destroy(h->states[i]);
h->count = h->cursor + 1;
if (h->count == XTC_HISTORY_EDITS + 1) {
xtc_project_destroy(h->states[0]);
memmove(h->states, h->states + 1, XTC_HISTORY_EDITS * sizeof(*h->states));
--h->count;
}
h->states[h->count++] = copy;
h->cursor = h->count - 1;
return XTC_OK;
}
static int available(const xtc_history *h, int redo)
{
return redo ? h->cursor + 1 < h->count : h->cursor > 0;
}
xtc_result xtc_history_checkout(const xtc_history *h, int redo, xtc_project **out)
{
if (!out)
return XTC_ERR_INVALID_ARGUMENT;
*out = NULL;
if (!h || (redo != 0 && redo != 1))
return XTC_ERR_INVALID_ARGUMENT;
if (!available(h, redo))
return XTC_ERR_NOT_FOUND;
{
xtc_result r = xtc_project_clone(h->states[redo ? h->cursor + 1 : h->cursor - 1], out);
if (r == XTC_OK) {
(*out)->next_track_id = h->next_track;
(*out)->next_clip_id = h->next_clip;
(*out)->next_note_id = h->next_note;
(*out)->graph->next_id = h->next_node;
}
return r;
}
}
xtc_result xtc_history_accept(xtc_history *h, int redo)
{
if (!h || (redo != 0 && redo != 1))
return XTC_ERR_INVALID_ARGUMENT;
if (!available(h, redo))
return XTC_ERR_NOT_FOUND;
if (redo)
++h->cursor;
else
--h->cursor;
return XTC_OK;
}

View file

@ -2,6 +2,7 @@
#include "xtc/xtc_audio.h"
#include "../graph/graph_internal.h"
#include <stdlib.h>
#include <math.h>
#include <string.h>
int xtc_project_valid_name(const char *name)
@ -127,7 +128,7 @@ xtc_result xtc_project_add_track(xtc_project *project, const char *name, uint64_
xtc_track_info track = {0};
if (project == NULL || id == NULL || !xtc_project_valid_name(name)) return XTC_ERR_INVALID_ARGUMENT;
if (project->track_count == XTC_PROJECT_MAX_TRACKS || project->next_track_id == UINT64_MAX) return XTC_ERR_CAPACITY;
track.id = project->next_track_id++;
track.id = project->next_track_id++; track.gain = 1.0f;
memcpy(track.name, name, strlen(name) + 1U);
project->tracks[project->track_count++] = track; *id = track.id; return XTC_OK;
}
@ -146,7 +147,11 @@ xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id)
count = 0;
for (i = 0; i < project->note_count; ++i)
if (clip_index(project, project->notes[i].clip_id) != SIZE_MAX) project->notes[count++] = project->notes[i];
project->note_count = count; return XTC_OK;
project->note_count = count;
count = 0;
for (i = 0; i < project->automation_count; ++i)
if (project->automation[i].track_id != id) project->automation[count++] = project->automation[i];
project->automation_count = count; return XTC_OK;
}
xtc_result xtc_project_add_clip(xtc_project *project, uint64_t track, const char *name,
uint64_t start, uint64_t length, uint64_t *id)
@ -246,6 +251,21 @@ xtc_result xtc_project_validate(const xtc_project *project)
n->length_tick > project->clips[c].source_length_tick - n->start_tick) return XTC_ERR_FORMAT;
for (j = 0; j < i; ++j) if (project->notes[j].id == n->id) return XTC_ERR_FORMAT;
}
if (project->automation_count > XTC_MAX_AUTOMATION_POINTS) return XTC_ERR_FORMAT;
for (i = 0; i < project->track_count; ++i) {
const xtc_track_info *t = project->tracks + i;
if (!isfinite(t->gain) || t->gain < 0 || t->gain > 4 || !isfinite(t->pan) || t->pan < -1 || t->pan > 1 ||
(t->mute != 0 && t->mute != 1) || (t->solo != 0 && t->solo != 1)) return XTC_ERR_FORMAT;
}
for (i = 0; i < project->automation_count; ++i) {
const xtc_automation_point *a = project->automation + i;
if (track_index(project, a->track_id) == SIZE_MAX || a->tick > XTC_PROJECT_MAX_TICK ||
!isfinite(a->value) || (a->parameter != XTC_AUTOMATION_GAIN && a->parameter != XTC_AUTOMATION_PAN) ||
a->value < (a->parameter == XTC_AUTOMATION_GAIN ? 0 : -1) ||
a->value > (a->parameter == XTC_AUTOMATION_GAIN ? 4 : 1)) return XTC_ERR_FORMAT;
for (j = 0; j < i; ++j) if (project->automation[j].track_id == a->track_id &&
project->automation[j].parameter == a->parameter && project->automation[j].tick == a->tick) return XTC_ERR_FORMAT;
}
return XTC_OK;
}

View file

@ -2,6 +2,7 @@
#define XTC_PROJECT_INTERNAL_H
#include "xtc/xtc_project.h"
#include "xtc/xtc_midi.h"
#include "xtc/xtc_mixer.h"
struct xtc_project {
xtc_graph *graph;
uint64_t next_track_id, next_clip_id;
@ -9,6 +10,8 @@ struct xtc_project {
size_t track_count, clip_count;
uint64_t next_note_id;
size_t note_count;
size_t automation_count;
xtc_automation_point automation[XTC_MAX_AUTOMATION_POINTS];
xtc_note_info notes[XTC_PROJECT_MAX_NOTES];
xtc_track_info tracks[XTC_PROJECT_MAX_TRACKS];
xtc_clip_info clips[XTC_PROJECT_MAX_CLIPS];

View file

@ -135,3 +135,24 @@ draw/move/resize notes, save/reopen and render them. The live MIDI target connec
only software clients and checks on/off, panic, disconnect, and saved-note
playback/seek. It needs an existing JACK-compatible server. No hardware routes
or system changes are made. MIDI recording/SMF and physical devices are deferred.
## Phase 8 verification
```sh
make all test gui debug test-gui test-gui-jack test-jack-audio test-jack-midi
make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui
```
`test_mixer` covers stereo gain/pan, mute/solo, sample automation, block
independence, WAV and graph routing, v4 persistence failures and bounded history.
GUI tests add automation mouse edits, mixer keys, history rollback/branching and
save/reopen. The existing live MIDI test now uses two software capture ports and
checks hard-right saved-note playback and output meter snapshots; mono audio
record/play acceptance remains covered. A native Xvfb mouse smoke check confirms
sliders and automation drawing. See MIXER.md for capacities and deferred features.
The fresh Phase 8 verification build, no-JACK core/GUI suite and Clang
ASan/UBSan core/GUI suite passed without compiler warnings. GUI leak checking
retains only the existing fontconfig suppression. ThreadSanitizer also passed
the recorder concurrency and realtime publication tests. Physical devices and
long-running stereo performance remain unverified.

View file

@ -2,6 +2,7 @@
#include "../../src/gui/graph_widget.hpp"
#include "../../src/gui/sequencer_widget.hpp"
#include "../../src/gui/piano_roll.hpp"
#include "../../src/gui/mixer_widget.hpp"
#include <unistd.h>
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
@ -89,7 +90,7 @@ void reset()
extern "C" {
xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out)
{
CHECK(config->kind == XTC_BACKEND_JACK && config->channel_count == 1U);
CHECK(config->kind == XTC_BACKEND_JACK && config->channel_count == 2U);
++opens;
*out = open_result == XTC_OK || partial_open ? &handle : nullptr;
if (*out != nullptr) { ++alive; handle.running = 0; CHECK(handle.plan == nullptr); engine_status.active_revision = engine_status.submitted_revision = 0U; }
@ -328,6 +329,41 @@ int main(int argc, char **argv)
press(find(&window, "Panic"));
CHECK(xtc_midi_plan_render(handle.midi, 18032, nullptr, 0, instrument, 32) == XTC_OK && instrument[0] == 0);
engine_status.sample_position = 0;
// Mixer edits and automation are undoable; failed backend install retains cursor.
auto *mixer_ui = window.mixer(); CHECK(mixer_ui != nullptr);
xtc_track_info mixed{};
CHECK(mixer_ui->mix(0.5f, -1, 0, 1) == XTC_OK);
CHECK(xtc_project_get_track(sequence->project(), 0, &mixed) == XTC_OK && mixed.gain == 0.5f && mixed.pan == -1 && mixed.solo);
CHECK(mixer_ui->point(0, 0) == XTC_OK && mixer_ui->point(960, 1) == XTC_OK);
size_t points = 0;
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 2);
CHECK(window.undo() == XTC_OK);
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 1);
submit_result = XTC_ERR_CAPACITY;
CHECK(window.undo(true) == XTC_ERR_CAPACITY);
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 1);
submit_result = XTC_OK;
CHECK(window.undo(true) == XTC_OK);
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 2);
CHECK(window.undo() == XTC_OK && mixer_ui->point(1920, 0.25f) == XTC_OK && window.undo(true) == XTC_ERR_NOT_FOUND);
CHECK(window.save_project(filename.c_str()) == XTC_OK && window.open_project(filename.c_str()) == XTC_OK);
CHECK(xtc_project_get_track(sequence->project(), 0, &mixed) == XTC_OK && mixed.gain == 0.5f && mixed.pan == -1);
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 2);
CHECK(window.undo() == XTC_ERR_NOT_FOUND); // Open resets history.
auto *lane = mixer_ui->child(mixer_ui->children() - 1);
saved_x = Fl::e_x; saved_y = Fl::e_y; saved_key = Fl::e_keysym;
Fl::e_x = lane->x() + (lane->w() - 1) / 4; Fl::e_y = lane->y() + lane->h() / 2;
Fl::e_keysym = FL_Button + FL_LEFT_MOUSE;
CHECK(lane->handle(FL_PUSH) == 1);
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 3);
Fl::e_keysym = FL_Button + FL_RIGHT_MOUSE;
CHECK(lane->handle(FL_PUSH) == 1);
CHECK(xtc_project_automation_count(sequence->project(), &points) == XTC_OK && points == 2);
Fl::e_keysym = 'm'; CHECK(mixer_ui->handle(FL_KEYDOWN) == 1);
CHECK(xtc_project_get_track(sequence->project(), 0, &mixed) == XTC_OK && mixed.mute);
CHECK(window.undo() == XTC_OK);
CHECK(xtc_project_get_track(sequence->project(), 0, &mixed) == XTC_OK && !mixed.mute);
Fl::e_x = saved_x; Fl::e_y = saved_y; Fl::e_keysym = saved_key;
CHECK(unlink(filename.c_str()) == 0 && rmdir(directory) == 0);
engine_status.xruns = 3U;
engine_status.processed_blocks = 123U;

View file

@ -81,7 +81,7 @@ int main(int argc, char **argv)
auto *status = find(window, "JACK |");
CHECK(status != nullptr);
unsigned int rate = 0U, frames = 0U, blocks = 0U;
CHECK(std::sscanf(status->label(), "JACK | %u Hz | %u frames | mono | %u blocks", &rate, &frames, &blocks) == 3);
CHECK(std::sscanf(status->label(), "JACK | %u Hz | %u frames | stereo | %u blocks", &rate, &frames, &blocks) == 3);
CHECK(rate > 0U && frames > 0U && blocks > 0U);
stop->do_callback();
CHECK(find(window, "ready / stopped") != nullptr);

View file

@ -1,5 +1,6 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_backend.h"
#include "xtc/xtc_mixer.h"
#include <jack/jack.h>
#include <jack/midiport.h>
#include <errno.h>
@ -9,17 +10,19 @@
#include <time.h>
#include <unistd.h>
typedef struct {
jack_port_t *input, *midi;
unsigned int command, seen, loud, heard, bad;
jack_port_t *input, *right, *midi;
unsigned int command, seen, loud, right_loud, heard, bad;
} probe;
static int process(jack_nframes_t frames, void *context)
{
probe *p = context;
const float *input = jack_port_get_buffer(p->input, frames);
const float *right = jack_port_get_buffer(p->right, frames);
void *midi = jack_port_get_buffer(p->midi, frames);
unsigned int right_loud = 0;
unsigned int command = __atomic_load_n(&p->command, __ATOMIC_ACQUIRE), loud = 0;
jack_nframes_t i;
if (!input || !midi) { __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); return 0; }
if (!input || !right || !midi) { __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); return 0; }
jack_midi_clear_buffer(midi);
if (command && command != p->seen) {
unsigned char bytes[3] = {0x90, 69, command % 2 ? 127 : 0};
@ -30,8 +33,11 @@ static int process(jack_nframes_t frames, void *context)
for (i = 0; i < frames; ++i) {
if (!isfinite(input[i]) || fabsf(input[i]) > 0.101f) __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED);
if (fabsf(input[i]) > 0.01f) loud = 1;
if (!isfinite(right[i]) || fabsf(right[i]) > 0.101f) __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED);
if (fabsf(right[i]) > 0.01f) right_loud = 1;
}
__atomic_store_n(&p->loud, loud, __ATOMIC_RELAXED);
__atomic_store_n(&p->right_loud, right_loud, __ATOMIC_RELAXED);
if (loud) __atomic_store_n(&p->heard, 1U, __ATOMIC_RELAXED);
return 0;
}
@ -41,7 +47,7 @@ static void wait_blocks(void)
int main(void)
{
char directory[] = "/tmp/xtc-live-midi-XXXXXX", song[256] = "", name[64];
xtc_backend_config config = {XTC_BACKEND_JACK, name, 65536, 1};
xtc_backend_config config = {XTC_BACKEND_JACK, name, 65536, 2};
xtc_backend *backend = NULL;
xtc_project *p = NULL, *q = NULL;
jack_client_t *client = NULL;
@ -60,8 +66,9 @@ int main(void)
REQUIRE(xtc_backend_prepare_project(backend, p) == XTC_OK);
client = jack_client_open("xtc-midi-probe", JackNoStartServer, &flags); REQUIRE(client);
state.input = jack_port_register(client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
state.right = jack_port_register(client, "right", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
state.midi = jack_port_register(client, "midi", JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0);
REQUIRE(state.input && state.midi);
REQUIRE(state.input && state.right && state.midi);
REQUIRE(jack_set_process_callback(client, process, &state) == 0 && jack_activate(client) == 0);
REQUIRE(xtc_backend_start(backend) == XTC_OK);
REQUIRE(xtc_backend_midi_port_name(backend, &destination) == XTC_OK);
@ -72,6 +79,10 @@ int main(void)
REQUIRE(connected == 0 || connected == EEXIST);
}
REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK);
{ const char *own; REQUIRE(xtc_backend_output_name(backend, 1, &own) == XTC_OK);
int connected = jack_connect(client, own, jack_port_name(state.right)); REQUIRE(connected == 0 || connected == EEXIST); }
{ const char *own; REQUIRE(xtc_backend_output_name(backend, 1, &own) == XTC_OK);
int connected = jack_connect(client, own, jack_port_name(state.right)); REQUIRE(connected == 0 || connected == EEXIST); }
__atomic_store_n(&state.command, 1U, __ATOMIC_RELEASE); wait_blocks();
REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
__atomic_store_n(&state.command, 2U, __ATOMIC_RELEASE); wait_blocks();
@ -86,17 +97,23 @@ int main(void)
wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(xtc_backend_stop(backend) == XTC_OK);
REQUIRE(xtc_project_add_note(p, clip, 0, 960, 69, 100, 0, &note) == XTC_OK);
REQUIRE(xtc_project_set_mix(p, track, 0.5f, 1, 0, 0) == XTC_OK);
REQUIRE(xtc_project_save(p, song) == XTC_OK && xtc_project_load(q, song) == XTC_OK);
REQUIRE(xtc_backend_prepare_project(backend, q) == XTC_OK);
REQUIRE(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK);
REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK);
wait_blocks(); REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
{ const char *own; REQUIRE(xtc_backend_output_name(backend, 1, &own) == XTC_OK);
int connected = jack_connect(client, own, jack_port_name(state.right)); REQUIRE(connected == 0 || connected == EEXIST); }
wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED) && __atomic_load_n(&state.right_loud, __ATOMIC_RELAXED));
{ xtc_backend_status status; REQUIRE(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_left == 0 && status.peak_right > 0); }
REQUIRE(xtc_backend_stop(backend) == XTC_OK);
REQUIRE(xtc_backend_seek(backend, 7680000) == XTC_OK && xtc_backend_start(backend) == XTC_OK);
REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK);
{ const char *own; REQUIRE(xtc_backend_output_name(backend, 1, &own) == XTC_OK);
int connected = jack_connect(client, own, jack_port_name(state.right)); REQUIRE(connected == 0 || connected == EEXIST); }
wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED));
REQUIRE(__atomic_load_n(&state.heard, __ATOMIC_RELAXED) && !__atomic_load_n(&state.bad, __ATOMIC_RELAXED));
puts("Live JACK MIDI input/note-off/panic and saved/reopened scheduled instrument playback passed.");
puts("Live JACK MIDI/panic/disconnect and saved stereo gain/pan playback/meters passed.");
result = EXIT_SUCCESS;
cleanup:
if (backend && xtc_backend_close(backend) != XTC_OK) return EXIT_FAILURE;

View file

@ -3,6 +3,7 @@
* Processing is driven synchronously here; realtime concurrency is tested
* separately by test_realtime. No fake header replaces installed declarations. */
#include "xtc/xtc_backend.h"
#include "xtc/xtc_mixer.h"
#include <jack/jack.h>
#include <jack/midiport.h>
#include <stdio.h>
@ -18,8 +19,8 @@ static JackBufferSizeCallback size_cb;
static JackXRunCallback xrun_cb;
static JackShutdownCallback shutdown_cb;
static void *context;
static float input[128], output[128];
static int client_token, input_token, output_token, midi_token;
static float input[128], output[128], output_right[128];
static int client_token, input_token, output_token, right_token, midi_token;
static unsigned int midi_count;
static jack_midi_event_t midi_events[256];
static int open_fail, register_fail, callback_fail, activate_fail, deactivate_fail, close_fail;
@ -44,6 +45,7 @@ jack_port_t *jack_port_register(jack_client_t *client, const char *name, const c
(void)client; (void)name; (void)type; (void)buffer_size;
++port_calls;
if (register_fail == port_calls) return NULL;
if (!strcmp(name, "output_right")) return (jack_port_t *)&right_token;
if (!strcmp(type, JACK_DEFAULT_MIDI_TYPE)) return (jack_port_t *)&midi_token;
return (jack_port_t *)(flags == JackPortIsInput ? &input_token : &output_token);
}
@ -66,12 +68,12 @@ int jack_activate(jack_client_t *client)
int jack_deactivate(jack_client_t *client)
{ (void)client; if (deactivate_fail) return -1; active = 0; return 0; }
void *jack_port_get_buffer(jack_port_t *port, jack_nframes_t frames)
{ (void)frames; if (null_buffer) return NULL; if (port == (jack_port_t *)&midi_token) return &midi_token; return port == (jack_port_t *)&input_token ? input : output; }
{ (void)frames; if (null_buffer) return NULL; if (port == (jack_port_t *)&midi_token) return &midi_token; if (port == (jack_port_t *)&right_token) return output_right; return port == (jack_port_t *)&input_token ? input : output; }
uint32_t jack_midi_get_event_count(void *buffer) { (void)buffer; return midi_count; }
int jack_midi_event_get(jack_midi_event_t *event, void *buffer, uint32_t index)
{ (void)buffer; if (index >= midi_count || index >= 256) return -1; *event = midi_events[index]; return 0; }
const char *jack_port_name(const jack_port_t *port)
{ if (port == (jack_port_t *)&midi_token) return "test:midi_in"; return port == (jack_port_t *)&input_token ? "test:input" : "test:output"; }
{ if (port == (jack_port_t *)&right_token) return "test:output_right"; if (port == (jack_port_t *)&midi_token) return "test:midi_in"; return port == (jack_port_t *)&input_token ? "test:input" : "test:output"; }
const char **jack_get_ports(jack_client_t *client, const char *pattern, const char *type, unsigned long flags)
{
const char **list = malloc(2U * sizeof(*list));
@ -97,7 +99,7 @@ int main(void)
const char *name;
int visits = 0, iteration;
CHECK(xtc_backend_open(NULL, &backend) == XTC_ERR_INVALID_ARGUMENT && backend == NULL);
config.channel_count = 2U;
config.channel_count = 3U;
CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_INVALID_ARGUMENT);
config.channel_count = 1U;
config.kind = (xtc_backend_kind)999;
@ -297,6 +299,25 @@ int main(void)
CHECK(xtc_backend_close(backend) == XTC_OK);
xtc_project_destroy(p);
}
{
xtc_project *p = NULL; uint64_t track, clip, note;
config.channel_count = 2;
register_fail = 4;
CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_IO && backend == NULL);
register_fail = 0;
CHECK(xtc_backend_open(&config, &backend) == XTC_OK);
CHECK(xtc_backend_output_name(backend, 1, &name) == XTC_OK && strcmp(name, "test:output_right") == 0);
CHECK(xtc_project_create(&p) == XTC_OK && xtc_project_add_track(p, "Right", &track) == XTC_OK);
CHECK(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK);
CHECK(xtc_project_add_note(p, clip, 0, 960, 69, 127, 0, &note) == XTC_OK);
CHECK(xtc_project_set_mix(p, track, 0.5f, 1, 0, 0) == XTC_OK);
CHECK(xtc_backend_prepare_project(backend, p) == XTC_OK && xtc_backend_start(backend) == XTC_OK);
CHECK(process_cb(32, context) == 0 && output[1] == 0 && output_right[1] > 0);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_left == 0 && status.peak_right > 0);
CHECK(xtc_backend_stop(backend) == XTC_OK);
CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_right == 0);
CHECK(xtc_backend_close(backend) == XTC_OK); xtc_project_destroy(p);
}
CHECK(closed >= 104);
CHECK(xtc_backend_close(NULL) == XTC_OK);
xtc_graph_destroy(invalid_graph);

254
tests/unit/test_mixer.c Normal file
View file

@ -0,0 +1,254 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_audio.h"
#include "xtc/xtc_midi.h"
#include "xtc/xtc_mixer.h"
#include "xtc/xtc_realtime.h"
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static void close_to(float a, float b)
{
assert(fabsf(a - b) < 0.000001f);
}
static void render(xtc_project *p, uint64_t position, float *l, float *r, size_t n)
{
xtc_midi_plan *plan = NULL;
assert(xtc_midi_plan_create(p, 48000, &plan) == XTC_OK);
assert(xtc_midi_plan_render_stereo(plan, position, NULL, 0, l, r, n) == XTC_OK);
xtc_midi_plan_destroy(plan);
}
int main(void)
{
xtc_project *p = NULL, *q = NULL, *candidate = NULL;
xtc_history *history = NULL;
uint64_t a, b, clip, note;
float l[512], r[512], base[512], split[512], split_r[512];
xtc_automation_point point;
xtc_track_info track;
xtc_midi_plan *plan = NULL;
xtc_graph *g = NULL;
xtc_plan *graph = NULL;
xtc_realtime *engine = NULL;
size_t source, gain, out, i, count;
char dir[] = "/tmp/xtc-mix-XXXXXX", path[256], copy[256], wave[256];
FILE *f;
char bytes[8192] = {0}, roundtrip[8192];
size_t length;
assert(mkdtemp(dir));
snprintf(path, sizeof(path), "%s/song.xtc", dir);
snprintf(copy, sizeof(copy), "%s/copy.xtc", dir);
snprintf(wave, sizeof(wave), "%s/take.wav", dir);
assert(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK);
assert(xtc_project_add_track(p, "One", &a) == XTC_OK &&
xtc_project_add_track(p, "Two", &b) == XTC_OK);
assert(xtc_project_add_midi_clip(p, a, 0, 3840, &clip) == XTC_OK);
assert(xtc_project_add_note(p, clip, 0, 3840, 69, 127, 0, &note) == XTC_OK);
render(p, 0, base, r, 512);
assert(memcmp(base, r, sizeof(base)) == 0);
assert(xtc_project_set_mix(p, a, 0.5f, -1, 0, 0) == XTC_OK);
render(p, 0, l, r, 512);
for (i = 0; i < 512; ++i) {
close_to(l[i], base[i] * 0.5f);
assert(r[i] == 0);
}
assert(xtc_project_set_mix(p, a, 1, 1, 0, 0) == XTC_OK);
render(p, 0, l, r, 512);
assert(memcmp(base, r, sizeof(base)) == 0);
for (i = 0; i < 512; ++i)
assert(l[i] == 0);
assert(xtc_project_set_mix(p, b, 1, 0, 0, 1) == XTC_OK);
render(p, 0, l, r, 512);
for (i = 0; i < 512; ++i)
assert(l[i] == 0 && r[i] == 0);
assert(xtc_project_set_mix(p, a, 1, 0, 1, 1) == XTC_OK);
render(p, 0, l, r, 512);
for (i = 0; i < 512; ++i)
assert(l[i] == 0);
assert(xtc_project_set_mix(p, a, 1, 0, 0, 1) == XTC_OK);
assert(xtc_project_set_mix(p, a, NAN, 0, 0, 0) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_set_mix(p, a, 1, 2, 0, 0) == XTC_ERR_INVALID_ARGUMENT);
assert(xtc_project_set_mix(p, a, 1, 0, 2, 0) == XTC_ERR_INVALID_ARGUMENT);
point = (xtc_automation_point){a, 0, XTC_AUTOMATION_GAIN, 0};
assert(xtc_project_set_automation(p, &point) == XTC_OK);
point.tick = 20;
point.value = 1;
assert(xtc_project_set_automation(p, &point) == XTC_OK); /* sample 500 */
point = (xtc_automation_point){a, 0, XTC_AUTOMATION_PAN, -1};
assert(xtc_project_set_automation(p, &point) == XTC_OK);
point.tick = 20;
point.value = 1;
assert(xtc_project_set_automation(p, &point) == XTC_OK);
render(p, 0, l, r, 512);
for (i = 0; i < 512; ++i) {
float t = i < 500 ? (float)i / 500 : 1, pan = -1 + 2 * t;
close_to(l[i], base[i] * t * (pan > 0 ? 1 - pan : 1));
close_to(r[i], base[i] * t * (pan < 0 ? 1 + pan : 1));
}
assert(xtc_midi_plan_create(p, 48000, &plan) == XTC_OK);
for (i = 0; i < 512; i += 64)
assert(xtc_midi_plan_render_stereo(plan, i, NULL, 0, split + i, split_r + i, 64) == XTC_OK);
assert(memcmp(l, split, sizeof(l)) == 0 && memcmp(r, split_r, sizeof(r)) == 0);
xtc_midi_plan_destroy(plan);
/* Real graph routing preserves stereo; oscillator phases advance only once. */
assert(xtc_graph_create(4, &g) == XTC_OK);
assert(xtc_graph_add_audio(g, &source) == XTC_OK);
assert(xtc_graph_add_gain(g, 0.5f, &gain) == XTC_OK);
assert(xtc_graph_add_output(g, &out) == XTC_OK);
assert(xtc_graph_connect(g, source, gain) == XTC_OK &&
xtc_graph_connect(g, gain, out) == XTC_OK);
assert(xtc_graph_compile(g, 48000, 512, &graph) == XTC_OK);
assert(xtc_plan_process_stereo(graph, NULL, l, r, NULL, NULL, split, split_r, 512) == XTC_OK);
for (i = 0; i < 512; ++i) {
close_to(split[i], l[i] * 0.5f);
close_to(split_r[i], r[i] * 0.5f);
}
xtc_plan_destroy(graph);
assert(xtc_realtime_create(48000, 512, &engine) == XTC_OK &&
xtc_realtime_submit(engine, g) == XTC_OK);
assert(xtc_realtime_process_stereo(engine, NULL, l, r, NULL, NULL, split, split_r, 512) ==
XTC_OK);
for (i = 0; i < 512; ++i)
close_to(split_r[i], r[i] * 0.5f);
xtc_realtime_destroy(engine);
xtc_graph_destroy(g);
assert(xtc_graph_create(1, &g) == XTC_OK &&
xtc_graph_add_oscillator(g, 440, &source) == XTC_OK &&
xtc_graph_compile(g, 48000, 512, &graph) == XTC_OK);
assert(xtc_plan_process_stereo(graph, NULL, NULL, NULL, NULL, NULL, l, r, 64) == XTC_OK &&
memcmp(l, r, 64 * sizeof(float)) == 0);
assert(xtc_plan_process_stereo(graph, NULL, NULL, NULL, NULL, NULL, l + 64, r + 64, 64) ==
XTC_OK &&
memcmp(l, r, 128 * sizeof(float)) == 0);
close_to(l[64], (float)sin(6.283185307179586 * 440 * 64 / 48000));
xtc_plan_destroy(graph);
xtc_graph_destroy(g);
/* Audio track uses the same mix/automation evaluator. */
{
xtc_recorder *rec = NULL;
xtc_audio_plan *audio = NULL;
uint64_t frames;
for (i = 0; i < 512; ++i)
base[i] = 0.25f;
assert(xtc_recorder_create(wave, 48000, 1024, &rec) == XTC_OK);
xtc_recorder_push(rec, base, 512);
assert(xtc_recorder_finish(rec) == XTC_OK);
assert(xtc_recorder_status(rec, &frames) == XTC_OK && frames == 512);
xtc_recorder_destroy(rec);
assert(xtc_project_import_audio(p, a, wave, 0, &clip) == XTC_OK &&
xtc_audio_plan_create(p, 48000, &audio) == XTC_OK);
assert(xtc_audio_plan_render_stereo(audio, 0, l, r, 512) == XTC_OK);
close_to(l[250], 0.125f);
close_to(r[250], 0.125f);
assert(l[500] == 0 && r[500] == 0.25f);
xtc_audio_plan_destroy(audio);
}
assert(xtc_project_save(p, path) == XTC_OK && xtc_project_load(q, path) == XTC_OK &&
xtc_project_save(q, copy) == XTC_OK);
f = fopen(path, "rb");
assert(f);
length = fread(bytes, 1, sizeof(bytes), f);
assert(length < sizeof(bytes));
fclose(f);
f = fopen(copy, "rb");
assert(f && fread(roundtrip, 1, sizeof(roundtrip), f) == length);
fclose(f);
assert(memcmp(bytes, roundtrip, length) == 0);
for (i = 0; i < length; ++i) {
f = fopen(copy, "wb");
assert(f && fwrite(bytes, 1, i, f) == i && fclose(f) == 0);
assert(xtc_project_load(q, copy) != XTC_OK);
}
/* Invalid mixer and automation records retain destination. */
{
const char *bad[] = {"MIX 7fc00000 00000000 0 0", "MIX 40800001 00000000 0 0",
"MIX 3f800000 40000000 0 0", "MIX 3f800000 00000000 2 0",
"POINT 999 0 0 3f800000", "POINT 0 2 0 3f800000",
"POINT 0 0 0 7f800000", "POINT 0 0 20 3f800000"};
for (i = 0; i < sizeof(bad) / sizeof(*bad); ++i) {
char *start = strstr(bytes, i < 4 ? "MIX " : "POINT "), *end;
assert(start);
end = strchr(start, '\n');
assert(end);
f = fopen(copy, "wb");
assert(f);
assert(fwrite(bytes, 1, (size_t)(start - bytes), f) == (size_t)(start - bytes));
assert(fprintf(f, "%s%s", bad[i], end) > 0);
fclose(f);
assert(xtc_project_load(q, copy) == XTC_ERR_FORMAT);
}
}
/* v3 retains notes but supplies default mix and no automation. */
{
char line[1536];
FILE *legacy;
f = fopen(path, "rb");
legacy = fopen(copy, "wb");
assert(f && legacy);
assert(fgets(line, sizeof(line), f));
assert(fputs("XTC_PROJECT 3\n", legacy) >= 0);
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) &&
strncmp(line, "POINT ", 6))
assert(fputs(line, legacy) >= 0);
}
assert(fclose(f) == 0 && fclose(legacy) == 0);
assert(xtc_project_load(q, copy) == XTC_OK);
assert(xtc_project_get_track(q, 0, &track) == XTC_OK && track.gain == 1 && track.pan == 0 &&
!track.mute && !track.solo);
assert(xtc_project_automation_count(q, &count) == XTC_OK && count == 0);
assert(xtc_project_note_count(q, &count) == XTC_OK && count == 1);
}
assert(xtc_history_create(p, &history) == XTC_OK);
for (i = 0; i < 40; ++i) {
assert(xtc_project_set_mix(p, a, (float)i / 10, 0, 0, 0) == XTC_OK &&
xtc_history_record(history, p) == XTC_OK);
}
for (i = 0; i < 32; ++i) {
assert(xtc_history_checkout(history, 0, &candidate) == XTC_OK);
xtc_project_destroy(candidate);
assert(xtc_history_accept(history, 0) == XTC_OK);
}
assert(xtc_history_checkout(history, 0, &candidate) == XTC_ERR_NOT_FOUND && candidate == NULL);
assert(xtc_history_checkout(history, 1, &candidate) == XTC_OK);
assert(xtc_project_get_track(candidate, 0, &track) == XTC_OK);
close_to(track.gain, 0.8f);
xtc_project_destroy(candidate);
assert(xtc_history_accept(history, 1) == XTC_OK);
assert(xtc_history_record(history, p) == XTC_OK &&
xtc_history_checkout(history, 1, &candidate) == XTC_ERR_NOT_FOUND);
{
uint64_t discarded, replacement;
assert(xtc_project_add_track(p, "Discarded", &discarded) == XTC_OK);
assert(xtc_history_record(history, p) == XTC_OK);
assert(xtc_history_checkout(history, 0, &candidate) == XTC_OK);
assert(xtc_project_add_track(candidate, "New branch", &replacement) == XTC_OK &&
replacement > discarded);
xtc_project_destroy(candidate);
}
xtc_history_destroy(history);
assert(xtc_project_remove_track(p, a) == XTC_OK &&
xtc_project_automation_count(p, &count) == XTC_OK && count == 0);
point = (xtc_automation_point){b, 0, XTC_AUTOMATION_GAIN, 1};
for (i = 0; i < XTC_MAX_AUTOMATION_POINTS; ++i) {
point.tick = i;
assert(xtc_project_set_automation(p, &point) == XTC_OK);
}
point.tick = i;
assert(xtc_project_set_automation(p, &point) == XTC_ERR_CAPACITY);
point.tick = 0;
point.value = 2;
assert(xtc_project_set_automation(p, &point) == XTC_OK);
assert(xtc_project_remove_automation(p, b, XTC_AUTOMATION_GAIN, 0) == XTC_OK);
assert(xtc_project_remove_automation(p, b, XTC_AUTOMATION_GAIN, 0) == XTC_ERR_NOT_FOUND);
xtc_project_destroy(p);
xtc_project_destroy(q);
unlink(path);
unlink(copy);
unlink(wave);
rmdir(dir);
puts("Mixer stereo/gain/pan/mute/solo/automation/history/persistence passed.");
return 0;
}