From acd5751f3b61369d2ad062616d6d95c701e71d13 Mon Sep 17 00:00:00 2001 From: Sig Date: Sat, 16 Dec 2023 21:09:55 +0900 Subject: [PATCH] =?UTF-8?q?=E9=8D=B5=E7=9B=A4=E3=82=AF=E3=83=AA=E3=83=83?= =?UTF-8?q?=E3=82=AF=E6=99=82=E3=81=A8=E3=83=8E=E3=83=BC=E3=83=88=E7=B7=A8?= =?UTF-8?q?=E9=9B=86=E6=99=82=E3=81=AB=E9=9F=B3=E3=82=92=E9=B3=B4=E3=82=89?= =?UTF-8?q?=E3=81=99=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/ScoreSequencer.vue | 54 ++++++++-- src/components/Sing/SequencerKeys.vue | 133 +++++++++++++++++-------- src/components/Sing/SequencerNote.vue | 31 +++--- src/helpers/singHelper.ts | 8 ++ src/infrastructures/AudioRenderer.ts | 29 ++++-- src/store/singing.ts | 34 +++++++ src/store/type.ts | 8 ++ 7 files changed, 228 insertions(+), 69 deletions(-) diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 8b81bc317a..eee0dde1b4 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -182,6 +182,13 @@ import { keyInfos, getDoremiFromNoteNumber, getNumOfMeasures, + ZOOM_X_MIN, + ZOOM_X_MAX, + ZOOM_X_STEP, + ZOOM_Y_MIN, + ZOOM_Y_MAX, + ZOOM_Y_STEP, + PREVIEW_SOUND_DURATION, } from "@/helpers/singHelper"; export default defineComponent({ @@ -193,13 +200,6 @@ export default defineComponent({ SequencerPhraseIndicator, }, setup() { - const ZOOM_X_MIN = 0.2; - const ZOOM_X_MAX = 1; - const ZOOM_X_STEP = 0.05; - const ZOOM_Y_MIN = 0.35; - const ZOOM_Y_MAX = 1; - const ZOOM_Y_STEP = 0.05; - enum DragMode { NONE = "NONE", MOVE = "MOVE", @@ -334,6 +334,10 @@ export default defineComponent({ lyric, }, }); + store.dispatch("PLAY_PREVIEW_SOUND", { + noteNumber, + duration: PREVIEW_SOUND_DURATION, + }); }; // マウスダウン @@ -359,6 +363,20 @@ export default defineComponent({ if (dragMode.value !== DragMode.NONE) { cancelAnimationFrame(dragId.value); dragMode.value = DragMode.NONE; + + if (selectedNoteIds.value.length === 1) { + const selectedNoteId = selectedNoteIds.value[0]; + const selectedNote = state.score.notes.find((value) => { + return value.id === selectedNoteId; + }); + if (!selectedNote) { + throw new Error("selectedNote is undefined."); + } + store.dispatch("PLAY_PREVIEW_SOUND", { + noteNumber: selectedNote.noteNumber, + duration: PREVIEW_SOUND_DURATION, + }); + } return; } }; @@ -582,9 +600,11 @@ export default defineComponent({ // キーボードイベント const handleNotesArrowUp = () => { + let changedNoteNumber: number | undefined; const newNotes = state.score.notes.map((note) => { if (selectedNoteIds.value.includes(note.id)) { const noteNumber = Math.min(note.noteNumber + 1, 127); + changedNoteNumber = noteNumber; return { ...note, noteNumber, @@ -597,12 +617,23 @@ export default defineComponent({ return; } store.dispatch("REPLACE_ALL_NOTES", { notes: newNotes }); + if ( + changedNoteNumber !== undefined && + selectedNoteIds.value.length === 1 + ) { + store.dispatch("PLAY_PREVIEW_SOUND", { + noteNumber: changedNoteNumber, + duration: PREVIEW_SOUND_DURATION, + }); + } }; const handleNotesArrowDown = () => { + let changedNoteNumber: number | undefined; const newNotes = state.score.notes.map((note) => { if (selectedNoteIds.value.includes(note.id)) { const noteNumber = Math.max(note.noteNumber - 1, 0); + changedNoteNumber = noteNumber; return { ...note, noteNumber, @@ -615,6 +646,15 @@ export default defineComponent({ return; } store.dispatch("REPLACE_ALL_NOTES", { notes: newNotes }); + if ( + changedNoteNumber !== undefined && + selectedNoteIds.value.length === 1 + ) { + store.dispatch("PLAY_PREVIEW_SOUND", { + noteNumber: changedNoteNumber, + duration: PREVIEW_SOUND_DURATION, + }); + } }; const handleNotesArrowRight = () => { diff --git a/src/components/Sing/SequencerKeys.vue b/src/components/Sing/SequencerKeys.vue index d61dd2e787..1f4918f614 100644 --- a/src/components/Sing/SequencerKeys.vue +++ b/src/components/Sing/SequencerKeys.vue @@ -1,45 +1,52 @@ @@ -103,10 +110,37 @@ export default defineComponent({ }; }); }); + const noteNumberOfKeyBeingPressed = ref(); const sequencerKeys = ref(null); let resizeObserver: ResizeObserver | undefined; + const onMouseDown = (noteNumber: number) => { + noteNumberOfKeyBeingPressed.value = noteNumber; + store.dispatch("PLAY_PREVIEW_SOUND", { noteNumber }); + }; + + const onMouseUp = () => { + if (noteNumberOfKeyBeingPressed.value !== undefined) { + const noteNumber = noteNumberOfKeyBeingPressed.value; + noteNumberOfKeyBeingPressed.value = undefined; + store.dispatch("STOP_PREVIEW_SOUND", { noteNumber }); + } + }; + + const onMouseEnter = (noteNumber: number) => { + if ( + noteNumberOfKeyBeingPressed.value !== undefined && + noteNumberOfKeyBeingPressed.value !== noteNumber + ) { + store.dispatch("STOP_PREVIEW_SOUND", { + noteNumber: noteNumberOfKeyBeingPressed.value, + }); + noteNumberOfKeyBeingPressed.value = noteNumber; + store.dispatch("PLAY_PREVIEW_SOUND", { noteNumber }); + } + }; + onMounted(() => { const sequencerKeysElement = sequencerKeys.value; if (!sequencerKeysElement) { @@ -124,10 +158,13 @@ export default defineComponent({ } }); resizeObserver.observe(sequencerKeysElement); + + document.addEventListener("mouseup", onMouseUp); }); onUnmounted(() => { resizeObserver?.disconnect(); + document.removeEventListener("mouseup", onMouseUp); }); return { @@ -141,6 +178,9 @@ export default defineComponent({ whiteKeyRects, blackKeyRects, sequencerKeys, + noteNumberOfKeyBeingPressed, + onMouseDown, + onMouseEnter, }; }, }); @@ -157,16 +197,25 @@ export default defineComponent({ overflow: hidden; } -.sequencer-keys-item-white { +.white-key { fill: #fff; stroke: #ccc; } -.sequencer-keys-item-black { +.white-key-being-pressed { + fill: colors.$primary; + stroke: colors.$primary; +} + +.black-key { fill: #5a5a5a; } -.sequencer-keys-item-pitchname { +.black-key-being-pressed { + fill: colors.$primary; +} + +.pitchname { fill: #555; } diff --git a/src/components/Sing/SequencerNote.vue b/src/components/Sing/SequencerNote.vue index 4518ebca62..149440b4fe 100644 --- a/src/components/Sing/SequencerNote.vue +++ b/src/components/Sing/SequencerNote.vue @@ -63,6 +63,7 @@ import { getKeyBaseHeight, tickToBaseX, noteNumberToBaseY, + PREVIEW_SOUND_DURATION, } from "@/helpers/singHelper"; export default defineComponent({ @@ -126,34 +127,40 @@ export default defineComponent({ } }; + const selectThisNote = () => { + const noteIds = [...state.selectedNoteIds, props.note.id]; + store.dispatch("SET_SELECTED_NOTE_IDS", { + noteIds, + }); + store.dispatch("PLAY_PREVIEW_SOUND", { + noteNumber: props.note.noteNumber, + duration: PREVIEW_SOUND_DURATION, + }); + }; + const handleKeydown = (event: KeyboardEvent) => { emit("handleNotesKeydown", event); }; const handleMouseDown = (event: MouseEvent) => { if (!state.selectedNoteIds.includes(props.note.id)) { - const noteIds = [...state.selectedNoteIds, props.note.id]; - store.dispatch("SET_SELECTED_NOTE_IDS", { - noteIds, - }); + selectThisNote(); } else { emit("handleDragMoveStart", event); } }; const handleDragRightStart = (event: MouseEvent) => { - const noteIds = [...state.selectedNoteIds, props.note.id]; - store.dispatch("SET_SELECTED_NOTE_IDS", { - noteIds, - }); + if (!state.selectedNoteIds.includes(props.note.id)) { + selectThisNote(); + } emit("handleDragRightStart", event); }; const handleDragLeftStart = (event: MouseEvent) => { - const noteIds = [...state.selectedNoteIds, props.note.id]; - store.dispatch("SET_SELECTED_NOTE_IDS", { - noteIds, - }); + if (!state.selectedNoteIds.includes(props.note.id)) { + selectThisNote(); + } emit("handleDragLeftStart", event); }; diff --git a/src/helpers/singHelper.ts b/src/helpers/singHelper.ts index 9ac118a7b6..f40efed8a0 100644 --- a/src/helpers/singHelper.ts +++ b/src/helpers/singHelper.ts @@ -10,6 +10,14 @@ export const DEFAULT_BEAT_TYPE = 4; const BASE_X_PER_QUARTER_NOTE = 120; const BASE_Y_PER_NOTE_NUMBER = 30; +export const ZOOM_X_MIN = 0.2; +export const ZOOM_X_MAX = 1; +export const ZOOM_X_STEP = 0.05; +export const ZOOM_Y_MIN = 0.35; +export const ZOOM_Y_MAX = 1; +export const ZOOM_Y_STEP = 0.05; +export const PREVIEW_SOUND_DURATION = 0.1; + export function noteNumberToFrequency(noteNumber: number) { return 440 * 2 ** ((noteNumber - 69) / 12); } diff --git a/src/infrastructures/AudioRenderer.ts b/src/infrastructures/AudioRenderer.ts index 0985f44764..beb32f36e5 100644 --- a/src/infrastructures/AudioRenderer.ts +++ b/src/infrastructures/AudioRenderer.ts @@ -664,10 +664,11 @@ type SynthVoiceParams = { */ class SynthVoice { readonly noteNumber: number; + private readonly audioContext: BaseAudioContext; + private readonly ampParams: SynthAmpParams; private readonly oscNode: OscillatorNode; private readonly gainNode: GainNode; private readonly filterNode: BiquadFilterNode; - private readonly ampParams: SynthAmpParams; private _isActive = false; private _isStopped = false; @@ -687,6 +688,7 @@ class SynthVoice { constructor(audioContext: BaseAudioContext, params: SynthVoiceParams) { this.noteNumber = params.noteNumber; + this.audioContext = audioContext; this.ampParams = params.amp; this.oscNode = new OscillatorNode(audioContext); @@ -702,7 +704,7 @@ class SynthVoice { params.noteNumber ); this.filterNode.Q.value = this.calcFilterQ(params.filter.resonance); - this.gainNode = new GainNode(audioContext); + this.gainNode = new GainNode(audioContext, { gain: 0 }); this.oscNode.connect(this.filterNode); this.filterNode.connect(this.gainNode); } @@ -726,18 +728,28 @@ class SynthVoice { return this.linearToDecibel(Math.SQRT1_2) + resonance; } + private getMinContextTime(audioContext: BaseAudioContext) { + if (audioContext instanceof AudioContext) { + const renderQuantumSize = 128; + const latency = (renderQuantumSize + 10) / audioContext.sampleRate; + return audioContext.currentTime + latency; + } else { + return 0; + } + } + /** * ノートオンをスケジュールします。 * @param contextTime ノートオンを行う時刻(コンテキスト時間) */ noteOn(contextTime: number) { - const t0 = contextTime; + const minContextTime = this.getMinContextTime(this.audioContext); + const t0 = Math.max(minContextTime, contextTime); const atk = this.ampParams.attack; const dcy = this.ampParams.decay; const sus = this.ampParams.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); @@ -757,7 +769,8 @@ class SynthVoice { * @param contextTime ノートオフを行う時刻(コンテキスト時間) */ noteOff(contextTime: number) { - const t0 = contextTime; + const minContextTime = this.getMinContextTime(this.audioContext); + const t0 = Math.max(minContextTime, contextTime); const rel = this.ampParams.release; const stopContextTime = t0 + rel * 4; @@ -806,14 +819,14 @@ export class PolySynth implements Instrument { type: "square", }; this.filterParams = options?.filter ?? { - cutoff: 3300, + cutoff: 3000, resonance: 0, - keyTrack: 0.25, + keyTrack: 0.26, }; this.ampParams = options?.amp ?? { attack: 0.001, decay: 0.18, - sustain: 0.3, + sustain: 0.4, release: 0.02, }; diff --git a/src/store/singing.ts b/src/store/singing.ts index c8aa966b6e..6ad04d893b 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -262,6 +262,7 @@ const isValidScore = (score: Score) => { let audioContext: AudioContext | undefined; let transport: Transport | undefined; +let previewSynth: PolySynth | undefined; let channelStrip: ChannelStrip | undefined; let limiter: Limiter | undefined; let clipper: Clipper | undefined; @@ -270,10 +271,12 @@ let clipper: Clipper | undefined; if (window.AudioContext) { audioContext = new AudioContext(); transport = new Transport(audioContext); + previewSynth = new PolySynth(audioContext); channelStrip = new ChannelStrip(audioContext); limiter = new Limiter(audioContext); clipper = new Clipper(audioContext); + previewSynth.output.connect(channelStrip.input); channelStrip.output.connect(limiter.input); limiter.output.connect(clipper.input); clipper.output.connect(audioContext.destination); @@ -895,6 +898,37 @@ export const singingStore = createPartialStore({ }, }, + PLAY_PREVIEW_SOUND: { + async action( + _, + { noteNumber, duration }: { noteNumber: number; duration?: number } + ) { + if (!audioContext) { + throw new Error("audioContext is undefined."); + } + if (!previewSynth) { + throw new Error("previewSynth is undefined."); + } + previewSynth.noteOn(0, noteNumber); + if (duration !== undefined) { + const contextTime = audioContext.currentTime; + previewSynth.noteOff(contextTime + duration, noteNumber); + } + }, + }, + + STOP_PREVIEW_SOUND: { + async action(_, { noteNumber }: { noteNumber: number }) { + if (!audioContext) { + throw new Error("audioContext is undefined."); + } + if (!previewSynth) { + throw new Error("previewSynth is undefined."); + } + previewSynth.noteOff(0, noteNumber); + }, + }, + SET_IS_DRAG: { mutation(state, { isDrag }: { isDrag: boolean }) { state.isDrag = isDrag; diff --git a/src/store/type.ts b/src/store/type.ts index b2a6deef02..a254428141 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -973,6 +973,14 @@ export type SingingStoreTypes = { action(payload: { volume: number }): void; }; + PLAY_PREVIEW_SOUND: { + action(payload: { noteNumber: number; duration?: number }): void; + }; + + STOP_PREVIEW_SOUND: { + action(payload: { noteNumber: number }): void; + }; + UPDATE_PERIODIC_PITCH: { action(payload: { audioQuery: AudioQuery;