diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java index 0974069..61f3ad4 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java @@ -1,7 +1,10 @@ package com.smartjam.smartjamanalyzer.application; import java.nio.file.Path; +import java.util.UUID; +import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; import com.smartjam.smartjamanalyzer.domain.port.*; import lombok.RequiredArgsConstructor; @@ -19,12 +22,21 @@ public class AudioAnalysisUseCase { private final WorkspaceFactory workspaceFactory; private final FeatureExtractor featureExtractor; + private final PerformanceEvaluator performanceEvaluator; + private final ReferenceRepository referenceRepository; + private final ResultRepository resultRepository; + private final DebugVisualizer debugVisualizer; + public void execute(String bucket, String fileKey) { - try (Workspace workspace = workspaceFactory.create()) { + UUID entityId = extractUuid(fileKey); + + // TODO: Добавить обработку(проверку типа) входящего файла + + // TODO: Добавить нормальный сбор метрик - // TODO: Добавить обработку(проверку типа) входящего файла + try (Workspace workspace = workspaceFactory.create()) { + updateStatus(bucket, entityId, AudioProcessingStatus.ANALYZING, null); - // TODO: Добавить нормальный сбор метрик StopWatch watch = new StopWatch(fileKey); log.info("=== Начало обработки файла: {} из бакета {} ===", fileKey, bucket); @@ -37,24 +49,84 @@ public void execute(String bucket, String fileKey) { Path cleanWavFile = audioConverter.convertToStandardWav(localFile, workspace); watch.stop(); - watch.start("Business Logic (Math)"); - + watch.start("Feature Extraction"); FeatureSequence features = featureExtractor.extract(cleanWavFile); + watch.stop(); log.info("Extracted {} feature frames", features.frames().size()); + watch.start("Evaluation & Persistence"); if ("references".equals(bucket)) { - log.info("Действие: Обработка ЭТАЛОНА учителя..."); + handleTeacherReference(entityId, features); } else if ("submissions".equals(bucket)) { - log.info("Действие: Обработка ПОПЫТКИ ученика..."); + handleStudentSubmission(entityId, features); } watch.stop(); log.info("Результаты обработки {}: \n{}", fileKey, watch.prettyPrint()); } catch (Exception e) { - log.error("Ошибка в UseCase для файла {}: {}", fileKey, e.getMessage()); + + String errorMsg = + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + + log.error("Ошибка в UseCase для файла {}: {}", fileKey, errorMsg, e); + updateStatus(bucket, entityId, AudioProcessingStatus.FAILED, errorMsg); + throw new RuntimeException("Business logic failed", e); } } + + private void updateStatus(String bucket, UUID id, AudioProcessingStatus status, String error) { + try { + if ("references".equals(bucket)) { + referenceRepository.updateStatus(id, status, error); + } else { + resultRepository.updateStatus(id, status, error); + } + } catch (Exception ex) { + log.error("Failed to update status in DB for {}: {}", id, ex.getMessage()); + } + } + + private void handleTeacherReference(UUID assignmentId, FeatureSequence teacherFeatures) { + log.info("Saving teacher reference features for assignment: {}", assignmentId); + referenceRepository.save(assignmentId, teacherFeatures); + } + + private void handleStudentSubmission(UUID submissionId, FeatureSequence studentFeatures) { + log.info("Evaluating student submission: {}", submissionId); + + UUID assignmentId = resultRepository + .findAssignmentIdBySubmissionId(submissionId) + .orElseThrow(() -> + new IllegalStateException("Submission " + submissionId + " is not linked to any assignment")); + + FeatureSequence teacherFeatures = referenceRepository + .findFeaturesById(assignmentId) + .orElseThrow(() -> new IllegalStateException( + "Teacher reference features not found for assignment: " + assignmentId)); + + AnalysisResult result = performanceEvaluator.evaluate(teacherFeatures, studentFeatures); + + resultRepository.save(submissionId, result); + + try { + debugVisualizer.generateHeatmap(result, "debug_" + submissionId + ".png"); + } catch (Exception e) { + log.error("Не удалось сгенерировать тепловую карту {}: {}", submissionId, e.getMessage()); + } + + log.info("Submission {} evaluation completed.", submissionId); + } + + private UUID extractUuid(String fileKey) { + try { + String rawUuid = fileKey.contains(".") ? fileKey.substring(0, fileKey.lastIndexOf(".")) : fileKey; + + return UUID.fromString(rawUuid); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid S3 file key. Expected UUID, got: " + fileKey); + } + } } diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java index 35d07bb..5826eaa 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java @@ -1,11 +1,36 @@ package com.smartjam.smartjamanalyzer.domain.port; +import java.util.Optional; import java.util.UUID; +import com.smartjam.common.model.AudioProcessingStatus; import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; +/** Domain port for managing teacher reference features. */ public interface ReferenceRepository { - void save(UUID targetId, FeatureSequence features); - FeatureSequence findById(UUID targetId); + /** + * Saves the spectral features of a teacher's reference track. + * + * @param assignmentId Unique identifier of the assignment. + * @param features Extracted features to persist. + */ + void save(UUID assignmentId, FeatureSequence features); + + /** + * Retrieves reference features for comparison. + * + * @param assignmentId Unique identifier of the assignment. + * @return The feature sequence if presented. + */ + Optional findFeaturesById(UUID assignmentId); + + /** + * Performs an optimized status update for an assignment, optionally recording an error message. + * + * @param assignmentId unique identifier of the submission. + * @param status the new processing state. + * @param errorMessage description of the failure, if applicable. + */ + void updateStatus(UUID assignmentId, AudioProcessingStatus status, String errorMessage); } diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java index 9137d41..3a37094 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java @@ -1,9 +1,40 @@ package com.smartjam.smartjamanalyzer.domain.port; +import java.util.Optional; import java.util.UUID; +import com.smartjam.common.model.AudioProcessingStatus; import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; +/** + * Port for managing student submissions and their analysis results. Handles the persistence of evaluation scores and + * detailed feedback events. + */ public interface ResultRepository { + + /** + * Persists the final analysis result for a specific submission. + * + * @param submissionId unique identifier of the student submission. + * @param result calculated scores and feedback events. + */ void save(UUID submissionId, AnalysisResult result); + + /** + * Resolves the teacher's assignment ID associated with a given student submission. + * + * @param submissionId unique identifier of the submission. + * @return the UUID of the parent assignment. + * @throws RuntimeException if no linked assignment ID found. + */ + Optional findAssignmentIdBySubmissionId(UUID submissionId); + + /** + * Update status for a submission, optionally recording an error message. + * + * @param submissionId unique identifier of the submission. + * @param status the new processing state. + * @param errorMessage description of the failure, if applicable. + */ + void updateStatus(UUID submissionId, AudioProcessingStatus status, String errorMessage); } diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java new file mode 100644 index 0000000..fe354a2 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java @@ -0,0 +1,64 @@ +package com.smartjam.smartjamanalyzer.infrastructure.persistence.adapter; + +import java.util.Optional; +import java.util.UUID; + +import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; +import com.smartjam.smartjamanalyzer.domain.port.ReferenceRepository; +import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.AssignmentEntity; +import com.smartjam.smartjamanalyzer.infrastructure.persistence.repository.JpaAssignmentRepository; +import com.smartjam.smartjamanalyzer.infrastructure.utils.FeatureBinarySerializer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * JPA implementation of {@link ReferenceRepository}. Bridges the domain logic with the database using binary + * serialization for spectral data. + */ +@Component +@RequiredArgsConstructor +public class AssignmentPersistenceAdapter implements ReferenceRepository { + private final JpaAssignmentRepository repository; + + /** + * Packs spectral features into a binary format and saves them to the assignment record. Transition the status to + * {@link AudioProcessingStatus#COMPLETED}. + */ + @Override + @Transactional + public void save(UUID assignmentId, FeatureSequence features) { + byte[] bytes = FeatureBinarySerializer.serialize(features); + + AssignmentEntity entity = repository + .findById(assignmentId) + .orElseThrow(() -> new IllegalStateException("Assignment metadata missing for ID: " + assignmentId + + ". It might have " + "been deleted or not created yet.")); + + entity.setReferenceSpectreCache(bytes); + entity.setStatus(AudioProcessingStatus.COMPLETED); + entity.setErrorMessage(null); + + repository.save(entity); + } + + /** + * Retrieves and unpacks binary features from the database. + * + * @return an Optional containing the FeatureSequence, or empty if not found. + */ + @Override + @Transactional(readOnly = true) + public Optional findFeaturesById(UUID assignmentId) { + return repository + .findById(assignmentId) + .map(e -> FeatureBinarySerializer.deserialize(e.getReferenceSpectreCache())); + } + + @Override + @Transactional + public void updateStatus(UUID id, AudioProcessingStatus status, String errorMessage) { + repository.updateStatus(id, status, errorMessage); + } +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java new file mode 100644 index 0000000..c1b73dc --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java @@ -0,0 +1,56 @@ +package com.smartjam.smartjamanalyzer.infrastructure.persistence.adapter; + +import java.util.Optional; +import java.util.UUID; + +import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; +import com.smartjam.smartjamanalyzer.domain.port.ResultRepository; +import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.SubmissionEntity; +import com.smartjam.smartjamanalyzer.infrastructure.persistence.repository.JpaSubmissionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * JPA implementation of {@link ResultRepository}. Manages storage of evaluation results and coordinates mapping between + * domain results and JSONB database columns. + */ +@Component +@RequiredArgsConstructor +public class SubmissionPersistenceAdapter implements ResultRepository { + private final JpaSubmissionRepository repository; + + /** + * Updates the submission record with scores and feedback. Transition the status to + * {@link AudioProcessingStatus#COMPLETED}. + */ + @Override + @Transactional + public void save(UUID submissionId, AnalysisResult result) { + SubmissionEntity entity = repository + .findById(submissionId) + .orElseThrow(() -> new IllegalStateException("Submission record missing for ID: " + submissionId)); + + entity.setTotalScore(result.totalScore()); + entity.setPitchScore(result.pitchScore()); + entity.setRhythmScore(result.rhythmScore()); + entity.setAnalysisFeedback(result.feedback()); + entity.setStatus(AudioProcessingStatus.COMPLETED); + entity.setErrorMessage(null); + + repository.save(entity); + } + + @Override + @Transactional(readOnly = true) + public Optional findAssignmentIdBySubmissionId(UUID submissionId) { + return repository.findById(submissionId).map(SubmissionEntity::getAssignmentId); + } + + @Override + @Transactional + public void updateStatus(UUID submissionId, AudioProcessingStatus status, String errorMessage) { + repository.updateStatus(submissionId, status, errorMessage); + } +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/AssignmentEntity.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/AssignmentEntity.java new file mode 100644 index 0000000..0c74d06 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/AssignmentEntity.java @@ -0,0 +1,30 @@ +package com.smartjam.smartjamanalyzer.infrastructure.persistence.entity; + +import java.util.UUID; + +import jakarta.persistence.*; + +import com.smartjam.common.model.AudioProcessingStatus; +import lombok.*; + +/** Database model for teacher assignments. Stores heavy spectral data as raw bytes (BYTEA) to optimize performance. */ +@Entity +@Table(name = "assignments") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssignmentEntity { + @Id + private UUID id; + + @Enumerated(EnumType.STRING) + private AudioProcessingStatus status; + + @Column(name = "reference_spectre_cache") + private byte[] referenceSpectreCache; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/SubmissionEntity.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/SubmissionEntity.java new file mode 100644 index 0000000..0340165 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/SubmissionEntity.java @@ -0,0 +1,51 @@ +package com.smartjam.smartjamanalyzer.infrastructure.persistence.entity; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.*; + +import com.smartjam.common.dto.FeedbackEvent; +import com.smartjam.common.model.AudioProcessingStatus; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * Database model for student submissions. Utilizes Hibernate 6 JSON support to store structured feedback events in a + * JSONB column. + */ +@Entity +@Table(name = "submissions") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionEntity { + + @Id + private UUID id; + + @Column(name = "assignment_id") + private UUID assignmentId; + + @Enumerated(EnumType.STRING) + private AudioProcessingStatus status; + + @Column(name = "total_score") + private Double totalScore; + + @Column(name = "pitch_score") + private Double pitchScore; + + @Column(name = "rhythm_score") + private Double rhythmScore; + + @Column(name = "error_message") + private String errorMessage; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "analysis_feedback") + private List analysisFeedback; +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java new file mode 100644 index 0000000..3b5185e --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java @@ -0,0 +1,20 @@ +package com.smartjam.smartjamanalyzer.infrastructure.persistence.repository; + +import java.util.UUID; + +import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.AssignmentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +/** Spring Data JPA repository for {@link AssignmentEntity}. Includes query for status management. */ +public interface JpaAssignmentRepository extends JpaRepository { + + /** + * Performs an update on the record status and error message without loading large binary spectral data into memory. + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE AssignmentEntity a SET a.status = :status, a.errorMessage = :error WHERE a.id " + "= :id") + void updateStatus(UUID id, AudioProcessingStatus status, String error); +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java new file mode 100644 index 0000000..792d99f --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java @@ -0,0 +1,27 @@ +package com.smartjam.smartjamanalyzer.infrastructure.persistence.repository; + +import java.util.UUID; + +import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.SubmissionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +/** + * Spring Data JPA repository for {@link SubmissionEntity}. Manages persistence for student attempts and their + * corresponding analysis metrics. + */ +public interface JpaSubmissionRepository extends JpaRepository { + + /** + * Updates the status and error message of a submission using a partial update query. * + * + * @param id Unique identifier of the submission. + * @param status The new processing status to be set. + * @param error Error message if the status is FAILED, otherwise null. + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE SubmissionEntity s SET s.status = :status, s.errorMessage = :error WHERE s.id " + "= :id") + void updateStatus(UUID id, AudioProcessingStatus status, String error); +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializer.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializer.java new file mode 100644 index 0000000..a83b16f --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializer.java @@ -0,0 +1,116 @@ +package com.smartjam.smartjamanalyzer.infrastructure.utils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; + +/** + * Binary serializer for spectral feature matrices. Storage format: [int:N frames][int:M bins][float: framerate][N * M + * raw floats] + */ +public class FeatureBinarySerializer { + + private static final int HEADER_SIZE = 12; + + private FeatureBinarySerializer() { + // utility class, no need to be constructed + } + + /** + * Serializes a list of equally sized float frames into a byte array. + * + * @param sequence list of frames (each frame is an array of floats) + * @return byte array with little-endian representation + * @throws NullPointerException if sequence is null. + * @throws IllegalArgumentException if frames are null or have different lengths + */ + public static byte[] serialize(FeatureSequence sequence) { + + Objects.requireNonNull(sequence, "Feature sequence must not be null"); + + if (sequence.frames().isEmpty()) { + return new byte[0]; + } + + List frames = sequence.frames(); + int frameCount = frames.size(); + int binCount = frames.getFirst().length; + float frameRate = sequence.frameRate(); + + for (var frame : frames) { + if (frame.length != binCount) { + throw new IllegalArgumentException("All frames must have the same number of bins. Expected " + binCount + + ", but got " + frame.length); + } + } + + long totalPayloadBytes = (long) frameCount * binCount * Float.BYTES; + if (totalPayloadBytes > Integer.MAX_VALUE - HEADER_SIZE) { + throw new IllegalArgumentException("Matrix is too large for binary serialization"); + } + + int totalBytes = HEADER_SIZE + (int) totalPayloadBytes; + + ByteBuffer buffer = ByteBuffer.allocate(totalBytes).order(ByteOrder.LITTLE_ENDIAN); + + buffer.putInt(frameCount); + buffer.putInt(binCount); + buffer.putFloat(frameRate); + + for (float[] frame : frames) { + for (float val : frame) { + buffer.putFloat(val); + } + } + + return buffer.array(); + } + + /** + * Deserializes a byte array back to a list of frames. + * + * @param data byte array produced by {@link #serialize(FeatureSequence)} + * @return FeatureSequence, or null if data is null or too short + * @throws IllegalArgumentException if the data is malformed (e.g., negative sizes, insufficient length) + */ + public static FeatureSequence deserialize(byte[] data) { + if (data == null || data.length < HEADER_SIZE) { + return null; + } + + ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + + int frameCount = buffer.getInt(); + int binCount = buffer.getInt(); + float frameRate = buffer.getFloat(); + + if (frameCount < 0 || binCount < 0) { + throw new IllegalArgumentException("Invalid header: frameCount or binCount is negative"); + } + + long totalPayloadBytes = (long) frameCount * binCount * Float.BYTES; + if (totalPayloadBytes > Integer.MAX_VALUE - HEADER_SIZE) { + throw new IllegalArgumentException("Too many elements to deserialize: " + totalPayloadBytes); + } + + int expectedBytes = HEADER_SIZE + (int) totalPayloadBytes; + if (data.length < expectedBytes) { + throw new IllegalArgumentException( + "Data too short. Expected " + expectedBytes + " bytes, got " + data.length); + } + + List frames = new ArrayList<>(frameCount); + for (int i = 0; i < frameCount; ++i) { + float[] frame = new float[binCount]; + for (int j = 0; j < binCount; ++j) { + frame[j] = buffer.getFloat(); + } + frames.add(frame); + } + return new FeatureSequence(frames, frameRate); + } +} diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java index 10ad953..ec50419 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java @@ -2,7 +2,9 @@ import java.nio.file.Path; import java.util.List; +import java.util.UUID; +import com.smartjam.common.model.AudioProcessingStatus; import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; import com.smartjam.smartjamanalyzer.domain.port.*; import org.junit.jupiter.api.DisplayName; @@ -16,11 +18,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class AudioAnalysisUseCaseTest { + private final String VALID_UUID_STR = "11111111-1111-1111-1111-111111111111"; + private final UUID VALID_UUID = UUID.fromString(VALID_UUID_STR); + @Mock private AudioStorage storage; @@ -36,39 +40,58 @@ class AudioAnalysisUseCaseTest { @Mock private FeatureExtractor featureExtractor; + @Mock + private PerformanceEvaluator performanceEvaluator; + + @Mock + private ReferenceRepository referenceRepository; + + @Mock + private ResultRepository resultRepository; + + @Mock + private DebugVisualizer debugVisualizer; + @InjectMocks private AudioAnalysisUseCase useCase; @Test @DisplayName("UseCase должен сначала скачать, потом конвертировать") void shouldProcessInOrder() { - String bucket = "sub"; - String key = "test.mp3"; + String bucket = "references"; + String fileKey = VALID_UUID_STR + ".m4a"; Path mockPath = Path.of("input"); - Path mockClean = Path.of("output"); - FeatureSequence mockSequence = new FeatureSequence(List.of(new float[84]), 20f); + Path mockWav = Path.of("output"); + FeatureSequence mockSeq = new FeatureSequence(List.of(new float[84]), 20f); - when(storage.downloadAudioFile(eq(bucket), eq(key), any())).thenReturn(mockPath); - when(converter.convertToStandardWav(eq(mockPath), any())).thenReturn(mockClean); when(workspaceFactory.create()).thenReturn(workspace); - when(featureExtractor.extract(eq(mockClean))).thenReturn(mockSequence); + when(storage.downloadAudioFile(eq(bucket), eq(fileKey), any())).thenReturn(mockPath); + when(converter.convertToStandardWav(eq(mockPath), any())).thenReturn(mockWav); + when(featureExtractor.extract(mockWav)).thenReturn(mockSeq); - useCase.execute(bucket, key); + useCase.execute(bucket, fileKey); - InOrder inOrder = inOrder(storage, converter, featureExtractor); - inOrder.verify(storage).downloadAudioFile(eq(bucket), eq(key), any()); + InOrder inOrder = inOrder(referenceRepository, storage, converter, featureExtractor); + + inOrder.verify(referenceRepository).updateStatus(VALID_UUID, AudioProcessingStatus.ANALYZING, null); + + inOrder.verify(storage).downloadAudioFile(eq(bucket), eq(fileKey), any()); inOrder.verify(converter).convertToStandardWav(eq(mockPath), any()); - inOrder.verify(featureExtractor).extract(mockClean); + + inOrder.verify(featureExtractor).extract(mockWav); + inOrder.verify(referenceRepository).save(VALID_UUID, mockSeq); } @Test - @DisplayName("UseCase должен бросать ошибку, если конвертация зависла") + @DisplayName("UseCase должен бросать ошибку, если конвертация зависла и писать FAILED в БД") void shouldThrowExceptionWhenConverterTimesOut() { when(workspaceFactory.create()).thenReturn(workspace); when(storage.downloadAudioFile(any(), any(), any())).thenReturn(Path.of("input")); when(converter.convertToStandardWav(any(), any())).thenThrow(new RuntimeException("FFmpeg timeout exceeded")); - assertThrows(RuntimeException.class, () -> useCase.execute("sub", "key.mp3")); + assertThrows(RuntimeException.class, () -> useCase.execute("submissions", VALID_UUID_STR)); + + verify(resultRepository).updateStatus(VALID_UUID, AudioProcessingStatus.FAILED, "FFmpeg timeout exceeded"); } @Test @@ -79,9 +102,18 @@ void shouldWrapStorageException() { when(workspaceFactory.create()).thenReturn(workspace); when(storage.downloadAudioFile(any(), any(), any())).thenThrow(new RuntimeException(errorMessage)); - RuntimeException exception = assertThrows(RuntimeException.class, () -> useCase.execute("sub", "key.mp3")); + RuntimeException exception = + assertThrows(RuntimeException.class, () -> useCase.execute("references", VALID_UUID_STR)); assertTrue(exception.getMessage().contains("Business logic failed")); assertEquals(errorMessage, exception.getCause().getMessage()); + + verify(referenceRepository).updateStatus(VALID_UUID, AudioProcessingStatus.FAILED, errorMessage); + } + + @Test + @DisplayName("UseCase должен выкинуть исключение, если ключ не UUID") + void shouldThrowOnInvalidUuidKey() { + assertThrows(IllegalArgumentException.class, () -> useCase.execute("submissions", "not-a-uuid.mp3")); } } diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java index 09fd272..6561f49 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java @@ -17,7 +17,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; -// NOTE: добавить прокидывание фильтра сюда +// TODO: добавить прокидывание фильтра сюда @ExtendWith(MockitoExtension.class) class FfmpegAudioConverterTest { diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializerTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializerTest.java new file mode 100644 index 0000000..b12df71 --- /dev/null +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializerTest.java @@ -0,0 +1,204 @@ +package com.smartjam.smartjamanalyzer.infrastructure.utils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FeatureBinarySerializerTest { + + private static final float TEST_FRAME_RATE = 21.53f; + + @Test + @DisplayName("Сериализация и десериализация сохраняет данные") + void shouldMaintainDataIntegrity() { + + float[] frame1 = {0.1f, 0.5f, 0.9f}; + float[] frame2 = {0.2f, 0.4f, 0.6f}; + FeatureSequence original = new FeatureSequence(List.of(frame1, frame2), TEST_FRAME_RATE); + + byte[] encoded = FeatureBinarySerializer.serialize(original); + assertNotNull(encoded); + assertTrue(encoded.length > 8); + assertEquals(36, encoded.length); + + FeatureSequence decoded = FeatureBinarySerializer.deserialize(encoded); + + assertEquals(original.frameRate(), decoded.frameRate(), 1e-6f); + assertEquals(original.frames().size(), decoded.frames().size()); + assertEquals(original.frames().getFirst().length, decoded.frames().getFirst().length); + + for (int i = 0; i < original.frames().size(); i++) { + assertArrayEquals(original.frames().get(i), decoded.frames().get(i), 1e-6f); + } + } + + @Test + @DisplayName("Сериализация с null вызывает NullPointerException") + void serializeNullThrows() { + + assertThrows(NullPointerException.class, () -> FeatureBinarySerializer.serialize(null)); + } + + @Test + @DisplayName("Десериализация слишком короткого массива возвращает null") + void deserializeTooShortReturnsNull() { + + byte[] shortData = new byte[4]; + assertNull(FeatureBinarySerializer.deserialize(shortData)); + } + + @Test + @DisplayName("Сериализация с фреймами разной длины вызывает исключение") + void serializeFramesWithDifferentLengthsThrows() { + // Технически, исключение вылетает вообще из конструктора FeatureSequence + assertThrows( + IllegalArgumentException.class, + () -> FeatureBinarySerializer.serialize(new FeatureSequence( + List.of(new float[] {1.0f, 2.0f}, new float[] {3.0f, 4.0f, 5.0f}), TEST_FRAME_RATE))); + } + + @Test + @DisplayName("Десериализация с отрицательным frameCount вызывает исключение") + void deserializeNegativeFrameCountThrows() { + + ByteBuffer buffer = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(-1); + buffer.putInt(10); + byte[] data = buffer.array(); + + assertThrows(IllegalArgumentException.class, () -> FeatureBinarySerializer.deserialize(data)); + } + + @Test + @DisplayName("Десериализация с отрицательным binCount вызывает исключение") + void deserializeNegativeBinCountThrows() { + + ByteBuffer buffer = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(5); + buffer.putInt(-3); + byte[] data = buffer.array(); + + assertThrows(IllegalArgumentException.class, () -> FeatureBinarySerializer.deserialize(data)); + } + + @Test + @DisplayName("Десериализация с недостаточным количеством данных вызывает исключение") + void deserializeInsufficientDataThrows() { + + ByteBuffer buffer = ByteBuffer.allocate(8 + Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(1); + buffer.putInt(2); + buffer.putFloat(1.0f); + byte[] data = new byte[buffer.position()]; + System.arraycopy(buffer.array(), 0, data, 0, data.length); + + assertThrows(IllegalArgumentException.class, () -> FeatureBinarySerializer.deserialize(data)); + } + + @Test + @DisplayName("Проверка little-endian порядка байтов") + void littleEndianOrder() { + + List frames = List.of(new float[] {1.0f}); + FeatureSequence seq = new FeatureSequence(frames, TEST_FRAME_RATE); + byte[] encoded = FeatureBinarySerializer.serialize(seq); + + ByteBuffer buffer = ByteBuffer.wrap(encoded).order(ByteOrder.LITTLE_ENDIAN); + assertEquals(1, buffer.getInt(), "Frame count mismatch"); + assertEquals(1, buffer.getInt(), "Bin count mismatch"); + assertEquals(TEST_FRAME_RATE, buffer.getFloat(), 1e-6f, "Frame rate mismatch"); + assertEquals(1.0f, buffer.getFloat(), 1e-6f, "First data value mismatch"); + } + + @Test + @DisplayName("Работа с большим количеством данных (3000 фреймов по 100 бинов)") + void largeData() { + + int framesCount = 3000; + int bins = 100; + + List frames = new ArrayList<>(framesCount); + + for (int i = 0; i < framesCount; i++) { + float[] frame = new float[bins]; + for (int j = 0; j < bins; j++) { + frame[j] = (float) (i + j); + } + frames.add(frame); + } + + FeatureSequence original = new FeatureSequence(frames, TEST_FRAME_RATE); + + byte[] encoded = FeatureBinarySerializer.serialize(original); + + FeatureSequence decoded = FeatureBinarySerializer.deserialize(encoded); + + assertEquals(framesCount, decoded.frames().size()); + assertEquals(TEST_FRAME_RATE, decoded.frameRate()); + + for (int i = 0; i < framesCount; i++) { + assertArrayEquals(frames.get(i), decoded.frames().get(i), 1e-6f); + } + } + + @Test + @DisplayName("Один фрейм с одним значением") + void singleFrameSingleValue() { + + List original = List.of(new float[] {42.0f}); + FeatureSequence seq = new FeatureSequence(original, TEST_FRAME_RATE); + + byte[] encoded = FeatureBinarySerializer.serialize(seq); + FeatureSequence decoded = FeatureBinarySerializer.deserialize(encoded); + + assertEquals(1, decoded.frames().size()); + assertEquals(1, decoded.frames().getFirst().length); + assertEquals(42.0f, decoded.frames().getFirst()[0], 1e-6f); + } + + @Test + @DisplayName("Сериализация и десериализация с максимальными значениями float") + void extremeFloatValues() { + + List frames = List.of( + new float[] {Float.MAX_VALUE, -Float.MAX_VALUE, Float.MIN_VALUE, Float.NaN, Float.POSITIVE_INFINITY}); + + FeatureSequence seq = new FeatureSequence(frames, TEST_FRAME_RATE); + + byte[] encoded = FeatureBinarySerializer.serialize(seq); + + FeatureSequence decoded = FeatureBinarySerializer.deserialize(encoded); + + float[] originalFrame = frames.getFirst(); + float[] decodedFrame = decoded.frames().getFirst(); + assertEquals(originalFrame.length, decodedFrame.length); + for (int i = 0; i < originalFrame.length; i++) { + if (Float.isNaN(originalFrame[i])) { + assertTrue(Float.isNaN(decodedFrame[i])); + } else { + assertEquals(originalFrame[i], decodedFrame[i], 1e-6f); + } + } + } + + @Test + @DisplayName("Защита от переполнения при десериализации") + void deserializeOverflowThrows() { + + ByteBuffer buffer = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN); + + int large = Integer.MAX_VALUE / 1000; + buffer.putInt(large); + buffer.putInt(large); + byte[] data = buffer.array(); + + assertThrows(IllegalArgumentException.class, () -> FeatureBinarySerializer.deserialize(data)); + } +}