diff --git a/cmake/pffft.cmake b/cmake/pffft.cmake index 7b0c2ec5..4e7e5d29 100644 --- a/cmake/pffft.cmake +++ b/cmake/pffft.cmake @@ -17,6 +17,7 @@ include(FetchContent) FetchContent_Declare( pffft GIT_REPOSITORY "https://github.com/marton78/pffft.git" + GIT_TAG a9786ad2e709dd2b8024f0da1ced26b083c1ffc9 SOURCE_DIR ${CMAKE_BINARY_DIR}/_deps/pffft-src ) diff --git a/common/data_structures/src/TimeFormatConverter.cpp b/common/data_structures/src/TimeFormatConverter.cpp index 237089c1..ea0a977b 100644 --- a/common/data_structures/src/TimeFormatConverter.cpp +++ b/common/data_structures/src/TimeFormatConverter.cpp @@ -57,22 +57,6 @@ juce::String TimeFormatConverter::secondsToBarsBeats( juce::String(ticks).paddedLeft('0', 3); } -juce::String TimeFormatConverter::secondsToTimecode( - int timeInSeconds, const juce::AudioPlayHead::FrameRate& frameRate) { - int hours = timeInSeconds / 3600; - int minutes = (timeInSeconds % 3600) / 60; - int seconds = timeInSeconds % 60; - int frames = 0; // For integer seconds, frames = 0 - - // Format: HH:MM:SS:FF - juce::String hourStr = juce::String(hours).paddedLeft('0', 2); - juce::String minStr = juce::String(minutes).paddedLeft('0', 2); - juce::String secStr = juce::String(seconds).paddedLeft('0', 2); - juce::String frameStr = juce::String(frames).paddedLeft('0', 2); - - return hourStr + ":" + minStr + ":" + secStr + ":" + frameStr; -} - int TimeFormatConverter::hmsToSeconds(const juce::String& val) { auto parts = juce::StringArray::fromTokens(val, ":", ""); if (parts.size() != 3) { @@ -130,33 +114,33 @@ int TimeFormatConverter::barsBeatsToSeconds( return static_cast(totalBeats * secondsPerBeat); } -int TimeFormatConverter::timecodeToSeconds(const juce::String& val) { +int TimeFormatConverter::timecodeToMs(const juce::String& val, double fps) { auto parts = juce::StringArray::fromTokens(val, ":", ""); - if (parts.size() != 4) { - return -1; - } - if (!parts[0].containsOnly("0123456789") || - !parts[1].containsOnly("0123456789") || - !parts[2].containsOnly("0123456789") || - !parts[3].containsOnly("0123456789")) { - return -1; - } + if (parts.size() != 4) return -1; + + for (auto& p : parts) + if (!p.containsOnly("0123456789")) return -1; int hours = parts[0].getIntValue(); int minutes = parts[1].getIntValue(); int seconds = parts[2].getIntValue(); int frames = parts[3].getIntValue(); - // Validate ranges (frames validation would require frame rate) if (minutes > 59 || seconds > 59 || hours < 0 || minutes < 0 || seconds < 0 || - frames < 0) { + frames < 0 || frames >= (int)fps) return -1; - } - // Note: Frames are ignored for now as we work with integer seconds - // This means 00:00:05:00 and 00:00:05:29 both become 5 seconds - // This is acceptable for the current use case but creates lossy conversion - return (hours * 3600 + minutes * 60 + seconds); + double totalSeconds = hours * 3600 + minutes * 60 + seconds + (frames / fps); + return static_cast(std::round(totalSeconds * 1000.0)); +} + +juce::String TimeFormatConverter::msToTimecode(const int ms, const double fps) { + double totalSeconds = ms / 1000.0; + int hh = (int)(totalSeconds / 3600); + int mm = (int)(std::fmod(totalSeconds, 3600.0) / 60.0); + int ss = (int)(std::fmod(totalSeconds, 60.0)); + int ff = (int)(std::fmod(totalSeconds, 1.0) * fps); + return juce::String::formatted("%02d:%02d:%02d:%02d", hh, mm, ss, ff); } juce::String TimeFormatConverter::getFormatDescription(TimeFormat format) { diff --git a/common/data_structures/src/TimeFormatConverter.h b/common/data_structures/src/TimeFormatConverter.h index 69bab251..2ee47b9f 100644 --- a/common/data_structures/src/TimeFormatConverter.h +++ b/common/data_structures/src/TimeFormatConverter.h @@ -25,14 +25,13 @@ class TimeFormatConverter { static juce::String secondsToBarsBeats( int timeInSeconds, double bpm, const juce::AudioPlayHead::TimeSignature& timeSig); - static juce::String secondsToTimecode( - int timeInSeconds, const juce::AudioPlayHead::FrameRate& frameRate); + static juce::String msToTimecode(int ms, double fps); static int hmsToSeconds(const juce::String& val); static int barsBeatsToSeconds( const juce::String& val, double bpm, const juce::AudioPlayHead::TimeSignature& timeSig); - static int timecodeToSeconds(const juce::String& val); + static int timecodeToMs(const juce::String& val, double fps); static juce::String getFormatDescription(TimeFormat format); }; diff --git a/common/logger/logger.h b/common/logger/logger.h index 55fa8d1e..9a62d88d 100644 --- a/common/logger/logger.h +++ b/common/logger/logger.h @@ -47,6 +47,13 @@ class Logger { bool isInitialized() const { return initialized; } + void resetForTesting() { + std::lock_guard lock(initMutex); + boost::log::core::get()->remove_all_sinks(); + initialized = false; + logFilePattern = ""; + } + private: Logger() = default; ~Logger() = default; diff --git a/common/logger/tests/Logger_test.cpp b/common/logger/tests/Logger_test.cpp index 3eb5cb8d..974e6508 100644 --- a/common/logger/tests/Logger_test.cpp +++ b/common/logger/tests/Logger_test.cpp @@ -90,6 +90,8 @@ TEST(LoggerTest, InitializeLogger) { // Test Logging Different Severity Levels TEST(LoggerTest, LogMessages) { + // Reset singleton so this test can re-initialize with debug severity + Logger::getInstance().resetForTesting(); // Clean up log files before the test Logger::getInstance().init("testlog", 1, boost::log::trivial::debug); std::vector existingLogFiles = @@ -235,6 +237,8 @@ TEST(LoggerTest, LoggerInitMultipleCalls) { // Test File Retention Policy TEST(LoggerTest, FileRetentionPolicy) { + // Reset singleton so this test can re-initialize with 1MB file size + Logger::getInstance().resetForTesting(); Logger::getInstance().init("testlog", 1, boost::log::trivial::info); // Clean up existing log files before the test @@ -310,6 +314,8 @@ TEST(LoggerTest, FileRetentionPolicy) { // Test File Retention During Active Logging TEST(LoggerTest, FileRetentionDuringActiveLogging) { + // Reset singleton so this test can re-initialize with 1MB file size + Logger::getInstance().resetForTesting(); Logger::getInstance().init("testlog", 1, boost::log::trivial::info); // Clean up existing log files before the test diff --git a/common/processors/file_output/FileOutputProcessor.cpp b/common/processors/file_output/FileOutputProcessor.cpp index 31eadb8e..d5fcdb44 100644 --- a/common/processors/file_output/FileOutputProcessor.cpp +++ b/common/processors/file_output/FileOutputProcessor.cpp @@ -40,15 +40,15 @@ FileOutputProcessor::FileOutputProcessor( mixPresentationRepository_(mixPresentationRepository), mixPresentationLoudnessRepository_(mixPresentationLoudnessRepository) {} -FileOutputProcessor::~FileOutputProcessor() {} +FileOutputProcessor::~FileOutputProcessor() = default; //============================================================================== const juce::String FileOutputProcessor::getName() const { return {"FileOutput"}; } //============================================================================== -void FileOutputProcessor::prepareToPlay(double sampleRate, - int samplesPerBlock) { +void FileOutputProcessor::prepareToPlay(const double sampleRate, + const int samplesPerBlock) { FileExport configParams = fileExportRepository_.get(); if (configParams.getSampleRate() != sampleRate) { LOG_ANALYTICS(0, "FileOutputProcessor sample rate changed to " + @@ -61,7 +61,8 @@ void FileOutputProcessor::prepareToPlay(double sampleRate, sampleRate_ = sampleRate; } -void FileOutputProcessor::setNonRealtime(bool isNonRealtime) noexcept { +void FileOutputProcessor::setNonRealtime(const bool isNonRealtime) noexcept { + // Bouncing in DAW and currently rendering || not bouncing and not rendering if (isNonRealtime == performingRender_) { return; } @@ -92,10 +93,12 @@ void FileOutputProcessor::processBlock(juce::AudioBuffer& buffer, return; } + // Process audio elements individually as Wav files for (int i = 0; i < iamfWavFileWriters_.size(); ++i) { iamfWavFileWriters_[i]->write(buffer); } + // Process IAMF File if (iamfFileWriter_) { iamfFileWriter_->writeFrame(buffer); } @@ -105,8 +108,8 @@ void FileOutputProcessor::processBlock(juce::AudioBuffer& buffer, void FileOutputProcessor::initializeFileExport(FileExport& config) { LOG_ANALYTICS(0, "Beginning .iamf file export"); performingRender_ = true; - startTime_ = config.getStartTime(); - endTime_ = config.getEndTime(); + startTime_ = config.getStartTime() / 1000.0; + endTime_ = config.getEndTime() / 1000.0; std::string exportFile = config.getExportFile().toStdString(); // To create the IAMF file, create a list of all the audio element wav @@ -159,10 +162,10 @@ void FileOutputProcessor::initializeFileExport(FileExport& config) { } } -void FileOutputProcessor::closeFileExport(FileExport& config) { +void FileOutputProcessor::closeFileExport(const FileExport& config) { LOG_ANALYTICS(0, "closing writers and exporting IAMF file"); // close the output file, since rendering is completed - for (auto& writer : iamfWavFileWriters_) { + for (const auto& writer : iamfWavFileWriters_) { writer->close(); } @@ -209,20 +212,24 @@ bool FileOutputProcessor::shouldBufferBeWritten( // Calculate the current time with the existing number of samples that have // been processed - long currentTime = sampleTally_ / sampleRate_; + const double currentTime = static_cast(sampleTally_) / sampleRate_; // update the sample tally sampleTally_ += buffer.getNumSamples(); - // with the updated sample tally, calculate the next time - long nextTime = sampleTally_ / sampleRate_; - if (startTime_ != 0 || endTime_ != 0) { - // Handle the case where startTime and endTime are set, implying we - // are only bouncing a subset of the mix + // No range specified — write everything + if (startTime_ <= 0 && endTime_ <= 0) { + return true; + } - // do not render - if (currentTime < startTime_ || nextTime > endTime_) { - return false; - } + // Skip if buffer starts before the requested start time + if (startTime_ > 0 && currentTime < startTime_) { + return false; } + + // Skip if buffer starts at or past the requested end time + if (endTime_ > 0 && currentTime >= endTime_) { + return false; + } + return true; } diff --git a/common/processors/file_output/FileOutputProcessor.h b/common/processors/file_output/FileOutputProcessor.h index e3dc076d..015ad46e 100644 --- a/common/processors/file_output/FileOutputProcessor.h +++ b/common/processors/file_output/FileOutputProcessor.h @@ -80,7 +80,7 @@ class FileOutputProcessor : public ProcessorBase { void initializeFileExport(FileExport& config); - void closeFileExport(FileExport& config); + void closeFileExport(const FileExport& config); bool shouldBufferBeWritten(const juce::AudioBuffer& buffer); @@ -92,9 +92,9 @@ class FileOutputProcessor : public ProcessorBase { MixPresentationLoudnessRepository& mixPresentationLoudnessRepository_; std::vector> iamfWavFileWriters_; int numSamples_; - long sampleRate_; - int startTime_; - int endTime_; + double sampleRate_; + double startTime_; + double endTime_; long sampleTally_; std::unique_ptr iamfFileWriter_; //============================================================================== diff --git a/common/processors/file_output/WavFileOutputProcessor.cpp b/common/processors/file_output/WavFileOutputProcessor.cpp index 172063f5..35215874 100644 --- a/common/processors/file_output/WavFileOutputProcessor.cpp +++ b/common/processors/file_output/WavFileOutputProcessor.cpp @@ -79,8 +79,8 @@ void WavFileOutputProcessor::setNonRealtime(bool isNonRealtime) noexcept { // Start Rendering FileExport configParams = fileExportRepository_.get(); RoomSetup roomSetup = roomSetupRepository_.get(); - startTime_ = configParams.getStartTime(); - endTime_ = configParams.getEndTime(); + startTime_ = configParams.getStartTime() / 1000.0; + endTime_ = configParams.getEndTime() / 1000.0; if ((configParams.getAudioFileFormat() == AudioFileFormat::WAV) && configParams.getExportAudio()) { fileWriter_ = new FileWriter( diff --git a/common/processors/file_output/WavFileOutputProcessor.h b/common/processors/file_output/WavFileOutputProcessor.h index e90da2bd..a5d86d37 100644 --- a/common/processors/file_output/WavFileOutputProcessor.h +++ b/common/processors/file_output/WavFileOutputProcessor.h @@ -64,9 +64,9 @@ class WavFileOutputProcessor final : public ProcessorBase, RoomSetupRepository& roomSetupRepository_; FileWriter* fileWriter_; int numSamples_; - int sampleRate_; - int startTime_; - int endTime_; + double sampleRate_; + double startTime_; + double endTime_; juce::SpinLock lock_; //============================================================================== JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(WavFileOutputProcessor) diff --git a/common/processors/loudness_export/LoudnessExportProcessor.cpp b/common/processors/loudness_export/LoudnessExportProcessor.cpp index 373c194b..31957000 100644 --- a/common/processors/loudness_export/LoudnessExportProcessor.cpp +++ b/common/processors/loudness_export/LoudnessExportProcessor.cpp @@ -253,8 +253,8 @@ void LoudnessExportProcessor::initializeLoudnessExport(FileExport& config) { sampleRate_ = config.getSampleRate(); sampleTally_ = 0; - startTime_ = config.getStartTime(); - endTime_ = config.getEndTime(); + startTime_ = config.getStartTime() / 1000; + endTime_ = config.getEndTime() / 1000; intializeExportContainers(); } diff --git a/common/processors/tests/FileOutputProcessor_test.cpp b/common/processors/tests/FileOutputProcessor_test.cpp index c275cc66..335527d2 100644 --- a/common/processors/tests/FileOutputProcessor_test.cpp +++ b/common/processors/tests/FileOutputProcessor_test.cpp @@ -479,4 +479,344 @@ TEST_F(FileOutputTests, mux_iamf_invalid_vout_path) { EXPECT_TRUE(std::filesystem::exists(iamfOutPath)); EXPECT_FALSE(std::filesystem::exists(videoOutPath)); +} + +// ============================================================================= +// Helper: bounce with explicit control over frame count, so we can produce +// a known duration of audio. Returns the number of blocks processed. +// ============================================================================= +static int bounceAudioForDuration( + FileOutputProcessor& fio_proc, + AudioElementRepository& audioElementRepository, double durationSeconds, + unsigned sampleRate = 48000, unsigned frameSize = 128) { + const unsigned kNumChannels = totalAudioChannels(audioElementRepository); + const auto kSineTone = generateSineWave(440.0f, sampleRate, frameSize); + + fio_proc.prepareToPlay(sampleRate, frameSize); + fio_proc.setNonRealtime(true); + + const int numBlocks = + static_cast(std::ceil(durationSeconds * sampleRate / frameSize)); + + juce::AudioBuffer audioBuffer(kNumChannels, frameSize); + juce::MidiBuffer dummyMidiBuffer; + for (int block = 0; block < numBlocks; ++block) { + for (unsigned i = 0; i < kNumChannels; ++i) { + audioBuffer.copyFrom(i, 0, kSineTone, 0, 0, frameSize); + } + fio_proc.processBlock(audioBuffer, dummyMidiBuffer); + } + fio_proc.setNonRealtime(false); + return numBlocks; +} + +// ============================================================================= +// Tests for start/end time (shouldBufferBeWritten) logic +// ============================================================================= + +// Baseline: no start/end time set (both 0) — all audio is written. +TEST_F(FileOutputTests, time_range_no_limits_writes_all) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + // startTime=0, endTime=0 (defaults) — no range limiting + auto config = fileExportRepository.get(); + ASSERT_EQ(config.getStartTime(), 0); + ASSERT_EQ(config.getEndTime(), 0); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudio(fio_proc, audioElementRepository); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + + // Verify the file has non-zero size (audio was written) + EXPECT_GT(std::filesystem::file_size(iamfOutPath), 0u); + std::filesystem::remove(iamfOutPath); +} + +// Start time only: skip the first N seconds of the timeline. +// We bounce 2 seconds of audio with startTime=1000ms. +// The output file should exist but be smaller than a full 2s export. +TEST_F(FileOutputTests, time_range_start_only) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // First: bounce full 2 seconds with no time limits for size reference + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto fullSize = std::filesystem::file_size(iamfOutPath); + std::filesystem::remove(iamfOutPath); + + // Now bounce with startTime = 1000ms (1 second in) + // Need to recreate processor state since setNonRealtime(false) closed export + auto config = fileExportRepository.get(); + config.setStartTime(1000); // 1000 ms = 1 second + config.setEndTime(0); // no end limit + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto startLimitedSize = std::filesystem::file_size(iamfOutPath); + + // The start-limited file should be smaller (roughly half the audio) + EXPECT_LT(startLimitedSize, fullSize); + std::filesystem::remove(iamfOutPath); + + // Reset for other tests + config.setStartTime(0); + fileExportRepository.update(config); +} + +// End time only: stop writing after N seconds. +TEST_F(FileOutputTests, time_range_end_only) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Full 2-second bounce for reference + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto fullSize = std::filesystem::file_size(iamfOutPath); + std::filesystem::remove(iamfOutPath); + + // Now bounce 2 seconds but with endTime = 1000ms (stop at 1 second) + auto config = fileExportRepository.get(); + config.setStartTime(0); + config.setEndTime(1000); // 1000 ms = 1 second + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto endLimitedSize = std::filesystem::file_size(iamfOutPath); + + EXPECT_LT(endLimitedSize, fullSize); + std::filesystem::remove(iamfOutPath); + + // Reset + config.setEndTime(0); + fileExportRepository.update(config); +} + +// Both start and end time: only write the window [start, end). +TEST_F(FileOutputTests, time_range_start_and_end) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Full 4-second bounce for reference + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 4.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto fullSize = std::filesystem::file_size(iamfOutPath); + std::filesystem::remove(iamfOutPath); + + // Bounce 4 seconds with window [1s, 3s) — should capture ~2s of audio + auto config = fileExportRepository.get(); + config.setStartTime(1000); // 1 second + config.setEndTime(3000); // 3 seconds + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 4.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto windowedSize = std::filesystem::file_size(iamfOutPath); + + EXPECT_LT(windowedSize, fullSize); + std::filesystem::remove(iamfOutPath); + + // Reset + config.setStartTime(0); + config.setEndTime(0); + fileExportRepository.update(config); +} + +// End time before start time: nothing should be written (nonsensical input). +// The file may still be created (IAMF header/structure) but should have +// minimal content compared to a normal export. +TEST_F(FileOutputTests, time_range_end_before_start) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Full bounce for reference + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto fullSize = std::filesystem::file_size(iamfOutPath); + std::filesystem::remove(iamfOutPath); + + // End at 1s, start at 2s — window is empty + auto config = fileExportRepository.get(); + config.setStartTime(2000); + config.setEndTime(1000); + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + + // File may or may not exist depending on IAMF writer behavior with 0 frames. + // If it exists, it should be much smaller than a full export. + if (std::filesystem::exists(iamfOutPath)) { + const auto emptyWindowSize = std::filesystem::file_size(iamfOutPath); + EXPECT_LT(emptyWindowSize, fullSize); + std::filesystem::remove(iamfOutPath); + } + + // Reset + config.setStartTime(0); + config.setEndTime(0); + fileExportRepository.update(config); +} + +// Start time beyond the total audio duration: no audio frames written. +TEST_F(FileOutputTests, time_range_start_beyond_duration) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Full 1-second bounce for reference + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 1.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + const auto fullSize = std::filesystem::file_size(iamfOutPath); + std::filesystem::remove(iamfOutPath); + + // Start at 5 seconds but only bounce 1 second of audio + auto config = fileExportRepository.get(); + config.setStartTime(5000); // 5 seconds — beyond the 1s bounce + config.setEndTime(0); + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 1.0, kSampleRate, + kSamplesPerFrame); + + if (std::filesystem::exists(iamfOutPath)) { + const auto noAudioSize = std::filesystem::file_size(iamfOutPath); + EXPECT_LT(noAudioSize, fullSize); + std::filesystem::remove(iamfOutPath); + } + + // Reset + config.setStartTime(0); + fileExportRepository.update(config); +} + +// Verify that the time values are correctly converted from ms to seconds. +// startTime and endTime are stored as int milliseconds in FileExport. +// FileOutputProcessor divides by 1000.0 to get seconds. +// This test uses a precise sub-second boundary. +TEST_F(FileOutputTests, time_range_subsecond_precision) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Bounce 1 second, window [250ms, 750ms) — should capture ~500ms + auto config = fileExportRepository.get(); + config.setStartTime(250); // 250ms + config.setEndTime(750); // 750ms + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 1.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + + // Just verify file was created with some content — the windowed export worked + EXPECT_GT(std::filesystem::file_size(iamfOutPath), 0u); + std::filesystem::remove(iamfOutPath); + + // Reset + config.setStartTime(0); + config.setEndTime(0); + fileExportRepository.update(config); +} + +// Large timecode values (simulating a TC-specified time deep into a session). +// e.g., TC 01:00:00:00 at any frame rate = 3,600,000 ms +TEST_F(FileOutputTests, time_range_large_tc_values) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Set start time to 1 hour in (3,600,000 ms), bounce only 1 second + // All buffers should be skipped since currentTime never reaches startTime + auto config = fileExportRepository.get(); + config.setStartTime(3600000); // 1 hour in milliseconds + config.setEndTime(3601000); // 1 hour + 1 second + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 1.0, kSampleRate, + kSamplesPerFrame); + + // No audio frames should have been written. File may or may not exist. + if (std::filesystem::exists(iamfOutPath)) { + // If file exists it's just IAMF structure with no audio data + // It should be smaller than a normal 1-second export + // (We mainly care that no crash occurred with large values) + std::filesystem::remove(iamfOutPath); + } + + // Reset + config.setStartTime(0); + config.setEndTime(0); + fileExportRepository.update(config); +} + +// End time at exactly 0 with a positive start time: endTime <= 0 means +// "no end limit", so audio from startTime onward should all be written. +TEST_F(FileOutputTests, time_range_start_set_end_zero) { + const juce::Uuid kAE = addAudioElement(Speakers::kStereo); + const juce::Uuid kMP = addMixPresentation(); + addAudioElementsToMix(kMP, {kAE}); + + setTestExportOpts({.codec = AudioCodec::LPCM}); + + // Bounce 2 seconds with startTime=500ms, endTime=0 (no end limit) + auto config = fileExportRepository.get(); + config.setStartTime(500); + config.setEndTime(0); + fileExportRepository.update(config); + + ASSERT_FALSE(std::filesystem::exists(iamfOutPath)); + bounceAudioForDuration(fio_proc, audioElementRepository, 2.0, kSampleRate, + kSamplesPerFrame); + ASSERT_TRUE(std::filesystem::exists(iamfOutPath)); + EXPECT_GT(std::filesystem::file_size(iamfOutPath), 0u); + std::filesystem::remove(iamfOutPath); + + // Reset + config.setStartTime(0); + fileExportRepository.update(config); } \ No newline at end of file diff --git a/common/processors/tests/LoudnessExportProcessor_test.cpp b/common/processors/tests/LoudnessExportProcessor_test.cpp index 4060e5e2..2cfd9a3e 100644 --- a/common/processors/tests/LoudnessExportProcessor_test.cpp +++ b/common/processors/tests/LoudnessExportProcessor_test.cpp @@ -468,7 +468,7 @@ TEST(test_loudness_proc, verify_metadata) { "test_resources/loudness_test_drums.wav"; } else { wavFilePath = - std::filesystem::current_path() / + std::filesystem::current_path().parent_path() / "common/processors/tests/test_resources/loudness_test_drums.wav"; } diff --git a/common/processors/tests/MP4IAMFDemuxer_test.cpp b/common/processors/tests/MP4IAMFDemuxer_test.cpp index a8de5998..5655459f 100644 --- a/common/processors/tests/MP4IAMFDemuxer_test.cpp +++ b/common/processors/tests/MP4IAMFDemuxer_test.cpp @@ -54,6 +54,25 @@ class MP4IAMFDemuxerTest : public FileOutputTests { MP4IAMFDemuxer demuxer; std::vector muxSources; + + const std::vector kAudioElementLayouts = + { + Speakers::kMono, + Speakers::kStereo, + Speakers::k5Point1, + Speakers::k5Point1Point2, + Speakers::k5Point1Point4, + Speakers::k7Point1, + Speakers::k7Point1Point2, + Speakers::k7Point1Point4, + Speakers::k3Point1Point2, + // TODO: Temporarily excluding binaural layouts here as the IAMF APIs + // (writer and reader!) have a problem with this layout. + // Speakers::kBinaural, + Speakers::kHOA1, + Speakers::kHOA2, + Speakers::kHOA3, + }; }; TEST_F(MP4IAMFDemuxerTest, mux_demux_iamf_1ae_cb) { diff --git a/rendererplugin/src/screens/FileExportScreen.cpp b/rendererplugin/src/screens/FileExportScreen.cpp index 1a0ea384..0c3bc4d8 100644 --- a/rendererplugin/src/screens/FileExportScreen.cpp +++ b/rendererplugin/src/screens/FileExportScreen.cpp @@ -780,44 +780,42 @@ void FileExportScreen::paint(juce::Graphics& g) { exportValidation_.setBounds(validationBounds); }; -juce::String FileExportScreen::timeToString(int timeInSeconds, - TimeFormat format) { - auto converterFormat = static_cast(format); - +juce::String FileExportScreen::timeToString(int timeInMs, TimeFormat format) { switch (format) { case TimeFormat::HoursMinutesSeconds: - return TimeFormatConverter::secondsToHMS(timeInSeconds); + return TimeFormatConverter::secondsToHMS(timeInMs / 1000); case TimeFormat::BarsBeats: - if (cachedBpm_.hasValue() && cachedTimeSignature_.hasValue()) { + if (cachedBpm_.hasValue() && cachedTimeSignature_.hasValue()) return TimeFormatConverter::secondsToBarsBeats( - timeInSeconds, *cachedBpm_, *cachedTimeSignature_); - } - return "1.1.000"; // Fallback + timeInMs / 1000, *cachedBpm_, *cachedTimeSignature_); + return "1.1.000"; case TimeFormat::Timecode: - if (cachedFrameRate_.hasValue()) { - return TimeFormatConverter::secondsToTimecode(timeInSeconds, - *cachedFrameRate_); - } - return "00:00:00:00"; // Fallback + if (cachedFrameRate_.hasValue()) + return TimeFormatConverter::msToTimecode( + timeInMs, cachedFrameRate_->getEffectiveRate()); + return "00:00:00:00"; default: - return TimeFormatConverter::secondsToHMS(timeInSeconds); + return TimeFormatConverter::secondsToHMS(timeInMs / 1000); } } int FileExportScreen::stringToTime(juce::String val, TimeFormat format) { switch (format) { case TimeFormat::HoursMinutesSeconds: - return TimeFormatConverter::hmsToSeconds(val); + return TimeFormatConverter::hmsToSeconds(val) * 1000; case TimeFormat::BarsBeats: - if (cachedBpm_.hasValue() && cachedTimeSignature_.hasValue()) { + if (cachedBpm_.hasValue() && cachedTimeSignature_.hasValue()) return TimeFormatConverter::barsBeatsToSeconds(val, *cachedBpm_, - *cachedTimeSignature_); - } - return -1; // Cannot convert without tempo info + *cachedTimeSignature_) * + 1000; + return -1; case TimeFormat::Timecode: - return TimeFormatConverter::timecodeToSeconds(val); + if (cachedFrameRate_.hasValue()) + return TimeFormatConverter::timecodeToMs( + val, cachedFrameRate_->getEffectiveRate()); + return -1; default: - return TimeFormatConverter::hmsToSeconds(val); + return TimeFormatConverter::hmsToSeconds(val) * 1000; } } @@ -1003,6 +1001,9 @@ void FileExportScreen::valueTreeChildRemoved( void FileExportScreen::refreshFileExportComponents() { // Set the sample rate information if possible FileExport config = repository_->get(); + // Refresh start/end timer display from stored ms values + startTimer_.setText(timeToString(config.getStartTime(), startTimeFormat_)); + endTimer_.setText(timeToString(config.getEndTime(), endTimeFormat_)); if (config.getSampleRate() > 0) { sampleRate_.setText(juce::String(config.getSampleRate()) + " Hz"); }