Skip to content
This repository has been archived by the owner on Sep 27, 2024. It is now read-only.

MIDIで書き出す機能 #54

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@tauri-apps/api": ">=2.0.0-rc.0",
"@tauri-apps/plugin-shell": ">=2.0.0-rc.0",
"jotai": "^2.9.3",
"midi-writer-js": "^3.1.1",
"next": "^14.2.10",
"paper": "^0.12.18",
"react": "^18.2.0",
Expand Down
14 changes: 12 additions & 2 deletions src/app/generate/_components/Controller/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
import IonIcon from '@reacticons/ionicons';
import styles from './index.module.scss';
import IconButton from '@/components/share/IconButton';
import { useAtomValue } from 'jotai';
import { bpnAtom, minNoteDurationAtom } from '@/stores/settings';
import { exportMidi } from '@/functions/midi';
import usePlayer from '@/hooks/usePlayer';
import { musicAtom } from '@/stores/music';

export default function Controller() {
const { isPlaying, play, pause, nextBar, prevBar, rewind, forward } = usePlayer();

const music = useAtomValue(musicAtom);
const bpm = useAtomValue(bpnAtom);
const minNoteDuration = useAtomValue(minNoteDurationAtom);

return (
<section className={styles.controller}>
<div className={styles.group}>
Expand All @@ -26,8 +34,10 @@ export default function Controller() {
</div>

<div className={styles.group}>
<IconButton icon={<IonIcon name="save-outline" size="large" />} onClick={() => console.log('clicked')} />
<IconButton icon={<IonIcon name="share-outline" size="large" />} onClick={() => console.log('clicked')} />
<IconButton
icon={<IonIcon name="share-outline" size="large" />}
onClick={() => exportMidi(music, bpm, minNoteDuration)}
/>
</div>
</section>
);
Expand Down
25 changes: 25 additions & 0 deletions src/const/sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Bar, DiatonicChord, Music, Note } from '@/models';

const music: Music = new Music({
bars: [
new Bar({
notes: [
new Note({
start: 0,
duration: 8,
octave: 4,
scale: 'C',
}),
],
chordIndex: 0,
chords: [
new DiatonicChord({
start: 0,
duration: 16,
octave: 2,
scale: 'C',
}),
],
}),
],
});
21 changes: 21 additions & 0 deletions src/functions/midi.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, test, expect, beforeAll, vi } from 'vitest';
import { createMidiFile, save } from './midi';
import { music } from '@/samples';

describe('MIDI', () => {
// midi.spec.ts のテストファイルで追加
beforeAll(() => {
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
// URL.revokeObjectURLのモック
global.URL.revokeObjectURL = vi.fn();
});
test('exportMidi', () => {
const midiContent = createMidiFile(music, 1);

// Blob型であることを確認
expect(midiContent).toBeInstanceOf(Blob);

const filename = 'test.mid';
save(midiContent, filename);
});
});
98 changes: 98 additions & 0 deletions src/functions/midi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Music } from '@/models';
import { Settings } from '@/types';
import MidiWriter from 'midi-writer-js';

export function exportMidi(music: Music, bpm: Settings['bpm'], minNoteDuration: Settings['minNoteDuration']) {
const durationRate = 60 / bpm / minNoteDuration;
const bynary = createMidiFile(music, durationRate, bpm);
save(bynary as unknown as Blob, 'music.mid');
}

/**
* MIDIファイルの中身を作成する
*
* @param music 音楽データ
* @param durationRate 音符の長さの倍率 (note.duration * durationRate = 音符の再生時間)
*/
export function createMidiFile(music: Music, durationRate: number, bpm: number): Blob {
// Cメジャーコードの音名を取得
const chord = ["C4", "E4", "G4"]
const track = new MidiWriter.Track();
const cordTrack = new MidiWriter.Track();

// テンポ設定
track.setTempo(bpm);
cordTrack.setTempo(bpm);
// ティック単位の設定
const ppq = durationRate * bpm;

// Musicオブジェクトから各Barを処理
music.bars.forEach((bar) => {
bar.notes.forEach((note) => {
const midiNoteNumber = noteNameToMidi(note.getName());
const durationStr = `T${Math.round(ppq * note.duration)}`;

// MIDIノートを生成
const midiNote = new MidiWriter.NoteEvent({
pitch: [midiNoteNumber], // "C4"のような音名
duration: durationStr, // 持続時間
});
// トラックにノートを追加
track.addEvent(midiNote);
});
});


// ノートを追加(Cメジャーコードを同時に鳴らす)
cordTrack.addEvent(new MidiWriter.NoteEvent({
pitch: chord, // ["C4", "E4", "G4"]
duration: `T${Math.round(ppq * 16)}`, // 1/4拍
}));

// MIDIファイルを生成
const writer = new MidiWriter.Writer([track,cordTrack]);
return new Blob([writer.buildFile()], { type: 'audio/midi' });
}
export function save(blob: Blob, name: string) {
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', name);
link.click();
link.remove();
URL.revokeObjectURL(url);
}

// 音名からMIDIノート番号に変換する関数
function noteNameToMidi(noteName: string): number {
const noteMap: Record<string, number> = {
C: 0,
'C#': 1,
Db: 1,
D: 2,
'D#': 3,
Eb: 3,
E: 4,
F: 5,
'F#': 6,
Gb: 6,
G: 7,
'G#': 8,
Ab: 8,
A: 9,
'A#': 10,
Bb: 10,
B: 11,
};

const note = noteName.slice(0, -1); // 音名部分を抽出 (例: "C#")
const octave = parseInt(noteName.slice(-1)); // オクターブ部分を抽出 (例: "4")

// noteMapで有効な音名が指定されているか確認
if (!(note in noteMap)) {
throw new Error(`Invalid note name: ${note}`);
}

// MIDIノート番号 = (12 * オクターブ) + 音の値
return 12 * (octave + 1) + noteMap[note as keyof typeof noteMap];
}
26 changes: 26 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,25 @@
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd"
integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==

"@tonaljs/midi@^4.9.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@tonaljs/midi/-/midi-4.10.0.tgz#0916bbb1f194d38dc8d1169b2101f0e5b198f805"
integrity sha512-LjwEzcBwJSNMLD+qDwVBWvkmIOAs59TPdlTQlooP3S1Au0jy7T+bZeIbBKtLEcDG14S2zyYZEZ1Mo+cxcumMmg==
dependencies:
"@tonaljs/pitch-note" "6.0.0"

"@tonaljs/[email protected]":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@tonaljs/pitch-note/-/pitch-note-6.0.0.tgz#dc12c753042fd62d717a61647417b456481da5ff"
integrity sha512-m4Ei7zwSsKwotVfnodA7m1SR7zD5NNIea+V7Mo35EcK32ZJBg+SvxdwgfNNdLO8bkDbVrZIgVYqeP3R3Jq7VFQ==
dependencies:
"@tonaljs/pitch" "5.0.2"

"@tonaljs/[email protected]":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@tonaljs/pitch/-/pitch-5.0.2.tgz#ca2dcd60b99e90536f52293d2491187edfbc9967"
integrity sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==

"@types/aria-query@^5.0.1":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708"
Expand Down Expand Up @@ -6514,6 +6533,13 @@ micromatch@^4.0.2, micromatch@^4.0.4:
braces "^3.0.3"
picomatch "^2.3.1"

midi-writer-js@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/midi-writer-js/-/midi-writer-js-3.1.1.tgz#8a53bf762a7ea2a77fd8e693236cafe094f617da"
integrity sha512-ruRzUWtmcvD7xQcrKRk9fH+1BELv8x07QJxhpM4PS5n6+MOyPb39ifxTV/oI1hHPgKMSuhnhWw2MaGWUAvH9DA==
dependencies:
"@tonaljs/midi" "^4.9.0"

miller-rabin@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
Expand Down
Loading