diff --git a/Makefile b/Makefile index a311c50..563414e 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/native/native.c 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 +CORE_SOURCES := src/dsp/ramp.c src/native/native.c 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) @@ -272,3 +272,18 @@ test-workflow: endif -include $(BUILD_DIR)/tests/integration/test_workflow.d + +$(BUILD_DIR)/test_quality: $(BUILD_DIR)/tests/unit/test_quality.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm +.PHONY: test-quality +test-quality: $(BUILD_DIR)/test_quality + ./$(BUILD_DIR)/test_quality +test: test-quality +-include $(BUILD_DIR)/tests/unit/test_quality.d + +$(BUILD_DIR)/quality-preview: $(BUILD_DIR)/examples/quality_preview.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm +.PHONY: quality-preview +quality-preview: $(BUILD_DIR)/quality-preview + ./$(BUILD_DIR)/quality-preview "$(abspath $(BUILD_DIR))/quality" +-include $(BUILD_DIR)/examples/quality_preview.d diff --git a/README.md b/README.md index 4d5f652..9a7454a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This repository is a starter scaffold, not a production DAW. It currently provid - 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; +- configurable boundary fades, gain ramps and graceful GUI playback transitions; - native C processors, deterministic source packaging and staged install/uninstall; - architecture, format, GUI, roadmap, and decision documents. @@ -109,3 +110,7 @@ Read `AGENT.md` and `docs/ARCHITECTURE.md`. Inspect the existing implementation, ## License The project is scaffolded under the MIT License; see `LICENSE`. Review the licenses of any future dependencies before distribution. + +Audio timing, transitions, latency limits and listening previews are described +in [docs/AUDIO_QUALITY.md](docs/AUDIO_QUALITY.md). Human listening acceptance +for the Phase 12 transitions remains pending. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 9adbcbb..67b1481 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -280,3 +280,13 @@ and serialization limits and PERFORMANCE.md for measured workload and budgets. reclamation ordering, callback work bounds and repeatable diagnostic tests. Graph stereo rendering completes both channels before writing caller outputs; either output may equal a source, but the output buffers must not overlap. + +## Phase 12 quality policy + +[AUDIO_QUALITY.md](AUDIO_QUALITY.md) defines absolute musical timing, tempo snapshot +changes, scheduled source envelopes and configurable master transitions. The GUI +enables 5 ms fades; low-level defaults remain raw. `xtc_backend_request_stop` is +asynchronous and does not establish quiescence; control-thread collection after +its callback acknowledgement deactivates JACK. Immediate stop/close remain +available. Sample rate and buffer frames are reported, but end-to-end latency +and native-node delay compensation are not implemented. diff --git a/docs/AUDIO_QUALITY.md b/docs/AUDIO_QUALITY.md new file mode 100644 index 0000000..5e6e27e --- /dev/null +++ b/docs/AUDIO_QUALITY.md @@ -0,0 +1,168 @@ +# Timing, transitions and audio quality + +Phase 12 adds deterministic transition processing and tests. Musical time still +uses a single constant tempo, 960 ticks per quarter note and fixed 4/4 meter; +it does not introduce a tempo map, meter editor or JACK transport following. +Project format remains v4 (reads v1–v3). + +## Timing policy + +`xtc_project_set_tempo` accepts 20000–300000 milli-BPM and changes the editable +model only. Build new audio/MIDI/mixer plans while stopped to adopt it. Existing +plans keep their prepared sample positions and mix snapshots. The backend retains +its absolute sample counter through preparation; explicitly seek if the caller +wants to preserve a particular musical tick after changing tempo. The GUI has no +tempo-change command yet. Meter is always 4/4, has no setter and does not vary +within a project. + +Every musical boundary is converted from its **absolute** tick: + +``` +sample = floor(tick * rate * 60000 / (tempo_milli_bpm * 960)) +``` + +Conversions use bounded unsigned integer arithmetic, not accumulated floating +beat durations. The inverse also floors; a sample-to-tick roundtrip can lose a +tick because of quantization. At very low supported rates several ticks can map +to one sample. Collapsed note spans are silent. Automation uses the last point +in tick order at a shared sample. Clip/note intervals are half-open `[start,end)`; +a boundary exactly at the next block belongs to that next block. The MIDI input +array preserves caller order for equal offsets (for example on/off versus off/on). +A discontinuous MIDI render position resets live voices before rendering. + +Changing tempo shifts musical boundaries, including source offsets, on newly +compiled plans. WAV samples keep their original playback speed with linear rate +conversion; this is not audio time stretching. Scheduled note phase comes from +absolute note age, so seeks and block partitions reproduce the same waveform. +The graph oscillator/native graph clock is plan-local, rather than project time. + +## Boundary fades and smoothing + +The GUI configures `xtc_backend_set_transition_frames(rate/200)`: approximately +5 ms at the current rate (integer frames, floor). Configuration is control-thread +only while stopped, before project preparation. The low-level backend and +source-plan APIs default to zero transition frames for source compatibility. +Call `xtc_audio_plan_set_fade_frames` and `xtc_midi_plan_set_fade_frames` before +publication to enable the same behavior in an offline/custom host. + +Each audio region and scheduled note gets an independent linear boundary +envelope. Its first and last audible samples are zero. At distance `d` from the +nearer boundary the factor is `min(1, d/fade_frames)`. For audio, the end is the +earlier of the clip end and the actual loaded media end after source offset/rate +conversion; rounded-up imported duration does not expose an unfaded last sample. +The envelope is computed from absolute sample position: render partitioning, +seeks, overlapping regions and mono/stereo calls do not advance shared envelope +state. Very short regions are attenuated throughout; a one-sample region becomes +silent. Adjacent regions each fade independently, so this is not a crossfade or +seamless-loop implementation. Fade configuration is a host policy, not persisted +per-clip editing metadata. + +`xtc_gain_ramp` in `xtc_dsp.h` is a single-owner C primitive for smooth gain and +parameter changes. Initialize with reset, set a finite target and length, then +process preallocated mono/stereo buffers. It reaches the exact target on the Nth +sample, with the first sample one step toward the target. Retargeting starts from +the current gain; zero frames changes it immediately. Stereo shares one gain and +advances once per frame. Invalid calls leave buffers/state intact. It allocates +nothing. Mixer automation already interpolates gain/pan per sample; the ramp +primitive does not add a second smoothing delay to that timeline. Static mixer +edits stop playback and use the master ramp on restart. Live native parameter +updates and live graph replacements have no automatic crossfade. + +## Graceful and immediate stopping + +GUI Play fades the master from zero to unity. Ordinary GUI Pause/Stop request a +fade to zero using `xtc_backend_request_stop`; repeated requests do not restart +the fade. The callback publishes completion with a release atomic, then outputs +silence and holds the sample position. `xtc_backend_collect` on the control thread +acquires completion and calls JACK deactivate, which establishes quiescence. +Until then, status reports `running` and `stopping`, and the GUI displays that it +is fading. The 100 ms GUI timer completes deactivation; it does not control fade +timing. If deactivation fails, context/ownership remains intact, silence +continues, the error is reported and later collection retries. + +The ramp length is sample-counted, but the last callback processes a whole block +and advances the sample position by that block, including its zero-valued tail. +A stop request starts at the next observed block boundary. Therefore Pause/Stop +can consume up to approximately `fade_frames + block_frames` after the request, +plus server scheduling; this is not an immediate sample-exact stop command. +Play resumes at the retained block-end sample position with a fresh start ramp. +No callback waits for control or performs deactivation/allocation/I/O. + +`xtc_backend_stop`/close remain synchronous immediate operations. Recording Stop +uses immediate deactivation before draining/finalizing the input take; recording +samples are never faded by the output master. Graceful requests refuse attached +recorders. Home/seek, project installation, recording start, panic, errors and +server loss retain immediate semantics. Those transitions can click; safety and +material preservation take priority. If callbacks cease, use immediate stop or +close rather than waiting for fade acknowledgement. Live note-off/panic remains +immediate with no release tail; scheduled notes use the boundary envelope. + +## Numerical and channel policy + +Core graph, native output, audio and MIDI rendering canonicalize nonfinite values +and float subnormals to zero at output boundaries; graph node buffers also use +this policy. Native callbacks remain trusted and may have their own internal +denormal behavior. No process-wide FTZ/DAZ flags are modified. Normal values as +small as `FLT_MIN` are retained. The raw constant-gain helper is unchanged; +`xtc_gain_ramp_process` applies the canonical output policy. + +Finite values above unity are preserved for mixing headroom. There is no limiter, +soft clipping or dither. The physical device/downstream system may clip; lower +track gains if meters exceed unity. Ports remain mono with paired stereo passes; +external mono input is duplicated. Audio assets are downmixed on import, so +stereo assets do not retain stereo image. Output buffers must not overlap each +other; graph sources may alias outputs because both source passes finish first. +Supported render sizes are 0–65536 frames within the prepared graph maximum. + +## Latency and synchronization limits + +Backend status reports sample rate and current buffer frames, not measured +end-to-end device latency. `buffer_frames/sample_rate` is one process quantum, +not capture-to-speaker latency. JACK owns hardware/graph scheduling and external +port latencies; xtc currently does not query/display JACK latency ranges or +publish an explicit per-node latency model. Fades shape amplitude without a +lookahead/delay buffer, so they add no timeline delay; they do attenuate attacks. + +Built-in gain/meter/output, preloaded sources and the sine instrument have no +algorithmic delay to compensate between tracks. The graph has no delay inserts, +external plugins or parallel compensated processing paths. Native extensions can +introduce unknown delay, which is neither reported nor compensated. A latency/ +tail contract and compensation are required before claiming synchronized delayed +inserts or external hosting. JACK transport sync, external MIDI clock, tempo +maps and physical recording-offset calibration remain unsupported. + +## Verification and listening + +``` +make all test gui test-reliability test-workflow test-gui test-gui-jack +make test-jack test-jack-audio test-jack-midi test-performance +make test-sanitize analyze +make quality-preview +``` + +`test_quality` is part of `make test`. It covers a sample-rate/tempo matrix up to +768 kHz and the maximum project tick, conversion errors, old/new tempo snapshots +for both audio and MIDI, exact offset/on/off order, sample boundary behavior, +raw versus faded endpoints, media ends shorter than rounded clip ends, split/ +whole equivalence, seeks, ramps, stereo parity, overload, NaN/infinity/subnormals +and block sizes 1, 2, 3, 17, 257, 4097, 65535 and 65536. Backend fault tests check +exact master ramps, repeated stop requests, held position/silence after completion, +deactivation failure/retry, immediate escape and recording guards. The workflow +render comparison now enables the GUI source fade policy before/after reopening. + +Phase 12 automated checks pass warning-enabled core/GUI and no-JACK builds, +ASan/UBSan/leak checks, static analysis, pthread TSan tests, mock and live GUI, +software JACK audio/MIDI tests and staged installation. GUI leak checks retain +the existing fontconfig-only suppression; core leak checks are unsuppressed. +Physical-device listening and human listening acceptance are **not yet verified**. + +`make quality-preview` writes ignored build artifacts `quality-raw.wav` and +`quality-smooth.wav` in BUILD_DIR using `examples/quality_preview.c`. Compare at +moderate playback level: first listen to the three-note phrase endings, then the +constant clip at two seconds, then a mid-period tone stop near three seconds. +Check that fades reduce edge clicks, do not add noise and do not unexpectedly +change pitch/timing. Also listen to the saved MIDI/audio workflow project with +repeated Play/Pause/Stop, Home/seek and representative gain/pan automation on the +actual target device. Record device, rate, block size and observations before +marking human listening acceptance complete. Generated previews are reproducible +test material, not a claim that anyone has listened to them. diff --git a/docs/BUILD.md b/docs/BUILD.md index 0011b3b..5d3b904 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -239,3 +239,10 @@ full verification commands and remaining limits. JACK-compatible server. It does not start/configure the server or connect physical devices. See [WORKFLOW.md](WORKFLOW.md) for the full GUI composition acceptance sequence and transport semantics. + +## Phase 12 audio quality + +`make test-quality` runs exact timing, source-boundary, gain-ramp, event-order +and numeric/layout tests (also included in `make test`). `make quality-preview` +builds the C example and writes raw/smoothed listening WAVs into BUILD_DIR without +playing them or configuring devices. See [AUDIO_QUALITY.md](AUDIO_QUALITY.md). diff --git a/docs/GUI.md b/docs/GUI.md index dde9f24..77b626a 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -193,3 +193,12 @@ even when the previous document is retained. stereo render comparisons across destruction/recreation of the GUI and live playhead/meter/transport checks. `test-gui` covers stop/seek failure and recording guards. No project files need manual editing for this workflow. + +## Phase 12 timing and transitions + +The GUI enables 5 ms source boundaries and master start/stop ramps. Ordinary +Pause/Stop now request a fade and show “stopping” until the timer collects its +acknowledgement and deactivates JACK. Recording Stop, Home, fault handling and +project installation retain immediate semantics. See +[AUDIO_QUALITY.md](AUDIO_QUALITY.md) for timing, overload/denormal behavior, latency +limits, test commands and the pending human-listening checklist. diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md index 90ae431..67a1a7d 100644 --- a/docs/RELIABILITY.md +++ b/docs/RELIABILITY.md @@ -114,3 +114,14 @@ arbitrary third-party native callbacks remain unqualified. Sanitizers change timing and should not be used to measure production realtime performance. See PERFORMANCE.md for workload measurements, RECOVERY.md for process-crash limits, and NATIVE_API.md for the trusted extension boundary. + +## Phase 12 transition ownership + +Source fade configuration is written before publication and immutable during +rendering. The master ramp and stop-observed flag belong to the audio consumer +while active; start initializes them only while quiescent. Control publishes an +atomic stop request; audio publishes completion after the last ramp sample. +Collection acquires completion and deactivates the backend before resetting +voices/flags. A completed callback continues silence/holds position until that +deactivation. No callback waits or calls JACK control functions. Immediate +stop/close handles faults or missing callbacks. See AUDIO_QUALITY.md. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bdec52b..bb049ec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -199,6 +199,18 @@ explicit; physical-device listening and endurance remain unqualified. - Listening tests cover transitions, start/stop behavior, and representative projects. - Known limitations involving latency and synchronization are documented. +Implemented: documented constant-tempo/fixed-4/4 snapshot policy; absolute timing +and sample-offset regression matrix; configurable audio/scheduled-note boundary +fades; single-owner gain ramps; GUI 5 ms start and asynchronous Pause/Stop fades +with control-thread acknowledgement/deactivation; finite/subnormal output policy; +exact endpoint, block-size, stereo, overload and failure/retry tests. Latency and +synchronization limits are documented in AUDIO_QUALITY.md. Normal/no-JACK, +sanitizer/static-analysis, TSan, GUI/workflow/live JACK and install checks pass. +`make quality-preview` generates matched listening WAVs. Human listening and +physical-device acceptance remain pending; immediate safety/recording/seek paths +and live graph swaps may still click. No tempo/meter map, latency compensation, +limiter or project-format change is claimed. + ## Phase 13 — Recording and Recovery **Goal:** Protect recorded material and handle recording failures predictably. diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 585968d..e2720f2 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -34,13 +34,17 @@ velocity 100; no notes or test oscillators are inserted automatically. | Command | Behavior | | --- | --- | | Play / F5 | Prepare the current arrangement and resume at the retained sample position. Already playing is a no-op. | -| Pause / F7 | Deactivate playback and hold the sample position. Play resumes from there. Disabled during recording. | -| Stop / F8 | Hold the sample position, preserving the established Stop behavior. During recording, finalize and add the take. | +| Pause / F7 | Fade out, then deactivate playback and hold the final block-end sample position. Play resumes from there. Disabled during recording. | +| Stop / F8 | Fade out and hold the final block-end sample position. During recording, finalize and add the take. | | Space | Toggle Play/Stop; while recording it finishes the take. | | To start / Home | Stop and seek to sample zero; remain stopped until Play. Disabled during recording. Also resets an offline cursor. | | Click timeline ruler | Seek on the sixteenth-note grid while stopped; during playback the seek is refused. | | New / Open / Reconnect | Stop and reset the position. New/Open show Sequencer; Reconnect leaves the document intact. | +Pause/Stop completion is asynchronous: the GUI briefly shows “stopping”. Recording +Stop and Home retain immediate deactivation. See [AUDIO_QUALITY.md](AUDIO_QUALITY.md) +for the 5 ms fade policy and timing bounds. + The backend sample count drives the playhead; its display refreshes every 100 ms. Pause/Stop retain the exact sample position even though the ruler displays ticks. Arrangement, note and mixer edits stop playback; graph edits can be adopted live. diff --git a/examples/README.md b/examples/README.md index 53ae476..8a55a38 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,3 +3,9 @@ `native_gain.c` is a complete trusted native C effect compiled into an offline graph. Build/run with `make build/native-gain && ./build/native-gain`; it needs no GUI/server/plugin SDK. See ../docs/NATIVE_API.md for lifecycle/event contracts. + +`quality_preview.c` uses public C MIDI, recorder and gain-ramp APIs to generate +matched raw/smoothed listening fixtures. Run `make quality-preview`; generated +WAVs remain in the ignored build directory. See docs/AUDIO_QUALITY.md for the +listening sequence and the distinction between automated checks and human +listening acceptance. It never starts a backend or plays audio. diff --git a/examples/quality_preview.c b/examples/quality_preview.c new file mode 100644 index 0000000..571c214 --- /dev/null +++ b/examples/quality_preview.c @@ -0,0 +1,70 @@ +/* Generate matched raw/smoothed previews for human listening; never plays audio. + * Usage: quality-preview /absolute/path/prefix (writes prefix-{raw,smooth}.wav). */ +#include "xtc/xtc_audio.h" +#include "xtc/xtc_midi.h" +#include "xtc/xtc_dsp.h" +#include +#include +#include +#include + +static xtc_result preview(const char *prefix, int smooth) +{ + xtc_project *project = NULL; + xtc_midi_plan *midi = NULL; + xtc_recorder *recorder = NULL; + xtc_gain_ramp ramp; + uint64_t track, clip, note, position; + char path[1024]; + xtc_result result; + int n = snprintf(path, sizeof(path), "%s-%s.wav", prefix, smooth ? "smooth" : "raw"); + if (n < 0 || (size_t)n >= sizeof(path)) return XTC_ERR_CAPACITY; + result = xtc_project_create(&project); + if (result == XTC_OK) result = xtc_project_add_track(project, "Listening phrase", &track); + if (result == XTC_OK) result = xtc_project_add_midi_clip(project, track, 0, 7680, &clip); + if (result == XTC_OK) result = xtc_project_add_note(project, clip, 0, 480, 60, 100, 0, ¬e); + if (result == XTC_OK) result = xtc_project_add_note(project, clip, 720, 480, 64, 100, 0, ¬e); + if (result == XTC_OK) result = xtc_project_add_note(project, clip, 1440, 1440, 67, 100, 0, ¬e); + if (result == XTC_OK) result = xtc_midi_plan_create(project, 48000, &midi); + if (result == XTC_OK) result = xtc_midi_plan_set_fade_frames(midi, smooth ? 240U : 0U); + if (result == XTC_OK) result = xtc_recorder_create(path, 48000, 256, &recorder); + (void)xtc_gain_ramp_reset(&ramp, smooth ? 0 : 1); + (void)xtc_gain_ramp_target(&ramp, 1, smooth ? 240U : 0U); + for (position = 0; result == XTC_OK && position < 192000; position += 256) { + float samples[256]; + size_t i; + result = xtc_midi_plan_render(midi, position, NULL, 0, samples, 256); + if (result != XTC_OK) break; + for (i = 0; i < 256; ++i) { + uint64_t sample = position + i; + /* Deliberately abrupt constant clips with 5 ms fades in the smooth + * version, followed by a tone stopped mid-period at three seconds. */ + if (sample >= 96000 && sample < 108000) { + uint64_t age = sample - 96000, tail = 107999 - sample; + uint64_t distance = age < tail ? age : tail; + float envelope = smooth && distance < 240 ? (float)distance / 240.0f : 1; + samples[i] += 0.2f * envelope; + } + if (sample >= 120000 && sample < 144000) + samples[i] += 0.2f * (float)sin(6.283185307179586 * 443.0 * (double)(sample - 120000) / 48000.0); + if (sample == 143760) + (void)xtc_gain_ramp_target(&ramp, 0, smooth ? 240U : 0U); + (void)xtc_gain_ramp_process(&ramp, samples + i, NULL, 1); + } + xtc_recorder_push(recorder, samples, 256); + result = xtc_recorder_drain(recorder); + } + if (result == XTC_OK) result = xtc_recorder_finish(recorder); + xtc_recorder_destroy(recorder); xtc_midi_plan_destroy(midi); xtc_project_destroy(project); + if (result == XTC_OK) printf("Wrote %s\n", path); + return result; +} +int main(int argc, char **argv) +{ + xtc_result result; + if (argc != 2) { fprintf(stderr, "Usage: %s OUTPUT_PREFIX\n", argv[0]); return EXIT_FAILURE; } + result = preview(argv[1], 0); + if (result == XTC_OK) result = preview(argv[1], 1); + if (result != XTC_OK) { fprintf(stderr, "%s\n", xtc_result_string(result)); return EXIT_FAILURE; } + return EXIT_SUCCESS; +} diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h index 0337292..d3c06a2 100644 --- a/include/xtc/xtc_audio.h +++ b/include/xtc/xtc_audio.h @@ -55,6 +55,10 @@ xtc_result xtc_audio_plan_create(const xtc_project *project, unsigned int rate, * @param plan Preloaded arrangement plan. */ void xtc_audio_plan_destroy(xtc_audio_plan *plan); +/* Control thread before publication only. Zero (default) preserves raw samples; + * 1..65536 applies linear fades to each audible region's actual media bounds. + * Rendering remains stateless, including seeks and variable block partitions. */ +xtc_result xtc_audio_plan_set_fade_frames(xtc_audio_plan *plan, size_t frames); /** * @brief Returns the number of asset issues and optionally the first affected path. * diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index ce8e715..619e23c 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -33,6 +33,7 @@ typedef struct { uint64_t sample_position; /* Successful callback frames; retained across stop/submit. */ unsigned int submitted_revision, active_revision; int running; + int stopping; /* Graceful stop requested; collect completes deactivation. */ int server_lost; int sample_rate_changed; /* Sticky: close/reopen and resubmit to recover. */ xtc_result process_result; @@ -75,6 +76,16 @@ xtc_result xtc_backend_start(xtc_backend *backend); * @return XTC_OK on success, or an error code describing the failure. */ xtc_result xtc_backend_stop(xtc_backend *backend); +/* Configure while stopped, before preparing a project. Default zero preserves + * raw rendering/immediate stop. GUI uses 5 ms (rate/200). Frames <=65536. + * Applies source-boundary fades and a master start/stop gain ramp. */ +xtc_result xtc_backend_set_transition_frames(xtc_backend *backend, size_t frames); +/* Non-blocking graceful stop request; not callback quiescence. Repeated calls + * are idempotent. Process finishes the ramp, then emits silence/holds position. + * Poll/collect on the control thread to deactivate. Recorder must be detached. + * Immediate stop/close remain the escape path if callbacks cease or errors occur. */ +xtc_result xtc_backend_request_stop(xtc_backend *backend); + /* Stopped only; changes transport clock, not oscillator phase. */ /** * @brief Changes the stopped backend's sample position and resets MIDI voices. @@ -140,7 +151,7 @@ xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **o */ xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder); /** - * @brief Reclaims retired realtime plans on the control thread. + * @brief Completes acknowledged fades and reclaims retired plans on the control thread. * * @param backend Backend handle. */ diff --git a/include/xtc/xtc_dsp.h b/include/xtc/xtc_dsp.h new file mode 100644 index 0000000..7fba4d2 --- /dev/null +++ b/include/xtc/xtc_dsp.h @@ -0,0 +1,20 @@ +#ifndef XTC_DSP_H +#define XTC_DSP_H +#include "xtc_types.h" +#ifdef __cplusplus +extern "C" { +#endif +/* Single processing owner (or quiescent control thread). No allocation or I/O. + * Initialize with reset before target/process. + * Linear ramps reach the target on the Nth sample, independent of block size. */ +typedef struct { float current, target; size_t remaining; } xtc_gain_ramp; +xtc_result xtc_gain_ramp_reset(xtc_gain_ramp *ramp, float value); +xtc_result xtc_gain_ramp_target(xtc_gain_ramp *ramp, float target, size_t frames); +xtc_result xtc_gain_ramp_process(xtc_gain_ramp *ramp, float *left, float *right, size_t frames); +/* Canonical silence for NaN, infinity and float subnormals; finite overloads + * are retained for mixing/headroom. This does not change CPU-wide FP flags. */ +float xtc_dsp_finite(float sample); +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/xtc/xtc_midi.h b/include/xtc/xtc_midi.h index 0306320..86f6212 100644 --- a/include/xtc/xtc_midi.h +++ b/include/xtc/xtc_midi.h @@ -129,6 +129,10 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int sample_ * @param plan Instrument plan and its single-owner runtime state. */ void xtc_midi_plan_destroy(xtc_midi_plan *plan); +/* Set before publication, while quiescent. Zero preserves raw scheduled notes. + * Linear boundary envelopes do not delay note scheduling; live note-offs and + * panic remain immediate. Max 65536 frames. */ +xtc_result xtc_midi_plan_set_fade_frames(xtc_midi_plan *plan, size_t frames); /* Audio owner or stopped only. Reset clears live notes and panic suppression. * Panic silences live notes and scheduled notes already started at position; * later scheduled notes can start. MIDI CC120/123 affects its own channel. */ diff --git a/src/audio/arrangement.c b/src/audio/arrangement.c index bc267df..35892c4 100644 --- a/src/audio/arrangement.c +++ b/src/audio/arrangement.c @@ -2,6 +2,7 @@ #include "wave_internal.h" #include "../mixer/mix_internal.h" #include "../graph/graph_internal.h" +#include "xtc/xtc_dsp.h" #include #include #include @@ -12,6 +13,7 @@ typedef struct { } region; struct xtc_audio_plan { + size_t fade_frames; xtc_mix_snapshot mix; xtc_wave waves[64]; char paths[64][XTC_ASSET_PATH_BYTES]; @@ -209,6 +211,13 @@ size_t xtc_audio_plan_issues(const xtc_audio_plan *plan, const char **path) return plan ? plan->issues : 0; } +xtc_result xtc_audio_plan_set_fade_frames(xtc_audio_plan *plan, size_t frames) +{ + if (!plan || frames > 65536) return XTC_ERR_INVALID_ARGUMENT; + plan->fade_frames = frames; + return XTC_OK; +} + /** * @brief Renders a block of the plan at unity gain with linear rate conversion. * @@ -235,8 +244,16 @@ xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *plan, uint64_t pos for (i = 0; i < plan->count; ++i) { const region *r = plan->regions + i; const xtc_wave *wave = plan->waves + r->asset; + uint64_t audible_end = r->end; uint64_t begin = position > r->start ? position : r->start; - uint64_t end = position + frames < r->end ? position + frames : r->end; + uint64_t end; + if (plan->fade_frames && wave->samples) { + uint64_t duration = ((uint64_t)wave->frames * plan->rate + wave->rate - 1U) / wave->rate; + if (duration <= r->offset) continue; + if (duration - r->offset < audible_end - r->start) + audible_end = r->start + duration - r->offset; + } + end = position + frames < audible_end ? position + frames : audible_end; if (!wave->samples || end <= begin) continue; for (j = (size_t)(begin - position); j < (size_t)(end - position); ++j) { @@ -250,16 +267,21 @@ xtc_result xtc_audio_plan_render_stereo(const xtc_audio_plan *plan, uint64_t pos float sample = (float)(wave->samples[index] * (1.0 - fraction) + (index + 1 < wave->frames ? wave->samples[index + 1] : wave->samples[index]) * fraction); + if (plan->fade_frames) { + uint64_t age = position + j - r->start; + uint64_t tail = audible_end - 1U - (position + j); + uint64_t distance = age < tail ? age : tail; + if (distance < plan->fade_frames) + sample *= (float)distance / (float)plan->fade_frames; + } 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; - if (right && !isfinite(right[j])) - right[j] = 0; + output[j] = xtc_dsp_finite(output[j]); + if (right) right[j] = xtc_dsp_finite(right[j]); } return XTC_OK; } diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 12d58aa..29a85ff 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -1,5 +1,6 @@ #include "xtc/xtc_backend.h" #include "xtc/xtc_realtime.h" +#include "xtc/xtc_dsp.h" #include #include @@ -19,6 +20,10 @@ struct xtc_backend { xtc_recorder *recorder; /* Borrowed, installed while callbacks quiescent. */ float *timeline, *instrument; size_t max_frames; + size_t transition_frames; /* Control owner while stopped. */ + xtc_gain_ramp ramp; /* Audio owner while active. */ + unsigned int stop_requested, stop_done; + int stop_seen; /* Audio owner. */ uint64_t sample_position; unsigned int expected_rate; unsigned int rate, frames, lost, rate_changed, xruns; @@ -104,6 +109,13 @@ static int process(jack_nframes_t frames, void *context) __atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED); return 0; } + if (__atomic_load_n(&backend->stop_done, __ATOMIC_ACQUIRE)) { + 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); + return 0; + } if (__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) { result = XTC_ERR_INVALID_STATE; @@ -128,6 +140,15 @@ static int process(jack_nframes_t frames, void *context) backend->midi && right ? backend->instrument + backend->max_frames : NULL, output, right, (size_t)frames); } + if (result == XTC_OK && backend->transition_frames) { + if (__atomic_load_n(&backend->stop_requested, __ATOMIC_ACQUIRE) && !backend->stop_seen) { + (void)xtc_gain_ramp_target(&backend->ramp, 0, backend->transition_frames); + backend->stop_seen = 1; + } + (void)xtc_gain_ramp_process(&backend->ramp, output, right, (size_t)frames); + if (backend->stop_seen && !backend->ramp.remaining) + __atomic_store_n(&backend->stop_done, 1U, __ATOMIC_RELEASE); + } if (result == XTC_OK) { uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED); if (position > UINT64_MAX - frames) @@ -361,6 +382,11 @@ xtc_result xtc_backend_start(xtc_backend *backend) return XTC_ERR_INVALID_STATE; xtc_midi_plan_reset(backend->midi); backend->panic_seen = __atomic_load_n(&backend->panic_requested, __ATOMIC_ACQUIRE); + __atomic_store_n(&backend->stop_requested, 0U, __ATOMIC_RELAXED); + __atomic_store_n(&backend->stop_done, 0U, __ATOMIC_RELAXED); + backend->stop_seen = 0; + (void)xtc_gain_ramp_reset(&backend->ramp, backend->transition_frames ? 0 : 1); + (void)xtc_gain_ramp_target(&backend->ramp, 1, backend->transition_frames); if (jack_activate(backend->client) != 0) return XTC_ERR_IO; backend->running = 1; @@ -386,6 +412,8 @@ xtc_result xtc_backend_stop(xtc_backend *backend) if (jack_deactivate(backend->client) != 0) return XTC_ERR_IO; backend->running = 0; + __atomic_store_n(&backend->stop_requested, 0U, __ATOMIC_RELAXED); + __atomic_store_n(&backend->stop_done, 0U, __ATOMIC_RELAXED); xtc_midi_plan_reset(backend->midi); xtc_realtime_collect(backend->engine); return XTC_OK; @@ -462,8 +490,12 @@ xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project * if (!backend->ready || backend->running) return XTC_ERR_INVALID_STATE; result = xtc_audio_plan_create(project, backend->expected_rate, &candidate); + if (result == XTC_OK) + result = xtc_audio_plan_set_fade_frames(candidate, backend->transition_frames); if (result == XTC_OK) result = xtc_midi_plan_create(project, backend->expected_rate, &midi); + if (result == XTC_OK) + result = xtc_midi_plan_set_fade_frames(midi, backend->transition_frames); if (result == XTC_OK) result = xtc_backend_submit(backend, xtc_project_graph(project)); if (result != XTC_OK) { @@ -547,15 +579,38 @@ xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **o return XTC_OK; } -/** - * @brief Reclaims retired realtime plans on the control thread. - * - * @param backend Backend handle. - */ +/* Control-only configuration; applied to source plans on next preparation. */ +xtc_result xtc_backend_set_transition_frames(xtc_backend *backend, size_t frames) +{ + if (!backend || frames > 65536) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || backend->running) return XTC_ERR_INVALID_STATE; + backend->transition_frames = frames; + return XTC_OK; +} + +xtc_result xtc_backend_request_stop(xtc_backend *backend) +{ + if (!backend) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || backend->recorder) return XTC_ERR_INVALID_STATE; + if (!backend->running || !backend->transition_frames || + __atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || + __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE) || + __atomic_load_n(&backend->process_result, __ATOMIC_RELAXED) != (unsigned int)XTC_OK) + return xtc_backend_stop(backend); + __atomic_store_n(&backend->stop_requested, 1U, __ATOMIC_RELEASE); + return XTC_OK; +} + +/* Control thread: acknowledgement is not quiescence until stop succeeds. */ void xtc_backend_collect(xtc_backend *backend) { - if (backend != NULL) + if (backend != NULL) { + if (__atomic_load_n(&backend->stop_done, __ATOMIC_ACQUIRE)) { + if (xtc_backend_stop(backend) != XTC_OK) + __atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED); + } xtc_realtime_collect(backend->engine); + } } /** @@ -577,6 +632,7 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status 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->stopping = (int)__atomic_load_n(&backend->stop_requested, __ATOMIC_ACQUIRE); 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); @@ -968,4 +1024,15 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc (void)remote; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_set_transition_frames(xtc_backend *backend, size_t frames) +{ + (void)backend; (void)frames; + return XTC_ERR_UNSUPPORTED; +} +xtc_result xtc_backend_request_stop(xtc_backend *backend) +{ + (void)backend; + return XTC_ERR_UNSUPPORTED; +} + #endif diff --git a/src/dsp/ramp.c b/src/dsp/ramp.c new file mode 100644 index 0000000..bbc254f --- /dev/null +++ b/src/dsp/ramp.c @@ -0,0 +1,38 @@ +#include "xtc/xtc_dsp.h" +#include + +float xtc_dsp_finite(float sample) +{ + return !isfinite(sample) || fpclassify(sample) == FP_SUBNORMAL ? 0.0f : sample; +} +xtc_result xtc_gain_ramp_reset(xtc_gain_ramp *ramp, float value) +{ + if (!ramp || !isfinite(value)) return XTC_ERR_INVALID_ARGUMENT; + ramp->current = ramp->target = value; + ramp->remaining = 0; + return XTC_OK; +} +xtc_result xtc_gain_ramp_target(xtc_gain_ramp *ramp, float target, size_t frames) +{ + if (!ramp || !isfinite(target) || frames > 65536) return XTC_ERR_INVALID_ARGUMENT; + ramp->target = target; + ramp->remaining = frames; + if (!frames) ramp->current = target; + return XTC_OK; +} +xtc_result xtc_gain_ramp_process(xtc_gain_ramp *ramp, float *left, float *right, size_t frames) +{ + size_t i; + if (!ramp || !left || (right && left == right) || frames > 65536) + return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < frames; ++i) { + if (ramp->remaining) { + ramp->current = (float)((double)ramp->current + + ((double)ramp->target - ramp->current) / (double)ramp->remaining); + if (!--ramp->remaining) ramp->current = ramp->target; + } + left[i] = xtc_dsp_finite(left[i] * ramp->current); + if (right) right[i] = xtc_dsp_finite(right[i] * ramp->current); + } + return XTC_OK; +} diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index bae9525..4c4edd1 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -1,3 +1,4 @@ +#include "xtc/xtc_dsp.h" #include "../graph/graph_internal.h" #include #include @@ -234,8 +235,7 @@ xtc_result xtc_plan_process_stereo(xtc_plan *plan, const float *input, const flo if (spec.type == XTC_NODE_GAIN) sample *= spec.value; } - if (!isfinite(sample)) - sample = 0.0f; + sample = xtc_dsp_finite(sample); buffer[frame] = sample; if (fabsf(sample) > peak) peak = fabsf(sample); @@ -269,7 +269,7 @@ xtc_result xtc_plan_process_stereo(xtc_plan *plan, const float *input, const flo 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; + destination[frame] = xtc_dsp_finite(sample); } } plan->position += frame_count; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 822a50e..7704271 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -366,7 +366,12 @@ void MainWindow::open_backend() if (result != XTC_OK) report_error("Open JACK failed", result); else { - result = xtc_backend_submit(backend_, xtc_project_graph(project_.get())); + xtc_backend_status format{}; + result = xtc_backend_get_status(backend_, &format); + if (result == XTC_OK) + result = xtc_backend_set_transition_frames(backend_, format.sample_rate / 200U); + if (result == XTC_OK) + result = xtc_backend_submit(backend_, xtc_project_graph(project_.get())); if (result != XTC_OK) { ready_ = false; // Handle is retained for close/reconnect; graph was not installed. report_error("Submit graph failed", result); @@ -410,7 +415,7 @@ void MainWindow::start_engine() } /** - * @brief Stops the audio engine, finalizing any active recording. + * @brief Requests a graceful playback stop, or immediately finalizes recording. * * Does nothing when disconnected. Failures are reported in the window. */ @@ -418,7 +423,7 @@ void MainWindow::stop_engine() { if (!ready_) return; - xtc_result result = xtc_backend_stop(backend_); + xtc_result result = recorder_ ? xtc_backend_stop(backend_) : xtc_backend_request_stop(backend_); if (result != XTC_OK) report_error("Stop engine failed", result); else { @@ -431,7 +436,7 @@ void MainWindow::stop_engine() xtc_result MainWindow::pause_playback() { - xtc_result result = recorder_ || !ready_ ? XTC_ERR_INVALID_STATE : xtc_backend_stop(backend_); + xtc_result result = recorder_ || !ready_ ? XTC_ERR_INVALID_STATE : xtc_backend_request_stop(backend_); if (result != XTC_OK) report_error("Pause failed; use Stop to finish recording or Reconnect audio", result); else @@ -504,6 +509,7 @@ void MainWindow::refresh_status() indicator_->labelcolor(fl_rgb_color(255, 155, 120)); set_label(graph_state_, "Graph: offline — edits stored locally; Reconnect to play"); } else { + xtc_backend_collect(backend_); // Completes an acknowledged graceful stop before reading status. xtc_backend_status status = {}; xtc_result result = xtc_backend_get_status(backend_, &status); if (result != XTC_OK) { @@ -523,6 +529,7 @@ void MainWindow::refresh_status() const char *state = status.server_lost ? "Audio: server lost — Reconnect required" : status.sample_rate_changed ? "Audio: sample rate changed — Reconnect required" : status.process_result != XTC_OK ? "Audio: processing error" + : status.stopping ? "Audio: stopping — fading to silence" : recorder_ ? "Audio: recording JACK input — Stop to finish" : running_ ? "Audio: running — modular graph" : "Audio: ready / stopped"; @@ -546,7 +553,6 @@ void MainWindow::refresh_status() else set_label(graph_state_, running_ ? "Graph: active in engine" : "Graph: stopped"); } - xtc_backend_collect(backend_); } if (can_play) play_->activate(); @@ -683,7 +689,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 11: create, arrange, save, reopen and play.", + fl_message("%s %s\nModular audio workstation\nPhase 12: timing, transitions and audio quality.", XTC_PROJECT_NAME, XTC_VERSION_STRING); } diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 9f335c9..49b33e2 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -148,8 +148,9 @@ class MainWindow : public Fl_Double_Window { /** Creates a fresh untitled project; the File menu checks unsaved changes first. * On failure the current document remains owned by the window. */ xtc_result new_project(); - /** Stops playback, retaining its exact sample position. Recording must be - * finalized with Stop instead. Returns the backend failure, if any. */ + /** Requests a playback fade/stop; the timer completes deactivation after + * callback acknowledgement. Recording must use immediate Stop instead. + * Returns request acceptance or the backend failure. */ xtc_result pause_playback(); /** Stops playback and seeks to zero. Refuses during recording. A failed * stop/seek is reported without moving the displayed cursor to zero. */ @@ -332,7 +333,7 @@ class MainWindow : public Fl_Double_Window { */ void start_engine(); /** - * @brief Stops the audio engine, finalizing any active recording. + * @brief Requests a graceful playback stop, or immediately finalizes recording. * * Does nothing when disconnected. Failures are reported in the window. */ diff --git a/src/midi/instrument.c b/src/midi/instrument.c index 1fdb0d0..08a39bf 100644 --- a/src/midi/instrument.c +++ b/src/midi/instrument.c @@ -1,4 +1,5 @@ #include "xtc/xtc_midi.h" +#include "xtc/xtc_dsp.h" #include "../mixer/mix_internal.h" #include #include @@ -24,6 +25,7 @@ typedef struct { } voice; struct xtc_midi_plan { + size_t fade_frames; xtc_mix_snapshot mix; span notes[XTC_PROJECT_MAX_NOTES]; size_t count; @@ -281,6 +283,13 @@ static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position) * @param right Destination right-channel buffer; NULL selects mono rendering. * @param frames Number of audio frames. */ +xtc_result xtc_midi_plan_set_fade_frames(xtc_midi_plan *plan, size_t frames) +{ + if (!plan || frames > 65536) return XTC_ERR_INVALID_ARGUMENT; + plan->fade_frames = frames; + return XTC_OK; +} + static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *right, size_t frames) { size_t i, j; @@ -294,6 +303,13 @@ static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *righ 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); float sample = (float)(sin(TAU * phase) * 0.1 * s->velocity / 127.0); + if (p->fade_frames) { + uint64_t age = position + j - s->start; + uint64_t tail = s->end - 1U - (position + j); + uint64_t distance = age < tail ? age : tail; + if (distance < p->fade_frames) + sample *= (float)distance / (float)p->fade_frames; + } 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); @@ -356,6 +372,10 @@ xtc_result xtc_midi_plan_render_stereo(xtc_midi_plan *p, uint64_t position, cons cursor = next; } segment(p, position + cursor, out + cursor, right ? right + cursor : NULL, frames - cursor); + for (i = 0; i < frames; ++i) { + out[i] = xtc_dsp_finite(out[i]); + if (right) right[i] = xtc_dsp_finite(right[i]); + } p->next_position = position + frames; return XTC_OK; } diff --git a/src/native/native.c b/src/native/native.c index 3c5837b..c509d7c 100644 --- a/src/native/native.c +++ b/src/native/native.c @@ -1,3 +1,4 @@ +#include "xtc/xtc_dsp.h" #include "xtc/xtc_native.h" #include #include @@ -139,8 +140,7 @@ xtc_result xtc_native_process(xtc_native *p, const xtc_native_block *b) return r; } for (i = 0; i < b->frames; ++i) - if (!isfinite(b->output[i])) - b->output[i] = 0; + b->output[i] = xtc_dsp_finite(b->output[i]); p->next_position = b->position + b->frames; return XTC_OK; } diff --git a/tests/integration/README.md b/tests/integration/README.md index 89de010..1151d86 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -202,3 +202,13 @@ window, reopens in another window, compares stereo rendering exactly, verifies playhead/meters and pause/resume/seek/home, and continues editing/saving. Mock GUI tests inject failed stop/seek/project preparation and recording guards. See [WORKFLOW.md](../../docs/WORKFLOW.md) for acceptance details and limitations. + +## Phase 12 transitions + +Backend fault tests verify start/stop ramp samples, repeated requests, callback +completion, silent/held blocks before collection and deactivation error/retry. +GUI tests keep immediate mocked stop semantics; real workflow/live GUI tests +wait for asynchronous fade acknowledgement. `test_quality` in the unit suite +checks exact source envelopes/timing and variable blocks, including tempo +snapshot changes. See [AUDIO_QUALITY.md](../../docs/AUDIO_QUALITY.md) for commands, +latency limitations and the pending listening checklist. diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index e8655fb..74e49fc 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -106,6 +106,10 @@ xtc_result xtc_backend_close(xtc_backend *backend) } xtc_result xtc_backend_start(xtc_backend *backend) { CHECK(backend == &handle); ++starts; if (start_result == XTC_OK) { handle.running = 1; engine_status.active_revision = engine_status.submitted_revision; } return start_result; } +xtc_result xtc_backend_set_transition_frames(xtc_backend *, size_t frames) +{ CHECK(frames == 240); return XTC_OK; } +xtc_result xtc_backend_request_stop(xtc_backend *backend) +{ return xtc_backend_stop(backend); } xtc_result xtc_backend_stop(xtc_backend *backend) { CHECK(backend == &handle); ++stops; if (stop_result == XTC_OK) handle.running = 0; return stop_result; } xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *status) diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index 2b39023..359bb0a 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -83,7 +83,7 @@ int main(int argc, char **argv) unsigned int rate = 0U, frames = 0U, blocks = 0U; 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(); + stop->do_callback(); pump(); CHECK(find(window, "ready / stopped") != nullptr); uint64_t paused = timeline->position(); pump(); CHECK(timeline->position() == paused); @@ -93,7 +93,7 @@ int main(int argc, char **argv) play->do_callback(); pump(); CHECK(find(window, "running — modular graph") != nullptr); - stop->do_callback(); + stop->do_callback(); pump(); char directory[] = "/tmp/xtc-live-record-XXXXXX"; CHECK(mkdtemp(directory) != nullptr); std::string take = std::string(directory) + "/take.wav"; std::string song = std::string(directory) + "/song.xtc"; diff --git a/tests/integration/test_workflow.cpp b/tests/integration/test_workflow.cpp index eaf2989..1699d36 100644 --- a/tests/integration/test_workflow.cpp +++ b/tests/integration/test_workflow.cpp @@ -66,6 +66,8 @@ static std::vector render(const xtc_project *project) CHECK(xtc_graph_compile(xtc_project_graph(project), 48000, 256, &graph) == XTC_OK); CHECK(xtc_audio_plan_create(project, 48000, &audio) == XTC_OK); CHECK(xtc_midi_plan_create(project, 48000, &midi) == XTC_OK); + CHECK(xtc_audio_plan_set_fade_frames(audio, 240) == XTC_OK); + CHECK(xtc_midi_plan_set_fade_frames(midi, 240) == XTC_OK); std::vector result; for (uint64_t pos = 0; pos < 96000; pos += 256) { float al[256], ar[256], ml[256], mr[256], left[256], right[256]; @@ -113,7 +115,7 @@ int main(int argc, char **argv) CHECK(arranged.start_tick == 1920 && arranged.length_tick == 3840); CHECK(window.import_audio(wave.c_str()) == XTC_OK); expected = render(timeline->project()); - CHECK(expected[0] > 0); // Imported audio is routed without graph-file edits. + CHECK(expected[0] == 0 && expected[2] > 0); // Imported audio is routed without graph-file edits. CHECK(expected[2 * 48001] != 0); // Arranged notes also reach the output. CHECK(xtc_graph_get_counts(xtc_project_graph(timeline->project()), &nodes, &edges) == XTC_OK); CHECK(nodes == 3 && edges == 2); // Audio and instrument -> output. @@ -144,12 +146,13 @@ int main(int argc, char **argv) auto *right_meter = dynamic_cast(button(window, "Output R")); CHECK(left_meter && right_meter && left_meter->value() > 0 && right_meter->value() > 0); CHECK(window.pause_playback() == XTC_OK); + pump(); // Fade completion is acknowledged by the GUI timer. CHECK(left_meter->value() == 0 && right_meter->value() == 0); uint64_t paused = timeline->position(); pump(); CHECK(timeline->position() == paused); press(window, "Play"); pump(); CHECK(timeline->position() > paused); - press(window, "Stop"); + press(window, "Stop"); pump(); paused = timeline->position(); pump(); CHECK(timeline->position() == paused); press(window, "To start"); CHECK(timeline->position() == 0); press(window, "Add MIDI clip"); diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index c241ed3..7b89bdc 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -216,6 +216,7 @@ int main(void) CHECK(xtc_backend_set_recorder(backend, recorder) == XTC_OK); CHECK(xtc_backend_start(backend) == XTC_OK); CHECK(xtc_backend_set_recorder(backend, NULL) == XTC_ERR_INVALID_STATE); + CHECK(xtc_backend_request_stop(backend) == XTC_ERR_INVALID_STATE); CHECK(xtc_backend_prepare_project(backend, p) == XTC_ERR_INVALID_STATE); for (i = 0; i < 32; ++i) input[i] = i % 2 ? -0.5f : 0.25f; CHECK(process_cb(32, context) == 0); @@ -318,6 +319,39 @@ int main(void) CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_right == 0); CHECK(xtc_backend_close(backend) == XTC_OK); xtc_project_destroy(p); } + { + uint64_t held; + config.channel_count = 2; + CHECK(xtc_backend_open(&config, &backend) == XTC_OK); + CHECK(xtc_backend_set_transition_frames(backend, 65537) == XTC_ERR_INVALID_ARGUMENT); + CHECK(xtc_backend_set_transition_frames(backend, 4) == XTC_OK); + CHECK(xtc_backend_submit(backend, graph) == XTC_OK); + for (i = 0; i < 128; ++i) input[i] = 1; + CHECK(xtc_backend_start(backend) == XTC_OK); + CHECK(xtc_backend_set_transition_frames(backend, 4) == XTC_ERR_INVALID_STATE); + CHECK(process_cb(4, context) == 0); + CHECK(output[0] == 0.125f && output[3] == 0.5f && output_right[3] == output[3]); + CHECK(xtc_backend_request_stop(backend) == XTC_OK); + CHECK(process_cb(2, context) == 0 && output[0] == 0.375f && output[1] == 0.25f); + CHECK(xtc_backend_request_stop(backend) == XTC_OK); /* Must not restart ramp. */ + CHECK(process_cb(2, context) == 0 && output[0] == 0.125f && output[1] == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.running && status.stopping); + held = status.sample_position; + CHECK(process_cb(32, context) == 0 && output[0] == 0 && output[31] == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == held); + deactivate_fail = 1; + xtc_backend_collect(backend); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.running && status.process_result == XTC_ERR_IO); + deactivate_fail = 0; + xtc_backend_collect(backend); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && !status.running && !status.stopping); + CHECK(xtc_backend_start(backend) == XTC_OK && process_cb(4, context) == 0 && output[3] == 0.5f); + CHECK(xtc_backend_stop(backend) == XTC_OK); /* Immediate escape stays available. */ + CHECK(xtc_backend_set_transition_frames(backend, 0) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_OK && xtc_backend_request_stop(backend) == XTC_OK); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && !status.running); + CHECK(xtc_backend_close(backend) == XTC_OK); + } CHECK(closed >= 104); CHECK(xtc_backend_close(NULL) == XTC_OK); xtc_graph_destroy(invalid_graph); diff --git a/tests/unit/test_quality.c b/tests/unit/test_quality.c new file mode 100644 index 0000000..bcdf56b --- /dev/null +++ b/tests/unit/test_quality.c @@ -0,0 +1,201 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_dsp.h" +#include "xtc/xtc_audio.h" +#include "xtc/xtc_midi.h" +#include +#include +#include +#include +#include +#include +#include + +static void timing(void) +{ + unsigned int rates[] = {1, 8000, 44100, 48000, 96000, 192000, 768000}; + unsigned int tempos[] = {20000, 120000, 123457, 300000}; + xtc_project *p = NULL; + size_t r, t; + assert(xtc_project_create(&p) == XTC_OK); + for (r = 0; r < sizeof(rates)/sizeof(*rates); ++r) + for (t = 0; t < sizeof(tempos)/sizeof(*tempos); ++t) { + uint64_t tick, sample, back, expected, previous = 0; + assert(xtc_project_set_tempo(p, tempos[t]) == XTC_OK); + for (tick = 0; tick <= XTC_PROJECT_MAX_TICK; tick += 9973) { + expected = tick * rates[r] * UINT64_C(60000) / ((uint64_t)tempos[t] * 960); + assert(xtc_project_tick_to_sample(p, tick, rates[r], &sample) == XTC_OK); + assert(sample == expected && sample >= previous); + previous = sample; + assert(xtc_project_sample_to_tick(p, sample, rates[r], &back) == XTC_OK); + assert(back == sample * tempos[t] * 960 / ((uint64_t)rates[r] * 60000)); + } + assert(xtc_project_tick_to_sample(p, XTC_PROJECT_MAX_TICK, rates[r], &sample) == XTC_OK); + } + { + uint64_t unchanged = 123; + assert(xtc_project_sample_to_tick(p, UINT64_MAX, 1, &unchanged) == XTC_ERR_CAPACITY && unchanged == 123); + assert(xtc_project_set_tempo(p, 0) == XTC_ERR_INVALID_ARGUMENT); + } + xtc_project_destroy(p); +} +static void ramps(void) +{ + xtc_gain_ramp a, b; + float whole[257], split[257], right[257]; + size_t i; + assert(xtc_gain_ramp_reset(&a, 0) == XTC_OK); + b = a; + assert(xtc_gain_ramp_target(&a, 2, 17) == XTC_OK); + assert(xtc_gain_ramp_target(&b, 2, 17) == XTC_OK); + for (i = 0; i < 257; ++i) whole[i] = split[i] = right[i] = 1; + assert(xtc_gain_ramp_process(&a, whole, right, 257) == XTC_OK); + for (i = 0; i < 257; ++i) + assert(xtc_gain_ramp_process(&b, split + i, NULL, 1) == XTC_OK); + assert(memcmp(whole, split, sizeof(whole)) == 0 && memcmp(whole, right, sizeof(whole)) == 0); + assert(whole[16] == 2 && whole[256] == 2); /* Headroom, no implicit clipping. */ + assert(xtc_gain_ramp_target(&a, 0, 3) == XTC_OK); + whole[0] = whole[1] = whole[2] = 1; + assert(xtc_gain_ramp_process(&a, whole, NULL, 3) == XTC_OK && whole[2] == 0); + assert(xtc_gain_ramp_target(&a, NAN, 1) == XTC_ERR_INVALID_ARGUMENT && a.target == 0); + assert(xtc_dsp_finite(NAN) == 0 && xtc_dsp_finite(INFINITY) == 0); + assert(xtc_dsp_finite(nextafterf(0, 1)) == 0 && xtc_dsp_finite(FLT_MIN) == FLT_MIN); + assert(xtc_dsp_finite(4) == 4); + assert(xtc_gain_ramp_reset(&a, 0) == XTC_OK); + assert(xtc_gain_ramp_target(&a, 1, 4) == XTC_OK); + whole[0] = 1; + assert(xtc_gain_ramp_process(&a, whole, NULL, 1) == XTC_OK && whole[0] == 0.25f); + assert(xtc_gain_ramp_target(&a, -0.25f, 2) == XTC_OK); + whole[0] = whole[1] = 1; + assert(xtc_gain_ramp_process(&a, whole, NULL, 2) == XTC_OK && whole[0] == 0 && whole[1] == -0.25f); + assert(xtc_gain_ramp_target(&a, 1, 0) == XTC_OK && a.current == 1); + whole[0] = 9; + assert(xtc_gain_ramp_process(&a, whole, whole, 1) == XTC_ERR_INVALID_ARGUMENT && whole[0] == 9); +} +static void events(void) +{ + xtc_project *p = NULL; + xtc_midi_plan *a = NULL, *b = NULL; + xtc_midi_event events[] = {{7, XTC_MIDI_NOTE_ON, 0, 69, 127}, {33, XTC_MIDI_NOTE_OFF, 0, 69, 0}}; + float whole[257], split[257], right[257]; + size_t i; + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK && xtc_midi_plan_create(p, 48000, &b) == XTC_OK); + assert(xtc_midi_plan_render_stereo(a, 0, events, 2, whole, right, 257) == XTC_OK); + for (i = 0; i < 257; ++i) { + xtc_midi_event event; + const xtc_midi_event *e = NULL; + if (i == 7 || i == 33) { event = events[i == 7 ? 0 : 1]; event.offset = 0; e = &event; } + assert(xtc_midi_plan_render(b, i, e, e ? 1U : 0U, split + i, 1) == XTC_OK); + } + assert(memcmp(whole, split, sizeof(whole)) == 0 && memcmp(whole, right, sizeof(whole)) == 0); + for (i = 0; i <= 7; ++i) assert(whole[i] == 0); + assert(whole[8] > 0); + for (i = 33; i < 257; ++i) assert(whole[i] == 0); + /* Equal-offset input events retain caller order: off then on starts a voice. */ + events[0].offset = events[1].offset = 0; + xtc_midi_plan_reset(a); + assert(xtc_midi_plan_render(a, 0, events, 2, whole, 257) == XTC_OK && whole[1] == 0); + { xtc_midi_event temp = events[0]; events[0] = events[1]; events[1] = temp; } + xtc_midi_plan_reset(a); + assert(xtc_midi_plan_render(a, 0, events, 2, whole, 257) == XTC_OK && whole[1] > 0); + xtc_midi_plan_destroy(a); xtc_midi_plan_destroy(b); xtc_project_destroy(p); +} +static void clips(void) +{ + xtc_project *p = NULL; + xtc_audio_plan *a = NULL; + xtc_midi_plan *old = NULL, *fresh = NULL; + xtc_recorder *rec = NULL; + uint64_t track, clip, note; + float input[1003], whole[4097], split[4097], midi[4097]; + size_t i, offset; + char dir[] = "/tmp/xtc-quality-XXXXXX", path[256]; + assert(mkdtemp(dir)); snprintf(path, sizeof(path), "%s/source.wav", dir); + for (i = 0; i < 1003; ++i) input[i] = 1; + assert(xtc_recorder_create(path, 48000, 1003, &rec) == XTC_OK); + xtc_recorder_push(rec, input, 1003); + assert(xtc_recorder_finish(rec) == XTC_OK); xtc_recorder_destroy(rec); + assert(xtc_project_create(&p) == XTC_OK && xtc_project_add_track(p, "Test", &track) == XTC_OK); + assert(xtc_project_import_audio(p, track, path, 1, &clip) == XTC_OK); + assert(xtc_audio_plan_create(p, 48000, &a) == XTC_OK); + assert(xtc_audio_plan_set_fade_frames(a, 8) == XTC_OK); + assert(xtc_audio_plan_render(a, 0, whole, 4097) == XTC_OK); + for (offset = 0; offset < 4097;) { + size_t n = offset % 131 + 1; + if (n > 4097 - offset) n = 4097 - offset; + assert(xtc_audio_plan_render(a, offset, split + offset, n) == XTC_OK); + offset += n; + } + assert(memcmp(whole, split, sizeof(whole)) == 0); + assert(whole[24] == 0 && whole[25] == 0 && whole[26] == 0.125f && whole[33] == 1); + /* Fade at the true media end, before the imported clip's rounded-up tick end. */ + assert(whole[1026] == 0.125f && whole[1027] == 0 && whole[1028] == 0); + assert(xtc_audio_plan_render(a, 26, split, 1) == XTC_OK && split[0] == whole[26]); + { + xtc_audio_plan *resampled = NULL; + assert(xtc_audio_plan_create(p, 8000, &resampled) == XTC_OK); + assert(xtc_audio_plan_set_fade_frames(resampled, 8) == XTC_OK); + assert(xtc_audio_plan_render(resampled, 0, split, 200) == XTC_OK); + assert(split[4] == 0 && split[5] == 0.125f && split[170] == 0.125f && split[171] == 0 && split[172] == 0); + xtc_audio_plan_destroy(resampled); + } + + assert(xtc_project_add_midi_clip(p, track, 1, 100, &clip) == XTC_OK); + assert(xtc_project_add_note(p, clip, 0, 80, 69, 127, 0, ¬e) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &old) == XTC_OK); + assert(xtc_midi_plan_set_fade_frames(old, 8) == XTC_OK); + assert(xtc_midi_plan_render(old, 0, NULL, 0, whole, 4097) == XTC_OK); + xtc_midi_plan_reset(old); + for (offset = 0; offset < 4097;) { + size_t n = offset % 131 + 1; + if (n > 4097 - offset) n = 4097 - offset; + assert(xtc_midi_plan_render(old, offset, NULL, 0, split + offset, n) == XTC_OK); + offset += n; + } + assert(memcmp(whole, split, sizeof(whole)) == 0); + assert(whole[25] == 0 && whole[26] > 0 && whole[2024] == 0 && whole[2025] == 0); + assert(xtc_project_set_tempo(p, 60000) == XTC_OK); + { + xtc_audio_plan *new_audio = NULL; + assert(xtc_audio_plan_create(p, 48000, &new_audio) == XTC_OK); + assert(xtc_audio_plan_set_fade_frames(new_audio, 8) == XTC_OK); + assert(xtc_audio_plan_render(new_audio, 0, midi, 4097) == XTC_OK); + assert(midi[26] == 0 && midi[50] == 0 && midi[51] == 0.125f); + assert(xtc_audio_plan_render(a, 26, split, 1) == XTC_OK && split[0] == 0.125f); + xtc_audio_plan_destroy(new_audio); + } + assert(xtc_midi_plan_create(p, 48000, &fresh) == XTC_OK); + assert(xtc_midi_plan_render(fresh, 0, NULL, 0, midi, 4097) == XTC_OK); + assert(midi[26] == 0 && midi[51] > 0); /* Newly prepared plan follows new tempo. */ + xtc_midi_plan_reset(old); + assert(xtc_midi_plan_render(old, 0, NULL, 0, split, 4097) == XTC_OK); + assert(memcmp(whole, split, sizeof(whole)) == 0); /* Existing snapshot unchanged. */ + xtc_audio_plan_destroy(a); xtc_midi_plan_destroy(old); xtc_midi_plan_destroy(fresh); xtc_project_destroy(p); + assert(unlink(path) == 0 && rmdir(dir) == 0); +} +static void layouts(void) +{ + size_t sizes[] = {1, 2, 3, 17, 257, 4097, 65535, 65536}, i, j, node; + xtc_graph *g = NULL; + xtc_plan *plan = NULL; + float *l = malloc(65536 * sizeof(float)), *r = malloc(65536 * sizeof(float)); + assert(l && r); + assert(xtc_graph_create(1, &g) == XTC_OK && xtc_graph_add_gain(g, 2, &node) == XTC_OK); + assert(xtc_graph_compile(g, 48000, 65536, &plan) == XTC_OK); + for (i = 0; i < sizeof(sizes)/sizeof(*sizes); ++i) { + for (j = 0; j < sizes[i]; ++j) l[j] = j % 3 == 0 ? nextafterf(0, 1) : j % 3 == 1 ? 2 : INFINITY; + assert(xtc_plan_process_stereo(plan, l, NULL, NULL, NULL, NULL, l, r, sizes[i]) == XTC_OK); + for (j = 0; j < sizes[i]; ++j) assert(l[j] == (j % 3 == 1 ? 4 : 0) && r[j] == l[j]); + assert(xtc_plan_process_stereo(plan, NULL, NULL, NULL, NULL, NULL, l, r, sizes[i]) == XTC_OK); + for (j = 0; j < sizes[i]; ++j) assert(l[j] == 0 && r[j] == 0); + } + assert(xtc_plan_process_stereo(plan, NULL, NULL, NULL, NULL, NULL, l, l, 1) == XTC_ERR_INVALID_ARGUMENT); + free(l); free(r); xtc_plan_destroy(plan); xtc_graph_destroy(g); +} + +int main(void) +{ + timing(); ramps(); events(); clips(); layouts(); + puts("Timing, sample-offset events, tempo snapshots, fades, ramps and block invariance passed."); + return 0; +}