Skip to content

マルチトラック対応 #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/css/sequenser.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

.main-track-panels{
width: 20%;
background-color: var(--bs-list-group-bg);
}

.main-piano-key{
Expand Down
25 changes: 12 additions & 13 deletions src/python/domain/midi_file.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions src/python/domain/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
28 changes: 17 additions & 11 deletions src/python/domain/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
43 changes: 38 additions & 5 deletions src/python/presentation/request_body.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,27 +28,59 @@ 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,
tick=message.tick,
)


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:
Expand Down
15 changes: 9 additions & 6 deletions src/python/presentation/routers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down
30 changes: 15 additions & 15 deletions src/typescript/component/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Expand All @@ -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 (
Expand All @@ -68,7 +68,7 @@ export const Header: React.FunctionComponent<{
<NavDropdown.Divider />
</NavDropdown>
<NavDropdown title="Player" id="header-dropdown-player">
<NavDropdown.Item href="#" onClick={() => play(props.messages, props.port)}>Play</NavDropdown.Item>
<NavDropdown.Item href="#" onClick={() => play(props.port, props.tracks)}>Play</NavDropdown.Item>
<NavDropdown.Item href="#">Stop</NavDropdown.Item>
<NavDropdown.Item href="#">Jump to</NavDropdown.Item>
</NavDropdown>
Expand Down
6 changes: 2 additions & 4 deletions src/typescript/component/piano-roll/piano-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <rect width={CONTAINER_WIDTH} height={height} x={0} y={totalY} fill={`#${WHITE_KEY_COLOR_FILL}`} stroke={`#${WHITE_KEY_COLOR_STROKE}`} key={crypto.randomUUID()}/>
Expand Down
20 changes: 9 additions & 11 deletions src/typescript/component/piano-roll/piano-roll.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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){
Expand All @@ -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 <rect width={width} height={height} x={x} y={y} fill={`#${NOTE_ON_COLOR}`} stroke={`black`} strokeWidth={0.1} rx={1} ry={1} key={crypto.randomUUID()}/>
});

Expand Down Expand Up @@ -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 = [];
Expand Down
2 changes: 1 addition & 1 deletion src/typescript/component/tracks/track-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const TrackPanel: React.FunctionComponent<{track: Track}> = (props) => {
<i className="bi bi-list-nested"></i>
</div>
<div className="p-1 flex-fill">
{props.track.getName}
{props.track.name}
</div>
</div>
</ListGroup.Item>
Expand Down
Loading