diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 2f65927870..a56aed928d 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1,4 +1,4 @@ -import { Note, Score, Tempo, TimeSignature } from "@/store/type"; +import { Note, Phrase, Score, Tempo, TimeSignature } from "@/store/type"; const BEAT_TYPES = [2, 4, 8, 16]; const MIN_BPM = 40; @@ -286,3 +286,43 @@ export function isValidKeyRangeAdjustment(keyRangeAdjustment: number) { keyRangeAdjustment >= -24 ); } + +export function toSortedPhrases(phrases: Map) { + return [...phrases.entries()].sort((a, b) => { + return a[1].startTicks - b[1].startTicks; + }); +} + +/** + * 次にレンダリングするべきPhraseを探す。 + * phrasesが空の場合はエラー + * 優先順: + * - 再生位置が含まれるPhrase + * - 再生位置より後のPhrase + * - 再生位置より前のPhrase + */ +export function selectPriorPhrase( + phrases: Map, + position: number +): [string, Phrase] { + if (phrases.size === 0) { + throw new Error("Received empty phrases"); + } + // 再生位置が含まれるPhrase + for (const [phraseKey, phrase] of phrases) { + if (phrase.startTicks <= position && position <= phrase.endTicks) { + return [phraseKey, phrase]; + } + } + + const sortedPhrases = toSortedPhrases(phrases); + // 再生位置より後のPhrase + for (const [phraseKey, phrase] of sortedPhrases) { + if (phrase.startTicks > position) { + return [phraseKey, phrase]; + } + } + + // 再生位置より前のPhrase + return sortedPhrases[0]; +} diff --git a/src/store/singing.ts b/src/store/singing.ts index ce4ec702d5..0a5f654a80 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -38,6 +38,7 @@ import { Transport, } from "@/sing/audioRendering"; import { + selectPriorPhrase, getMeasureDuration, isValidNote, isValidScore, @@ -787,12 +788,6 @@ export const singingStore = createPartialStore({ return foundPhrases; }; - const getSortedPhrasesEntries = (phrases: Map) => { - return [...phrases.entries()].sort((a, b) => { - return a[1].startTicks - b[1].startTicks; - }); - }; - const fetchQuery = async ( engineId: EngineId, notes: Note[], @@ -1003,12 +998,28 @@ export const singingStore = createPartialStore({ return; } + const phrasesToBeRendered = new Map( + [...state.phrases.entries()].filter(([, phrase]) => { + return ( + (phrase.state === "WAITING_TO_BE_RENDERED" || + phrase.state === "COULD_NOT_RENDER") && + phrase.singer + ); + }) + ); // 各フレーズのレンダリングを行う - const sortedPhrasesEntries = getSortedPhrasesEntries(state.phrases); - for (const [phraseKey, phrase] of sortedPhrasesEntries) { + while ( + !(startRenderingRequested() || stopRenderingRequested()) && + phrasesToBeRendered.size > 0 + ) { + const [phraseKey, phrase] = selectPriorPhrase( + phrasesToBeRendered, + playheadPosition.value + ); if (!phrase.singer) { - continue; + throw new Error("assert: phrase.singer != undefined"); } + phrasesToBeRendered.delete(phraseKey); if ( phrase.state === "WAITING_TO_BE_RENDERED" || @@ -1137,10 +1148,6 @@ export const singingStore = createPartialStore({ phraseState: "PLAYABLE", }); } - - if (startRenderingRequested() || stopRenderingRequested()) { - return; - } } }; diff --git a/tests/unit/lib/selectPriorPhrase.spec.ts b/tests/unit/lib/selectPriorPhrase.spec.ts new file mode 100644 index 0000000000..dcdc46d272 --- /dev/null +++ b/tests/unit/lib/selectPriorPhrase.spec.ts @@ -0,0 +1,66 @@ +import { it, expect } from "vitest"; +import { Phrase, PhraseState } from "@/store/type"; +import { DEFAULT_TPQN } from "@/sing/storeHelper"; +import { selectPriorPhrase } from "@/sing/domain"; +import { EngineId, StyleId } from "@/type/preload"; + +const tempos = [ + { + position: 0, + bpm: 60, + }, +]; +const createPhrase = ( + start: number, + end: number, + state: PhraseState +): Phrase => { + return { + notes: [], + startTicks: start * DEFAULT_TPQN, + endTicks: end * DEFAULT_TPQN, + keyRangeAdjustment: 0, + state, + tempos, + tpqn: DEFAULT_TPQN, + singer: { + engineId: EngineId("00000000-0000-0000-0000-000000000000"), + styleId: StyleId(0), + }, + }; +}; +const basePhrases = new Map([ + ["1", createPhrase(0, 1, "WAITING_TO_BE_RENDERED")], + ["2", createPhrase(1, 2, "WAITING_TO_BE_RENDERED")], + ["3", createPhrase(2, 3, "WAITING_TO_BE_RENDERED")], + ["4", createPhrase(3, 4, "WAITING_TO_BE_RENDERED")], + ["5", createPhrase(4, 5, "WAITING_TO_BE_RENDERED")], +]); + +it("しっかり優先順位に従って探している", () => { + const phrases = structuredClone(basePhrases); + const position = 2.5 * DEFAULT_TPQN; + for (const expectation of [ + // 再生位置が含まれるPhrase + "3", + // 再生位置より後のPhrase + "4", // 早い方 + "5", // 遅い方 + // 再生位置より前のPhrase + "1", // 早い方 + "2", // 遅い方 + ]) { + const [key] = selectPriorPhrase(phrases, position); + expect(key).toEqual(expectation); + if (key == undefined) { + // 型アサーションのためにthrowを使う + throw new Error("key is undefined"); + } + phrases.delete(key); + } + + // もう再生可能なPhraseがないのでthrow + expect(() => { + selectPriorPhrase(phrases, position); + }).toThrow("Received empty phrases"); +});