Phase 12: Improve audio transitions and verify sample-accurate timing

This commit is contained in:
Johannes Findeisen 2026-10-27 03:37:19 +01:00
commit 05140c2e93
29 changed files with 795 additions and 33 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

168
docs/AUDIO_QUALITY.md Normal file
View file

@ -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.

View file

@ -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).

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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 <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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, &note);
if (result == XTC_OK) result = xtc_project_add_note(project, clip, 720, 480, 64, 100, 0, &note);
if (result == XTC_OK) result = xtc_project_add_note(project, clip, 1440, 1440, 67, 100, 0, &note);
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;
}

View file

@ -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.
*

View file

@ -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.
*/

20
include/xtc/xtc_dsp.h Normal file
View file

@ -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

View file

@ -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. */

View file

@ -2,6 +2,7 @@
#include "wave_internal.h"
#include "../mixer/mix_internal.h"
#include "../graph/graph_internal.h"
#include "xtc/xtc_dsp.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
@ -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;
}

View file

@ -1,5 +1,6 @@
#include "xtc/xtc_backend.h"
#include "xtc/xtc_realtime.h"
#include "xtc/xtc_dsp.h"
#include <stdlib.h>
#include <string.h>
@ -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

38
src/dsp/ramp.c Normal file
View file

@ -0,0 +1,38 @@
#include "xtc/xtc_dsp.h"
#include <math.h>
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;
}

View file

@ -1,3 +1,4 @@
#include "xtc/xtc_dsp.h"
#include "../graph/graph_internal.h"
#include <math.h>
#include <stdlib.h>
@ -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;

View file

@ -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);
}

View file

@ -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.
*/

View file

@ -1,4 +1,5 @@
#include "xtc/xtc_midi.h"
#include "xtc/xtc_dsp.h"
#include "../mixer/mix_internal.h"
#include <math.h>
#include <stdlib.h>
@ -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;
}

View file

@ -1,3 +1,4 @@
#include "xtc/xtc_dsp.h"
#include "xtc/xtc_native.h"
#include <math.h>
#include <stdlib.h>
@ -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;
}

View file

@ -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.

View file

@ -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)

View file

@ -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";

View file

@ -66,6 +66,8 @@ static std::vector<float> 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<float> 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<Fl_Progress *>(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");

View file

@ -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);

201
tests/unit/test_quality.c Normal file
View file

@ -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 <assert.h>
#include <float.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
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, &note) == 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;
}