From 7db5fb77380f5ce131f8f0192a4e4624d54975cd Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 01:06:57 +0100 Subject: [PATCH] Phase 8: Add stereo track mixer, automation and undo/redo --- Makefile | 18 +- README.md | 10 +- docs/ARCHITECTURE.md | 12 + docs/AUDIO_ENGINE.md | 23 +- docs/AUDIO_FILES.md | 6 +- docs/BUILD.md | 21 ++ docs/GUI.md | 15 +- docs/MIDI.md | 5 +- docs/MIXER.md | 98 ++++++ docs/PROJECT_FORMAT.md | 34 +- docs/ROADMAP.md | 10 + ...0007-track-mixer-automation-and-history.md | 17 + include/xtc/xtc_audio.h | 2 + include/xtc/xtc_backend.h | 5 +- include/xtc/xtc_engine.h | 6 + include/xtc/xtc_midi.h | 2 + include/xtc/xtc_mixer.h | 40 +++ include/xtc/xtc_project.h | 4 +- include/xtc/xtc_realtime.h | 3 + src/audio/README.md | 5 +- src/audio/arrangement.c | 26 +- src/backend/jack_backend.c | 58 +++- src/engine/offline_engine.c | 83 ++--- src/engine/realtime.c | 21 +- src/gui/main_window.cpp | 54 ++- src/gui/main_window.hpp | 9 +- src/gui/mixer_widget.cpp | 314 ++++++++++++++++++ src/gui/mixer_widget.hpp | 44 +++ src/midi/instrument.c | 37 ++- src/mixer/README.md | 6 + src/mixer/mix_internal.h | 20 ++ src/mixer/model.c | 81 +++++ src/mixer/render.c | 87 +++++ src/persistence/README.md | 4 + src/persistence/project_file.c | 41 ++- src/project/README.md | 5 + src/project/history.c | 111 +++++++ src/project/project.c | 24 +- src/project/project_internal.h | 3 + tests/integration/README.md | 21 ++ tests/integration/test_gui.cpp | 38 ++- tests/integration/test_gui_jack.cpp | 2 +- tests/integration/test_jack_midi.c | 31 +- tests/unit/test_backend.c | 31 +- tests/unit/test_mixer.c | 254 ++++++++++++++ 45 files changed, 1611 insertions(+), 130 deletions(-) create mode 100644 docs/MIXER.md create mode 100644 docs/decisions/0007-track-mixer-automation-and-history.md create mode 100644 include/xtc/xtc_mixer.h create mode 100644 src/gui/mixer_widget.cpp create mode 100644 src/gui/mixer_widget.hpp create mode 100644 src/mixer/README.md create mode 100644 src/mixer/mix_internal.h create mode 100644 src/mixer/model.c create mode 100644 src/mixer/render.c create mode 100644 src/project/history.c create mode 100644 tests/unit/test_mixer.c diff --git a/Makefile b/Makefile index ed52831..22b604b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index e87d0ab..0dc0cab 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4880b8d..2b76c9a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 9dd5867..cfb558b 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -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. diff --git a/docs/AUDIO_FILES.md b/docs/AUDIO_FILES.md index 6bb3e03..30fafaf 100644 --- a/docs/AUDIO_FILES.md +++ b/docs/AUDIO_FILES.md @@ -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 diff --git a/docs/BUILD.md b/docs/BUILD.md index 98940f5..24e817c 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -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. diff --git a/docs/GUI.md b/docs/GUI.md index dd15398..8b3a472 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -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. diff --git a/docs/MIDI.md b/docs/MIDI.md index c330866..1aa2d78 100644 --- a/docs/MIDI.md +++ b/docs/MIDI.md @@ -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. diff --git a/docs/MIXER.md b/docs/MIXER.md new file mode 100644 index 0000000..1460ee2 --- /dev/null +++ b/docs/MIXER.md @@ -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. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 924c03d..3fabd46 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cb0a7e9..6ed4936 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/docs/decisions/0007-track-mixer-automation-and-history.md b/docs/decisions/0007-track-mixer-automation-and-history.md new file mode 100644 index 0000000..00b7c0f --- /dev/null +++ b/docs/decisions/0007-track-mixer-automation-and-history.md @@ -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. diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h index 55e0e2d..4096919 100644 --- a/include/xtc/xtc_audio.h +++ b/include/xtc/xtc_audio.h @@ -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. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index b9f972f..679f6f8 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -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 diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index abfbd44..70dc69a 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -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. */ diff --git a/include/xtc/xtc_midi.h b/include/xtc/xtc_midi.h index c16fc1e..3a5e61b 100644 --- a/include/xtc/xtc_midi.h +++ b/include/xtc/xtc_midi.h @@ -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 diff --git a/include/xtc/xtc_mixer.h b/include/xtc/xtc_mixer.h new file mode 100644 index 0000000..8473c24 --- /dev/null +++ b/include/xtc/xtc_mixer.h @@ -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 diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index 021b534..4461c0b 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -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 { diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h index 802c8e7..0d63105 100644 --- a/include/xtc/xtc_realtime.h +++ b/include/xtc/xtc_realtime.h @@ -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, diff --git a/src/audio/README.md b/src/audio/README.md index c6a553b..49b5e93 100644 --- a/src/audio/README.md +++ b/src/audio/README.md @@ -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. diff --git a/src/audio/arrangement.c b/src/audio/arrangement.c index 0d05128..dc56f11 100644 --- a/src/audio/arrangement.c +++ b/src/audio/arrangement.c @@ -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 #include #include -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); } diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 8b13ff7..8658816 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -7,14 +7,15 @@ #include #include #include +#include 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) diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index c710be9..9009c55 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -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); } diff --git a/src/engine/realtime.c b/src/engine/realtime.c index 1bd4072..fdcbb0d 100644 --- a/src/engine/realtime.c +++ b/src/engine/realtime.c @@ -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); } diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 61a6e41..6025cda 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -2,6 +2,7 @@ #include "graph_widget.hpp" #include "sequencer_widget.hpp" #include "piano_roll.hpp" +#include "mixer_widget.hpp" #include #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(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 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(context); + auto *menu = static_cast(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); +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index a547671..efa4d1e 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -6,8 +6,10 @@ #include #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 history_{nullptr, xtc_history_destroy}; + static void history_callback(Fl_Widget *, void *); std::unique_ptr project_{nullptr, xtc_project_destroy}; std::unique_ptr 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; diff --git a/src/gui/mixer_widget.cpp b/src/gui/mixer_widget.cpp new file mode 100644 index 0000000..b657c74 --- /dev/null +++ b/src/gui/mixer_widget.cpp @@ -0,0 +1,314 @@ +#include "mixer_widget.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +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(mixer_.lane_start() + + static_cast(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 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((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(static_cast(tick - a.tick) / + static_cast(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(px - 1) * lane_ticks / + static_cast(w() - 1); + uint64_t b = mixer_.lane_start() + static_cast(px) * lane_ticks / + static_cast(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((a.tick - mixer_.lane_start()) * + static_cast(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(std::clamp(Fl::event_x() - x(), 0, w() - 1)) * + lane_ticks / static_cast(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(std::clamp(y() + h() - 6 - Fl::event_y(), 0, h() - 28)) / + static_cast(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(8) * lane_ticks / static_cast(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(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( + std::clamp(start_->value(), 0.0, static_cast(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(i); + } + tracks_->value(n ? index : -1); + selection(); + redraw(); +} +void MixerWidget::selection() +{ + xtc_track_info t{}; + bool valid = + xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK; + for (Fl_Widget *w : {static_cast(gain_), static_cast(pan_), + static_cast(mute_), static_cast(solo_), + static_cast(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(c); + m->mix(static_cast(m->gain_->value()), static_cast(m->pan_->value()), + m->mute_->value(), m->solo_->value()); +} +void MixerWidget::select(Fl_Widget *, void *c) +{ + static_cast(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(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(n) - 1)); + selection(); + return 1; + } + } + } + return Fl_Group::handle(e); +} diff --git a/src/gui/mixer_widget.hpp b/src/gui/mixer_widget.hpp new file mode 100644 index 0000000..18fa1ac --- /dev/null +++ b/src/gui/mixer_widget.hpp @@ -0,0 +1,44 @@ +#ifndef XTC_MIXER_WIDGET_HPP +#define XTC_MIXER_WIDGET_HPP +#include "xtc/xtc_mixer.h" +#include +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 diff --git a/src/midi/instrument.c b/src/midi/instrument.c index 501dc3c..824fbdb 100644 --- a/src/midi/instrument.c +++ b/src/midi/instrument.c @@ -1,13 +1,14 @@ #include "xtc/xtc_midi.h" -#include "../project/project_internal.h" +#include "../mixer/mix_internal.h" #include #include #include #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); } diff --git a/src/mixer/README.md b/src/mixer/README.md new file mode 100644 index 0000000..42c61f8 --- /dev/null +++ b/src/mixer/README.md @@ -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. diff --git a/src/mixer/mix_internal.h b/src/mixer/mix_internal.h new file mode 100644 index 0000000..0b0f9df --- /dev/null +++ b/src/mixer/mix_internal.h @@ -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 diff --git a/src/mixer/model.c b/src/mixer/model.c new file mode 100644 index 0000000..a73966f --- /dev/null +++ b/src/mixer/model.c @@ -0,0 +1,81 @@ +#include "../project/project_internal.h" +#include +#include +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; +} diff --git a/src/mixer/render.c b/src/mixer/render.c new file mode 100644 index 0000000..015490c --- /dev/null +++ b/src/mixer/render.c @@ -0,0 +1,87 @@ +#include "mix_internal.h" +#include +#include +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)); +} diff --git a/src/persistence/README.md b/src/persistence/README.md index 0077da3..09925ca 100644 --- a/src/persistence/README.md +++ b/src/persistence/README.md @@ -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. diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 22990d4..25c056c 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -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) diff --git a/src/project/README.md b/src/project/README.md index 8893122..ee1ac88 100644 --- a/src/project/README.md +++ b/src/project/README.md @@ -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. diff --git a/src/project/history.c b/src/project/history.c new file mode 100644 index 0000000..62fdc1f --- /dev/null +++ b/src/project/history.c @@ -0,0 +1,111 @@ +#include "../graph/graph_internal.h" +#include "project_internal.h" +#include +#include +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, ©); + 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; +} diff --git a/src/project/project.c b/src/project/project.c index 2bd4c53..c2b1fea 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -2,6 +2,7 @@ #include "xtc/xtc_audio.h" #include "../graph/graph_internal.h" #include +#include #include 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; } diff --git a/src/project/project_internal.h b/src/project/project_internal.h index 34b78b0..c044df9 100644 --- a/src/project/project_internal.h +++ b/src/project/project_internal.h @@ -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]; diff --git a/tests/integration/README.md b/tests/integration/README.md index a3058cc..0f05d5d 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -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. diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 9be88a3..8b76e11 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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 #include #include @@ -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; diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index f195b8c..74c6397 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -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); diff --git a/tests/integration/test_jack_midi.c b/tests/integration/test_jack_midi.c index 8144f0f..ff0424b 100644 --- a/tests/integration/test_jack_midi.c +++ b/tests/integration/test_jack_midi.c @@ -1,5 +1,6 @@ #define _POSIX_C_SOURCE 200809L #include "xtc/xtc_backend.h" +#include "xtc/xtc_mixer.h" #include #include #include @@ -9,17 +10,19 @@ #include #include 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, ¬e) == 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; diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index e9ae8e0..c241ed3 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -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 #include #include @@ -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, ¬e) == 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); diff --git a/tests/unit/test_mixer.c b/tests/unit/test_mixer.c new file mode 100644 index 0000000..e6e9949 --- /dev/null +++ b/tests/unit/test_mixer.c @@ -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 +#include +#include +#include +#include +#include +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, ¬e) == 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; +}