diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEvent.java b/src/main/java/com/mr/domain/playing/entity/MidiEvent.java new file mode 100644 index 00000000..9494e32a --- /dev/null +++ b/src/main/java/com/mr/domain/playing/entity/MidiEvent.java @@ -0,0 +1,104 @@ +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/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java new file mode 100644 index 00000000..f8cd4e68 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -0,0 +1,145 @@ +package com.mr.domain.playing.entity; + +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.global.apipayload.exception.GeneralException; +import com.mr.global.entity.BaseCreatedDeletedEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "playing") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +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; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "playing_id") + private Long id; + + // TODO: 유저 ID 연관 관계 설정 예정 + @Column(name = "user_id", nullable = false) + private Long userId; + + // TODO: 백킹트랙 ID 연관 관계 설정 예정 + @Column(name = "backing_track_id") + private Long backingTrackId; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false) + private PlayingMode mode; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PlayingStatus status; + + @Column(name = "bpm", nullable = false) + private Integer bpm; + + @Column(name = "metronome_enabled", nullable = false) + private boolean metronomeEnabled; + + // 실제 연주가 시작된 시간 + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "ended_at") + private LocalDateTime endedAt; + + // 사용자가 연주한 녹음 파일 URL + @Column(name = "recording_file_url", length = 255) + private String recordingFileUrl; + + @Column(name = "duration") + private Integer duration; + + @Column(name = "is_public", nullable = false) + private boolean isPublic; + + @Builder(access = AccessLevel.PRIVATE) + private Playing( + Long userId, + Long backingTrackId, + PlayingMode mode, + PlayingStatus status, + Integer bpm, + boolean metronomeEnabled, + boolean isPublic + ) { + + validateUserId(userId); + validateBackingTrack(mode, backingTrackId); + + this.userId = userId; + this.backingTrackId = backingTrackId; + this.mode = mode; + this.status = status; + this.bpm = resolveBpm(bpm); + this.metronomeEnabled = metronomeEnabled; + this.isPublic = isPublic; + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_USER_ID); + } + } + + private static void validateBackingTrack( + PlayingMode mode, Long backingTrackId) { + if (mode == PlayingMode.BACKING_TRACK && backingTrackId == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_BACKING_TRACK_ID); + } + } + + private static int resolveBpm(Integer bpm) { + int resolvedBpm = bpm == null ? DEFAULT_BPM : bpm; + + if (resolvedBpm < MIN_BPM || resolvedBpm > MAX_BPM) { + throw new GeneralException(PlayingErrorStatus.INVALID_BPM_RANGE); + } + + 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 + ) { + return Playing.builder() + .userId(userId) + .backingTrackId(backingTrackId) + .mode(PlayingMode.BACKING_TRACK) + .status(PlayingStatus.READY) + .bpm(bpm) + .metronomeEnabled(metronomeEnabled) + .isPublic(false) + .build(); + } +} diff --git a/src/main/java/com/mr/domain/playing/entity/enums/MidiType.java b/src/main/java/com/mr/domain/playing/entity/enums/MidiType.java new file mode 100644 index 00000000..f873e09d --- /dev/null +++ b/src/main/java/com/mr/domain/playing/entity/enums/MidiType.java @@ -0,0 +1,6 @@ +package com.mr.domain.playing.entity.enums; + +public enum MidiType { + NOTE_ON, + NOTE_OFF +} diff --git a/src/main/java/com/mr/domain/playing/entity/enums/PlayingMode.java b/src/main/java/com/mr/domain/playing/entity/enums/PlayingMode.java new file mode 100644 index 00000000..c5bfa09f --- /dev/null +++ b/src/main/java/com/mr/domain/playing/entity/enums/PlayingMode.java @@ -0,0 +1,6 @@ +package com.mr.domain.playing.entity.enums; + +public enum PlayingMode { + FREE_PLAY, + BACKING_TRACK +} diff --git a/src/main/java/com/mr/domain/playing/entity/enums/PlayingStatus.java b/src/main/java/com/mr/domain/playing/entity/enums/PlayingStatus.java new file mode 100644 index 00000000..8d024a49 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/entity/enums/PlayingStatus.java @@ -0,0 +1,9 @@ +package com.mr.domain.playing.entity.enums; + +public enum PlayingStatus { + READY, + IN_PROGRESS, + PAUSED, + COMPLETED, + AUTO_STOPPED +} diff --git a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java new file mode 100644 index 00000000..5ac33811 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -0,0 +1,25 @@ +package com.mr.domain.playing.exception; + +import com.mr.global.apipayload.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum PlayingErrorStatus implements BaseCode { + + MISSING_USER_ID(HttpStatus.BAD_REQUEST, "PLAYING_400_01", "연주를 기록할 유저 정보(ID)가 누락되었습니다."), + INVALID_BPM_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_02", "BPM은 50~200 사이의 값이어야 합니다."), + MISSING_BACKING_TRACK_ID(HttpStatus.BAD_REQUEST, "PLAYING_400_03", "백킹트랙 연주 모드에서는 백킹트랙 ID가 필수입니다."), + MISSING_PLAYING(HttpStatus.BAD_REQUEST, "PLAYING_400_04", "MIDI 이벤트를 기록할 연주 정보가 누락되었습니다."), + MISSING_MIDI_TYPE(HttpStatus.BAD_REQUEST, "PLAYING_400_05", "MIDI Type은 필수 입력 값입니다."), + 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 이상이어야 합니다."), + ; + + private final HttpStatus status; + private final String code; + private final String message; +}