Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f7e68c8
Let push be told the name a file lands under on the card
AliceLafox Oct 4, 2026
2292bc8
Name the mark for converted uploads, and keep it beside the upload pr…
AliceLafox Oct 4, 2026
7aff531
Convert into a directory per job, and report what the rebuild changed
AliceLafox Oct 4, 2026
9273aaa
Decide the on-pedal name at the push, and say what the conversion cha…
AliceLafox Oct 4, 2026
5cee405
Explain the mark on converted uploads in the guide
AliceLafox Oct 4, 2026
2a6d00a
Draw the conversion's arrow the way the normalization already draws it
AliceLafox Oct 4, 2026
029f372
Refuse a landed name the card or a host could not create, before the …
AliceLafox Oct 4, 2026
6942d98
Mark only an upload whose samples changed, with a mark a name can carry
AliceLafox Oct 4, 2026
5dfdad4
Draw the push note's sentences in the history row
AliceLafox Oct 4, 2026
ca915f6
Cap a landed name where Windows caps it, and know the superscript dev…
AliceLafox Oct 4, 2026
36f6c6b
Name a RIFF cut short as its own fact
AliceLafox Oct 4, 2026
cdbb656
Repack only on a sample reader's word, and refuse a WAV cut short
AliceLafox Oct 4, 2026
ed47de0
Cap the mark where the card caps a name, and trim a hand-edited one
AliceLafox Oct 4, 2026
ffef6be
Draw a push note's sentences only for a job that finished
AliceLafox Oct 4, 2026
21b3de3
Name a RIFF's structural fault, and let a cut trailer through
AliceLafox Oct 4, 2026
d1776b1
Refuse a WAV whose structure is at fault; repack only on a shape refusal
AliceLafox Oct 4, 2026
3b45042
Rewrite a mark only when it is all whitespace
AliceLafox Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ loopercat_core_test(connect_gate_tests)
# What the app remembers about the pedals it has met (issue #98): pure, so
# it is a core suite though it lives in app/.
loopercat_core_test(pedal_book_tests)
# What a file name on the card may be (FatName.hpp): FAT's rules and Windows's.
loopercat_core_test(fat_name_tests)
# The name a pushed file lands under (issue #139): pure, so it is a core
# suite though it lives in app/.
loopercat_core_test(upload_mark_tests)
# The empty window's words about the bus, by the profile table's read gate.
loopercat_core_test(pedal_presence_tests)
loopercat_core_test(params_tests)
Expand Down Expand Up @@ -714,6 +719,25 @@ target_link_libraries(wav_import_tests PRIVATE
juce::juce_recommended_warning_flags)
add_test(NAME wav_import_tests COMMAND wav_import_tests)

# ----------------------------------------------------------------------------
# The upload preferences (issues #53, #139) in the app's own PropertiesFile:
# defaults for an untouched file, round trips through a fresh open, a mark
# cleared to nothing staying nothing.
# ----------------------------------------------------------------------------
juce_add_console_app(import_prefs_tests PRODUCT_NAME "import_prefs_tests")
target_sources(import_prefs_tests PRIVATE tests/import_prefs_tests.cpp)
target_compile_definitions(import_prefs_tests PRIVATE
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
LOOPERCAT_GOLDEN_JSON="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/golden.json")
target_link_libraries(import_prefs_tests PRIVATE
loopercat::core
nlohmann_json::nlohmann_json
juce::juce_gui_basics
juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)
add_test(NAME import_prefs_tests COMMAND import_prefs_tests)

# ----------------------------------------------------------------------------
# The history store (issue #72): theory tests against real SQLite files — the
# storage properties read back, atomicity of a take and its row, dedup,
Expand Down
86 changes: 86 additions & 0 deletions app/ImportPrefs.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE.
// SPDX-License-Identifier: AGPL-3.0-or-later

#pragma once

#include "UploadMark.h"

#include <juce_data_structures/juce_data_structures.h>

//==============================================================================
// loopercat::ImportPrefs — what happens to an upload on its way to the card,
// and where the app keeps the answer: three keys in the per-user
// PropertiesFile (AppSettings), beside the column and history preferences.
//
// One reader and one writer, so the Settings dialog that edits them, the push
// job that acts on them and a test that round-trips them all see the same
// keys with the same defaults. The push job reads them when the player acts
// — as the job is queued — and carries them with it: a settings change
// after that must not rewrite a job already promised.
//==============================================================================
namespace loopercat
{

struct ImportPrefs {
// Normalize-on-upload (issue #53): OFF by default so every existing
// workflow keeps the byte-exact pass-through; -18 LUFS is ReplayGain
// 2.0's reference — the modern spelling of the "89 dB" the request
// arrived in.
bool normalizeOnUpload;
double targetLufs;
// The mark a converted upload lands with (issue #139): "-pedal" out of
// the box, which is the spelling existing cards already carry; empty
// means converted uploads land under their own name.
juce::String convertedMark;
};

namespace importprefs
{
// The one rewrite a mark gets, typed or stored: a mark of only
// whitespace is the empty mark — "Empty: no mark", as the hint says.
// Any other mark stays as typed, spaces before, inside and after
// included: a rewritten mark is a different mark (UploadMark.h).
inline juce::String markAsTyped(const juce::String& text)
{
return text.trim().isEmpty() ? juce::String() : text;
}

inline constexpr auto kNormalizeOnUploadKey = "normalizeOnUpload";
inline constexpr auto kTargetLufsKey = "normalizeTargetLufs";
inline constexpr auto kConvertedMarkKey = "convertedUploadMark";
inline constexpr double kDefaultTargetLufs = -18.0;

// What a player who never opened Settings gets.
inline ImportPrefs defaults()
{
return { .normalizeOnUpload = false,
.targetLufs = kDefaultTargetLufs,
.convertedMark = juce::String(uploadmark::kDefaultSuffix.data(),
uploadmark::kDefaultSuffix.size()) };
}

// A key that is absent reads as its default; a key that is present reads
// as stored, so a mark cleared to "" stays cleared — PropertySet::getValue
// falls back only when the key is missing, not when its value is empty.
// The mark gets the field's one rule (markAsTyped), so "only spaces is
// the empty mark" holds whichever hand wrote the file — and nothing else
// about it is rewritten.
inline ImportPrefs read(juce::PropertiesFile& file)
{
const ImportPrefs shipped = defaults();
return { .normalizeOnUpload =
file.getBoolValue(kNormalizeOnUploadKey, shipped.normalizeOnUpload),
.targetLufs = file.getDoubleValue(kTargetLufsKey, shipped.targetLufs),
.convertedMark = markAsTyped(file.getValue(kConvertedMarkKey, shipped.convertedMark)) };
}

inline void write(juce::PropertiesFile& file, const ImportPrefs& prefs)
{
file.setValue(kNormalizeOnUploadKey, prefs.normalizeOnUpload);
file.setValue(kTargetLufsKey, prefs.targetLufs);
file.setValue(kConvertedMarkKey, prefs.convertedMark);
file.saveIfNeeded();
}
} // namespace importprefs

} // namespace loopercat
88 changes: 46 additions & 42 deletions app/MainComponent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
#include "OperationsLog.h"
#include "PedalPortName.h"
#include "Strings.h"
#include "ImportPrefs.h"
#include "UploadMark.h"
#include "WavImport.h"

#include "CardPermissions.h"
Expand Down Expand Up @@ -48,13 +50,10 @@ namespace
// until a measure or a check fills it, and an empty column is noise.
constexpr auto kLoudnessColumnKey = "columnLoudness";

// Normalize-on-upload (issue #53): OFF by default so every existing
// workflow keeps the byte-exact pass-through; -18 LUFS is ReplayGain
// 2.0's reference — the modern spelling of the "89 dB" the request
// arrived in.
constexpr auto kNormalizeOnUploadKey = "normalizeOnUpload";
constexpr auto kNormalizeTargetLufsKey = "normalizeTargetLufs";
constexpr double kDefaultTargetLufs = -18.0;
// The upload preferences live in ImportPrefs.h (issues #53, #139); the
// Normalize commands read the target under its name here.
constexpr auto kNormalizeTargetLufsKey = importprefs::kTargetLufsKey;
constexpr double kDefaultTargetLufs = importprefs::kDefaultTargetLufs;

// The history's storage limit (issue #74): the bytes of takes the app
// keeps before it starts offering the oldest for release. 5 GB out of
Expand Down Expand Up @@ -2076,50 +2075,62 @@ void MainComponent::pushWav(int slot, const juce::String& sourcePath, bool slotO
{
const auto enqueuePush = [this, slot, sourcePath](bool force) {
releasePlayerIfHolding(slot, slot); // a replace rewrites the WAV under preview (issue #26)
// The normalize preference is read HERE, when the player acts — a
// The upload preferences are read HERE, when the player acts — a
// settings change mid-queue must not rewrite jobs already promised.
std::optional<double> normalizeTarget;
if (auto* file = settings.file();
file != nullptr && file->getBoolValue(kNormalizeOnUploadKey, false))
normalizeTarget = file->getDoubleValue(kNormalizeTargetLufsKey, kDefaultTargetLufs);
auto* file = settings.file();
const ImportPrefs prefs = file != nullptr ? importprefs::read(*file) : importprefs::defaults();
const std::optional<double> normalizeTarget =
prefs.normalizeOnUpload ? std::optional<double>(prefs.targetLufs) : std::nullopt;
auto note = std::make_shared<juce::String>();
const auto options = makeWriteOptions();
worker.enqueue(recorded("push", options, { "Push " + juce::File(sourcePath).getFileName() + " to slot "
+ juce::String(slot),
slot,
[source = sourcePath, slot, force, normalizeTarget, note,
options,
mark = prefs.convertedMark.toStdString(), options,
importTmp = settings.dataDir().getChildFile("import-tmp"),
logDir = settings.dataDir()](
const volume::fs::path& volumePath) {
// DAW exports arrive as anything — convert off the
// message thread, on this worker, before the push
// (issue #20). The temp conversion dies with the job.
// (issue #20). The conversion's directory dies with
// `prepared`, on success and on a throw alike.
const juce::String sourceName = juce::File(source).getFileName();
wavimport::Prepared prepared;
const juce::Result ok =
wavimport::prepare(juce::File(source), importTmp, prepared,
{ .normalizeTargetLufs = normalizeTarget });
if (ok.failed())
throw Error(ok.getErrorMessage().toStdString());
commands::PushResult pushed;
try {
pushed = commands::push(volumePath,
prepared.file.getFullPathName().toStdString(),
slot,
{ .force = force, .write = options });
} catch (...) {
if (prepared.converted)
prepared.file.deleteFile();
throw;
}
if (prepared.converted)
prepared.file.deleteFile();
if (prepared.normalize.has_value() && normalizeTarget.has_value()) {
*note = describeNormalize(*prepared.normalize, *normalizeTarget);
// The name on the card is decided here, not inherited
// from the conversion's temp file (issue #139): a
// rebuilt upload carries the mark, one only repacked
// into the pedal's container lands under its own
// stem, an untouched one keeps its name.
const uploadmark::Audio audio = !prepared.converted
? uploadmark::Audio::untouched
: prepared.rebuilt ? uploadmark::Audio::rebuilt
: uploadmark::Audio::repackaged;
const std::string landed =
uploadmark::landedName(sourceName.toStdString(), mark, audio);
const commands::PushResult pushed = commands::push(
volumePath, prepared.file.getFullPathName().toStdString(), slot,
{ .landedName = landed, .force = force, .write = options });
// What changed on the way to the card: the rebuild's
// facts, then the normalization's — each only when it
// has something to say.
if (prepared.sourceFormat.has_value())
*note = wavimport::describeConversion(*prepared.sourceFormat);
if (prepared.normalize.has_value() && normalizeTarget.has_value())
*note << (note->isEmpty() ? "" : "; ")
<< describeNormalize(*prepared.normalize, *normalizeTarget);
if (note->isNotEmpty()) {
const juce::String as = juce::String(landed) == sourceName
? juce::String()
: " as " + juce::String(landed);
oplog::append(logDir,
"push " + juce::File(source).getFileName()
+ " to slot " + juce::String(slot) + ": "
+ *note);
"push " + sourceName + " to slot " + juce::String(slot)
+ as + ": " + *note);
}
// The slot's length was a note value and this take
// replaced it with a bar count (issue #92): said out
Expand Down Expand Up @@ -2616,17 +2627,10 @@ std::unique_ptr<SettingsDialog> MainComponent::makeSettingsDialog()
}
applyColumnPreferences();
},
SettingsDialog::ImportPrefs {
settings.file() != nullptr && settings.file()->getBoolValue(kNormalizeOnUploadKey, false),
settings.file() != nullptr
? settings.file()->getDoubleValue(kNormalizeTargetLufsKey, kDefaultTargetLufs)
: kDefaultTargetLufs },
settings.file() != nullptr ? importprefs::read(*settings.file()) : importprefs::defaults(),
[this](SettingsDialog::ImportPrefs prefs) {
if (auto* file = settings.file()) {
file->setValue(kNormalizeOnUploadKey, prefs.normalizeOnUpload);
file->setValue(kNormalizeTargetLufsKey, prefs.targetLufs);
file->saveIfNeeded();
}
if (auto* file = settings.file())
importprefs::write(*file, prefs);
});

// The storage panel (issue #74) is fed by the worker, where the store
Expand Down
2 changes: 1 addition & 1 deletion app/Mp3AudioFormat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ namespace
{
public:
explicit Mp3Reader(juce::InputStream* stream)
: juce::AudioFormatReader(stream, "MP3")
: juce::AudioFormatReader(stream, Mp3AudioFormat::kReaderName)
{
input->readIntoMemoryBlock(data);
if (mp3dec_ex_open_buf(&dec, static_cast<const juce::uint8*>(data.getData()),
Expand Down
4 changes: 4 additions & 0 deletions app/Mp3AudioFormat.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ namespace loopercat
class Mp3AudioFormat final : public juce::AudioFormat
{
public:
// What the reader calls itself (AudioFormatReader::getFormatName): the
// import's report tells a codec's reader from a sample reader by this.
static constexpr const char* kReaderName = "MP3";

Mp3AudioFormat();

juce::Array<int> getPossibleSampleRates() override;
Expand Down
Loading
Loading