diff --git a/Makefile b/Makefile index 563414e..a96684f 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -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 +CORE_SOURCES := src/audio/recovery.c 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) @@ -287,3 +287,10 @@ $(BUILD_DIR)/quality-preview: $(BUILD_DIR)/examples/quality_preview.o $(BUILD_DI quality-preview: $(BUILD_DIR)/quality-preview ./$(BUILD_DIR)/quality-preview "$(abspath $(BUILD_DIR))/quality" -include $(BUILD_DIR)/examples/quality_preview.d + +$(BUILD_DIR)/test_recording: $(BUILD_DIR)/tests/integration/test_recording.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm +.PHONY: test-recording +test-recording: $(BUILD_DIR)/test_recording + ./$(BUILD_DIR)/test_recording +-include $(BUILD_DIR)/tests/integration/test_recording.d diff --git a/docs/AUDIO_FILES.md b/docs/AUDIO_FILES.md index 30fafaf..f3f7e1a 100644 --- a/docs/AUDIO_FILES.md +++ b/docs/AUDIO_FILES.md @@ -80,7 +80,7 @@ error rather than hiding gaps or compressing time. Stop first synchronizes the callback, detaches the recorder, drains remaining samples, writes the final WAV header, flushes/fsyncs/closes and renames the -same-directory temporary over the destination. A successful nonempty take is +same-directory temporary using an atomic no-clobber link; an existing destination is refused. A successful nonempty take is imported at its capture position on the selected track (or a new Recording track), then the document becomes unsaved. Save the project after Stop. Seeking back to the take and pressing Play auditions it. Recording continues existing @@ -92,7 +92,7 @@ until Stop finishes the take. Deactivation failure retains the recorder for a safe retry. On overflow a finalized partial WAV is retained for manual recovery and is not automatically added. On import/preparation failure the completed take remains at its chosen path with an explicit recovery message. I/O errors -are reported; an unfinished temporary is removed on destruction. Recordings +are reported; failed temporary data is retained and its recovery path is reported. Recordings are RIFF-limited to 1073741814 frames, but the smaller playback/import asset limit still applies: longer takes may need external splitting before import. There is no crash-recovery journal. The file is mode 0600; parent-directory @@ -109,3 +109,31 @@ make test-jack-audio routes only two software clients: known source -> recorded WAV -> saved/reopened project -> graph playback -> capture assertion. Physical input/output, device latency, slow-disk endurance and server loss during a real recording remain unverified. + +## Phase 13 recording protection + +The default recorder and GUI require a new destination, including rejecting +symlinks. Publication also refuses files created during recording. The explicit +`xtc_recorder_create_replace` API retains authorized replacement for callers +that deliberately need it; the GUI never uses it. After a failed finish, copy +`xtc_recorder_recovery_path` before destruction. Disk-write/finalization failures +retain the temporary. Destruction after producer quiescence attempts to drain +and finalize accepted data; normal code should call finish and inspect errors. +The GUI refuses closure while recording and prints the retained/finalized path +on exceptional destruction. + +Use `xtc-check --recover-recording TEMP NEW.wav` to copy complete float32 frames +from an interrupted xtc WAV into a new WAV. Source and existing destinations are +never modified. A trailing partial frame is ignored. This handles xtc's canonical +44-byte mono float32 header only, not arbitrary damaged WAVs. Recovery is explicit +and control-thread only; no orphan scan runs during playback. + +`make test-recording` simulates 30 minutes at 8 kHz with repeated ring wrapping +and draining, destination races/symlinks, a child-local disk-size limit, prefix +recovery and incomplete/missing asset reopening. `test-recovery` retains process +kill coverage; `test_audio` covers saturation and concurrent drain. This is +virtual-duration testing, not a wall-clock hardware endurance qualification. +Recordings remain RIFF mono float32, bounded to roughly 4 GiB; the preloaded +player still limits each asset to 16M frames. Longer valid takes may require +external splitting before import. No directory fsync/power-loss guarantee is +claimed. WAV destinations are chosen explicitly by the user. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index f4bd65c..2747f30 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -46,3 +46,14 @@ eight project writers while temporaries are visible, and kills a recorder after draining but before finalization. Complete old/new destinations and explicit orphan temporaries are verified. It does not cut power, terminate the shared audio server, simulate a failing filesystem, or qualify disk/device endurance. + +## Phase 13 retained recording prefixes + +Recording now defaults to no-clobber publication; the GUI requires an unused +path. Failed nonempty temporary recordings survive destruction. Query the +recorder recovery path before destruction, or use the GUI's reported path. +`xtc-check --recover-recording TEMP NEW.wav` reconstructs a canonical mono +float32 WAV from complete on-disk frames, ignoring a trailing partial frame. +It never changes the source or overwrites an existing destination. This +supersedes the earlier lack of a WAV recovery tool described above. See +AUDIO_FILES.md for format/ownership/size limits and `make test-recording`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb049ec..c122614 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -233,6 +233,15 @@ limiter or project-format change is claimed. - Incomplete recordings and missing media are detected and explained. - Recording and recovery behavior is covered by automated or repeatable integration tests. +Implemented: no-clobber recording destinations and race-safe publication; +explicit opt-in replacement API; retained failed temporaries and recovery-path +reporting; streaming `xtc-check --recover-recording TEMP NEW.wav` salvage; +quiescent destructor draining and exceptional-shutdown reporting. Virtual +30-minute recording, saturation/concurrency, destination races, disk-write +failure/prefix recovery, killed writers and missing/incomplete media tests pass. +See AUDIO_FILES.md and RECOVERY.md. Physical endurance, power-loss guarantees +and playback of assets above the existing preload limit remain unqualified. + ## Phase 14 — Native Plugin, Instrument, and Effect Architecture **Goal:** Provide a stable internal extension interface before hosting external plugins. diff --git a/examples/quality_preview.c b/examples/quality_preview.c index 571c214..a8f1a7e 100644 --- a/examples/quality_preview.c +++ b/examples/quality_preview.c @@ -27,7 +27,7 @@ static xtc_result preview(const char *prefix, int smooth) 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); + if (result == XTC_OK) result = xtc_recorder_create_replace(path, 48000, 256, 1, &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) { diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h index d3c06a2..1e07ef3 100644 --- a/include/xtc/xtc_audio.h +++ b/include/xtc/xtc_audio.h @@ -95,7 +95,7 @@ xtc_result xtc_audio_plan_render(const xtc_audio_plan *plan, uint64_t position, * Ring capacity 1..2097152 frames. One callback producer, one control consumer. * push accepts whole blocks; overflow is sticky and all later input is dropped. * drain performs file I/O; call regularly. finish requires producer quiescence, - * drains, finalizes and atomically renames even on overflow (partial recovery). + * drains, finalizes and publishes without overwriting even on overflow (partial recovery). * No source/recorder is freed until destroy, which also removes unfinished temp. * Counts and sticky error can be observed while producing. */ /** @@ -139,11 +139,23 @@ xtc_result xtc_recorder_finish(xtc_recorder *recorder); */ xtc_result xtc_recorder_status(const xtc_recorder *recorder, uint64_t *out_frames); /** - * @brief Releases recorder storage and removes any unfinished temporary file; accepts NULL. + * @brief Finalizes pending accepted frames after producer quiescence; preserves failed temporaries. * * @param recorder Recorder handle. */ void xtc_recorder_destroy(xtc_recorder *recorder); +/* Default create refuses any existing destination (including symlinks). Explicit + * replacement retains the old behavior for callers that obtained authorization. + * Final publication is race-safe: no-clobber link, or explicit replace/rename. */ +xtc_result xtc_recorder_create_replace(const char *path, unsigned int rate, size_t capacity, int replace, + xtc_recorder **out_recorder); +/* Control-thread borrowed path. After a failed finish, copy before destroy. + * Useful temporary data survives destroy on failed finalization. */ +const char *xtc_recorder_recovery_path(const xtc_recorder *recorder); +/* Salvage complete mono float32 frames from xtc's interrupted recording format + * to a NEW destination. Never modifies the source or overwrites an existing file. */ +xtc_result xtc_recorder_recover(const char *temporary, const char *destination); + #ifdef __cplusplus } diff --git a/src/audio/recorder.c b/src/audio/recorder.c index f64a038..a6f735a 100644 --- a/src/audio/recorder.c +++ b/src/audio/recorder.c @@ -4,6 +4,8 @@ #include #include #include +#include +#include struct xtc_recorder { float *ring; @@ -12,7 +14,7 @@ struct xtc_recorder { unsigned int error, rate; FILE *file; char *path, *temporary; - int finished; + int finished, replace, committed, preserve; }; /** @@ -24,7 +26,7 @@ struct xtc_recorder { * @param out Receives the newly allocated object; caller owns it on success. * @return XTC_OK on success, or an error code describing the failure. */ -xtc_result xtc_recorder_create(const char *path, unsigned int rate, size_t capacity, xtc_recorder **out) +xtc_result xtc_recorder_create_replace(const char *path, unsigned int rate, size_t capacity, int replace, xtc_recorder **out) { xtc_recorder *r; size_t length = 0; @@ -32,7 +34,7 @@ xtc_result xtc_recorder_create(const char *path, unsigned int rate, size_t capac if (!out) return XTC_ERR_INVALID_ARGUMENT; *out = NULL; - if (!path || !rate || rate > 768000 || !capacity || capacity > 2097152U) + if ((replace != 0 && replace != 1) || !path || !rate || rate > 768000 || !capacity || capacity > 2097152U) return XTC_ERR_INVALID_ARGUMENT; while (length < 4096 && path[length]) ++length; @@ -42,9 +44,14 @@ xtc_result xtc_recorder_create(const char *path, unsigned int rate, size_t capac return XTC_ERR_UNSUPPORTED; if (!__atomic_always_lock_free(sizeof(uint64_t), 0) || !__atomic_always_lock_free(sizeof(unsigned int), 0)) return XTC_ERR_UNSUPPORTED; + if (!replace) { + struct stat info; + if (lstat(path, &info) == 0 || errno != ENOENT) return XTC_ERR_IO; + } r = calloc(1, sizeof(*r)); if (!r) return XTC_ERR_OUT_OF_MEMORY; + r->replace = replace; r->capacity = capacity; r->rate = rate; r->ring = malloc(capacity * sizeof(float)); @@ -166,8 +173,15 @@ xtc_result xtc_recorder_finish(xtc_recorder *r) if (fclose(r->file)) failed = 1; r->file = NULL; - if (!failed && rename(r->temporary, r->path)) - failed = 1; + if (!failed) { + if (r->replace ? rename(r->temporary, r->path) : link(r->temporary, r->path)) + failed = 1; + else { + r->committed = 1; + if (!r->replace) (void)unlink(r->temporary); + } + } + r->preserve = failed && (__atomic_load_n(&r->write_position, __ATOMIC_ACQUIRE) != 0 || r->written != 0); r->finished = 1; return failed ? XTC_ERR_IO : result; } @@ -181,12 +195,36 @@ void xtc_recorder_destroy(xtc_recorder *r) { if (!r) return; - if (r->file) - fclose(r->file); - if (r->temporary) + if (r->file) { + if (__atomic_load_n(&r->write_position, __ATOMIC_ACQUIRE)) + (void)xtc_recorder_finish(r); /* Producer must already be quiescent. */ + else { fclose(r->file); r->file = NULL; } + } + if (r->temporary && !r->preserve) unlink(r->temporary); free(r->temporary); free(r->path); free(r->ring); free(r); } + +xtc_result xtc_recorder_create(const char *path, unsigned int rate, size_t capacity, xtc_recorder **out) +{ + return xtc_recorder_create_replace(path, rate, capacity, 0, out); +} +const char *xtc_recorder_recovery_path(const xtc_recorder *r) +{ + if (!r) return ""; + return r->committed ? r->path : r->temporary ? r->temporary : ""; +} + +/* Internal cancellation for a failed recovery transaction; never removes the + * destination, which may have been created by another process. */ +void xtc_recorder_abort(xtc_recorder *r) +{ + if (!r) return; + if (r->file) fclose(r->file); + r->file = NULL; + r->finished = 1; + r->preserve = 0; +} diff --git a/src/audio/recovery.c b/src/audio/recovery.c new file mode 100644 index 0000000..28d229d --- /dev/null +++ b/src/audio/recovery.c @@ -0,0 +1,50 @@ +#define _POSIX_C_SOURCE 200809L +#include "wave_internal.h" +#include +#include +#include +#include +static uint32_t little(const unsigned char *p) +{ return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; } +xtc_result xtc_recorder_recover(const char *source, const char *destination) +{ + unsigned char header[44], bytes[1024]; + struct stat info; + FILE *file; + xtc_recorder *recorder = NULL; + xtc_result result = XTC_ERR_FORMAT; + uint64_t remaining; + unsigned int rate; + if (!source || !destination) return XTC_ERR_INVALID_ARGUMENT; + file = fopen(source, "rb"); + if (!file) return XTC_ERR_IO; + if (fstat(fileno(file), &info) || !S_ISREG(info.st_mode) || info.st_size < 48 || + (uint64_t)info.st_size > UINT64_C(4294967292) || fread(header, 1, 44, file) != 44) goto done; + rate = little(header + 24); + if (memcmp(header, "RIFF", 4) || memcmp(header + 8, "WAVEfmt ", 8) || little(header + 16) != 16 || + header[20] != 3 || header[21] || header[22] != 1 || header[23] || !rate || rate > 768000 || + little(header + 28) != rate * 4 || header[32] != 4 || header[33] || header[34] != 32 || header[35] || + memcmp(header + 36, "data", 4)) goto done; + remaining = ((uint64_t)info.st_size - 44) / 4; + result = xtc_recorder_create(destination, rate, 256, &recorder); + while (result == XTC_OK && remaining) { + float samples[256]; + size_t i, count = remaining > 256 ? 256 : (size_t)remaining; + if (fread(bytes, 4, count, file) != count) { result = XTC_ERR_IO; break; } + for (i = 0; i < count; ++i) { + uint32_t bits = little(bytes + i * 4); + memcpy(samples + i, &bits, sizeof(bits)); + if (!isfinite(samples[i])) { result = XTC_ERR_FORMAT; break; } + } + if (result != XTC_OK) break; + xtc_recorder_push(recorder, samples, count); + result = xtc_recorder_drain(recorder); + remaining -= count; + } + if (result == XTC_OK) result = xtc_recorder_finish(recorder); + if (result != XTC_OK) xtc_recorder_abort(recorder); + xtc_recorder_destroy(recorder); +done: + if (fclose(file) && result == XTC_OK) result = XTC_ERR_IO; + return result; +} diff --git a/src/audio/wave_internal.h b/src/audio/wave_internal.h index ffd8adf..3603c68 100644 --- a/src/audio/wave_internal.h +++ b/src/audio/wave_internal.h @@ -36,4 +36,5 @@ int xtc_wave_header(FILE *file, unsigned int rate, uint32_t frames); * @return Nonzero if all samples were written, zero on a write failure. */ int xtc_wave_write(FILE *file, const float *input, size_t frames); +void xtc_recorder_abort(xtc_recorder *recorder); #endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 7704271..a254eb1 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -282,7 +282,9 @@ MainWindow::~MainWindow() std::fputs("Failed to close JACK during GUI destruction; backend and recording retained until process exit.\n", stderr); else if (recorder_) { - (void)xtc_recorder_finish(recorder_); + xtc_result result = xtc_recorder_finish(recorder_); + std::fprintf(stderr, "Recording finalized during shutdown (%s): %s\n", xtc_result_string(result), + xtc_recorder_recovery_path(recorder_)); xtc_recorder_destroy(recorder_); } } @@ -1091,13 +1093,14 @@ void MainWindow::finish_recording() uint64_t frames = 0; (void)xtc_recorder_status(recorder_, &frames); xtc_result r = xtc_recorder_finish(recorder_); + std::string recovery_path = xtc_recorder_recovery_path(recorder_); xtc_recorder_destroy(recorder_); recorder_ = nullptr; if (r != XTC_OK) { char text[768]; std::snprintf(text, sizeof(text), - "Recording failed (%s). Partial take, if finalized: %s. It was not added to the project.", - xtc_result_string(r), recording_path_.c_str()); + "Recording failed (%s). Retained take/temporary: %s. Recover with xtc-check --recover-recording. Not added.", + xtc_result_string(r), recovery_path.c_str()); set_label(error_, text); return; } @@ -1155,8 +1158,7 @@ void MainWindow::audio_file_callback(Fl_Widget *widget, void *context) chooser.title(recording ? "Record JACK input to WAV" : relink ? "Relink selected clip" : "Import WAV"); chooser.type(recording ? Fl_Native_File_Chooser::BROWSE_SAVE_FILE : Fl_Native_File_Chooser::BROWSE_FILE); chooser.filter("WAV audio\t*.wav"); - if (recording) - chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM); + // Recordings require a new destination; existing files are never replaced. int chosen = chooser.show(); if (chosen == 1) return; @@ -1173,7 +1175,8 @@ void MainWindow::audio_file_callback(Fl_Widget *widget, void *context) } else if (r == XTC_OK) r = recording ? window->record_audio(chooser.filename()) : window->import_audio(chooser.filename()); if (r != XTC_OK) - window->report_error("Project audio operation failed", r); + window->report_error(recording ? "Project recording failed; choose a new WAV path in a writable directory" + : "Project audio operation failed", r); } /** diff --git a/src/main.c b/src/main.c index 5fd543f..0182e4b 100644 --- a/src/main.c +++ b/src/main.c @@ -5,6 +5,7 @@ #include #include #include "xtc/xtc_project.h" +#include "xtc/xtc_audio.h" /** * @brief Runs the xtc-check command-line entry point. @@ -25,10 +26,20 @@ int main(int argc, char **argv) return EXIT_SUCCESS; } if (argc == 2 && !strcmp(argv[1], "--help")) { - puts("Usage: xtc-check [--version | --help | --validate PROJECT]\nNo arguments checks core startup/shutdown. " + puts("Usage: xtc-check [--version | --help | --validate PROJECT | --recover-recording TEMP NEW.wav]\nNo arguments checks core startup/shutdown. " "Validation checks metadata only."); return EXIT_SUCCESS; } + if (argc == 4 && !strcmp(argv[1], "--recover-recording")) { + xtc_result result = xtc_recorder_recover(argv[2], argv[3]); + if (result != XTC_OK) { + fprintf(stderr, "Recording recovery failed: %s. Source retained; choose a new writable destination.\n", + xtc_result_string(result)); + return EXIT_FAILURE; + } + puts("Complete recorded frames recovered; source retained."); + return EXIT_SUCCESS; + } if (argc == 3 && !strcmp(argv[1], "--validate")) { xtc_project *project = NULL; xtc_result result = xtc_project_create(&project); @@ -43,7 +54,7 @@ int main(int argc, char **argv) return EXIT_SUCCESS; } if (argc != 1) { - fputs("Usage: xtc-check [--version | --help | --validate PROJECT]\n", stderr); + fputs("Usage: xtc-check [--version | --help | --validate PROJECT | --recover-recording TEMP NEW.wav]\n", stderr); return EXIT_FAILURE; } diff --git a/tests/integration/test_recording.c b/tests/integration/test_recording.c new file mode 100644 index 0000000..5d861aa --- /dev/null +++ b/tests/integration/test_recording.c @@ -0,0 +1,108 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_audio.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +static void file(const char *path, const char *text) +{ FILE *f = fopen(path, "wb"); assert(f); assert(fputs(text, f) >= 0 && fclose(f) == 0); } +int main(void) +{ + char dir[] = "/tmp/xtc-recording-XXXXXX", take[256], recovered[256], retained[4096], song[256]; + xtc_recorder *r = NULL; + float samples[4096]; + struct stat info; + uint64_t position; + size_t i; + assert(mkdtemp(dir)); + snprintf(take, sizeof(take), "%s/take.wav", dir); + snprintf(recovered, sizeof(recovered), "%s/recovered.wav", dir); + snprintf(song, sizeof(song), "%s/song.xtc", dir); + for (i = 0; i < 4096; ++i) samples[i] = 0.25f; + file(take, "existing"); + assert(xtc_recorder_create(take, 8000, 4096, &r) == XTC_ERR_IO && !r); + assert(unlink(take) == 0 && symlink("missing", take) == 0); + assert(xtc_recorder_create(take, 8000, 4096, &r) == XTC_ERR_IO && !r); + assert(unlink(take) == 0); + assert(xtc_recorder_create("/missing/xtc/take.wav", 8000, 4096, &r) == XTC_ERR_IO && !r); + /* Destination appears after creation: no-clobber publication must preserve it. */ + assert(xtc_recorder_create(take, 8000, 4096, &r) == XTC_OK); + xtc_recorder_push(r, samples, 4096); + file(take, "racing writer"); + assert(xtc_recorder_finish(r) == XTC_ERR_IO); + snprintf(retained, sizeof(retained), "%s", xtc_recorder_recovery_path(r)); + xtc_recorder_destroy(r); + assert(stat(take, &info) == 0 && info.st_size == 13); + assert(xtc_recorder_recover(retained, take) == XTC_ERR_IO); + assert(xtc_recorder_recover(retained, recovered) == XTC_OK); + assert(stat(recovered, &info) == 0 && info.st_size == 44 + 4096 * 4); + assert(unlink(retained) == 0 && unlink(recovered) == 0 && unlink(take) == 0); + /* Last-resort destruction drains accepted samples after producer quiescence. */ + assert(xtc_recorder_create(take, 8000, 4096, &r) == XTC_OK); + xtc_recorder_push(r, samples, 4096); xtc_recorder_destroy(r); + assert(stat(take, &info) == 0 && info.st_size == 44 + 4096 * 4); + assert(unlink(take) == 0); + /* Thirty minutes of virtual 8 kHz audio, repeated ring wrap/drain, no sleep. */ + assert(xtc_recorder_create(take, 8000, 4096, &r) == XTC_OK); + for (position = 0; position < 14400000;) { + size_t count = 14400000 - position > 4096 ? 4096 : (size_t)(14400000 - position); + xtc_recorder_push(r, samples, count); + assert(xtc_recorder_drain(r) == XTC_OK); + position += count; + } + assert(xtc_recorder_finish(r) == XTC_OK); xtc_recorder_destroy(r); + assert(stat(take, &info) == 0 && info.st_size == 57600044); + assert(unlink(take) == 0); + /* Child-local disk-size failure preserves a recoverable on-disk prefix. */ + { + int pipes[2], status; + pid_t child; + assert(pipe(pipes) == 0); + child = fork(); assert(child >= 0); + if (!child) { + struct rlimit limit = {128, 128}; + close(pipes[0]); signal(SIGXFSZ, SIG_IGN); + assert(setrlimit(RLIMIT_FSIZE, &limit) == 0); + assert(xtc_recorder_create(take, 8000, 4096, &r) == XTC_OK); + xtc_recorder_push(r, samples, 4096); + (void)xtc_recorder_drain(r); + assert(xtc_recorder_finish(r) == XTC_ERR_IO); + snprintf(retained, sizeof(retained), "%s", xtc_recorder_recovery_path(r)); + xtc_recorder_destroy(r); + assert(write(pipes[1], retained, sizeof(retained)) == (ssize_t)sizeof(retained)); + _exit(0); + } + close(pipes[1]); + assert(read(pipes[0], retained, sizeof(retained)) == (ssize_t)sizeof(retained)); close(pipes[0]); + assert(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0); + assert(access(take, F_OK) != 0 && stat(retained, &info) == 0 && info.st_size == 128); + assert(xtc_recorder_recover(retained, recovered) == XTC_OK); + assert(stat(recovered, &info) == 0 && info.st_size == 128); + assert(unlink(retained) == 0 && unlink(recovered) == 0); + } + /* Incomplete and missing references survive save/reopen and are reported. */ + { + xtc_project *p = NULL; + xtc_audio_plan *plan = NULL; + uint64_t track, clip; + const char *issue; + assert(xtc_project_create(&p) == XTC_OK && xtc_project_add_track(p, "Incomplete", &track) == XTC_OK); + assert(xtc_project_add_clip(p, track, "Broken take", 0, 960, &clip) == XTC_OK); + assert(xtc_project_set_clip_asset(p, clip, take) == XTC_OK); + file(take, "RIFF incomplete"); + assert(xtc_project_save(p, song) == XTC_OK && xtc_project_load(p, song) == XTC_OK); + assert(xtc_audio_plan_create(p, 8000, &plan) == XTC_OK && xtc_audio_plan_issues(plan, &issue) == 1); + assert(!strcmp(issue, take)); xtc_audio_plan_destroy(plan); + assert(unlink(take) == 0); + assert(xtc_audio_plan_create(p, 8000, &plan) == XTC_OK && xtc_audio_plan_issues(plan, &issue) == 1); + xtc_audio_plan_destroy(plan); xtc_project_destroy(p); + assert(unlink(song) == 0); + } + assert(rmdir(dir) == 0); + puts("30-minute virtual recording, no-clobber races, disk failure recovery and incomplete/missing media passed."); +} diff --git a/tests/integration/test_recovery.c b/tests/integration/test_recovery.c index 3aaf51a..f6b91ca 100644 --- a/tests/integration/test_recovery.c +++ b/tests/integration/test_recovery.c @@ -115,7 +115,7 @@ int main(void) float samples[128]; for (i = 0; i < 128; ++i) samples[i] = 0.25f; - assert(xtc_recorder_create(wave, 48000, 256, &recorder) == XTC_OK); + assert(xtc_recorder_create_replace(wave, 48000, 256, 1, &recorder) == XTC_OK); xtc_recorder_push(recorder, samples, 128); assert(xtc_recorder_finish(recorder) == XTC_OK); xtc_recorder_destroy(recorder); @@ -125,7 +125,7 @@ int main(void) assert(child >= 0); if (!child) { close(pipefd[0]); - if (xtc_recorder_create(wave, 48000, 256, &recorder) != XTC_OK) + if (xtc_recorder_create_replace(wave, 48000, 256, 1, &recorder) != XTC_OK) _exit(6); xtc_recorder_push(recorder, samples, 128); if (xtc_recorder_drain(recorder) != XTC_OK) diff --git a/tests/unit/test_audio.c b/tests/unit/test_audio.c index 66a0f3f..15502f3 100644 --- a/tests/unit/test_audio.c +++ b/tests/unit/test_audio.c @@ -90,7 +90,7 @@ int main(void) } /* Recording ring wraps, exact samples survive WAV -> project -> reopen. */ for (i = 0; i < 64; ++i) input[i] = i % 2 ? -0.5f : 0.25f; - assert(xtc_recorder_create(take, 48000, 128, &rec) == XTC_OK); + assert(xtc_recorder_create_replace(take, 48000, 128, 1, &rec) == XTC_OK); for (i = 0; i < 10; ++i) { xtc_recorder_push(rec, input, 64); assert(xtc_recorder_drain(rec) == XTC_OK); @@ -137,18 +137,18 @@ int main(void) } xtc_project_destroy(p); xtc_project_destroy(q); /* Overflow is sticky, partial file recoverable, destination unchanged until finish. */ - assert(xtc_recorder_create(take, 48000, 64, &rec) == XTC_OK); + assert(xtc_recorder_create_replace(take, 48000, 64, 1, &rec) == XTC_OK); xtc_recorder_push(rec, input, 64); xtc_recorder_push(rec, input, 1); assert(xtc_recorder_status(rec, &frames) == XTC_ERR_CAPACITY && frames == 64); assert(xtc_recorder_finish(rec) == XTC_ERR_CAPACITY); xtc_recorder_destroy(rec); - assert(xtc_recorder_create(take, 48000, 64, &rec) == XTC_OK); + assert(xtc_recorder_create_replace(take, 48000, 64, 1, &rec) == XTC_OK); xtc_recorder_push(rec, NULL, 1); assert(xtc_recorder_status(rec, &frames) == XTC_ERR_IO); xtc_recorder_destroy(rec); /* Actual concurrent producer/control consumer; no GUI or JACK required. */ { stress s = {0}; pthread_t thread; - assert(xtc_recorder_create(take, 48000, 524288, &rec) == XTC_OK); s.r = rec; + assert(xtc_recorder_create_replace(take, 48000, 524288, 1, &rec) == XTC_OK); s.r = rec; assert(pthread_create(&thread, NULL, producer, &s) == 0); while (!__atomic_load_n(&s.complete, __ATOMIC_ACQUIRE)) assert(xtc_recorder_drain(rec) == XTC_OK); assert(pthread_join(thread, NULL) == 0); diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index 7b89bdc..b6b3621 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -212,7 +212,7 @@ int main(void) CHECK(xtc_project_add_track(p, "Take", &track) == XTC_OK); CHECK(xtc_backend_open(&config, &backend) == XTC_OK); CHECK(xtc_backend_prepare_project(backend, p) == XTC_OK); - CHECK(xtc_recorder_create(take, 48000, 256, &recorder) == XTC_OK); + CHECK(xtc_recorder_create_replace(take, 48000, 256, 1, &recorder) == XTC_OK); 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); @@ -229,7 +229,7 @@ int main(void) CHECK(xtc_recorder_finish(recorder) == XTC_OK); CHECK(xtc_recorder_status(recorder, &captured) == XTC_OK && captured == 64); xtc_recorder_destroy(recorder); - CHECK(xtc_recorder_create(take, 48000, 256, &recorder) == XTC_OK); + CHECK(xtc_recorder_create_replace(take, 48000, 256, 1, &recorder) == XTC_OK); CHECK(xtc_backend_set_recorder(backend, recorder) == XTC_OK && xtc_backend_start(backend) == XTC_OK); null_buffer = 1; CHECK(process_cb(32, context) == 0); null_buffer = 0; CHECK(process_cb(32, context) == 0); /* Never silently resume after missing callback data. */