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

ソング:歌詞の一括入力を追加 #1952

Merged
merged 33 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
26f2610
WIP
sevenc-nanashi Mar 10, 2024
cd7ab8a
Merge: upstream/main -> add/multi-mora-at-once
sevenc-nanashi Mar 19, 2024
49f551d
Merge: main -> add/multi-mora-at-once
sevenc-nanashi Mar 19, 2024
4d45e12
Merge: upstream/main -> add/multi-mora-at-once
sevenc-nanashi Mar 23, 2024
3a84949
Add: 実装
sevenc-nanashi Mar 23, 2024
4692530
Add: テストを追加
sevenc-nanashi Mar 23, 2024
d281830
Change: splitMorasAndNonMoras -> splitLyricsByMoras
sevenc-nanashi Mar 27, 2024
2e042b5
Change: with-preview-lyric -> preview-lyric
sevenc-nanashi Mar 27, 2024
c7af372
Code: 参考を追加
sevenc-nanashi Mar 27, 2024
866ca2e
Refactor: splitLyricsByMorasを読みやすく
sevenc-nanashi Mar 27, 2024
a3fa553
Refactor: もう少し詳しく
sevenc-nanashi Mar 27, 2024
452c778
Add: テストケースを追加
sevenc-nanashi Mar 27, 2024
07477df
Change: lyricUpdated -> lyricUpdate
sevenc-nanashi Mar 27, 2024
343828d
Code: lyric周りのコメントを追加
sevenc-nanashi Mar 27, 2024
c38078e
Code: ScoreSequencerにコメントを追加
sevenc-nanashi Mar 27, 2024
668c821
Refactor: useLyricInputに切り出し
sevenc-nanashi Mar 27, 2024
bb2a86f
Add: TODOコメントを追加
sevenc-nanashi Mar 27, 2024
30ed8f3
Code: 日本語を修正
sevenc-nanashi Mar 27, 2024
f0ecdae
Change: !! -> != undefiend
sevenc-nanashi Apr 5, 2024
1664d82
Change: displayedLyric -> lyricToDisplay
sevenc-nanashi Apr 5, 2024
80b15bc
Change: lyricUpdate -> lyric-input
sevenc-nanashi Apr 5, 2024
66a6537
Change: 良い感じの名前に
sevenc-nanashi Apr 5, 2024
4775728
Code: 可読性を向上
sevenc-nanashi Apr 5, 2024
146cf30
Merge: upstream/main -> add/multi-mora-at-once
sevenc-nanashi Apr 5, 2024
2dc420e
Improve: ひらがな/カタカナを保持するように
sevenc-nanashi Apr 5, 2024
c99deb0
Fix: 長いときの表示を修正
sevenc-nanashi Apr 5, 2024
68f32c0
Code: ドキュメントを追加
sevenc-nanashi Apr 5, 2024
2eb9712
Refactor: v-elseにまとめる
sevenc-nanashi Apr 5, 2024
2e180cb
Refactor: splitLyricsByMorasをリファクタ
sevenc-nanashi Apr 5, 2024
2f2ba18
Merge: main -> add/multi-mora-at-once
sevenc-nanashi Apr 14, 2024
f4582f7
いくつか更新
Hiroshiba Apr 17, 2024
29363f6
Merge remote-tracking branch 'upstream/main' into pr/sevenc-nanashi/1…
Hiroshiba Apr 17, 2024
6d8a0e5
fmt
Hiroshiba Apr 17, 2024
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
20 changes: 20 additions & 0 deletions src/components/Sing/ScoreSequencer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,25 +110,32 @@
}"
></div>
<!-- TODO: 1つのv-forで全てのノートを描画できるようにする -->
<!-- undefinedだと警告が出るのでnullを渡す -->
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved
<SequencerNote
v-for="note in unselectedNotes"
:key="note.id"
:note="note"
:preview-lyric="previewLyrics.get(note.id) || null"
:is-selected="false"
@bar-mousedown="onNoteBarMouseDown($event, note)"
@left-edge-mousedown="onNoteLeftEdgeMouseDown($event, note)"
@right-edge-mousedown="onNoteRightEdgeMouseDown($event, note)"
@lyric-mouse-down="onNoteLyricMouseDown($event, note)"
@lyric-input="onNoteLyricInput($event, note)"
@lyric-blur="onNoteLyricBlur()"
/>
<SequencerNote
v-for="note in nowPreviewing ? previewNotes : selectedNotes"
:key="note.id"
:note="note"
:preview-lyric="previewLyrics.get(note.id) || null"
:is-selected="true"
@bar-mousedown="onNoteBarMouseDown($event, note)"
@left-edge-mousedown="onNoteLeftEdgeMouseDown($event, note)"
@right-edge-mousedown="onNoteRightEdgeMouseDown($event, note)"
@lyric-mouse-down="onNoteLyricMouseDown($event, note)"
@lyric-input="onNoteLyricInput($event, note)"
@lyric-blur="onNoteLyricBlur()"
/>
</div>
<SequencerPitch
Expand Down Expand Up @@ -261,6 +268,7 @@ import SequencerPitch from "@/components/Sing/SequencerPitch.vue";
import { isOnCommandOrCtrlKeyDown } from "@/store/utility";
import { useHotkeyManager } from "@/plugins/hotkeyPlugin";
import { useShiftKey } from "@/composables/useModifierKey";
import { useLyricInput } from "@/composables/useLyricInput";

type PreviewMode = "ADD" | "MOVE" | "RESIZE_RIGHT" | "RESIZE_LEFT";

Expand Down Expand Up @@ -388,6 +396,18 @@ const sequencerBody = ref<HTMLElement | null>(null);
const cursorX = ref(0);
const cursorY = ref(0);

// 歌詞入力
const { previewLyrics, commitPreviewLyrics, splitAndUpdatePreview } =
useLyricInput();

const onNoteLyricInput = (text: string, note: Note) => {
splitAndUpdatePreview(text, note);
};

const onNoteLyricBlur = () => {
commitPreviewLyrics();
};
Comment on lines +409 to +415
Copy link
Member

Choose a reason for hiding this comment

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

ちなみにreactではemit側をon〜、引数指定側をhandle〜にしてたりします。
https://ja.react.dev/learn/responding-to-events#adding-event-handlers
ややこしくなったらそういうルールにしても良さそう。


// プレビュー
// FIXME: 関連する値を1つのobjectにまとめる
const nowPreviewing = ref(false);
Expand Down
47 changes: 33 additions & 14 deletions src/components/Sing/SequencerNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class="note"
:class="{
selected: noteState === 'SELECTED',
'preview-lyric': props.previewLyric != undefined,
overlapping: hasOverlappingError,
'invalid-phrase': hasPhraseError,
'below-pitch': showPitch,
Expand All @@ -24,13 +25,14 @@
data-testid="note-lyric"
@mousedown="onLyricMouseDown"
>
{{ lyric }}
{{ lyricToDisplay }}
</div>
<input
v-if="showLyricInput"
sevenc-nanashi marked this conversation as resolved.
Show resolved Hide resolved
v-model.lazy.trim="lyric"
v-focus
:value="lyricToDisplay"
class="note-lyric-input"
@input="onLyricInput"
@mousedown.stop
@dblclick.stop
@keydown.stop="onLyricInputKeyDown"
Expand Down Expand Up @@ -88,6 +90,7 @@ const props = withDefaults(
defineProps<{
note: Note;
isSelected: boolean;
previewLyric: string | null;
}>(),
{
isSelected: false,
Expand All @@ -100,6 +103,8 @@ const emit =
(name: "rightEdgeMousedown", event: MouseEvent): void;
(name: "leftEdgeMousedown", event: MouseEvent): void;
(name: "lyricMouseDown", event: MouseEvent): void;
(name: "lyricInput", text: string): void;
(name: "lyricBlur"): void;
}>();

const store = useStore();
Expand Down Expand Up @@ -145,18 +150,13 @@ const hasPhraseError = computed(() => {
);
});

const lyric = computed({
get() {
return props.note.lyric;
},
set(value) {
if (!value) {
return;
}
const note: Note = { ...props.note, lyric: value };
store.dispatch("COMMAND_UPDATE_NOTES", { notes: [note] });
},
});
// 表示する歌詞。
// 優先度:入力中の歌詞 > 他ノートの入力中の歌詞 > 渡された(=Storeの)歌詞
const lyricToDisplay = computed(
() => temporaryLyric.value ?? props.previewLyric ?? props.note.lyric
);
// 歌詞入力中の一時的な歌詞
const temporaryLyric = ref<string | undefined>(undefined);
const showLyricInput = computed(() => {
return state.editingLyricNoteId === props.note.id;
});
Expand Down Expand Up @@ -255,6 +255,12 @@ const onLyricInputBlur = () => {
if (state.editingLyricNoteId === props.note.id) {
store.dispatch("SET_EDITING_LYRIC_NOTE_ID", { noteId: undefined });
}
temporaryLyric.value = undefined;
emit("lyricBlur");
};
const onLyricInput = (event: Event) => {
temporaryLyric.value = (event.target as HTMLInputElement).value;
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved
emit("lyricInput", temporaryLyric.value);
};
</script>

Expand Down Expand Up @@ -286,6 +292,19 @@ const onLyricInputBlur = () => {
}
}
}
// TODO:もっといい見た目を考える
&.preview-lyric {
.note-bar {
background-color: hsl(130, 35%, 90%);
border: 2px solid colors.$primary;
}

&.below-pitch {
.note-bar {
background-color: rgba(hsl(130, 100%, 50%), 0.18);
}
}
}

&.overlapping,
&.invalid-phrase {
Expand Down
57 changes: 57 additions & 0 deletions src/composables/useLyricInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { computed, ref } from "vue";
import { splitLyricsByMoras } from "@/sing/domain";
import { useStore } from "@/store";
import { Note } from "@/store/type";

// 歌詞入力のロジック。
export const useLyricInput = () => {
sevenc-nanashi marked this conversation as resolved.
Show resolved Hide resolved
const store = useStore();
const notes = computed(() => store.getters.SELECTED_TRACK.notes);

// プレビュー中の歌詞。NoteID -> 歌詞のMap。
const previewLyrics = ref<Map<string, string>>(new Map());
// 入力中の歌詞を分割してプレビューに反映する。
const splitAndUpdatePreview = (lyric: string, note: Note) => {
// TODO: マルチトラック対応
const inputNoteIndex = store.state.tracks[0].notes.findIndex(
(value) => value.id === note.id
);
if (inputNoteIndex === -1) {
throw new Error("inputNoteIndex is -1.");
}
const newPreviewLyrics = new Map();

const lyricPerNote = splitLyricsByMoras(
lyric,
store.state.tracks[0].notes.length - inputNoteIndex
);
for (const [index, mora] of lyricPerNote.entries()) {
const noteIndex = inputNoteIndex + index;
if (noteIndex >= notes.value.length) {
// splitLyricsByMorasで制限してるのでUnreachableのはず。
throw new Error("noteIndex is out of range.");
}
const note = notes.value[noteIndex];
newPreviewLyrics.set(note.id, mora);
}
previewLyrics.value = newPreviewLyrics;
};
// プレビューの歌詞を確定する。
const commitPreviewLyrics = () => {
const newNotes: Note[] = [];
if (previewLyrics.value.size === 0) {
return;
}
for (const [noteId, lyric] of previewLyrics.value) {
const note = notes.value.find((value) => value.id === noteId);
if (!note) {
throw new Error("note is undefined.");
}
newNotes.push({ ...note, lyric });
}
previewLyrics.value = new Map();
store.dispatch("COMMAND_UPDATE_NOTES", { notes: newNotes });
};

return { previewLyrics, splitAndUpdatePreview, commitPreviewLyrics };
};
54 changes: 54 additions & 0 deletions src/sing/domain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Note, Phrase, Score, Tempo, TimeSignature } from "@/store/type";
import { convertHiraToKana, convertLongVowel } from "@/store/utility";

const BEAT_TYPES = [2, 4, 8, 16];
const MIN_BPM = 40;
Expand Down Expand Up @@ -334,3 +335,56 @@ export function selectPriorPhrase(
// 再生位置より前のPhrase
return sortedPhrases[0];
}

// 参考:https://github.com/VOICEVOX/voicevox_core/blob/0848630d81ae3e917c6ff2038f0b15bbd4270702/crates/voicevox_core/src/user_dict/word.rs#L83-L90
export const moraPattern = new RegExp(
"(?:" +
"[イ][ェ]|[ヴ][ャュョ]|[トド][ゥ]|[テデ][ィャュョ]|[デ][ェ]|[クグ][ヮ]|" + // rule_others
"[キシチニヒミリギジビピ][ェャュョ]|" + // rule_line_i
"[ツフヴ][ァ]|[ウスツフヴズ][ィ]|[ウツフヴ][ェォ]|" + // rule_line_u
"[ァ-ヴー]" + // rule_one_mora
")",
"g"
);
sevenc-nanashi marked this conversation as resolved.
Show resolved Hide resolved

/**
* 文字列をモーラと非モーラに分割する。
* 例:"カナ漢字" -> ["カ", "ナ", "漢字"]
*
* @param text 分割する文字列
* @param maxLength 最大の要素数
* @returns 分割された文字列
*/
export const splitLyricsByMoras = (
text: string,
maxLength = Infinity
): string[] => {
const baseMoraAndNonMoras: string[] = [];
const matches = convertLongVowel(convertHiraToKana(text)).matchAll(
sevenc-nanashi marked this conversation as resolved.
Show resolved Hide resolved
moraPattern
);
let lastMatchEnd = 0;
// aアbイウc で説明:
for (const match of matches) {
// 直前のモーラとの間 = a、b、空文字列
baseMoraAndNonMoras.push(text.substring(lastMatchEnd, match.index));
// モーラ = ア、イ、ウ
baseMoraAndNonMoras.push(match[0]);
if (match.index == undefined) {
throw new Error("match.index is undefined.");
}
lastMatchEnd = match.index + match[0].length;
}
// 最後のモーラから後 = cの部分
baseMoraAndNonMoras.push(text.substring(lastMatchEnd));
// 空文字列を削除(モーラが連続する時やモーラで始まったり終わったりする時に発生)
const moraAndNonMoras = baseMoraAndNonMoras.filter((value) => value !== "");
sevenc-nanashi marked this conversation as resolved.
Show resolved Hide resolved
if (moraAndNonMoras.length > maxLength) {
Copy link
Member

Choose a reason for hiding this comment

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

こっちもついでにコメント書いてあげようかなと

Suggested change
if (moraAndNonMoras.length > maxLength) {
// 指定した最大要素数より多い場合は配列を削る
if (moraAndNonMoras.length > maxLength) {

moraAndNonMoras.splice(
maxLength - 1,
moraAndNonMoras.length,
moraAndNonMoras.slice(maxLength - 1).join("")
);
}
return moraAndNonMoras;
};
34 changes: 34 additions & 0 deletions tests/unit/lib/splitLyricsByMoras.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, it } from "vitest";
import { splitLyricsByMoras } from "@/sing/domain";

it("モーラを分割する", () => {
expect(splitLyricsByMoras("アイウエオ")).toEqual([
"ア",
"イ",
"ウ",
"エ",
"オ",
]);
expect(splitLyricsByMoras("キャット")).toEqual(["キャ", "ッ", "ト"]);
});
it("平仮名対応", () => {
expect(splitLyricsByMoras("あいうえお")).toEqual([
"ア",
"イ",
"ウ",
"エ",
"オ",
]);
});
it("長音対応", () => {
expect(splitLyricsByMoras("アーイー")).toEqual(["ア", "ア", "イ", "イ"]);
});

it("モーラ以外が混ざっても残す", () => {
expect(splitLyricsByMoras("アaイ")).toEqual(["ア", "a", "イ"]);
expect(splitLyricsByMoras("bウc")).toEqual(["b", "ウ", "c"]);
expect(splitLyricsByMoras("愛")).toEqual(["愛"]);
});
it("最大の要素数を指定できる", () => {
expect(splitLyricsByMoras("アイウエオ", 3)).toEqual(["ア", "イ", "ウエオ"]);
});
Loading