diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEvent.java b/src/main/java/com/mr/domain/playing/entity/MidiEvent.java deleted file mode 100644 index 9494e32a..00000000 --- a/src/main/java/com/mr/domain/playing/entity/MidiEvent.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.mr.domain.playing.entity; - -import com.mr.domain.playing.entity.enums.MidiType; -import com.mr.domain.playing.exception.PlayingErrorStatus; -import com.mr.global.apipayload.exception.GeneralException; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Table( - name = "midi_event", - indexes = { - @Index(name = "idx_midi_event_playing_timestamp", - columnList = "playing_id, timestamp_ms") - } -) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MidiEvent { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "midi_event_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "playing_id", nullable = false) - private Playing playing; - - @Enumerated(EnumType.STRING) - @Column(name = "type", nullable = false) - private MidiType type; - - @Column(name = "pitch", nullable = false) - private Integer pitch; - - @Column(name = "velocity", nullable = false) - private Integer velocity; - - @Column(name = "timestamp_ms", nullable = false) - private Long timestampMs; - - @Builder(access = AccessLevel.PRIVATE) - private MidiEvent( - Playing playing, MidiType type, Integer pitch, Integer velocity, Long timestampMs - ) { - validatePlaying(playing); - validateMidiType(type); - validatePitch(pitch); - validateVelocity(velocity); - validateTimestampMs(timestampMs); - - this.playing = playing; - this.type = type; - this.pitch = pitch; - this.velocity = velocity; - this.timestampMs = timestampMs; - } - - private static void validatePlaying(Playing playing) { - if (playing == null) { - throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING); - } - } - - private static void validateMidiType(MidiType type) { - if (type == null) { - throw new GeneralException(PlayingErrorStatus.MISSING_MIDI_TYPE); - } - } - - private static void validatePitch(Integer pitch) { - if (pitch == null || pitch < 0 || pitch > 127) { - throw new GeneralException(PlayingErrorStatus.INVALID_PITCH_RANGE); - } - } - - private static void validateVelocity(Integer velocity) { - if (velocity == null || velocity < 0 || velocity > 127) { - throw new GeneralException(PlayingErrorStatus.INVALID_VELOCITY_RANGE); - } - } - - private static void validateTimestampMs(Long timestampMs) { - if (timestampMs == null || timestampMs < 0) { - throw new GeneralException(PlayingErrorStatus.INVALID_TIMESTAMP); - } - } - - public static MidiEvent create( - Playing playing, MidiType type, Integer pitch, Integer velocity, Long timestampMs - ) { - return MidiEvent.builder() - .playing(playing) - .type(type) - .pitch(pitch) - .velocity(velocity) - .timestampMs(timestampMs) - .build(); - } -} diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java new file mode 100644 index 00000000..3a2af67c --- /dev/null +++ b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java @@ -0,0 +1,84 @@ +package com.mr.domain.playing.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.mr.domain.playing.entity.enums.MidiType; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.Getter; + +@Getter +public class MidiEventData { + + // 동일 timestamp 내 순서 값 + @JsonProperty("sequence") + private final Integer sequence; + + private final MidiType type; + private final Integer pitch; + private final Integer velocity; + + @JsonProperty("timestamp_ms") + private final Long timestampMs; + + private MidiEventData( + Integer sequence, + MidiType type, + Integer pitch, + Integer velocity, + Long timestampMs + ) { + validateSequence(sequence); + validateMidiType(type); + validatePitch(pitch); + validateVelocity(velocity); + validateTimestampMs(timestampMs); + + this.sequence = sequence; + this.type = type; + this.pitch = pitch; + this.velocity = velocity; + this.timestampMs = timestampMs; + } + + @JsonCreator + public static MidiEventData of( + @JsonProperty("sequence") Integer sequence, + @JsonProperty("type") MidiType type, + @JsonProperty("pitch") Integer pitch, + @JsonProperty("velocity") Integer velocity, + @JsonProperty("timestamp_ms") Long timestampMs + ) { + return new MidiEventData(sequence, type, pitch, velocity, timestampMs); + } + + private static void validateSequence(Integer sequence) { + if (sequence == null || sequence < 0) { + throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_SEQUENCE); + } + } + + private static void validateMidiType(MidiType type) { + if (type == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_MIDI_TYPE); + } + } + + private static void validatePitch(Integer pitch) { + if (pitch == null || pitch < 0 || pitch > 127) { + throw new GeneralException(PlayingErrorStatus.INVALID_PITCH_RANGE); + } + } + + private static void validateVelocity(Integer velocity) { + if (velocity == null || velocity < 0 || velocity > 127) { + throw new GeneralException(PlayingErrorStatus.INVALID_VELOCITY_RANGE); + } + } + + private static void validateTimestampMs(Long timestampMs) { + if (timestampMs == null || timestampMs < 0) { + throw new GeneralException(PlayingErrorStatus.INVALID_TIMESTAMP); + } + } +} diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index f8cd4e68..870c3edd 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -1,17 +1,39 @@ package com.mr.domain.playing.entity; +import com.mr.domain.backingTrack.entity.BackingTrack; import com.mr.domain.playing.entity.enums.PlayingMode; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedDeletedEntity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; @Entity @Table(name = "playing") @@ -22,19 +44,28 @@ public class Playing extends BaseCreatedDeletedEntity { private static final int DEFAULT_BPM = 120; private static final int MIN_BPM = 50; private static final int MAX_BPM = 200; + private static final int MAX_DURATION_SEC = 600; + private static final long MAX_DURATION_MS = MAX_DURATION_SEC * 1000L; + + private static final long MIDI_TIMESTAMP_TOLERANCE_MS = 500L; // 실제 연주 시간과 MIDI 이벤트 수집 종료 시점 간 허용 오차 + private static final int MAX_MIDI_EVENT_COUNT = 100_000; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "playing_id") private Long id; - // TODO: 유저 ID 연관 관계 설정 예정 - @Column(name = "user_id", nullable = false) - private Long userId; + @Version + @Column(name = "version", nullable = false) + private Long version; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; - // TODO: 백킹트랙 ID 연관 관계 설정 예정 - @Column(name = "backing_track_id") - private Long backingTrackId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "backing_track_id") + private BackingTrack backingTrack; @Enumerated(EnumType.STRING) @Column(name = "mode", nullable = false) @@ -47,9 +78,6 @@ public class Playing extends BaseCreatedDeletedEntity { @Column(name = "bpm", nullable = false) private Integer bpm; - @Column(name = "metronome_enabled", nullable = false) - private boolean metronomeEnabled; - // 실제 연주가 시작된 시간 @Column(name = "started_at") private LocalDateTime startedAt; @@ -61,48 +89,71 @@ public class Playing extends BaseCreatedDeletedEntity { @Column(name = "recording_file_url", length = 255) private String recordingFileUrl; - @Column(name = "duration") - private Integer duration; + @Column(name = "duration_sec") + private Integer durationSec; @Column(name = "is_public", nullable = false) private boolean isPublic; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "midi_data", columnDefinition = "jsonb", nullable = false) + private List midiData = new ArrayList<>(); + @Builder(access = AccessLevel.PRIVATE) private Playing( - Long userId, - Long backingTrackId, + User user, + BackingTrack backingTrack, PlayingMode mode, PlayingStatus status, Integer bpm, - boolean metronomeEnabled, boolean isPublic ) { - validateUserId(userId); - validateBackingTrack(mode, backingTrackId); + validateUser(user); + validateMode(mode); + validateStatus(status); + validateBackingTrack(mode, backingTrack); - this.userId = userId; - this.backingTrackId = backingTrackId; + this.user = user; + this.backingTrack = backingTrack; this.mode = mode; this.status = status; this.bpm = resolveBpm(bpm); - this.metronomeEnabled = metronomeEnabled; this.isPublic = isPublic; + this.midiData = new ArrayList<>(); } - private static void validateUserId(Long userId) { - if (userId == null) { + private static void validateUser(User user) { + if (user == null) { throw new GeneralException(PlayingErrorStatus.MISSING_USER_ID); } } private static void validateBackingTrack( - PlayingMode mode, Long backingTrackId) { - if (mode == PlayingMode.BACKING_TRACK && backingTrackId == null) { + PlayingMode mode, BackingTrack backingTrack) { + + // 자유 연주 미지원 (BACKING_TRACK이 포함된 연주만 가능) + if (mode != PlayingMode.BACKING_TRACK) { + throw new GeneralException(PlayingErrorStatus.UNSUPPORTED_PLAYING_MODE); + } + + if (backingTrack == null) { throw new GeneralException(PlayingErrorStatus.MISSING_BACKING_TRACK_ID); } } + private static void validateMode(PlayingMode mode) { + if (mode == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING_MODE); + } + } + + private static void validateStatus(PlayingStatus status) { + if (status == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING_STATUS); + } + } + private static int resolveBpm(Integer bpm) { int resolvedBpm = bpm == null ? DEFAULT_BPM : bpm; @@ -113,33 +164,130 @@ private static int resolveBpm(Integer bpm) { return resolvedBpm; } - // 자유 연주 생성 - public static Playing createFreePlay( - Long userId, Integer bpm, boolean metronomeEnabled - ) { - return Playing.builder() - .userId(userId) - .mode(PlayingMode.FREE_PLAY) - .status(PlayingStatus.READY) - .bpm(bpm) - .metronomeEnabled(metronomeEnabled) - .isPublic(false) - .build(); - } - // 백킹트랙 연주 생성 public static Playing createBackingTrack( - Long userId, Long backingTrackId, - Integer bpm, boolean metronomeEnabled + User user, BackingTrack backingTrack, Integer bpm ) { return Playing.builder() - .userId(userId) - .backingTrackId(backingTrackId) + .user(user) + .backingTrack(backingTrack) .mode(PlayingMode.BACKING_TRACK) .status(PlayingStatus.READY) .bpm(bpm) - .metronomeEnabled(metronomeEnabled) .isPublic(false) .build(); } + + // 연주 완료 시 전체 MIDI 데이터를 저장하고 완료 상태로 전환 + public void completeWithMidiData( + List midiData + ) { + validateCompletableStatus(); + validateMidiData(midiData); + + LocalDateTime completedAt = LocalDateTime.now(); + long savedDurationMs = calculateDurationMs(this.startedAt, completedAt); + + // 실제 연주 시간에 500ms의 오차를 허용하되, 최대 10분까지만 저장 + long allowedTimestampMs = Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS, + MAX_DURATION_MS); + + List sortedMidiData = midiData.stream() + .filter(event -> event.getTimestampMs() <= allowedTimestampMs) + .sorted( + Comparator.comparingLong(MidiEventData::getTimestampMs) + .thenComparingInt(MidiEventData::getSequence)) + .toList(); + + if (sortedMidiData.isEmpty()) { + throw new GeneralException( + PlayingErrorStatus.EMPTY_MIDI_EVENTS + ); + } + + LocalDateTime maxEndedAt = this.startedAt.plusSeconds(MAX_DURATION_SEC); + + this.midiData = new ArrayList<>(sortedMidiData); + this.endedAt = completedAt.isAfter(maxEndedAt) ? maxEndedAt : completedAt; + this.durationSec = Math.toIntExact(Duration.ofMillis(savedDurationMs).toSeconds()); + this.status = PlayingStatus.COMPLETED; + } + + private void validateCompletableStatus() { + if (this.status != PlayingStatus.IN_PROGRESS) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS); + } + + if (startedAt == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING_START_TIME); + } + } + + private static void validateMidiData(List midiData) { + if (midiData == null || midiData.isEmpty()) { + throw new GeneralException(PlayingErrorStatus.EMPTY_MIDI_EVENTS); + } + + if (midiData.size() > MAX_MIDI_EVENT_COUNT) { + throw new GeneralException(PlayingErrorStatus.EXCEEDED_MIDI_EVENT_COUNT); + } + + if (midiData.stream().anyMatch(Objects::isNull)) { + throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_EVENT); + } + + Set seenOrders = new HashSet<>(); + + for (MidiEventData event : midiData) { + MidiEventOrder order = new MidiEventOrder( + event.getTimestampMs(), + event.getSequence() + ); + + if (!seenOrders.add(order)) { + throw new GeneralException( + PlayingErrorStatus.DUPLICATE_MIDI_SEQUENCE + ); + } + } + } + + private record MidiEventOrder( + Long timestampMs, + Integer sequence + ) { + } + + private static long calculateDurationMs( + LocalDateTime startedAt, LocalDateTime completedAt + ) { + long durationMs = Duration.between(startedAt, completedAt).toMillis(); + + if (durationMs < 0) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_DURATION); + } + + return Math.min(durationMs, MAX_DURATION_MS); + } + + public List getMidiData() { + + if (this.midiData == null) { + return List.of(); + } + return List.copyOf(this.midiData); + } + + public void start() { + validateStartableStatus(); + + this.status = PlayingStatus.IN_PROGRESS; + this.startedAt = LocalDateTime.now(); + } + + private void validateStartableStatus() { + if (status != PlayingStatus.READY) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS); + } + } } diff --git a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java index 5ac33811..e445c7e8 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -17,6 +17,19 @@ public enum PlayingErrorStatus implements BaseCode { INVALID_PITCH_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_06", "피치 값은 0~127 사이의 값이어야 합니다."), INVALID_VELOCITY_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_07", "강도는 0~127 사이의 값이어야 합니다."), INVALID_TIMESTAMP(HttpStatus.BAD_REQUEST, "PLAYING_400_08", "MIDI 이벤트 타임스탬프는 0 이상이어야 합니다."), + EMPTY_MIDI_EVENTS(HttpStatus.BAD_REQUEST, "PLAYING_400_09", "저장할 MIDI 이벤트가 없습니다."), + INVALID_MIDI_EVENT(HttpStatus.BAD_REQUEST,"PLAYING_400_10", "MIDI 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."), + MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_11", "연주 모드는 필수 입력값입니다."), + MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_12", "연주 상태는 필수 입력값입니다."), + UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_13", "현재 지원하지 않는 연주 모드입니다."), + EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "PLAYING_400_14", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."), + INVALID_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_15", "MIDI 이벤트 순서(sequence) 값이 유효하지 않습니다."), + DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_16", "중복된 MIDI sequence 값이 존재합니다."), + + INVALID_PLAYING_STATUS(HttpStatus.CONFLICT, "PLAYING_409_01", "현재 연주 상태에서는 요청한 작업을 수행할 수 없습니다."), + MISSING_PLAYING_START_TIME(HttpStatus.CONFLICT, "PLAYING_409_02", "연주 시작 시간이 기록되지 않았습니다."), + INVALID_PLAYING_DURATION(HttpStatus.CONFLICT, "PLAYING_409_03", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), + ; private final HttpStatus status;