Skip to content
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

ソング:MIDI読み込みを改善 #1982

Merged
merged 21 commits into from
May 4, 2024
Merged
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
16 changes: 1 addition & 15 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"dependencies": {
"@gtm-support/vue-gtm": "1.2.3",
"@quasar/extras": "1.10.10",
"@tonejs/midi": "2.0.28",
"async-lock": "1.4.0",
"buffer": "6.0.3",
"clone-deep": "4.0.1",
Expand All @@ -48,6 +47,7 @@
"hotkeys-js": "3.13.6",
"immer": "9.0.21",
"markdown-it": "13.0.2",
"midi-file": "1.2.4",
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved
"move-file": "3.0.0",
"multistream": "4.1.0",
"pixi.js": "7.4.0",
Expand Down
30 changes: 21 additions & 9 deletions src/components/Dialog/ImportMidiDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
v-if="midi"
v-model="selectedTrack"
:options="tracks"
:disable="midiFileError != undefined"
emit-value
map-options
label="インポートするトラック"
Expand All @@ -47,7 +48,7 @@
color="toolbar-button"
text-color="toolbar-button-display"
class="text-no-wrap text-bold q-mr-sm"
:disabled="selectedTrack === null"
:disabled="selectedTrack === null || midiFileError != undefined"
@click="handleImportTrack"
/>
</QToolbar>
Expand All @@ -59,7 +60,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useDialogPluginComponent } from "quasar";
import { Midi } from "@tonejs/midi";
import { Midi } from "@/sing/midi";
import { useStore } from "@/store";

const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
Expand All @@ -73,23 +74,30 @@ const midiFile = ref<File | null>(null);
const midiFileError = computed(() => {
if (midiFile.value && !midi.value) {
return "MIDIファイルの読み込みに失敗しました";
} else if (midiFile.value && midi.value && !midi.value.tracks.length) {
return "トラックがありません";
} else if (midiFile.value && midi.value) {
if (!midi.value.tracks.length) {
return "トラックがありません";
} else if (midi.value.tracks.every((track) => track.notes.length === 0)) {
return "ノートがありません";
}
}
return undefined;
});
// MIDIデータ(tone.jsでパースしたもの)
// MIDIデータ
const midi = ref<Midi | null>(null);
// トラック
const tracks = computed(() => {
if (!midi.value) {
return [];
}
// トラックリストを生成
// トラックNo: トラック名 形式
// "トラックNo: トラック名 / ノート数" の形式で表示
return midi.value.tracks.map((track, index) => ({
label: `${index + 1}: ${track.name}`,
label: `${index + 1}: ${track.name || "(トラック名なし)"} / ノート数:${
track.notes.length
}`,
value: index,
disable: track.notes.length === 0,
}));
});
// 選択中のトラック
Expand Down Expand Up @@ -130,8 +138,12 @@ const handleMidiFileChange = (event: Event) => {
) {
// MIDIファイルをパース
midi.value = new Midi(e.target.result);
const DEFAULT_TRACK = 0;
selectedTrack.value = DEFAULT_TRACK;
selectedTrack.value = midi.value.tracks.findIndex(
(track) => track.notes.length > 0,
);
if (selectedTrack.value === -1) {
selectedTrack.value = 0;
}
} else {
throw new Error("Could not find MIDI file data");
}
Expand Down
167 changes: 167 additions & 0 deletions src/sing/midi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORT_MIDI_FILEの大半をこっちのファイルに移動しちゃっても良い気がしますね!
だいぶ見やすくなりそうだし。こっちはindex.tsにして、import.tsを作るとかで。
まあもしよかったら別のプルリクエストでリファクタリングお願いできるとめちゃくちゃ助かります!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(base: UInt8Array) => AbstractScore に抽象化するのを考えています。

MidiData,
parseMidi,
MidiTrackNameEvent,
MidiEvent,
MidiSetTempoEvent,
MidiTimeSignatureEvent,
MidiLyricsEvent,
MidiNoteOnEvent,
MidiNoteOffEvent,
} from "midi-file";
type Tempo = { ticks: number; bpm: number };
type TimeSignature = {
ticks: number;
numerator: number;
denominator: number;
};
type Note = {
ticks: number;
noteNumber: number;
duration: number;
lyric?: string;
};

// BPMの精度。(小数点以下の桁数)
const bpmPrecision = 2;
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved

/**
* midi-fileの軽いラッパー。
*/
export class Midi {
data: MidiData;
tracks: Track[];
constructor(data: ArrayBuffer) {
this.data = parseMidi(new Uint8Array(data));
this.tracks = this.data.tracks.map((track) => new Track(track));
}

get header() {
return this.data.header;
}

get ticksPerBeat() {
const maybeTicksPerBeat = this.data.header.ticksPerBeat;
if (maybeTicksPerBeat == undefined) {
throw new Error("ticksPerBeat is undefined");
}
return maybeTicksPerBeat;
}

get tempos(): Tempo[] {
const tempos = this.tracks.flatMap((track) => track.tempos);
tempos.sort((a, b) => a.ticks - b.ticks);
return tempos;
}

get timeSignatures(): TimeSignature[] {
const timeSignatures = this.tracks.flatMap((track) => track.timeSignatures);
timeSignatures.sort((a, b) => a.ticks - b.ticks);
return timeSignatures;
}
}

type MidiEventWithTime<T extends MidiEvent = MidiEvent> = {
time: number;
} & T;
export class Track {
readonly data: MidiData["tracks"][0];
readonly events: MidiEventWithTime[];
readonly notes: Note[];
constructor(data: MidiData["tracks"][0]) {
this.data = data;
let time = 0;
this.events = data.map((event) => {
time += event.deltaTime;
return { time, ...event };
});
const lyrics = this.events.filter(
(e) => e.type === "lyrics",
) as MidiEventWithTime<MidiLyricsEvent>[];
const lyricsMap = new Map<number, string>(
lyrics.map((e) => {
// midi-fileはUTF-8としてデコードしてくれないので、ここでデコードする
const buffer = new Uint8Array(
e.text.split("").map((c) => c.charCodeAt(0)),
);
const decoder = new TextDecoder("utf-8");
return [e.time, decoder.decode(buffer)];
}),
);

const noteOnOffs = this.events.filter(
(e) => e.type === "noteOn" || e.type === "noteOff",
) as MidiEventWithTime<MidiNoteOnEvent | MidiNoteOffEvent>[];
noteOnOffs.sort((a, b) => a.time - b.time);
this.notes = [];
const temporaryNotes = new Map<
number,
{ noteNumber: number; time: number }
>();
for (const event of noteOnOffs) {
if (event.type === "noteOn") {
if (temporaryNotes.has(event.noteNumber)) {
throw new Error("noteOn without noteOff");
}
temporaryNotes.set(event.noteNumber, {
noteNumber: event.noteNumber,
time: event.time,
});
} else {
const note = temporaryNotes.get(event.noteNumber);
if (!note) {
throw new Error("noteOff without noteOn");
}
temporaryNotes.delete(event.noteNumber);
this.notes.push({
ticks: note.time,
noteNumber: note.noteNumber,
duration: event.time - note.time,
// 同じタイミングの歌詞をノートの歌詞として使う
lyric: lyricsMap.get(note.time),
});
}
}
}

get name() {
const nameEvent = this.data.find(
(e) => e.type === "trackName",
) as MidiTrackNameEvent;
if (!nameEvent) {
return "";
}
return nameEvent.text;
}

get tempos(): Tempo[] {
const tempoEvents = this.events.filter(
(e) => e.type === "setTempo",
) as MidiEventWithTime<MidiSetTempoEvent>[];

const tempos = tempoEvents.map((e) => ({
ticks: e.time,
bpm:
Math.round(
((60 * 1000000) / e.microsecondsPerBeat) * 10 ** bpmPrecision,
) /
10 ** bpmPrecision,
}));
tempos.sort((a, b) => a.ticks - b.ticks);
return tempos;
}

get timeSignatures(): TimeSignature[] {
const timeSignatureEvents = this.events.filter(
(e) => e.type === "timeSignature",
) as MidiEventWithTime<MidiTimeSignatureEvent>[];

const timeSignatures = timeSignatureEvents.map((e) => ({
ticks: e.time,
numerator: e.numerator,
denominator: e.denominator,
}));
timeSignatures.sort((a, b) => a.ticks - b.ticks);
return timeSignatures;
}
}
22 changes: 10 additions & 12 deletions src/store/singing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from "path";
import { Midi } from "@tonejs/midi";
import { v4 as uuidv4 } from "uuid";
import { toRaw } from "vue";
import { createPartialStore } from "./vuex";
Expand All @@ -25,6 +24,7 @@ import {
SequencerEditTarget,
} from "./type";
import { sanitizeFileName } from "./utility";
import { Midi } from "@/sing/midi";
import { EngineId, StyleId } from "@/type/preload";
import { FrameAudioQuery, Note as NoteForRequestToEngine } from "@/openapi";
import { ResultError, getValueOrThrow } from "@/type/result";
Expand Down Expand Up @@ -1600,14 +1600,12 @@ export const singingStore = createPartialStore<SingingStoreTypes>({
await window.backend.readFile({ filePath }),
);
const midi = new Midi(midiData);
const midiTpqn = midi.header.ppq;
const midiTempos = [...midi.header.tempos];
const midiTimeSignatures = [...midi.header.timeSignatures];
const midiTpqn = midi.ticksPerBeat;
const midiTempos = midi.tempos;
const midiTimeSignatures = midi.timeSignatures;

const midiNotes = [...midi.tracks[trackIndex].notes];
const midiNotes = midi.tracks[trackIndex].notes;

midiTempos.sort((a, b) => a.ticks - b.ticks);
midiTimeSignatures.sort((a, b) => a.ticks - b.ticks);
midiNotes.sort((a, b) => a.ticks - b.ticks);

const tpqn = DEFAULT_TPQN;
Expand All @@ -1618,12 +1616,12 @@ export const singingStore = createPartialStore<SingingStoreTypes>({
position: convertPosition(value.ticks, midiTpqn, tpqn),
duration: convertDuration(
value.ticks,
value.ticks + value.durationTicks,
value.ticks + value.duration,
midiTpqn,
tpqn,
),
noteNumber: value.midi,
lyric: getDoremiFromNoteNumber(value.midi),
noteNumber: value.noteNumber,
lyric: value.lyric || getDoremiFromNoteNumber(value.noteNumber),
};
});
// ノートの重なりを考慮して、一番音が高いノート(トップノート)のみインポートする
Expand All @@ -1646,8 +1644,8 @@ export const singingStore = createPartialStore<SingingStoreTypes>({
let measureNumber = 1;
for (let i = 0; i < midiTimeSignatures.length; i++) {
const midiTs = midiTimeSignatures[i];
const beats = midiTs.timeSignature[0];
const beatType = midiTs.timeSignature[1];
const beats = midiTs.numerator;
const beatType = midiTs.denominator;
timeSignatures.push({ measureNumber, beats, beatType });
if (i < midiTimeSignatures.length - 1) {
const nextTsTicks = midiTimeSignatures[i + 1].ticks;
Expand Down
Binary file added tests/unit/lib/midi/bpm.mid
Binary file not shown.
Loading
Loading