diff --git a/package-lock.json b/package-lock.json index ff62423..fc79d3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "bootstrap": "^5.3.0", "bootstrap-icons": "^1.10.5", "clean-webpack-plugin": "^4.0.0", + "clone": "^2.1.2", "html-webpack-plugin": "5.5.0", "react": "18.2.0", "react-bootstrap": "^2.7.4", @@ -2906,6 +2907,14 @@ "webpack": ">=4.0.0 <6.0.0" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", diff --git a/package.json b/package.json index be17956..4a5c768 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "bootstrap": "^5.3.0", "bootstrap-icons": "^1.10.5", "clean-webpack-plugin": "^4.0.0", + "clone": "^2.1.2", "html-webpack-plugin": "5.5.0", "react": "18.2.0", "react-bootstrap": "^2.7.4", diff --git a/src/css/sequenser.css b/src/css/sequenser.css index d35a220..5c34544 100644 --- a/src/css/sequenser.css +++ b/src/css/sequenser.css @@ -8,6 +8,7 @@ .main-track-panels{ width: 20%; + background-color: var(--bs-list-group-bg); } .main-piano-key{ diff --git a/src/python/domain/midi_file.py b/src/python/domain/midi_file.py index a4bc3d9..e8a19bf 100644 --- a/src/python/domain/midi_file.py +++ b/src/python/domain/midi_file.py @@ -1,22 +1,21 @@ from mido import MidiFile, MidiTrack -from domain.message import NoteOnMessage from domain.track import MidoTrackHelper +from domain.track import Track class MIDIFile: - def __init__(self, messages: list[NoteOnMessage]): - # TODO: マルチトラック化する - sys_track: MidiTrack = MidoTrackHelper.mido_system_track() - track0: MidiTrack = MidoTrackHelper.mido_instrument_track("track0", messages) - + def __init__(self, tracks: list[Track]): self.midi: MidiFile = MidiFile(type=1) - self.midi.tracks.append(sys_track) - self.midi.tracks.append(track0) + + for track in tracks: + mido_track: MidiTrack = MidoTrackHelper.mido_instrument_track(track) + self.midi.tracks.append(mido_track) @staticmethod - def file_to_obj(path: str): + def file_to_sequencer_model(path: str): midi = MidiFile(path) - # TODO: マルチトラックに対応する - # for track in midi.tracks: - # messages = Track.to_messages(track) - return MidoTrackHelper.to_sequencer_messages(midi.tracks[0]) + tracks = list( + MidoTrackHelper.to_sequencer_track(idx, track) + for idx, track in enumerate(midi.tracks) + ) + return tracks diff --git a/src/python/domain/player.py b/src/python/domain/player.py index 63f2283..610115c 100644 --- a/src/python/domain/player.py +++ b/src/python/domain/player.py @@ -2,14 +2,15 @@ from domain.ports import Ports from domain.message import NoteOnMessage from domain.midi_file import MIDIFile +from domain.track import Track class Player: - def __init__(self, messages: list[NoteOnMessage]): - self.messages: list[NoteOnMessage] = messages + def __init__(self, tracks: list[Track]): + self.tracks: list[Track] = tracks def play(self, output_port_name: str): - midi: MidiFile = MIDIFile(self.messages).midi + midi: MidiFile = MIDIFile(self.tracks).midi port = Ports.open_output_port(output_port_name) for message in midi.play(): diff --git a/src/python/domain/track.py b/src/python/domain/track.py index da6d659..1f0ce69 100644 --- a/src/python/domain/track.py +++ b/src/python/domain/track.py @@ -3,21 +3,27 @@ from domain.parser import Parser -class MidoTrackHelper: - @staticmethod - def mido_system_track(): - sys_track = MidiTrack() - sys_track.name = "System Track" - return sys_track +class Track: + def __init__( + self, no: int, name: str, instrument_id: int, messages: list[NoteOnMessage] + ) -> None: + self.no = no + self.name = name + self.instrument_id = instrument_id + self.messages = messages + +class MidoTrackHelper: @staticmethod - def mido_instrument_track(name: str, messages: list[NoteOnMessage]): + def mido_instrument_track(track: Track): mido_track = MidiTrack() - mido_track.name = name - for message in Parser.to_mido_messages(messages): + mido_track.name = track.name + for message in Parser.to_mido_messages(track.messages): mido_track.append(message) return mido_track @staticmethod - def to_sequencer_messages(mido_track: MidiTrack[Message]): - return Parser.to_sequencer_messages(mido_track) + def to_sequencer_track(no: int, mido_track: MidiTrack[Message]): + messages = Parser.to_sequencer_messages(mido_track) + # TODO: メッセージからinstrument_id を取得できるようにする + return Track(no, mido_track.name, 0, messages) diff --git a/src/python/presentation/request_body.py b/src/python/presentation/request_body.py index 4aaeac4..67d5dfd 100644 --- a/src/python/presentation/request_body.py +++ b/src/python/presentation/request_body.py @@ -1,9 +1,10 @@ from pydantic import BaseModel import stringcase from domain.message import NoteOnMessage +from domain.track import Track -class NoteOnMessageRequest(BaseModel): +class NoteOnMessageModel(BaseModel): note_number: int started_at: int velocity: int @@ -27,7 +28,7 @@ def toDomain(self): @staticmethod def fromDomain(message: NoteOnMessage): - return NoteOnMessageRequest( + return NoteOnMessageModel( note_number=message.note_number, started_at=message.started_at, velocity=message.velocity, @@ -35,19 +36,51 @@ def fromDomain(message: NoteOnMessage): ) +class TrackModel(BaseModel): + no: int + name: str + instrument_id: int + messages: list[NoteOnMessageModel] + + class Config: + alias_generator = stringcase.camelcase + allow_population_by_field_name = True + + def toDomain(self): + messages = list(message.toDomain() for message in self.messages) + return Track( + self.no, + self.name, + self.instrument_id, + messages, + ) + + @staticmethod + def fromDomain(track: Track): + messages = list( + NoteOnMessageModel.fromDomain(message) for message in track.messages + ) + return TrackModel( + no=track.no, + name=track.name, + instrument_id=track.instrument_id, + messages=messages, + ) + + class PlayRequest(BaseModel): - messages: list[NoteOnMessageRequest] + tracks: list[TrackModel] port_name: str class Config: alias_generator = stringcase.camelcase def toDomain(self): - return list(message.toDomain() for message in self.messages) + return list(track.toDomain() for track in self.tracks) class SaveRequest(BaseModel): - messages: list[NoteOnMessageRequest] + messages: list[NoteOnMessageModel] filename: str class Config: diff --git a/src/python/presentation/routers.py b/src/python/presentation/routers.py index 4994157..46d4901 100644 --- a/src/python/presentation/routers.py +++ b/src/python/presentation/routers.py @@ -1,6 +1,11 @@ from fastapi import APIRouter, Body, UploadFile from fastapi.responses import FileResponse -from presentation.request_body import SaveRequest, PlayRequest, NoteOnMessageRequest +from presentation.request_body import ( + SaveRequest, + PlayRequest, + TrackModel, + NoteOnMessageModel, +) from domain.player import Player from domain.ports import Ports from domain.midi_file import MIDIFile @@ -23,12 +28,10 @@ async def save(body: SaveRequest = Body()): @router.post("/v1.0/upload") -async def openFile(file: UploadFile) -> list[NoteOnMessageRequest]: +async def openFile(file: UploadFile) -> list[TrackModel]: repository.upload(file.file, file.filename) - messages = MIDIFile.file_to_obj(file.filename) - # TODO: 曲のメタ情報(テンポ)やトラック情報を返す - # TODO: マルチトラックに対応する - return list(NoteOnMessageRequest.fromDomain(message) for message in messages) + tracks = MIDIFile.file_to_sequencer_model(file.filename) + return list(TrackModel.fromDomain(track) for track in tracks) @router.post("/v1.0/player") diff --git a/src/typescript/component/header/header.tsx b/src/typescript/component/header/header.tsx index 6062f1c..b0a82de 100644 --- a/src/typescript/component/header/header.tsx +++ b/src/typescript/component/header/header.tsx @@ -3,29 +3,29 @@ import Container from 'react-bootstrap/Container'; import Nav from 'react-bootstrap/Nav'; import Navbar from 'react-bootstrap/Navbar'; import NavDropdown from 'react-bootstrap/NavDropdown'; -import NoteOnMessage from '../../domain/message'; +import { nowDateTime } from '../../common/date-utils'; import { play, saveAndDownload, selectFile, uploadFile } from '../../repository/repository'; import { SettingsModal } from './settings-modal'; -import { nowDateTime } from '../../common/date-utils'; +import { Tracks } from '../../domain/track'; export const Header: React.FunctionComponent<{ + /** 現在選択しているMIDI出力ポート。 */ + port: string, + /** 現在開いているMIDIファイル。 */ file: File, - /** シーケンサーで入力したノートオンメッセージのリスト。 */ - messages: NoteOnMessage[], - - /** シーケンサーに設定済みのMIDI出力ポート。 */ - port: string, + /** トラックのリスト。 */ + tracks: Tracks, - /** 指定したファイルを、現在開いているMIDIファイルとする。 */ + /** MIDIファイルを設定する。 */ setFile: (file: File) => void, - /** シーケンサーのMIDI出力ポートを設定する。 */ + /** MIDI出力ポートを設定する。 */ setPort: (port: string) => void, - /** ノートオンメッセージをシーケンサーに設定する。 */ - setMessage: (messages: NoteOnMessage[]) => void + /** トラックを設定する。 */ + setTracks: (tracks: Tracks) => void }> = (props) => { @@ -34,14 +34,14 @@ export const Header: React.FunctionComponent<{ const openFile = async () => { const file = await selectFile(); - const messages = await uploadFile(file); + const tracks = await uploadFile(file); props.setFile(file); - props.setMessage(messages) + props.setTracks(tracks); } const saveAndDownloadFile = () => { const filename = `new_file_${nowDateTime()}.mid`; - saveAndDownload(props.messages, filename); + // saveAndDownload(props.tracks, filename); } return ( @@ -68,7 +68,7 @@ export const Header: React.FunctionComponent<{ - play(props.messages, props.port)}>Play + play(props.port, props.tracks)}>Play Stop Jump to diff --git a/src/typescript/component/piano-roll/piano-key.tsx b/src/typescript/component/piano-roll/piano-key.tsx index 6d1df6d..da883ce 100644 --- a/src/typescript/component/piano-roll/piano-key.tsx +++ b/src/typescript/component/piano-roll/piano-key.tsx @@ -38,15 +38,13 @@ const whiteKeyElements = () => { const rects = []; /* * 白鍵の始点となるY座標。それぞれの白鍵の高さを加えたものを、次の白鍵の始点にする。 - * 水平線と最初の白鍵の縦の軸を合わせるために、始点の初期値は負の値をとる。 - * ピアノロールの最上部の水平線は、最初の白鍵の途中から始まるため。 + * 水平線と最初の白鍵の縦の軸を合わせるために、ズレを補正する初期値を設定する。 */ var totalY = X_LINE_SPACING * 3 - WHITE_KEY_HEIGHT_F * 2; for(let i = 0; i <= WHITE_KEY_AMOUNT + 1; i++){ /* * 白鍵は [C, D, E] と [F, G, A, B] でピアノロール上の高さが異なる。そのため高さを分けて算出する。 - * ピアノロールの最上部はGであり、下側にG, F, E, D…と続く。 - * 起点0をGとして鍵盤の高さを算出する。 + * ピアノロールの最上部はGのため、起点0をGとして音程を判定する。 */ const height = [0, 1, 5, 6].some(note => i % 7 === note) ? WHITE_KEY_HEIGHT_F : WHITE_KEY_HEIGHT_C; const rect = diff --git a/src/typescript/component/piano-roll/piano-roll.tsx b/src/typescript/component/piano-roll/piano-roll.tsx index dd96bb3..d11280e 100644 --- a/src/typescript/component/piano-roll/piano-roll.tsx +++ b/src/typescript/component/piano-roll/piano-roll.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import NoteOnMessage from '../../domain/message'; +import NoteOnMessage, { Message } from '../../domain/message'; /** 音程の最大値 */ export const MAX_NOTE_NUMBER = 127; @@ -29,16 +29,17 @@ const Y_MEASURE_LINE_COLOR = X_OCTAVE_LINE_COLOR export const PianoRoll: React.FunctionComponent<{ - messages: NoteOnMessage[], + messages: Message[], addMessage: (message: NoteOnMessage) => void }> = (props) => { - const pianoRollElement = React.useRef(null); - + // 読み込み時に画面中央部までスクロールする。 + const pianoRollElement = React.useRef(null) React.useEffect(() => { pianoRollElement.current.scrollTop = PIANO_ROLLE_HEIGHT / 2; }, []); + // ピアノロールを縦スクロールしたら白鍵・黒鍵のスクロールを追従させる。 const onScroll = (e) => { const pianoKey = document.getElementById("piano-key"); if(!pianoKey){ @@ -55,15 +56,16 @@ export const PianoRoll: React.FunctionComponent<{ * width は長方形の横幅、height は長方形の縦幅、x 及び y は長方形の左上の開始位置を表す。 */ const messageRects = props.messages.map(message => { - const width = widthFromTick(message.tick); + const noteOn = message as NoteOnMessage; + const width = widthFromTick(noteOn.tick); const height = X_LINE_SPACING; - const x = widthFromTick(message.startedAt); + const x = widthFromTick(noteOn.startedAt); // x座標+発声の長さが画面右端に到達したら、水平スクロールを1小説分足す if(pianoRollWidth < x + Y_LINE_SPACING * 8){ pianoRollWidth = x + Y_LINE_SPACING * 8; } // yは画面最上部を0にとるが、ノートナンバー(音程)は画面最下部を0とするため最大値を基準にして逆転させる - const y = (MAX_NOTE_NUMBER - message.noteNumber) * X_LINE_SPACING; + const y = (MAX_NOTE_NUMBER - noteOn.noteNumber) * X_LINE_SPACING; return }); @@ -122,10 +124,6 @@ export const PianoRoll: React.FunctionComponent<{ /** * 入力領域の音程を表す平行線のSVG要素。 * 音程ごと、1オクターブごとに色分けして区切る。 - * - * path の d は以下の構文を取り、SVG上で直線を表現する。 - * d="M {x1} {y1} L {x2} {y2}" - * 上記のように指定すると、起点(x1y1)から終点(x2y2)に線を引く。 */ const xLineElements = () => { const elements = []; diff --git a/src/typescript/component/tracks/track-panel.tsx b/src/typescript/component/tracks/track-panel.tsx index 2e0a287..5a3612a 100644 --- a/src/typescript/component/tracks/track-panel.tsx +++ b/src/typescript/component/tracks/track-panel.tsx @@ -10,7 +10,7 @@ export const TrackPanel: React.FunctionComponent<{track: Track}> = (props) => {
- {props.track.getName} + {props.track.name}
diff --git a/src/typescript/component/tracks/track-panels.tsx b/src/typescript/component/tracks/track-panels.tsx index d0844d8..bea82ad 100644 --- a/src/typescript/component/tracks/track-panels.tsx +++ b/src/typescript/component/tracks/track-panels.tsx @@ -6,7 +6,7 @@ import { TrackPanel } from './track-panel'; export const TrackPanels: React.FunctionComponent<{tracks: Tracks}> = (props) => { return ( - {props.tracks.asList.map(track => )} + {props.tracks.tracks.map(track => )} ); } \ No newline at end of file diff --git a/src/typescript/domain/track.ts b/src/typescript/domain/track.ts index 5376463..3556f9b 100644 --- a/src/typescript/domain/track.ts +++ b/src/typescript/domain/track.ts @@ -1,44 +1,78 @@ +import { Message } from "./message"; + export default class Track { - private no: number; - private name: string; - private instrumentId: number; - private sequenseId: number; + no: number; + name: string; + instrumentId: number = 0; // TODO: GUIから選択できるようにする。 + messages: Message[] = []; - constructor(no: number){ - this.no = no; - this.name = `track${no}`; + // TODO: conductorTrack(), instrumentalTrack() の用途が限定的なため、Tracks.default() に処理を移動する + static conductorTrack(): Track{ + const track = new Track(0, `Conductor Track`, 0, []); + + return track; } - public get getNo(): number{ - return this.no; + + static instrumentalTrack(no: number): Track{ + if(no === 0){ + throw new Error("Track No.0 is used as Conductor Track."); + } + + const track = new Track(no, `Track${no}`, 0, []); + return track; } - public get getName(): string{ - return this.name; + + constructor(no: number, name: string, instrumentId: number, messages: Message[]){ + this.no = no; + this.name = name; + this.instrumentId = instrumentId; + this.messages = messages; } + + addMessage(message: Message): void { + this.messages.push(message); + } + } export class Tracks { - private tracks: Track[]; + tracks: Track[]; + constructor(tracks: Track[]){ this.tracks = tracks; } - public static empty(): Tracks { + + static empty(): Tracks { return new Tracks([]); } - public static default(): Tracks { - let tracks = this.empty(); + + static default(): Tracks { + const tracks = []; + + tracks.push(Track.conductorTrack()); for(let idx = 1; idx <= 16; idx++){ - tracks.add(tracks.size + 1); + const track = Track.instrumentalTrack(idx); + tracks.push(track); } - return tracks; + + return new Tracks(tracks); } - public add(no: number): void { - const track = new Track(no); + + add(no: number): void { + const track = Track.instrumentalTrack(no); this.tracks.push(track); } - public get asList(): Track[]{ - return this.tracks; + + get(no: number): Track { + if(no > this.tracks.length){ + throw new Error("OutOfRangeError."); + } + + return this.tracks[no]; } - public get size(): number { - return this.tracks.length; + + addMessage(idx: number, message: Message): void { + const track = this.get(idx); + track.addMessage(message); } } \ No newline at end of file diff --git a/src/typescript/layout.tsx b/src/typescript/layout.tsx index 112342c..2589844 100644 --- a/src/typescript/layout.tsx +++ b/src/typescript/layout.tsx @@ -1,45 +1,52 @@ import * as React from 'react'; +import { Container } from 'react-bootstrap'; +import clone from "clone"; import { Header } from './component/header/header'; -import { Tracks } from './domain/track'; import { TrackPanels } from './component/tracks/track-panels'; -import { Container } from 'react-bootstrap'; -import { PIANO_ROLLE_HEIGHT, PianoRoll } from './component/piano-roll/piano-roll'; -import NoteOnMessage from './domain/message'; import { PianoKey } from './component/piano-roll/piano-key'; +import { PianoRoll } from './component/piano-roll/piano-roll'; +import NoteOnMessage from './domain/message'; +import { Tracks } from './domain/track'; export const Layout: React.FunctionComponent<{}> = (props) => { + /** 現在選択しているMIDI出力ポート。 */ + const [port, setPort] = React.useState(''); + /** 現在開いているMIDIファイル。 */ const [file, setFile] = React.useState(); - /** シーケンサーで入力したノートオンメッセージのリスト。 */ - const [messages, setMessages] = React.useState([]); - - /** シーケンサーに設定済みのMIDI出力ポート。 */ - const [port, setPort] = React.useState(''); + /** 現在選択しているトラックの番号。 */ + const [trackIdx, setTrackIdx] = React.useState(0); - /** シーケンサーで設定したトラックのリスト。 */ - const tracks: Tracks = Tracks.default(); + /** トラックのリスト。 */ + const [tracks, setTracks]= React.useState(Tracks.default()); - /** ノートオンメッセージのリストに要素を追加する。 */ + /** 現在選択しているトラックにノートオンメッセージを追加する。 */ const addMessage = (message: NoteOnMessage) => { - setMessages(messages.concat(message)); + const newTracks: Tracks = clone(tracks); + newTracks.addMessage(trackIdx, message); + setTracks(newTracks); + } + + const selectTrack = (targetIdx) => { + setTrackIdx(targetIdx); } return (
setFile(file)} setPort={(port: string) => setPort(port)} - setMessage={(messages: []) => setMessages(messages)} + setTracks={(tracks: Tracks) => setTracks(tracks)} />
addMessage(message)} />
diff --git a/src/typescript/repository/repository.ts b/src/typescript/repository/repository.ts index f7c3ac0..32c4c67 100644 --- a/src/typescript/repository/repository.ts +++ b/src/typescript/repository/repository.ts @@ -1,5 +1,6 @@ import axios, { ResponseType } from "axios"; import NoteOnMessage from "../domain/message"; +import Track, { Tracks } from "../domain/track"; // TODO: FQDNを共通化する @@ -22,8 +23,8 @@ export const saveAndDownload = (messages: NoteOnMessage[], filename: string) => }) } -export const play = (messages: NoteOnMessage[], portName: string) => { - const data = {messages: messages, portName: portName}; +export const play = (portName: string, tracks: Tracks) => { + const data = {tracks: tracks.tracks, portName: portName}; axios.post('http://localhost:8000/v1.0/player', data) .then((response) => console.log(response)) .catch((error) => console.log(error)); @@ -39,10 +40,16 @@ export const uploadFile = async (file: File) => { formData.set("file", blob, file.name); const response = await axios.post('http://localhost:8000/v1.0/upload', formData); - return response.data.map( - message => new NoteOnMessage( - message.noteNumber, message.startedAt, message.velocity, message.tick) + // TODO: TS側のオブジェクトに変換する処理のリファクタ + const tracks = response.data.map( + track => { + const messages = track.messages.map(message => + new NoteOnMessage(message.noteNumber, message.startedAt, message.velocity, message.tick)); + + return new Track(track.no, track.name, track.instrumentId, messages) + } ); + return new Tracks(tracks); } export const selectFile = () => {