diff --git a/src/components/Sing/ToolBar.vue b/src/components/Sing/ToolBar.vue
index edadfece9b..c4b9765211 100644
--- a/src/components/Sing/ToolBar.vue
+++ b/src/components/Sing/ToolBar.vue
@@ -8,9 +8,29 @@
-
-
-
00:00
+
+
+
+
+
{{ playbackPositionStr }}
@@ -31,7 +51,7 @@
hide-bottom-space
class="sing-time-signature"
@update:model-value="setBeatsInputBuffer"
- @change="setTimeSignature()"
+ @change="setTimeSignature"
>
@@ -45,7 +65,7 @@
hide-bottom-space
class="sing-time-signature"
@update:model-value="setBeatTypeInputBuffer"
- @change="setTimeSignature()"
+ @change="setTimeSignature"
>
@@ -53,7 +73,7 @@
-
+
@@ -103,24 +123,43 @@ export default defineComponent({
const beatsInputBuffer = ref(0);
const beatTypeInputBuffer = ref(0);
- const setTempoInputBuffer = (tempoStr: string) => {
+ const setTempoInputBuffer = (tempoStr: string | number | null) => {
const tempo = Number(tempoStr);
- if (Number.isNaN(tempo) || tempo <= 0) return;
+ if (!Number.isFinite(tempo) || tempo <= 0) return;
tempoInputBuffer.value = tempo;
};
- const setBeatsInputBuffer = (beatsStr: string) => {
+ const setBeatsInputBuffer = (beatsStr: string | number | null) => {
const beats = Number(beatsStr);
if (!Number.isInteger(beats) || beats <= 0) return;
beatsInputBuffer.value = beats;
};
- const setBeatTypeInputBuffer = (beatTypeStr: string) => {
+ const setBeatTypeInputBuffer = (beatTypeStr: string | number | null) => {
const beatType = Number(beatTypeStr);
if (!Number.isInteger(beatType) || beatType <= 0) return;
beatTypeInputBuffer.value = beatType;
};
+ const playPos = ref(0);
+
+ const playbackPositionStr = computed(() => {
+ let playTime = 0;
+ if (store.state.score) {
+ playTime = store.getters.POSITION_TO_TIME(playPos.value);
+ }
+
+ const intPlayTime = Math.floor(playTime);
+ const min = Math.floor(intPlayTime / 60);
+ const minStr = String(min).padStart(2, "0");
+ const secStr = String(intPlayTime - min * 60).padStart(2, "0");
+ const match = String(playTime).match(/\.(\d+)$/);
+ const milliSecStr = (match?.[1] ?? "0").padEnd(3, "0").substring(0, 3);
+
+ return `${minStr}:${secStr}.${milliSecStr}`;
+ });
+
const tempos = computed(() => store.state.score?.tempos);
const timeSignatures = computed(() => store.state.score?.timeSignatures);
+ const nowPlaying = computed(() => store.state.nowPlaying);
watch(
tempos,
@@ -133,17 +172,26 @@ export default defineComponent({
timeSignatures,
() => {
beatsInputBuffer.value = timeSignatures.value?.[0].beats ?? 0;
- },
- { deep: true }
- );
- watch(
- timeSignatures,
- () => {
beatTypeInputBuffer.value = timeSignatures.value?.[0].beatType ?? 0;
},
{ deep: true }
);
+ const timeout = 1 / 60;
+ let timeoutId: number | undefined = undefined;
+ watch(nowPlaying, (newState) => {
+ if (newState) {
+ const updateView = () => {
+ playPos.value = store.getters.GET_PLAYBACK_POSITION();
+ timeoutId = window.setTimeout(updateView, timeout);
+ };
+ updateView();
+ } else if (timeoutId !== undefined) {
+ window.clearTimeout(timeoutId);
+ timeoutId = undefined;
+ }
+ });
+
const setTempo = async () => {
const tempo = tempoInputBuffer.value;
if (tempo === 0) return;
@@ -168,6 +216,28 @@ export default defineComponent({
});
};
+ const play = () => {
+ store.dispatch("SING_PLAY_AUDIO");
+ };
+
+ const stop = () => {
+ store.dispatch("SING_STOP_AUDIO");
+ };
+
+ const seek = async (position: number) => {
+ await store.dispatch("SET_PLAYBACK_POSITION", { position });
+ playPos.value = position;
+ };
+
+ const volume = computed({
+ get() {
+ return store.state.volume * 100;
+ },
+ set(value: number) {
+ store.dispatch("SET_VOLUME", { volume: value / 100 });
+ },
+ });
+
return {
isShowSinger,
toggleShowSinger,
@@ -180,6 +250,12 @@ export default defineComponent({
setBeatTypeInputBuffer,
setTempo,
setTimeSignature,
+ playbackPositionStr,
+ nowPlaying,
+ play,
+ stop,
+ seek,
+ volume,
};
},
});
@@ -228,12 +304,17 @@ export default defineComponent({
display: flex;
}
-.sing-button-temp {
+.sing-transport-button {
+ margin: 0 1px;
+}
+
+.sing-playback-button {
margin: 0 4px;
}
.sing-tempo {
- margin: 0 4px;
+ margin-left: 16px;
+ margin-right: 4px;
width: 64px;
}
@@ -242,9 +323,10 @@ export default defineComponent({
width: 36px;
}
-.sing-player-position {
+.sing-playback-position {
font-size: 18px;
margin: 0 4px;
+ min-width: 82px;
}
.sing-setting {
@@ -254,7 +336,7 @@ export default defineComponent({
}
.sing-volume {
- margin-right: 4px;
+ margin-right: 10px;
width: 72px;
}
diff --git a/src/helpers/singHelper.ts b/src/helpers/singHelper.ts
index cfe517eb64..f55e41c3e7 100644
--- a/src/helpers/singHelper.ts
+++ b/src/helpers/singHelper.ts
@@ -61,3 +61,8 @@ export const midiKeys = [...Array(128)]
};
})
.reverse();
+
+export function round(value: number, digits: number) {
+ const powerOf10 = 10 ** digits;
+ return Math.round(value * powerOf10) / powerOf10;
+}
diff --git a/src/infrastructures/AudioRenderer.ts b/src/infrastructures/AudioRenderer.ts
new file mode 100644
index 0000000000..2dcbeef549
--- /dev/null
+++ b/src/infrastructures/AudioRenderer.ts
@@ -0,0 +1,509 @@
+class Timer {
+ private timeoutId?: number;
+
+ constructor(interval: number, callback: () => void) {
+ const tick = () => {
+ callback();
+ this.timeoutId = window.setTimeout(tick, interval);
+ };
+ tick();
+ }
+
+ dispose() {
+ if (this.timeoutId !== undefined) {
+ window.clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+ }
+}
+
+type SchedulableEvent = {
+ readonly time: number;
+ readonly schedule: (contextTime: number) => void;
+};
+
+export interface SoundSequence {
+ generateEvents(startTime: number): SchedulableEvent[];
+ scheduleStop(contextTime: number): void;
+}
+
+class SoundScheduler {
+ readonly sequence: SoundSequence;
+ private readonly startContextTime: number;
+ private readonly startTime: number;
+ private readonly events: SchedulableEvent[];
+
+ private index = 0;
+
+ constructor(
+ sequence: SoundSequence,
+ startContextTime: number,
+ startTime: number
+ ) {
+ this.sequence = sequence;
+ this.startContextTime = startContextTime;
+ this.startTime = startTime;
+ this.events = this.sequence.generateEvents(startTime);
+ }
+
+ private calculateContextTime(time: number) {
+ return this.startContextTime + (time - this.startTime);
+ }
+
+ // `time`から`time + period`までの範囲のイベントをスケジュールする
+ scheduleEvents(time: number, period: number) {
+ if (time < this.startTime) {
+ throw new Error("The specified time is invalid.");
+ }
+
+ while (this.index < this.events.length) {
+ const event = this.events[this.index];
+ const eventContextTime = this.calculateContextTime(event.time);
+
+ if (event.time < time + period) {
+ event.schedule(eventContextTime);
+ this.index++;
+ } else break;
+ }
+ }
+
+ stop(contextTime: number) {
+ this.sequence.scheduleStop(contextTime);
+ }
+}
+
+/**
+ * 登録されているシーケンスのイベントをスケジュールし、再生を行います。
+ */
+export class Transport {
+ private readonly audioContext: AudioContext;
+ private readonly timer: Timer;
+ private readonly lookAhead: number;
+
+ private _state: "started" | "stopped" = "stopped";
+ private _time = 0;
+ private sequences: SoundSequence[] = [];
+
+ private startContextTime = 0;
+ private startTime = 0;
+ private schedulers: SoundScheduler[] = [];
+ private schedulersToBeStopped: SoundScheduler[] = [];
+
+ get state() {
+ return this._state;
+ }
+
+ get time() {
+ if (this._state === "started") {
+ const contextTime = this.audioContext.currentTime;
+ this._time = this.calculateTime(contextTime);
+ }
+ return this._time;
+ }
+
+ set time(value: number) {
+ if (this._state === "started") {
+ this.stop();
+ this._time = value;
+ this.start();
+ } else {
+ this._time = value;
+ }
+ }
+
+ /**
+ * @param audioContext コンテキスト時間の取得に使用するAudioContext。
+ * @param interval スケジューリングを行う間隔。
+ * @param lookAhead スケジューリングで先読みする時間。スケジューリングが遅れた場合でも正しく再生されるように、スケジューリングを行う間隔より長く設定する必要があります。
+ */
+ constructor(audioContext: AudioContext, interval: number, lookAhead: number) {
+ if (lookAhead <= interval) {
+ throw new Error("Look-ahead time must be longer than the interval.");
+ }
+
+ this.audioContext = audioContext;
+ this.lookAhead = lookAhead;
+ this.timer = new Timer(interval * 1000, () => {
+ if (this._state === "started") {
+ const contextTime = this.audioContext.currentTime;
+ this.scheduleEvents(contextTime);
+ }
+ });
+ }
+
+ private calculateTime(contextTime: number) {
+ const elapsedTime = contextTime - this.startContextTime;
+ return this.startTime + elapsedTime;
+ }
+
+ private getScheduler(sequence: SoundSequence) {
+ return this.schedulers.find((value) => {
+ return value.sequence === sequence;
+ });
+ }
+
+ private scheduleEvents(contextTime: number) {
+ const time = this.calculateTime(contextTime);
+
+ this.schedulersToBeStopped.forEach((value) => {
+ value.stop(contextTime);
+ });
+ this.schedulersToBeStopped = [];
+
+ this.sequences.forEach((value) => {
+ let scheduler = this.getScheduler(value);
+ if (!scheduler) {
+ scheduler = new SoundScheduler(value, contextTime, time);
+ this.schedulers.push(scheduler);
+ }
+ scheduler.scheduleEvents(time, this.lookAhead);
+ });
+ }
+
+ /**
+ * シーケンスを追加します。再生中に追加した場合は、次のスケジューリングで反映されます。
+ */
+ addSequence(sequence: SoundSequence) {
+ const exists = this.sequences.some((value) => {
+ return value === sequence;
+ });
+ if (exists) {
+ throw new Error("The specified sequence has already been added.");
+ }
+ this.sequences.push(sequence);
+ }
+
+ /**
+ * シーケンスを削除します。再生中に削除した場合は、次のスケジューリングで反映されます。
+ */
+ removeSequence(sequence: SoundSequence) {
+ const index = this.sequences.findIndex((value) => {
+ return value === sequence;
+ });
+ if (index === -1) {
+ throw new Error("The specified sequence does not exist.");
+ }
+ this.sequences.splice(index, 1);
+
+ if (this.state === "started") {
+ const index = this.schedulers.findIndex((value) => {
+ return value.sequence === sequence;
+ });
+ if (index === -1) return;
+
+ const removedScheduler = this.schedulers.splice(index, 1)[0];
+ this.schedulersToBeStopped.push(removedScheduler);
+ }
+ }
+
+ start() {
+ if (this._state === "started") return;
+ const contextTime = this.audioContext.currentTime;
+
+ this._state = "started";
+
+ this.startContextTime = contextTime;
+ this.startTime = this._time;
+ this.schedulers = [];
+ this.schedulersToBeStopped = [];
+
+ this.scheduleEvents(contextTime);
+ }
+
+ stop() {
+ if (this._state === "stopped") return;
+ const contextTime = this.audioContext.currentTime;
+ this._time = this.calculateTime(contextTime);
+
+ this._state = "stopped";
+
+ this.schedulers.forEach((value) => {
+ value.stop(contextTime);
+ });
+ this.schedulersToBeStopped.forEach((value) => {
+ value.stop(contextTime);
+ });
+ }
+
+ dispose() {
+ if (this.state === "started") {
+ this.stop();
+ }
+ this.timer.dispose();
+ }
+}
+
+export interface Instrument {
+ noteOn(contextTime: number, midi: number): void;
+ noteOff(contextTime: number, midi: number): void;
+ allSoundOff(contextTime?: number): void;
+}
+
+export type NoteEvent = {
+ readonly noteOnTime: number;
+ readonly noteOffTime: number;
+ readonly midi: number;
+};
+
+export class NoteSequence implements SoundSequence {
+ private readonly instrument: Instrument;
+ private readonly noteEvents: NoteEvent[];
+
+ constructor(instrument: Instrument, noteEvents: NoteEvent[]) {
+ this.instrument = instrument;
+ this.noteEvents = noteEvents;
+ }
+
+ // スケジュール可能なイベントを生成する
+ generateEvents(startTime: number) {
+ return this.noteEvents
+ .sort((a, b) => a.noteOnTime - b.noteOnTime)
+ .filter((value) => value.noteOffTime > startTime)
+ .map((value): SchedulableEvent[] => [
+ {
+ time: Math.max(value.noteOnTime, startTime),
+ schedule: (contextTime: number) => {
+ this.instrument.noteOn(contextTime, value.midi);
+ },
+ },
+ {
+ time: value.noteOffTime,
+ schedule: (contextTime: number) => {
+ this.instrument.noteOff(contextTime, value.midi);
+ },
+ },
+ ])
+ .flat()
+ .sort((a, b) => a.time - b.time);
+ }
+
+ // シーケンス(楽器)の停止をスケジュールする
+ scheduleStop(contextTime: number) {
+ this.instrument.allSoundOff(contextTime);
+ }
+}
+
+export type Envelope = {
+ readonly attack: number;
+ readonly decay: number;
+ readonly sustain: number;
+ readonly release: number;
+};
+
+type SynthVoiceOptions = {
+ readonly midi: number;
+ readonly oscillatorType: OscillatorType;
+ readonly envelope: Envelope;
+};
+
+class SynthVoice {
+ readonly midi: number;
+ private readonly oscillatorNode: OscillatorNode;
+ private readonly gainNode: GainNode;
+ private readonly envelope: Envelope;
+
+ private _isActive = false;
+ private _isStopped = false;
+ private stopContextTime?: number;
+
+ get isActive() {
+ return this._isActive;
+ }
+
+ get isStopped() {
+ return this._isStopped;
+ }
+
+ constructor(audioContext: BaseAudioContext, options: SynthVoiceOptions) {
+ this.midi = options.midi;
+ this.envelope = options.envelope;
+
+ this.oscillatorNode = audioContext.createOscillator();
+ this.oscillatorNode.onended = () => {
+ this._isStopped = true;
+ };
+ this.gainNode = audioContext.createGain();
+ this.oscillatorNode.type = options.oscillatorType;
+ this.oscillatorNode.connect(this.gainNode);
+ }
+
+ private midiToFrequency(midi: number) {
+ return 440 * 2 ** ((midi - 69) / 12);
+ }
+
+ connect(inputNode: AudioNode) {
+ this.gainNode.connect(inputNode);
+ }
+
+ noteOn(contextTime: number) {
+ const t0 = contextTime;
+ const atk = this.envelope.attack;
+ const dcy = this.envelope.decay;
+ const sus = this.envelope.sustain;
+
+ this.gainNode.gain.value = 0;
+ this.gainNode.gain.setValueAtTime(0, t0);
+ this.gainNode.gain.linearRampToValueAtTime(1, t0 + atk);
+ this.gainNode.gain.setTargetAtTime(sus, t0 + atk, dcy);
+
+ const freq = this.midiToFrequency(this.midi);
+ this.oscillatorNode.frequency.value = freq;
+
+ this.oscillatorNode.start(contextTime);
+ this._isActive = true;
+ }
+
+ noteOff(contextTime: number) {
+ const t0 = contextTime;
+ const rel = this.envelope.release;
+ const stopContextTime = t0 + rel * 4;
+
+ if (
+ this.stopContextTime === undefined ||
+ stopContextTime < this.stopContextTime
+ ) {
+ this.gainNode.gain.cancelAndHoldAtTime(t0);
+ this.gainNode.gain.setTargetAtTime(0, t0, rel);
+
+ this.oscillatorNode.stop(stopContextTime);
+ this._isActive = false;
+
+ this.stopContextTime = stopContextTime;
+ }
+ }
+
+ soundOff(contextTime?: number) {
+ if (
+ contextTime === undefined ||
+ this.stopContextTime === undefined ||
+ contextTime < this.stopContextTime
+ ) {
+ this.oscillatorNode.stop(contextTime);
+ this._isActive = false;
+
+ this.stopContextTime = contextTime ?? 0;
+ }
+ }
+}
+
+export type SynthOptions = {
+ readonly volume: number;
+ readonly oscillatorType: OscillatorType;
+ readonly envelope: Envelope;
+};
+
+/**
+ * ポリフォニックなシンセサイザー。
+ */
+export class Synth implements Instrument {
+ private readonly audioContext: BaseAudioContext;
+ private readonly gainNode: GainNode;
+ private readonly oscillatorType: OscillatorType;
+ private readonly envelope: Envelope;
+
+ private voices: SynthVoice[] = [];
+
+ constructor(
+ context: Context,
+ options: SynthOptions = {
+ volume: 0.1,
+ oscillatorType: "square",
+ envelope: {
+ attack: 0.001,
+ decay: 0.1,
+ sustain: 0.7,
+ release: 0.02,
+ },
+ }
+ ) {
+ this.audioContext = context.audioContext;
+
+ this.oscillatorType = options.oscillatorType;
+ this.envelope = options.envelope;
+ this.gainNode = this.audioContext.createGain();
+ this.gainNode.gain.value = options.volume;
+ }
+
+ connect(destination: AudioNode) {
+ this.gainNode.connect(destination);
+ }
+
+ disconnect() {
+ this.gainNode.disconnect();
+ }
+
+ noteOn(contextTime: number, midi: number) {
+ const exists = this.voices.some((value) => {
+ return value.isActive && value.midi === midi;
+ });
+ if (exists) return;
+
+ const voice = new SynthVoice(this.audioContext, {
+ midi,
+ oscillatorType: this.oscillatorType,
+ envelope: this.envelope,
+ });
+ this.voices = this.voices.filter((value) => {
+ return !value.isStopped;
+ });
+ this.voices.push(voice);
+ voice.connect(this.gainNode);
+ voice.noteOn(contextTime);
+ }
+
+ noteOff(contextTime: number, midi: number) {
+ const voice = this.voices.find((value) => {
+ return value.isActive && value.midi === midi;
+ });
+ if (!voice) return;
+
+ voice.noteOff(contextTime);
+ }
+
+ allSoundOff(contextTime?: number) {
+ if (contextTime === undefined) {
+ this.voices.forEach((value) => {
+ value.soundOff();
+ });
+ this.voices = [];
+ } else {
+ this.voices.forEach((value) => {
+ value.soundOff(contextTime);
+ });
+ }
+ }
+}
+
+export type Context = {
+ readonly audioContext: BaseAudioContext;
+ readonly transport: Transport;
+};
+
+export class AudioRenderer {
+ private readonly onlineContext: {
+ readonly audioContext: AudioContext;
+ readonly transport: Transport;
+ };
+
+ get context(): Context {
+ return {
+ audioContext: this.onlineContext.audioContext,
+ transport: this.onlineContext.transport,
+ };
+ }
+
+ get transport() {
+ return this.onlineContext.transport;
+ }
+
+ constructor() {
+ const audioContext = new AudioContext();
+ const transport = new Transport(audioContext, 0.2, 0.6);
+ this.onlineContext = { audioContext, transport };
+ }
+
+ dispose() {
+ this.onlineContext.transport.dispose();
+ this.onlineContext.audioContext.close();
+ }
+}
diff --git a/src/store/singing.ts b/src/store/singing.ts
index 497722b22c..cddd50d96f 100644
--- a/src/store/singing.ts
+++ b/src/store/singing.ts
@@ -1,3 +1,12 @@
+import {
+ AudioRenderer,
+ Context,
+ NoteEvent,
+ NoteSequence,
+ SoundSequence,
+ Synth,
+ Transport,
+} from "@/infrastructures/AudioRenderer";
import {
Score,
Tempo,
@@ -9,7 +18,206 @@ import {
import { createPartialStore } from "./vuex";
import { createUILockAction } from "./ui";
import { Midi } from "@tonejs/midi";
-import { getDoremiFromMidi } from "@/helpers/singHelper";
+import { getDoremiFromMidi, round } from "@/helpers/singHelper";
+
+const ticksToSecondsForConstantBpm = (
+ resolution: number,
+ bpm: number,
+ ticks: number
+) => {
+ const ticksPerBeat = resolution;
+ const beatsPerSecond = bpm / 60;
+ return ticks / ticksPerBeat / beatsPerSecond;
+};
+
+const secondsToTickForConstantBpm = (
+ resolution: number,
+ bpm: number,
+ seconds: number
+) => {
+ const ticksPerBeat = resolution;
+ const beatsPerSecond = bpm / 60;
+ return seconds * beatsPerSecond * ticksPerBeat;
+};
+
+const ticksToSeconds = (resolution: number, tempos: Tempo[], ticks: number) => {
+ let timeOfTempo = 0;
+ let tempo = tempos[tempos.length - 1];
+ for (let i = 0; i < tempos.length; i++) {
+ if (i === tempos.length - 1) {
+ break;
+ }
+ if (tempos[i + 1].position > ticks) {
+ tempo = tempos[i];
+ break;
+ }
+ timeOfTempo += ticksToSecondsForConstantBpm(
+ resolution,
+ tempos[i].tempo,
+ tempos[i + 1].position - tempos[i].position
+ );
+ }
+ return (
+ timeOfTempo +
+ ticksToSecondsForConstantBpm(
+ resolution,
+ tempo.tempo,
+ ticks - tempo.position
+ )
+ );
+};
+
+const secondsToTicks = (
+ resolution: number,
+ tempos: Tempo[],
+ seconds: number
+) => {
+ let timeOfTempo = 0;
+ let tempo = tempos[tempos.length - 1];
+ for (let i = 0; i < tempos.length; i++) {
+ if (i === tempos.length - 1) {
+ break;
+ }
+ const timeOfNextTempo =
+ timeOfTempo +
+ ticksToSecondsForConstantBpm(
+ resolution,
+ tempos[i].tempo,
+ tempos[i + 1].position - tempos[i].position
+ );
+ if (timeOfNextTempo > seconds) {
+ tempo = tempos[i];
+ break;
+ }
+ timeOfTempo = timeOfNextTempo;
+ }
+ return (
+ tempo.position +
+ secondsToTickForConstantBpm(resolution, tempo.tempo, seconds - timeOfTempo)
+ );
+};
+
+type ChannelOptions = {
+ readonly volume: number;
+};
+
+class SingChannel {
+ private readonly context: Context;
+ private readonly synth: Synth;
+ private readonly gainNode: GainNode;
+
+ private sequence?: SoundSequence;
+
+ get volume() {
+ return this.gainNode.gain.value;
+ }
+
+ set volume(value: number) {
+ this.gainNode.gain.value = value;
+ }
+
+ constructor(context: Context, options: ChannelOptions = { volume: 0.1 }) {
+ this.context = context;
+ const audioContext = context.audioContext;
+
+ this.synth = new Synth(context);
+ this.gainNode = audioContext.createGain();
+
+ this.synth.connect(this.gainNode);
+ this.gainNode.connect(audioContext.destination);
+
+ this.gainNode.gain.value = options.volume;
+ }
+
+ setNoteEvents(noteEvents: NoteEvent[]) {
+ if (this.sequence) {
+ this.context.transport.removeSequence(this.sequence);
+ }
+ const sequence = new NoteSequence(this.synth, noteEvents);
+ this.context.transport.addSequence(sequence);
+ this.sequence = sequence;
+ }
+
+ dispose() {
+ if (this.sequence) {
+ this.context.transport.removeSequence(this.sequence);
+ }
+ }
+}
+
+const isValidTempo = (tempo: Tempo) => {
+ return (
+ Number.isInteger(tempo.position) &&
+ Number.isFinite(tempo.tempo) &&
+ tempo.position >= 0 &&
+ tempo.tempo > 0
+ );
+};
+
+const isValidTimeSignature = (timeSignature: TimeSignature) => {
+ return (
+ Number.isInteger(timeSignature.position) &&
+ Number.isInteger(timeSignature.beats) &&
+ Number.isInteger(timeSignature.beatType) &&
+ timeSignature.position >= 0 &&
+ timeSignature.beats > 0 &&
+ timeSignature.beatType > 0
+ );
+};
+
+const isValidNote = (note: Note) => {
+ return (
+ Number.isInteger(note.position) &&
+ Number.isInteger(note.duration) &&
+ Number.isInteger(note.midi) &&
+ note.position >= 0 &&
+ note.duration > 0 &&
+ note.midi >= 0 &&
+ note.midi <= 127
+ );
+};
+
+/**
+ * ノートオンの時間とノートオフの時間を計算し、ノートイベントを生成します。
+ */
+const generateNoteEvents = (score: Score, notes: Note[]) => {
+ const resolution = score.resolution;
+ const tempos = score.tempos;
+ return notes.map((value): NoteEvent => {
+ const noteOnPos = value.position;
+ const noteOffPos = value.position + value.duration;
+ return {
+ midi: value.midi,
+ noteOnTime: ticksToSeconds(resolution, tempos, noteOnPos),
+ noteOffTime: ticksToSeconds(resolution, tempos, noteOffPos),
+ };
+ });
+};
+
+const getFromOptional = (value: T | undefined): T => {
+ if (value === undefined) {
+ throw new Error("The value is undefined.");
+ }
+ return value;
+};
+
+const DEFAULT_RESOLUTION = 480;
+const DEFAULT_TEMPO = 120;
+const DEFAULT_BEATS = 4;
+const DEFAULT_BEAT_TYPE = 4;
+
+let audioRenderer: AudioRenderer | undefined;
+let transport: Transport | undefined;
+let singChannel: SingChannel | undefined;
+
+// テスト時はAudioContextが存在しないのでAudioRendererを作らない
+if (window.AudioContext) {
+ audioRenderer = new AudioRenderer();
+ transport = audioRenderer.transport;
+ singChannel = new SingChannel(audioRenderer.context);
+}
+
+let playbackPosition = 0;
export const singingStoreState: SingingStoreState = {
engineId: undefined,
@@ -23,6 +231,8 @@ export const singingStoreState: SingingStoreState = {
sequencerScrollY: 60, // Y軸 midi number
sequencerScrollX: 0, // X軸 midi duration(仮)
sequencerSnapSize: 120, // スナップサイズ 試行用で1/16(ppq=480)のmidi durationで固定
+ nowPlaying: false,
+ volume: 0,
};
export const singingStore = createPartialStore({
@@ -87,10 +297,12 @@ export const singingStore = createPartialStore({
GET_EMPTY_SCORE: {
async action() {
- const score: Score = {
- resolution: 480,
- tempos: [{ position: 0, tempo: 120 }],
- timeSignatures: [{ position: 0, beats: 4, beatType: 4 }],
+ const score = {
+ resolution: DEFAULT_RESOLUTION,
+ tempos: [{ position: 0, tempo: DEFAULT_TEMPO }],
+ timeSignatures: [
+ { position: 0, beats: DEFAULT_BEATS, beatType: DEFAULT_BEAT_TYPE },
+ ],
notes: [],
};
if (score.tempos.length !== 1 || score.tempos[0].position !== 0) {
@@ -112,29 +324,42 @@ export const singingStore = createPartialStore({
mutation(state, { score }: { score: Score }) {
state.score = score;
},
- async action({ commit }, { score }: { score: Score }) {
+ async action({ state, getters, commit, dispatch }, { score }) {
console.log(score);
+ if (!transport || !singChannel) {
+ throw new Error("transport or singChannel is undefined.");
+ }
+ if (state.nowPlaying) {
+ await dispatch("SING_STOP_AUDIO");
+ }
+
commit("SET_SCORE", { score });
+
+ const noteEvents = generateNoteEvents(score, score.notes);
+ singChannel.setNoteEvents(noteEvents);
+
+ transport.time = getters.POSITION_TO_TIME(playbackPosition);
},
},
SET_TEMPO: {
mutation(state, { index, tempo }: { index: number; tempo: Tempo }) {
- state.score?.tempos.splice(index, 0, tempo);
+ const score = getFromOptional(state.score);
+ const tempos = [...score.tempos];
+ tempos.splice(index, 0, tempo);
+ score.tempos = tempos;
},
// テンポを設定する。既に同じ位置にテンポが存在する場合は置き換える。
- async action({ state, commit }, { tempo }: { tempo: Tempo }) {
+ async action({ state, getters, commit }, { tempo }) {
const score = state.score;
if (score === undefined || score.tempos.length === 0) {
throw new Error("Score is not initialized.");
}
- if (
- Number.isNaN(tempo.position) ||
- tempo.position < 0 ||
- Number.isNaN(tempo.tempo) ||
- tempo.tempo <= 0
- ) {
- throw new Error("The value is invalid.");
+ if (!transport || !singChannel) {
+ throw new Error("transport or singChannel is undefined.");
+ }
+ if (!isValidTempo(tempo)) {
+ throw new Error("The tempo is invalid.");
}
const duplicate = score.tempos.some((value) => {
return value.position === tempo.position;
@@ -144,29 +369,33 @@ export const singingStore = createPartialStore({
});
if (index === -1) return;
- const round = (value: number, digits: number) => {
- const powerOf10 = 10 ** digits;
- return Math.round(value * powerOf10) / powerOf10;
- };
-
tempo.tempo = round(tempo.tempo, 2);
+ if (state.nowPlaying) {
+ playbackPosition = getters.TIME_TO_POSITION(transport.time);
+ }
+
if (duplicate) {
commit("REMOVE_TEMPO", { index });
}
commit("SET_TEMPO", { index, tempo });
+
+ const noteEvents = generateNoteEvents(score, score.notes);
+ singChannel.setNoteEvents(noteEvents);
+
+ transport.time = getters.POSITION_TO_TIME(playbackPosition);
},
},
REMOVE_TEMPO: {
mutation(state, { index }: { index: number }) {
- state.score?.tempos.splice(index, 1);
+ const score = getFromOptional(state.score);
+ const tempos = [...score.tempos];
+ tempos.splice(index, 1);
+ score.tempos = tempos;
},
// テンポを削除する。先頭のテンポの場合はデフォルトのテンポに置き換える。
- async action(
- { state, commit, dispatch },
- { position }: { position: number }
- ) {
+ async action({ state, getters, commit, dispatch }, { position }) {
const emptyScore = await dispatch("GET_EMPTY_SCORE");
const defaultTempo = emptyScore.tempos[0];
@@ -174,15 +403,27 @@ export const singingStore = createPartialStore({
if (score === undefined || score.tempos.length === 0) {
throw new Error("Score is not initialized.");
}
+ if (!transport || !singChannel) {
+ throw new Error("transport or singChannel is undefined.");
+ }
const index = score.tempos.findIndex((value) => {
return value.position === position;
});
if (index === -1) return;
+ if (state.nowPlaying) {
+ playbackPosition = getters.TIME_TO_POSITION(transport.time);
+ }
+
commit("REMOVE_TEMPO", { index });
- if (score.tempos.length === 0) {
+ if (index === 0) {
commit("SET_TEMPO", { index, tempo: defaultTempo });
}
+
+ const noteEvents = generateNoteEvents(score, score.notes);
+ singChannel.setNoteEvents(noteEvents);
+
+ transport.time = getters.POSITION_TO_TIME(playbackPosition);
},
},
@@ -191,7 +432,10 @@ export const singingStore = createPartialStore({
state,
{ index, timeSignature }: { index: number; timeSignature: TimeSignature }
) {
- state.score?.timeSignatures.splice(index, 0, timeSignature);
+ const score = getFromOptional(state.score);
+ const timeSignatures = [...score.timeSignatures];
+ timeSignatures.splice(index, 0, timeSignature);
+ score.timeSignatures = timeSignatures;
},
// 拍子を設定する。既に同じ位置に拍子が存在する場合は置き換える。
async action(
@@ -202,15 +446,8 @@ export const singingStore = createPartialStore({
if (score === undefined || score.timeSignatures.length === 0) {
throw new Error("Score is not initialized.");
}
- if (
- Number.isNaN(timeSignature.position) ||
- timeSignature.position < 0 ||
- !Number.isInteger(timeSignature.beats) ||
- !Number.isInteger(timeSignature.beatType) ||
- timeSignature.beats <= 0 ||
- timeSignature.beatType <= 0
- ) {
- throw new Error("The value is invalid.");
+ if (!isValidTimeSignature(timeSignature)) {
+ throw new Error("The time signature is invalid.");
}
const duplicate = score.timeSignatures.some((value) => {
return value.position === timeSignature.position;
@@ -229,13 +466,13 @@ export const singingStore = createPartialStore({
REMOVE_TIME_SIGNATURE: {
mutation(state, { index }: { index: number }) {
- state.score?.timeSignatures.splice(index, 1);
+ const score = getFromOptional(state.score);
+ const timeSignatures = [...score.timeSignatures];
+ timeSignatures.splice(index, 1);
+ score.timeSignatures = timeSignatures;
},
// 拍子を削除する。先頭の拍子の場合はデフォルトの拍子に置き換える。
- async action(
- { state, commit, dispatch },
- { position }: { position: number }
- ) {
+ async action({ state, commit, dispatch }, { position }) {
const emptyScore = await dispatch("GET_EMPTY_SCORE");
const defaultTimeSignature = emptyScore.timeSignatures[0];
@@ -249,7 +486,7 @@ export const singingStore = createPartialStore({
if (index === -1) return;
commit("REMOVE_TIME_SIGNATURE", { index });
- if (score.timeSignatures.length === 0) {
+ if (index === 0) {
commit("SET_TIME_SIGNATURE", {
index,
timeSignature: defaultTimeSignature,
@@ -273,7 +510,18 @@ export const singingStore = createPartialStore({
if (state.score === undefined) {
throw new Error("Score is not initialized.");
}
+ if (!singChannel) {
+ throw new Error("singChannel is undefined.");
+ }
+ if (!isValidNote(note)) {
+ throw new Error("The note is invalid.");
+ }
+
commit("ADD_NOTE", { note });
+
+ const score = getFromOptional(state.score);
+ const noteEvents = generateNoteEvents(score, score.notes);
+ singChannel.setNoteEvents(noteEvents);
},
},
@@ -292,7 +540,18 @@ export const singingStore = createPartialStore({
if (state.score === undefined) {
throw new Error("Score is not initialized.");
}
+ if (!singChannel) {
+ throw new Error("singChannel is undefined.");
+ }
+ if (!isValidNote(note)) {
+ throw new Error("The note is invalid.");
+ }
+
commit("CHANGE_NOTE", { index, note });
+
+ const score = getFromOptional(state.score);
+ const noteEvents = generateNoteEvents(score, score.notes);
+ singChannel.setNoteEvents(noteEvents);
},
},
@@ -308,7 +567,15 @@ export const singingStore = createPartialStore({
if (state.score === undefined) {
throw new Error("Score is not initialized.");
}
+ if (!singChannel) {
+ throw new Error("singChannel is undefined.");
+ }
+
commit("REMOVE_NOTE", { index });
+
+ const score = getFromOptional(state.score);
+ const noteEvents = generateNoteEvents(score, score.notes);
+ singChannel.setNoteEvents(noteEvents);
},
},
@@ -355,6 +622,82 @@ export const singingStore = createPartialStore({
});
},
},
+ POSITION_TO_TIME: {
+ getter: (state) => (position) => {
+ const score = getFromOptional(state.score);
+ return ticksToSeconds(score.resolution, score.tempos, position);
+ },
+ },
+ TIME_TO_POSITION: {
+ getter: (state) => (time) => {
+ const score = getFromOptional(state.score);
+ return secondsToTicks(score.resolution, score.tempos, time);
+ },
+ },
+ GET_PLAYBACK_POSITION: {
+ getter: (state, getters) => () => {
+ if (!transport) {
+ throw new Error("transport is undefined.");
+ }
+ if (state.nowPlaying) {
+ playbackPosition = getters.TIME_TO_POSITION(transport.time);
+ }
+ return playbackPosition;
+ },
+ },
+
+ SET_PLAYBACK_POSITION: {
+ async action({ getters }, { position }) {
+ if (!transport) {
+ throw new Error("transport is undefined.");
+ }
+ playbackPosition = position;
+
+ transport.time = getters.POSITION_TO_TIME(position);
+ },
+ },
+
+ SET_PLAYBACK_STATE: {
+ mutation(state, { nowPlaying }) {
+ state.nowPlaying = nowPlaying;
+ },
+ },
+
+ SING_PLAY_AUDIO: {
+ async action({ commit }) {
+ if (!transport) {
+ throw new Error("transport is undefined.");
+ }
+ commit("SET_PLAYBACK_STATE", { nowPlaying: true });
+
+ transport.start();
+ },
+ },
+
+ SING_STOP_AUDIO: {
+ async action({ commit }) {
+ if (!transport) {
+ throw new Error("transport is undefined.");
+ }
+ commit("SET_PLAYBACK_STATE", { nowPlaying: false });
+
+ transport.stop();
+ },
+ },
+
+ SET_VOLUME: {
+ mutation(state, { volume }) {
+ state.volume = volume;
+ },
+ async action({ commit }, { volume }) {
+ if (!singChannel) {
+ throw new Error("singChannel is undefined.");
+ }
+ commit("SET_VOLUME", { volume });
+
+ singChannel.volume = volume;
+ },
+ },
IMPORT_MIDI_FILE: {
action: createUILockAction(
@@ -387,11 +730,6 @@ export const singingStore = createPartialStore({
return Math.max(0, endPosition - position);
};
- const round = (value: number, digits: number) => {
- const powerOf10 = 10 ** digits;
- return Math.round(value * powerOf10) / powerOf10;
- };
-
// TODO: UIで読み込むトラックを選択できるようにする
// ひとまず1トラック目のみを読み込む
midi.tracks[0].notes
@@ -523,11 +861,6 @@ export const singingStore = createPartialStore({
return value;
};
- const round = (value: number, digits: number) => {
- const powerOf10 = 10 ** digits;
- return Math.round(value * powerOf10) / powerOf10;
- };
-
const getStepNumber = (stepElement: Element) => {
const stepNumberDict: { [key: string]: number } = {
C: 0,
diff --git a/src/store/type.ts b/src/store/type.ts
index aa1a7484a8..7dc22cdc17 100644
--- a/src/store/type.ts
+++ b/src/store/type.ts
@@ -706,6 +706,8 @@ export type SingingStoreState = {
sequencerScrollX: number;
sequencerScrollY: number;
sequencerSnapSize: number;
+ nowPlaying: boolean;
+ volume: number;
};
export type SingingStoreTypes = {
@@ -790,6 +792,39 @@ export type SingingStoreTypes = {
IMPORT_MUSICXML_FILE: {
action(payload: { filePath?: string }): void;
};
+
+ POSITION_TO_TIME: {
+ getter(position: number): number;
+ };
+
+ TIME_TO_POSITION: {
+ getter(time: number): number;
+ };
+
+ GET_PLAYBACK_POSITION: {
+ getter(): number;
+ };
+
+ SET_PLAYBACK_POSITION: {
+ action(payload: { position: number }): void;
+ };
+
+ SET_PLAYBACK_STATE: {
+ mutation: { nowPlaying: boolean };
+ };
+
+ SING_PLAY_AUDIO: {
+ action(): void;
+ };
+
+ SING_STOP_AUDIO: {
+ action(): void;
+ };
+
+ SET_VOLUME: {
+ mutation: { volume: number };
+ action(payload: { volume: number }): void;
+ };
};
/*
diff --git a/src/views/SingerHome.vue b/src/views/SingerHome.vue
index 5a854f3235..3851dc2e3a 100644
--- a/src/views/SingerHome.vue
+++ b/src/views/SingerHome.vue
@@ -53,6 +53,9 @@ export default defineComponent({
await store.dispatch("SET_SCORE", { score: emptyScore });
}
await store.dispatch("SET_SINGER", {});
+
+ await store.dispatch("SET_VOLUME", { volume: 0.3 });
+ await store.dispatch("SET_PLAYBACK_POSITION", { position: 0 });
return {};
});
},
diff --git a/tests/unit/store/Vuex.spec.ts b/tests/unit/store/Vuex.spec.ts
index 2b81e5c4d5..98f981cc54 100644
--- a/tests/unit/store/Vuex.spec.ts
+++ b/tests/unit/store/Vuex.spec.ts
@@ -124,6 +124,8 @@ describe("store/vuex.js test", () => {
sequencerScrollX: 0,
sequencerScrollY: 60,
sequencerSnapSize: 120,
+ nowPlaying: false,
+ volume: 0,
},
getters: {
...uiStore.getters,