diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index 86ce4956a9..83121f836b 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -2,10 +2,8 @@ import { noteNumberToFrequency, decibelToLinear, linearToDecibel, - tickToSecond, } from "@/sing/domain"; import { Timer } from "@/sing/utility"; -import { Tempo } from "@/store/type"; const getEarliestSchedulableContextTime = (audioContext: BaseAudioContext) => { const renderQuantumSize = 128; @@ -64,29 +62,20 @@ export class Transport { private _state: "started" | "stopped" = "stopped"; private _time = 0; + public loop = false; + public loopStartTime = 0; + public loopEndTime = 0; private sequences = new Set(); private startContextTime = 0; private startTime = 0; private schedulers = new Map(); - - // ループ設定 - // TODO: いったん動作するようにする - private isLoopEnabled = false; - private loopStartTime = 0; - private loopEndTime = 0; - - setLoopSettings( - isLoopEnabled: boolean, - startTick: number, - endTick: number, - tempos: Tempo[], - tpqn: number, - ) { - this.isLoopEnabled = isLoopEnabled; - this.loopStartTime = tickToSecond(startTick, tempos, tpqn); - this.loopEndTime = tickToSecond(endTick, tempos, tpqn); - } + private scheduledContextTime = 0; + private uncompletedLoopInfos: { + readonly contextTime: number; + readonly timeBeforeLoop: number; + readonly timeAfterLoop: number; + }[] = []; get state() { return this._state; @@ -97,10 +86,8 @@ export class Transport { */ get time() { if (this._state === "started") { - // 再生中の場合は、現在時刻から再生位置を計算する const contextTime = this.audioContext.currentTime; - const elapsedTime = contextTime - this.startContextTime; - this._time = this.startTime + elapsedTime; + this._time = this.calcTime(contextTime); } return this._time; } @@ -133,13 +120,38 @@ export class Transport { this.audioContext = audioContext; this.scheduleAheadTime = scheduleAheadTime; this.timer = new Timer(lookahead * 1000); + this.timer.start(() => { if (this._state === "started") { - this.schedule(this.audioContext.currentTime); + this.scheduleEvents(this.audioContext.currentTime); } }); } + /** + * 再生位置を計算します。再生中にのみ使用可能です。 + * @param contextTime コンテキスト時刻(この時刻から再生位置を計算) + * @returns 計算された再生位置(秒) + */ + private calcTime(contextTime: number) { + if (this._state !== "started") { + throw new Error("This method can only be used during playback."); + } + if (contextTime >= this.startContextTime) { + const elapsedTime = contextTime - this.startContextTime; + return this.startTime + elapsedTime; + } + while (this.uncompletedLoopInfos.length !== 0) { + const loopInfo = this.uncompletedLoopInfos[0]; + if (contextTime < loopInfo.contextTime) { + const timeUntilLoop = loopInfo.contextTime - contextTime; + return loopInfo.timeBeforeLoop - timeUntilLoop; + } + this.uncompletedLoopInfos.shift(); + } + throw new Error("Loop events are not scheduled correctly."); + } + /** * スケジューラーを作成します。 * @param sequence スケジューラーでスケジューリングを行うシーケンス @@ -158,35 +170,16 @@ export class Transport { } /** - * スケジューリングを行います。 + * シーケンスのイベントのスケジューリングを行います。 * @param contextTime スケジューリングを行う時刻(コンテキスト時刻) */ - private schedule(contextTime: number) { - // 再生位置を計算 - const elapsedTime = contextTime - this.startContextTime; - let time = this.startTime + elapsedTime; - - // ループ処理 - // TODO: いったん動作するようにする - if (this.isLoopEnabled && time >= this.loopEndTime) { - const loopDuration = this.loopEndTime - this.loopStartTime; - // おそらくscheduleAheadTimeを考慮する必要ある - time = this.loopStartTime + ((time - this.loopStartTime) % loopDuration); - this.startTime = time; - this.startContextTime = contextTime; - // スケジューラーをループ後に初期化して再スケジューリングする...ダメな気がする - // うまく使い回すほうがよさそうだが、動作があまり理解できていない... - // クリアしないとループ中に行った変更が反映されないように思えるが違う? - this.schedulers.forEach((scheduler) => { - scheduler.stop(contextTime); - }); - this.schedulers.clear(); - this.sequences.forEach((sequence) => { - const scheduler = this.createScheduler(sequence); - scheduler.start(contextTime, time); - this.schedulers.set(sequence, scheduler); - }); + private scheduleSequenceEvents(contextTime: number) { + if (contextTime < this.startContextTime) { + // NOTE: ループ未完了の場合にここに来る + return; } + const time = this.calcTime(contextTime); + // シーケンスの削除を反映 const removedSequences: Sequence[] = []; this.schedulers.forEach((scheduler, sequence) => { @@ -198,6 +191,7 @@ export class Transport { removedSequences.forEach((sequence) => { this.schedulers.delete(sequence); }); + // シーケンスの追加を反映 this.sequences.forEach((sequence) => { if (!this.schedulers.has(sequence)) { @@ -206,10 +200,72 @@ export class Transport { this.schedulers.set(sequence, scheduler); } }); + + // スケジューリングを行う this.schedulers.forEach((scheduler) => { scheduler.schedule(time + this.scheduleAheadTime); }); - this._time = time; + } + + /** + * ループイベントのスケジューリングを行います。 + * @param contextTime スケジューリングを行う時刻(コンテキスト時刻) + */ + private scheduleLoopEvents(contextTime: number) { + if ( + !this.loop || + this.loopEndTime <= this.loopStartTime || + this.startTime >= this.loopEndTime + ) { + return; + } + + const timeUntilLoop = this.loopEndTime - this.startTime; + let contextTimeToLoop = this.startContextTime + timeUntilLoop; + if (contextTimeToLoop < this.scheduledContextTime) { + return; + } + if (contextTimeToLoop < contextTime) { + contextTimeToLoop = contextTime; + } + + const loopDuration = this.loopEndTime - this.loopStartTime; + + while (contextTimeToLoop < contextTime + this.scheduleAheadTime) { + this.uncompletedLoopInfos.push({ + contextTime: contextTimeToLoop, + timeBeforeLoop: this.loopEndTime, + timeAfterLoop: this.loopStartTime, + }); + + this.startContextTime = contextTimeToLoop; + this.startTime = this.loopStartTime; + + this.schedulers.forEach((value) => { + value.stop(contextTimeToLoop); + }); + this.schedulers.clear(); + + this.sequences.forEach((sequence) => { + const scheduler = this.createScheduler(sequence); + scheduler.start(contextTimeToLoop, this.loopStartTime); + scheduler.schedule(this.loopStartTime + this.scheduleAheadTime); + this.schedulers.set(sequence, scheduler); + }); + + contextTimeToLoop += loopDuration; + } + } + + /** + * イベントのスケジューリングを行います。 + * @param contextTime スケジューリングを行う時刻(コンテキスト時刻) + */ + private scheduleEvents(contextTime: number) { + this.scheduleSequenceEvents(contextTime); + this.scheduleLoopEvents(contextTime); + + this.scheduledContextTime = contextTime + this.scheduleAheadTime; } /** @@ -245,8 +301,10 @@ export class Transport { this.startContextTime = contextTime; this.startTime = this._time; + this.scheduledContextTime = contextTime; + this.uncompletedLoopInfos = []; - this.schedule(contextTime); + this.scheduleEvents(contextTime); } /** @@ -257,8 +315,7 @@ export class Transport { const contextTime = this.audioContext.currentTime; // 停止する前に再生位置を更新する - const elapsedTime = contextTime - this.startContextTime; - this._time = this.startTime + elapsedTime; + this._time = this.calcTime(contextTime); this._state = "stopped"; diff --git a/src/store/singing.ts b/src/store/singing.ts index aff537b547..7b581e75e8 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -1165,9 +1165,16 @@ export const singingStore = createPartialStore({ } mutations.SET_PLAYBACK_STATE({ nowPlaying: true }); - transport.setLoopSettings( - state.isLoopEnabled, + // TODO: 以下の処理(ループの設定)は再生開始時に毎回行う必要はないので、 + // ソングエディタ初期化時に1回だけ行うようにする + // NOTE: 初期化のactionを作った方が良いかも + transport.loop = state.isLoopEnabled; + transport.loopStartTime = tickToSecond( state.loopStartTick, + state.tempos, + state.tpqn, + ); + transport.loopEndTime = tickToSecond( state.loopEndTick, state.tempos, state.tpqn, @@ -2459,8 +2466,13 @@ export const singingStore = createPartialStore({ mutation(state, { isLoopEnabled }) { state.isLoopEnabled = isLoopEnabled; }, - action({ mutations }, { isLoopEnabled }) { + action({ mutations, state }, { isLoopEnabled }) { + if (!transport) { + throw new Error("transport is undefined."); + } mutations.SET_LOOP_ENABLED({ isLoopEnabled }); + + transport.loop = state.isLoopEnabled; }, }, @@ -2470,17 +2482,21 @@ export const singingStore = createPartialStore({ state.loopEndTick = loopEndTick; }, action({ mutations, state }, { loopStartTick, loopEndTick }) { - mutations.SET_LOOP_RANGE({ loopStartTick, loopEndTick }); - if (transport) { - // TODO: いったん動作するようにする - transport.setLoopSettings( - true, - loopStartTick, - loopEndTick, - state.tempos, - state.tpqn, - ); + if (!transport) { + throw new Error("transport is undefined."); } + mutations.SET_LOOP_RANGE({ loopStartTick, loopEndTick }); + + transport.loopStartTime = tickToSecond( + state.loopStartTick, + state.tempos, + state.tpqn, + ); + transport.loopEndTime = tickToSecond( + state.loopEndTick, + state.tempos, + state.tpqn, + ); }, }, @@ -2491,8 +2507,17 @@ export const singingStore = createPartialStore({ } state.loopStartTick = loopStartTick; }, - action({ mutations }, { loopStartTick }) { + action({ mutations, state }, { loopStartTick }) { + if (!transport) { + throw new Error("transport is undefined."); + } mutations.SET_LOOP_START({ loopStartTick }); + + transport.loopStartTime = tickToSecond( + state.loopStartTick, + state.tempos, + state.tpqn, + ); }, }, @@ -2503,14 +2528,28 @@ export const singingStore = createPartialStore({ } state.loopEndTick = loopEndTick; }, - action({ mutations }, { loopEndTick }) { + action({ mutations, state }, { loopEndTick }) { + if (!transport) { + throw new Error("transport is undefined."); + } mutations.SET_LOOP_END({ loopEndTick }); + + transport.loopEndTime = tickToSecond( + state.loopEndTick, + state.tempos, + state.tpqn, + ); }, }, TOGGLE_LOOP: { action({ state, mutations }) { + if (!transport) { + throw new Error("transport is undefined."); + } mutations.SET_LOOP_ENABLED({ isLoopEnabled: !state.isLoopEnabled }); + + transport.loop = state.isLoopEnabled; }, }, });