From 7fe6564573a517e2fc6ad60fe7bc5f7c39eb7dc7 Mon Sep 17 00:00:00 2001 From: Sig Date: Sun, 17 Mar 2024 00:11:43 +0900 Subject: [PATCH 01/14] =?UTF-8?q?=E3=83=9C=E3=82=A4=E3=82=B9=E6=95=B0?= =?UTF-8?q?=E3=82=92=E5=88=B6=E9=99=90=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=81lowCut=E3=81=A8highCut?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=81=E3=83=97=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E9=9F=B3=E3=82=92=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/audioRendering.ts | 124 +++++++++++++++++++++++-------------- src/sing/viewHelper.ts | 2 +- src/store/singing.ts | 14 ++--- 3 files changed, 84 insertions(+), 56 deletions(-) diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index afcaa7711d..f6e24ae7c9 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -653,16 +653,15 @@ class SynthVoice { private readonly gainNode: GainNode; private readonly filterNode: BiquadFilterNode; - private _isActive = false; private _isStopped = false; - private stopContextTime?: number; + private noteOffContextTime?: number; get output(): AudioNode { return this.gainNode; } get isActive() { - return this._isActive; + return this.noteOffContextTime == undefined; } get isStopped() { @@ -700,6 +699,13 @@ class SynthVoice { return linearToDecibel(Math.SQRT1_2) + resonance; } + isActiveAt(contextTime: number) { + return ( + this.noteOffContextTime == undefined || + this.noteOffContextTime > contextTime + ); + } + /** * ノートオンをスケジュールします。 * @param contextTime ノートオンを行う時刻(コンテキスト時刻) @@ -721,8 +727,6 @@ class SynthVoice { this.oscNode.frequency.value = freq; this.oscNode.start(t0); - - this._isActive = true; } /** @@ -737,37 +741,38 @@ class SynthVoice { getEarliestSchedulableContextTime(this.audioContext), contextTime ); - const stopContextTime = t0 + rel * 4; - if ( - this.stopContextTime == undefined || - stopContextTime < this.stopContextTime - ) { + if (this.noteOffContextTime == undefined || t0 < this.noteOffContextTime) { // リリースのスケジュールを行う this.gainNode.gain.cancelAndHoldAtTime?.(t0); // Fiefoxで未対応 this.gainNode.gain.setTargetAtTime(0, t0, rel); - this.oscNode.stop(stopContextTime); + this.oscNode.stop(t0 + rel * 4); - this._isActive = false; - this.stopContextTime = stopContextTime; + this.noteOffContextTime = t0; } } } -export type PolySynthOptions = { - readonly volume?: number; +export type SynthOptions = { + readonly maxNumOfVoices?: number; readonly osc?: SynthOscParams; readonly filter?: SynthFilterParams; readonly amp?: SynthAmpParams; + readonly lowCutFrequency?: number; + readonly highCutFrequency?: number; + readonly volume?: number; }; /** - * ポリフォニックシンセサイザーです。 + * シンセサイザーです。 */ -export class PolySynth implements Instrument { +export class Synth implements Instrument { private readonly audioContext: BaseAudioContext; + private readonly highPassFilterNode: BiquadFilterNode; + private readonly lowPassFilterNode: BiquadFilterNode; private readonly gainNode: GainNode; + private readonly maxNumOfActiveVoices: number; private readonly oscParams: SynthOscParams; private readonly filterParams: SynthFilterParams; private readonly ampParams: SynthAmpParams; @@ -778,31 +783,49 @@ export class PolySynth implements Instrument { return this.gainNode; } - constructor(audioContext: BaseAudioContext, options?: PolySynthOptions) { + constructor(audioContext: BaseAudioContext, options?: SynthOptions) { this.audioContext = audioContext; + this.maxNumOfActiveVoices = options?.maxNumOfVoices ?? 1; this.oscParams = options?.osc ?? { type: "square", }; this.filterParams = options?.filter ?? { - cutoff: 2500, + cutoff: 2100, resonance: 0, - keyTrack: 0.25, + keyTrack: 0.22, }; this.ampParams = options?.amp ?? { attack: 0.001, - decay: 0.18, - sustain: 0.5, + decay: 0.23, + sustain: 0, release: 0.02, }; - this.gainNode = new GainNode(this.audioContext); - this.gainNode.gain.value = options?.volume ?? 0.1; + this.highPassFilterNode = new BiquadFilterNode(audioContext, { + type: "highpass", + frequency: options?.lowCutFrequency ?? 160, + Q: linearToDecibel(Math.SQRT1_2), + }); + this.lowPassFilterNode = new BiquadFilterNode(audioContext, { + type: "lowpass", + frequency: options?.highCutFrequency ?? 4300, + Q: linearToDecibel(Math.SQRT1_2), + }); + this.gainNode = new GainNode(this.audioContext, { + gain: options?.volume ?? 0.09, + }); + + this.highPassFilterNode.connect(this.lowPassFilterNode); + this.lowPassFilterNode.connect(this.gainNode); + } + + private getActiveVoicesAt(contextTime: number) { + return this.voices.filter((value) => value.isActiveAt(contextTime)); } /** * ノートオンをスケジュールします。 * ノートの長さが指定された場合は、ノートオフもスケジュールします。 - * すでに指定されたノート番号でノートオンがスケジュールされている場合は何も行いません。 * @param contextTime ノートオンを行う時刻(コンテキスト時刻) * @param noteNumber MIDIノート番号 * @param duration ノートの長さ(秒) @@ -812,29 +835,34 @@ export class PolySynth implements Instrument { noteNumber: number, duration?: number ) { - let voice = this.voices.find((value) => { - return value.isActive && value.noteNumber === noteNumber; - }); + this.voices = this.voices.filter((value) => !value.isStopped); if (contextTime === "immediately") { contextTime = getEarliestSchedulableContextTime(this.audioContext); } - if (!voice) { - voice = new SynthVoice(this.audioContext, { - noteNumber, - osc: this.oscParams, - filter: this.filterParams, - amp: this.ampParams, - }); - voice.output.connect(this.gainNode); - voice.noteOn(contextTime); - - this.voices = this.voices.filter((value) => { - return !value.isStopped; - }); - this.voices.push(voice); + const activeVoices = this.getActiveVoicesAt(contextTime); + + const newVoice = new SynthVoice(this.audioContext, { + noteNumber, + osc: this.oscParams, + filter: this.filterParams, + amp: this.ampParams, + }); + newVoice.output.connect(this.highPassFilterNode); + newVoice.noteOn(contextTime); + + activeVoices.push(newVoice); + for (let i = 0; activeVoices.length - i > this.maxNumOfActiveVoices; i++) { + activeVoices[i].noteOff(contextTime); } + + this.voices.push(newVoice); + if (duration != undefined) { - voice.noteOff(contextTime + duration); + for (const voice of this.voices) { + if (voice.isActive && voice.noteNumber === noteNumber) { + voice.noteOff(contextTime + duration); + } + } } } @@ -845,14 +873,14 @@ export class PolySynth implements Instrument { * @param noteNumber MIDIノート番号 */ noteOff(contextTime: number | "immediately", noteNumber: number) { - const voice = this.voices.find((value) => { - return value.isActive && value.noteNumber === noteNumber; - }); if (contextTime === "immediately") { contextTime = getEarliestSchedulableContextTime(this.audioContext); } - if (voice) { - voice.noteOff(contextTime); + + for (const voice of this.voices) { + if (voice.isActive && voice.noteNumber === noteNumber) { + voice.noteOff(contextTime); + } } } diff --git a/src/sing/viewHelper.ts b/src/sing/viewHelper.ts index 116eaca27e..f696cdbad8 100644 --- a/src/sing/viewHelper.ts +++ b/src/sing/viewHelper.ts @@ -9,7 +9,7 @@ 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.15; +export const PREVIEW_SOUND_DURATION = 0.8; export function getKeyBaseHeight() { return BASE_Y_PER_SEMITONE; diff --git a/src/store/singing.ts b/src/store/singing.ts index 720d442e60..53ec1f6569 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -33,7 +33,7 @@ import { NoteEvent, NoteSequence, OfflineTransport, - PolySynth, + Synth, Sequence, Transport, } from "@/sing/audioRendering"; @@ -92,7 +92,7 @@ const generateNoteEvents = (notes: Note[], tempos: Tempo[], tpqn: number) => { let audioContext: AudioContext | undefined; let transport: Transport | undefined; -let previewSynth: PolySynth | undefined; +let previewSynth: Synth | undefined; let channelStrip: ChannelStrip | undefined; let limiter: Limiter | undefined; let clipper: Clipper | undefined; @@ -101,7 +101,7 @@ let clipper: Clipper | undefined; if (window.AudioContext) { audioContext = new AudioContext(); transport = new Transport(audioContext); - previewSynth = new PolySynth(audioContext); + previewSynth = new Synth(audioContext); channelStrip = new ChannelStrip(audioContext); limiter = new Limiter(audioContext); clipper = new Clipper(audioContext); @@ -1003,16 +1003,16 @@ export const singingStore = createPartialStore({ phrase.tempos, phrase.tpqn ); - const polySynth = new PolySynth(audioContextRef); - polySynth.output.connect(channelStripRef.input); + const synth = new Synth(audioContextRef); + synth.output.connect(channelStripRef.input); const noteSequence: NoteSequence = { type: "note", - instrument: polySynth, + instrument: synth, noteEvents, }; transportRef.addSequence(noteSequence); phraseDataMap.set(phraseKey, { - source: polySynth, + source: synth, sequence: noteSequence, }); } From 530400a3f63a79edad2af4d3268999adc322fcc1 Mon Sep 17 00:00:00 2001 From: Sig Date: Sun, 17 Mar 2024 00:50:29 +0900 Subject: [PATCH 02/14] =?UTF-8?q?=E5=B0=91=E3=81=97=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/audioRendering.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index f6e24ae7c9..8fee9ba92b 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -702,7 +702,7 @@ class SynthVoice { isActiveAt(contextTime: number) { return ( this.noteOffContextTime == undefined || - this.noteOffContextTime > contextTime + contextTime < this.noteOffContextTime ); } From 863a33be82d0f519e50bbbe5617b29af3fa0e0e1 Mon Sep 17 00:00:00 2001 From: Sig Date: Thu, 16 May 2024 21:16:00 +0900 Subject: [PATCH 03/14] =?UTF-8?q?=E9=9F=B3=E8=89=B2=E3=81=A8=E9=9F=B3?= =?UTF-8?q?=E3=81=AE=E9=95=B7=E3=81=95=E3=82=92=E5=A4=89=E6=9B=B4=E3=80=81?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E7=94=A8=E3=81=AE?= =?UTF-8?q?=E3=82=B7=E3=83=B3=E3=82=BB=E3=82=92=E4=BD=9C=E6=88=90=E3=81=99?= =?UTF-8?q?=E3=82=8B=E9=96=A2=E6=95=B0=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=81?= =?UTF-8?q?=E3=83=8F=E3=82=A4=E3=82=AB=E3=83=83=E3=83=88=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/ScoreSequencer.vue | 10 +++---- src/sing/audioRendering.ts | 33 ++++++++++----------- src/sing/domain.ts | 40 ++++++++++++++++++++++++++ src/sing/viewHelper.ts | 1 - src/store/singing.ts | 21 +++++++------- 5 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 6bfcd457b9..6780925d8c 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -233,6 +233,7 @@ import { NoteId } from "@/type/preload"; import { useStore } from "@/store"; import { Note, SequencerEditTarget } from "@/store/type"; import { + PREVIEW_SOUND_DURATION_SECONDS, getEndTicksOfPhrase, getMeasureDuration, getNoteDuration, @@ -255,7 +256,6 @@ import { ZOOM_Y_MIN, ZOOM_Y_MAX, ZOOM_Y_STEP, - PREVIEW_SOUND_DURATION, DoubleClickDetector, NoteAreaInfo, GridAreaInfo, @@ -776,7 +776,7 @@ const selectOnlyThis = (note: Note) => { store.dispatch("SELECT_NOTES", { noteIds: [note.id] }); store.dispatch("PLAY_PREVIEW_SOUND", { noteNumber: note.noteNumber, - duration: PREVIEW_SOUND_DURATION, + duration: PREVIEW_SOUND_DURATION_SECONDS, }); }; @@ -916,7 +916,7 @@ const endPreview = () => { if (previewNotes.value.length === 1) { store.dispatch("PLAY_PREVIEW_SOUND", { noteNumber: previewNotes.value[0].noteNumber, - duration: PREVIEW_SOUND_DURATION, + duration: PREVIEW_SOUND_DURATION_SECONDS, }); } } @@ -1183,7 +1183,7 @@ const handleNotesArrowUp = () => { if (editedNotes.length === 1) { store.dispatch("PLAY_PREVIEW_SOUND", { noteNumber: editedNotes[0].noteNumber, - duration: PREVIEW_SOUND_DURATION, + duration: PREVIEW_SOUND_DURATION_SECONDS, }); } }; @@ -1202,7 +1202,7 @@ const handleNotesArrowDown = () => { if (editedNotes.length === 1) { store.dispatch("PLAY_PREVIEW_SOUND", { noteNumber: editedNotes[0].noteNumber, - duration: PREVIEW_SOUND_DURATION, + duration: PREVIEW_SOUND_DURATION_SECONDS, }); } }; diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index a1dc9ae114..c497e7a71b 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -620,6 +620,7 @@ export class AudioPlayer { export type SynthOscParams = { readonly type: OscillatorType; + readonly periodicWave?: PeriodicWave; }; export type SynthFilterParams = { @@ -674,7 +675,11 @@ class SynthVoice { this.ampParams = params.amp; this.oscNode = new OscillatorNode(audioContext); - this.oscNode.type = params.osc.type; + if (params.osc.periodicWave != undefined) { + this.oscNode.setPeriodicWave(params.osc.periodicWave); + } else { + this.oscNode.type = params.osc.type; + } this.oscNode.onended = () => { this._isStopped = true; }; @@ -760,7 +765,6 @@ export type SynthOptions = { readonly filter?: SynthFilterParams; readonly amp?: SynthAmpParams; readonly lowCutFrequency?: number; - readonly highCutFrequency?: number; readonly volume?: number; }; @@ -770,7 +774,6 @@ export type SynthOptions = { export class Synth implements Instrument { private readonly audioContext: BaseAudioContext; private readonly highPassFilterNode: BiquadFilterNode; - private readonly lowPassFilterNode: BiquadFilterNode; private readonly gainNode: GainNode; private readonly maxNumOfActiveVoices: number; private readonly oscParams: SynthOscParams; @@ -785,38 +788,32 @@ export class Synth implements Instrument { constructor(audioContext: BaseAudioContext, options?: SynthOptions) { this.audioContext = audioContext; - this.maxNumOfActiveVoices = options?.maxNumOfVoices ?? 1; + this.maxNumOfActiveVoices = options?.maxNumOfVoices ?? 16; this.oscParams = options?.osc ?? { - type: "square", + type: "triangle", }; this.filterParams = options?.filter ?? { - cutoff: 2100, + cutoff: 15000, resonance: 0, - keyTrack: 0.22, + keyTrack: 0, }; this.ampParams = options?.amp ?? { attack: 0.001, - decay: 0.23, - sustain: 0, + decay: 0.2, + sustain: 0.8, release: 0.02, }; this.highPassFilterNode = new BiquadFilterNode(audioContext, { type: "highpass", - frequency: options?.lowCutFrequency ?? 160, - Q: linearToDecibel(Math.SQRT1_2), - }); - this.lowPassFilterNode = new BiquadFilterNode(audioContext, { - type: "lowpass", - frequency: options?.highCutFrequency ?? 4300, + frequency: options?.lowCutFrequency ?? 60, Q: linearToDecibel(Math.SQRT1_2), }); this.gainNode = new GainNode(this.audioContext, { - gain: options?.volume ?? 0.09, + gain: options?.volume ?? 0.1, }); - this.highPassFilterNode.connect(this.lowPassFilterNode); - this.lowPassFilterNode.connect(this.gainNode); + this.highPassFilterNode.connect(this.gainNode); } private getActiveVoicesAt(contextTime: number) { diff --git a/src/sing/domain.ts b/src/sing/domain.ts index e7d8e24066..b55b5be175 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1,4 +1,5 @@ import { calculateHash } from "./utility"; +import { Synth } from "./audioRendering"; import { convertLongVowel } from "@/store/utility"; import { Note, @@ -519,3 +520,42 @@ export const splitLyricsByMoras = ( } return moraAndNonMoras; }; + +export const PREVIEW_SOUND_DURATION_SECONDS = 0.55; + +export function createSynthForPreview(audioContext: BaseAudioContext) { + const maxHarmonics = 36; + const oddHarmonicsAmount = 0.88; + const real = new Float32Array(maxHarmonics); + const imag = new Float32Array(maxHarmonics); + for (let i = 0; i <= maxHarmonics; i++) { + real[i] = 0; + if (i === 0 || (i & 1) === 0) { + imag[i] = 0; + } else { + imag[i] = 1 / Math.pow(i, 1 / oddHarmonicsAmount); + } + } + const periodicWave = audioContext.createPeriodicWave(real, imag); + const synth = new Synth(audioContext, { + osc: { + type: "custom", + periodicWave, + }, + filter: { + cutoff: 1900, + resonance: 0, + keyTrack: 0.01, + }, + amp: { + attack: 0.002, + decay: 0.15, + sustain: 0, + release: 0.02, + }, + maxNumOfVoices: 1, + lowCutFrequency: 150, + volume: 0.1, + }); + return synth; +} diff --git a/src/sing/viewHelper.ts b/src/sing/viewHelper.ts index be6f672dfb..7ef1dc1ed7 100644 --- a/src/sing/viewHelper.ts +++ b/src/sing/viewHelper.ts @@ -11,7 +11,6 @@ export const ZOOM_X_STEP = 0.05; export const ZOOM_Y_MIN = 0.5; export const ZOOM_Y_MAX = 1.5; export const ZOOM_Y_STEP = 0.05; -export const PREVIEW_SOUND_DURATION = 0.8; export function getKeyBaseHeight() { return BASE_Y_PER_SEMITONE; diff --git a/src/store/singing.ts b/src/store/singing.ts index 40f2483e97..ea0d945e84 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -62,6 +62,7 @@ import { applyPitchEdit, VALUE_INDICATING_NO_DATA, isValidPitchEditData, + createSynthForPreview, } from "@/sing/domain"; import { DEFAULT_BEATS, @@ -111,7 +112,7 @@ const generateNoteEvents = (notes: Note[], tempos: Tempo[], tpqn: number) => { let audioContext: AudioContext | undefined; let transport: Transport | undefined; -let previewSynth: Synth | undefined; +let synthForPreview: Synth | undefined; let channelStrip: ChannelStrip | undefined; let limiter: Limiter | undefined; let clipper: Clipper | undefined; @@ -120,12 +121,12 @@ let clipper: Clipper | undefined; if (window.AudioContext) { audioContext = new AudioContext(); transport = new Transport(audioContext); - previewSynth = new Synth(audioContext); + synthForPreview = createSynthForPreview(audioContext); channelStrip = new ChannelStrip(audioContext); limiter = new Limiter(audioContext); clipper = new Clipper(audioContext); - previewSynth.output.connect(channelStrip.input); + synthForPreview.output.connect(channelStrip.input); channelStrip.output.connect(limiter.input); limiter.output.connect(clipper.input); clipper.output.connect(audioContext.destination); @@ -833,10 +834,10 @@ export const singingStore = createPartialStore({ if (!audioContext) { throw new Error("audioContext is undefined."); } - if (!previewSynth) { - throw new Error("previewSynth is undefined."); + if (!synthForPreview) { + throw new Error("synthForPreview is undefined."); } - previewSynth.noteOn("immediately", noteNumber, duration); + synthForPreview.noteOn("immediately", noteNumber, duration); }, }, @@ -845,10 +846,10 @@ export const singingStore = createPartialStore({ if (!audioContext) { throw new Error("audioContext is undefined."); } - if (!previewSynth) { - throw new Error("previewSynth is undefined."); + if (!synthForPreview) { + throw new Error("synthForPreview is undefined."); } - previewSynth.noteOff("immediately", noteNumber); + synthForPreview.noteOff("immediately", noteNumber); }, }, @@ -1257,7 +1258,7 @@ export const singingStore = createPartialStore({ if (!sequences.has(phraseKey)) { const noteEvents = generateNoteEvents(phrase.notes, tempos, tpqn); - const synth = new Synth(audioContextRef); + const synth = createSynthForPreview(audioContextRef); const noteSequence: NoteSequence = { type: "note", instrument: synth, From d06020397a1046888cab38633fc27a322a072df6 Mon Sep 17 00:00:00 2001 From: Sig Date: Thu, 16 May 2024 23:08:55 +0900 Subject: [PATCH 04/14] =?UTF-8?q?=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/domain.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sing/domain.ts b/src/sing/domain.ts index b55b5be175..c059062bc3 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -521,11 +521,11 @@ export const splitLyricsByMoras = ( return moraAndNonMoras; }; -export const PREVIEW_SOUND_DURATION_SECONDS = 0.55; +export const PREVIEW_SOUND_DURATION_SECONDS = 0.6; export function createSynthForPreview(audioContext: BaseAudioContext) { const maxHarmonics = 36; - const oddHarmonicsAmount = 0.88; + const oddHarmonicsAmount = 0.89; const real = new Float32Array(maxHarmonics); const imag = new Float32Array(maxHarmonics); for (let i = 0; i <= maxHarmonics; i++) { @@ -543,18 +543,18 @@ export function createSynthForPreview(audioContext: BaseAudioContext) { periodicWave, }, filter: { - cutoff: 1900, + cutoff: 1930, resonance: 0, - keyTrack: 0.01, + keyTrack: 0, }, amp: { attack: 0.002, - decay: 0.15, + decay: 0.16, sustain: 0, release: 0.02, }, maxNumOfVoices: 1, - lowCutFrequency: 150, + lowCutFrequency: 130, volume: 0.1, }); return synth; From b8cb9d56307f1f75b3b95b1113c1256201fc7ec8 Mon Sep 17 00:00:00 2001 From: Sig Date: Thu, 16 May 2024 23:13:35 +0900 Subject: [PATCH 05/14] =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/domain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sing/domain.ts b/src/sing/domain.ts index c059062bc3..636a32ac4c 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -525,7 +525,7 @@ export const PREVIEW_SOUND_DURATION_SECONDS = 0.6; export function createSynthForPreview(audioContext: BaseAudioContext) { const maxHarmonics = 36; - const oddHarmonicsAmount = 0.89; + const oddHarmonicsAmount = 0.89; // 1のとき矩形波、0.5のとき三角波 const real = new Float32Array(maxHarmonics); const imag = new Float32Array(maxHarmonics); for (let i = 0; i <= maxHarmonics; i++) { From bfac3969fe1c44c7a50d2aa9454b7bc3d5989c11 Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Fri, 10 Jan 2025 23:47:50 +0900 Subject: [PATCH 06/14] =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=95=E3=83=AA?= =?UTF-8?q?=E3=82=AF=E3=83=88=E8=A7=A3=E6=B6=88=E6=99=82=E3=81=AE=E3=83=9F?= =?UTF-8?q?=E3=82=B9=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/singing.ts | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/store/singing.ts b/src/store/singing.ts index 595e532cdb..125726ce15 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -627,7 +627,7 @@ const generateNoteSequence = ( throw new Error("audioContext is undefined."); } const noteEvents = generateNoteEvents(notes, tempos, tpqn); - const polySynth = new PolySynth(audioContext); + const polySynth = new Synth(audioContext); return { type: "note", instrument: polySynth, @@ -2568,32 +2568,6 @@ export const singingStore = createPartialStore({ return phrase.state === "WAITING_TO_BE_RENDERED"; }), ); - for (const [phraseKey, phrase] of phrasesToBeRendered) { - // シーケンスが存在する場合、シーケンスの接続を解除して削除する - // TODO: ピッチを編集したときは行わないようにする - - const sequence = sequences.get(phraseKey); - if (sequence) { - getAudioSourceNode(sequence).disconnect(); - transportRef.removeSequence(sequence); - sequences.delete(phraseKey); - } - - // シーケンスが存在しない場合、ノートシーケンスを作成してプレビュー音が鳴るようにする - - if (!sequences.has(phraseKey)) { - const noteEvents = generateNoteEvents(phrase.notes, tempos, tpqn); - const synth = createSynthForPreview(audioContextRef); - const noteSequence: NoteSequence = { - type: "note", - instrument: synth, - noteEvents, - }; - synth.output.connect(channelStripRef.input); - transportRef.addSequence(noteSequence); - sequences.set(phraseKey, noteSequence); - } - } while (phrasesToBeRendered.size > 0) { if (startRenderingRequested() || stopRenderingRequested()) { return; From 9ff196faa0e80ace1bc7fd6cb6c62d9c19b90d0f Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Fri, 10 Jan 2025 23:59:18 +0900 Subject: [PATCH 07/14] =?UTF-8?q?=E7=AF=84=E5=9B=B2=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=81=AE=E8=BF=BD=E5=8A=A0=E3=81=A8=E3=83=AA?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=AF=E3=82=BF=E3=83=AA=E3=83=B3=E3=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/domain.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/sing/domain.ts b/src/sing/domain.ts index a48942ef12..4d4388c14d 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1010,6 +1010,12 @@ export const PREVIEW_SOUND_DURATION_SECONDS = 0.6; export function createSynthForPreview(audioContext: BaseAudioContext) { const maxHarmonics = 36; const oddHarmonicsAmount = 0.89; // 1のとき矩形波、0.5のとき三角波 + if (maxHarmonics <= 0 || oddHarmonicsAmount <= 0) { + throw new Error( + "maxHarmonics and oddHarmonicsAmount must be greater than 0.", + ); + } + const real = new Float32Array(maxHarmonics); const imag = new Float32Array(maxHarmonics); for (let i = 0; i <= maxHarmonics; i++) { @@ -1021,7 +1027,8 @@ export function createSynthForPreview(audioContext: BaseAudioContext) { } } const periodicWave = audioContext.createPeriodicWave(real, imag); - const synth = new Synth(audioContext, { + + return new Synth(audioContext, { osc: { type: "custom", periodicWave, @@ -1041,7 +1048,6 @@ export function createSynthForPreview(audioContext: BaseAudioContext) { lowCutFrequency: 130, volume: 0.1, }); - return synth; } /** From 085f653fa6d1e73f54726d9d11940048c9275465 Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Sat, 11 Jan 2025 11:14:39 +0900 Subject: [PATCH 08/14] =?UTF-8?q?=E3=83=A2=E3=83=8E=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=8B=E3=83=83=E3=82=AF=E6=A9=9F=E8=83=BD=E3=82=92=E4=B8=80?= =?UTF-8?q?=E6=97=A6=E5=89=8A=E9=99=A4=E3=81=97=E3=81=A6=E3=80=81=E9=9F=B3?= =?UTF-8?q?=E8=89=B2=E3=82=92=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/audioRendering.ts | 92 +++++++++++++++++--------------------- src/sing/domain.ts | 9 ++-- src/store/singing.ts | 6 +-- 3 files changed, 48 insertions(+), 59 deletions(-) diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index d666f4c79e..173784d093 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -654,15 +654,16 @@ class SynthVoice { private readonly gainNode: GainNode; private readonly filterNode: BiquadFilterNode; + private _isActive = false; private _isStopped = false; - private noteOffContextTime?: number; + private stopContextTime?: number; get output(): AudioNode { return this.gainNode; } get isActive() { - return this.noteOffContextTime == undefined; + return this._isActive; } get isStopped() { @@ -704,13 +705,6 @@ class SynthVoice { return linearToDecibel(Math.SQRT1_2) + resonance; } - isActiveAt(contextTime: number) { - return ( - this.noteOffContextTime == undefined || - contextTime < this.noteOffContextTime - ); - } - /** * ノートオンをスケジュールします。 * @param contextTime ノートオンを行う時刻(コンテキスト時刻) @@ -732,6 +726,8 @@ class SynthVoice { this.oscNode.frequency.value = freq; this.oscNode.start(t0); + + this._isActive = true; } /** @@ -746,21 +742,25 @@ class SynthVoice { getEarliestSchedulableContextTime(this.audioContext), contextTime, ); + const stopContextTime = t0 + rel * 4; - if (this.noteOffContextTime == undefined || t0 < this.noteOffContextTime) { + if ( + this.stopContextTime == undefined || + stopContextTime < this.stopContextTime + ) { // リリースのスケジュールを行う this.gainNode.gain.cancelAndHoldAtTime?.(t0); // Fiefoxで未対応 this.gainNode.gain.setTargetAtTime(0, t0, rel); - this.oscNode.stop(t0 + rel * 4); + this.oscNode.stop(stopContextTime); - this.noteOffContextTime = t0; + this._isActive = false; + this.stopContextTime = stopContextTime; } } } -export type SynthOptions = { - readonly maxNumOfVoices?: number; +export type PolySynthOptions = { readonly osc?: SynthOscParams; readonly filter?: SynthFilterParams; readonly amp?: SynthAmpParams; @@ -769,13 +769,12 @@ export type SynthOptions = { }; /** - * シンセサイザーです。 + * ポリフォニックシンセサイザーです。 */ -export class Synth implements Instrument { +export class PolySynth implements Instrument { private readonly audioContext: BaseAudioContext; private readonly highPassFilterNode: BiquadFilterNode; private readonly gainNode: GainNode; - private readonly maxNumOfActiveVoices: number; private readonly oscParams: SynthOscParams; private readonly filterParams: SynthFilterParams; private readonly ampParams: SynthAmpParams; @@ -786,9 +785,8 @@ export class Synth implements Instrument { return this.gainNode; } - constructor(audioContext: BaseAudioContext, options?: SynthOptions) { + constructor(audioContext: BaseAudioContext, options?: PolySynthOptions) { this.audioContext = audioContext; - this.maxNumOfActiveVoices = options?.maxNumOfVoices ?? 16; this.oscParams = options?.osc ?? { type: "triangle", }; @@ -816,13 +814,10 @@ export class Synth implements Instrument { this.highPassFilterNode.connect(this.gainNode); } - private getActiveVoicesAt(contextTime: number) { - return this.voices.filter((value) => value.isActiveAt(contextTime)); - } - /** * ノートオンをスケジュールします。 * ノートの長さが指定された場合は、ノートオフもスケジュールします。 + * すでに指定されたノート番号でノートオンがスケジュールされている場合は何も行いません。 * @param contextTime ノートオンを行う時刻(コンテキスト時刻) * @param noteNumber MIDIノート番号 * @param duration ノートの長さ(秒) @@ -832,34 +827,29 @@ export class Synth implements Instrument { noteNumber: number, duration?: number, ) { - this.voices = this.voices.filter((value) => !value.isStopped); + let voice = this.voices.find((value) => { + return value.isActive && value.noteNumber === noteNumber; + }); if (contextTime === "immediately") { contextTime = getEarliestSchedulableContextTime(this.audioContext); } - const activeVoices = this.getActiveVoicesAt(contextTime); - - const newVoice = new SynthVoice(this.audioContext, { - noteNumber, - osc: this.oscParams, - filter: this.filterParams, - amp: this.ampParams, - }); - newVoice.output.connect(this.highPassFilterNode); - newVoice.noteOn(contextTime); - - activeVoices.push(newVoice); - for (let i = 0; activeVoices.length - i > this.maxNumOfActiveVoices; i++) { - activeVoices[i].noteOff(contextTime); + if (!voice) { + voice = new SynthVoice(this.audioContext, { + noteNumber, + osc: this.oscParams, + filter: this.filterParams, + amp: this.ampParams, + }); + voice.output.connect(this.highPassFilterNode); + voice.noteOn(contextTime); + + this.voices = this.voices.filter((value) => { + return !value.isStopped; + }); + this.voices.push(voice); } - - this.voices.push(newVoice); - if (duration != undefined) { - for (const voice of this.voices) { - if (voice.isActive && voice.noteNumber === noteNumber) { - voice.noteOff(contextTime + duration); - } - } + voice.noteOff(contextTime + duration); } } @@ -870,14 +860,14 @@ export class Synth implements Instrument { * @param noteNumber MIDIノート番号 */ noteOff(contextTime: number | "immediately", noteNumber: number) { + const voice = this.voices.find((value) => { + return value.isActive && value.noteNumber === noteNumber; + }); if (contextTime === "immediately") { contextTime = getEarliestSchedulableContextTime(this.audioContext); } - - for (const voice of this.voices) { - if (voice.isActive && voice.noteNumber === noteNumber) { - voice.noteOff(contextTime); - } + if (voice) { + voice.noteOff(contextTime); } } diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 4d4388c14d..5b5f21788e 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1,5 +1,5 @@ import { calculateHash, getLast, getNext, getPrev, isSorted } from "./utility"; -import { Synth } from "./audioRendering"; +import { PolySynth } from "./audioRendering"; import { convertLongVowel, moraPattern } from "@/domain/japanese"; import { Note, @@ -1005,7 +1005,7 @@ export const splitLyricsByMoras = ( return moraAndNonMoras; }; -export const PREVIEW_SOUND_DURATION_SECONDS = 0.6; +export const PREVIEW_SOUND_DURATION_SECONDS = 0.15; export function createSynthForPreview(audioContext: BaseAudioContext) { const maxHarmonics = 36; @@ -1028,7 +1028,7 @@ export function createSynthForPreview(audioContext: BaseAudioContext) { } const periodicWave = audioContext.createPeriodicWave(real, imag); - return new Synth(audioContext, { + return new PolySynth(audioContext, { osc: { type: "custom", periodicWave, @@ -1044,8 +1044,7 @@ export function createSynthForPreview(audioContext: BaseAudioContext) { sustain: 0, release: 0.02, }, - maxNumOfVoices: 1, - lowCutFrequency: 130, + lowCutFrequency: 125, volume: 0.1, }); } diff --git a/src/store/singing.ts b/src/store/singing.ts index 125726ce15..88e2985b4e 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -54,7 +54,7 @@ import { NoteEvent, NoteSequence, OfflineTransport, - Synth, + PolySynth, Sequence, Transport, } from "@/sing/audioRendering"; @@ -514,7 +514,7 @@ const offlineRenderTracks = async ( let audioContext: AudioContext | undefined; let transport: Transport | undefined; -let synthForPreview: Synth | undefined; +let synthForPreview: PolySynth | undefined; let mainChannelStrip: ChannelStrip | undefined; const trackChannelStrips = new Map(); let limiter: Limiter | undefined; @@ -627,7 +627,7 @@ const generateNoteSequence = ( throw new Error("audioContext is undefined."); } const noteEvents = generateNoteEvents(notes, tempos, tpqn); - const polySynth = new Synth(audioContext); + const polySynth = new PolySynth(audioContext); return { type: "note", instrument: polySynth, From 3c53f4ba6c1e57c357be459f46c37405b1c43451 Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Wed, 15 Jan 2025 00:19:58 +0900 Subject: [PATCH 09/14] =?UTF-8?q?PreviewSoundEditor=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dialog/SettingDialog/SettingDialog.vue | 9 + src/components/Sing/PreviewSoundEditor.vue | 509 ++++++++++++++++++ .../Sing/PreviewSoundEditorKnob.vue | 313 +++++++++++ src/components/Sing/SingEditor.vue | 16 + src/sing/audioRendering.ts | 164 +++++- src/sing/domain.ts | 62 ++- src/sing/utility.ts | 9 + src/store/setting.ts | 1 + src/store/singing.ts | 82 ++- src/store/type.ts | 61 +++ src/type/preload.ts | 1 + .../__snapshots__/configManager.spec.ts.snap | 1 + 12 files changed, 1170 insertions(+), 58 deletions(-) create mode 100644 src/components/Sing/PreviewSoundEditor.vue create mode 100644 src/components/Sing/PreviewSoundEditorKnob.vue diff --git a/src/components/Dialog/SettingDialog/SettingDialog.vue b/src/components/Dialog/SettingDialog/SettingDialog.vue index 33cec7090a..7b74e785cb 100644 --- a/src/components/Dialog/SettingDialog/SettingDialog.vue +++ b/src/components/Dialog/SettingDialog/SettingDialog.vue @@ -444,6 +444,15 @@ ) " /> +
データ収集
diff --git a/src/components/Sing/PreviewSoundEditor.vue b/src/components/Sing/PreviewSoundEditor.vue new file mode 100644 index 0000000000..f8fe4d3eb6 --- /dev/null +++ b/src/components/Sing/PreviewSoundEditor.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/src/components/Sing/PreviewSoundEditorKnob.vue b/src/components/Sing/PreviewSoundEditorKnob.vue new file mode 100644 index 0000000000..ffd18cd432 --- /dev/null +++ b/src/components/Sing/PreviewSoundEditorKnob.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index ca8703c774..4ccf2e8cc6 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -3,6 +3,10 @@
+ { + return store.state.experimentalSetting.showPreviewSoundEditor; +}); + const isSidebarOpen = computed(() => store.state.isSongSidebarOpen); const sidebarWidth = ref(300); @@ -119,4 +128,11 @@ onetimeWatch( overflow: hidden; position: relative; } + +.preview-sound-editor { + position: fixed; + bottom: 48px; + right: 48px; + z-index: 40; +} diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index 173784d093..c3269416a1 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -3,7 +3,7 @@ import { decibelToLinear, linearToDecibel, } from "@/sing/domain"; -import { Timer } from "@/sing/utility"; +import { clamp, Timer } from "@/sing/utility"; const getEarliestSchedulableContextTime = (audioContext: BaseAudioContext) => { const renderQuantumSize = 128; @@ -618,10 +618,15 @@ export class AudioPlayer { } } -export type SynthOscParams = { - readonly type: OscillatorType; - readonly periodicWave?: PeriodicWave; -}; +export type SynthOscParams = + | { + readonly type: Exclude; + } + | { + readonly type: Extract; + readonly oddHarmonicsAmount: number; + readonly evenHarmonicsAmount: number; + }; export type SynthFilterParams = { readonly cutoff: number; @@ -638,9 +643,10 @@ export type SynthAmpParams = { type SynthVoiceParams = { readonly noteNumber: number; - readonly osc: SynthOscParams; - readonly filter: SynthFilterParams; - readonly amp: SynthAmpParams; + readonly oscParams: SynthOscParams; + readonly oscPeriodicWave?: PeriodicWave; + readonly filterParams: SynthFilterParams; + readonly ampParams: SynthAmpParams; }; /** @@ -673,13 +679,13 @@ class SynthVoice { constructor(audioContext: BaseAudioContext, params: SynthVoiceParams) { this.noteNumber = params.noteNumber; this.audioContext = audioContext; - this.ampParams = params.amp; + this.ampParams = params.ampParams; this.oscNode = new OscillatorNode(audioContext); - if (params.osc.periodicWave != undefined) { - this.oscNode.setPeriodicWave(params.osc.periodicWave); + if (params.oscPeriodicWave != undefined) { + this.oscNode.setPeriodicWave(params.oscPeriodicWave); } else { - this.oscNode.type = params.osc.type; + this.oscNode.type = params.oscParams.type; } this.oscNode.onended = () => { this._isStopped = true; @@ -687,11 +693,11 @@ class SynthVoice { this.filterNode = new BiquadFilterNode(audioContext); this.filterNode.type = "lowpass"; this.filterNode.frequency.value = this.calcFilterFreq( - params.filter.cutoff, - params.filter.keyTrack, + params.filterParams.cutoff, + params.filterParams.keyTrack, params.noteNumber, ); - this.filterNode.Q.value = this.calcFilterQ(params.filter.resonance); + this.filterNode.Q.value = this.calcFilterQ(params.filterParams.resonance); this.gainNode = new GainNode(audioContext, { gain: 0 }); this.oscNode.connect(this.filterNode); this.filterNode.connect(this.gainNode); @@ -761,9 +767,9 @@ class SynthVoice { } export type PolySynthOptions = { - readonly osc?: SynthOscParams; - readonly filter?: SynthFilterParams; - readonly amp?: SynthAmpParams; + readonly oscParams?: SynthOscParams; + readonly filterParams?: SynthFilterParams; + readonly ampParams?: SynthAmpParams; readonly lowCutFrequency?: number; readonly volume?: number; }; @@ -775,9 +781,11 @@ export class PolySynth implements Instrument { private readonly audioContext: BaseAudioContext; private readonly highPassFilterNode: BiquadFilterNode; private readonly gainNode: GainNode; - private readonly oscParams: SynthOscParams; - private readonly filterParams: SynthFilterParams; - private readonly ampParams: SynthAmpParams; + + private _oscParams: SynthOscParams; + private oscPeriodicWave: PeriodicWave | undefined; + private _filterParams: SynthFilterParams; + private _ampParams: SynthAmpParams; private voices: SynthVoice[] = []; @@ -785,22 +793,62 @@ export class PolySynth implements Instrument { return this.gainNode; } + get oscParams() { + return this._oscParams; + } + set oscParams(value: SynthOscParams) { + this._oscParams = value; + this.updateOscPeriodicWave(value); + this.restartCurrentlyActiveVoices(); + } + + get filterParams() { + return this._filterParams; + } + set filterParams(value: SynthFilterParams) { + this._filterParams = value; + this.restartCurrentlyActiveVoices(); + } + + get ampParams() { + return this._ampParams; + } + set ampParams(value: SynthAmpParams) { + this._ampParams = value; + this.restartCurrentlyActiveVoices(); + } + + get lowCutFrequency() { + return this.highPassFilterNode.frequency.value; + } + set lowCutFrequency(value: number) { + this.highPassFilterNode.frequency.value = value; + } + + get volume() { + return this.gainNode.gain.value; + } + set volume(value: number) { + this.gainNode.gain.value = value; + } + constructor(audioContext: BaseAudioContext, options?: PolySynthOptions) { this.audioContext = audioContext; - this.oscParams = options?.osc ?? { + this._oscParams = options?.oscParams ?? { type: "triangle", }; - this.filterParams = options?.filter ?? { + this._filterParams = options?.filterParams ?? { cutoff: 15000, resonance: 0, keyTrack: 0, }; - this.ampParams = options?.amp ?? { + this._ampParams = options?.ampParams ?? { attack: 0.001, decay: 0.2, sustain: 0.8, release: 0.02, }; + this.updateOscPeriodicWave(this._oscParams); this.highPassFilterNode = new BiquadFilterNode(audioContext, { type: "highpass", @@ -814,6 +862,67 @@ export class PolySynth implements Instrument { this.highPassFilterNode.connect(this.gainNode); } + private updateOscPeriodicWave(oscParams: SynthOscParams) { + if (oscParams.type !== "custom") { + this.oscPeriodicWave = undefined; + return; + } + const MAX_NUMBER = 1e10; + const MIN_NUMBER = 1e-10; + + const NUM_HARMONICS = 36; + const MIN_HARMONICS_AMOUNT = 0; + const MAX_HARMONICS_AMOUNT = 1; + + const real = new Float32Array(NUM_HARMONICS); + const imag = new Float32Array(NUM_HARMONICS); + + imag[1] = 1; + + for (let i = 2; i <= NUM_HARMONICS; i++) { + const isEven = (i & 1) === 0; + const harmonicsAmount = isEven + ? oscParams.evenHarmonicsAmount + : oscParams.oddHarmonicsAmount; + const clampedHarmonicsAmount = clamp( + harmonicsAmount, + MIN_HARMONICS_AMOUNT, + MAX_HARMONICS_AMOUNT, + ); + const maxHarmonics = Math.pow(MAX_NUMBER, clampedHarmonicsAmount); + if (i > maxHarmonics || clampedHarmonicsAmount < MIN_NUMBER) { + imag[i] = 0; + } else { + imag[i] = 1 / Math.pow(i, 1 / clampedHarmonicsAmount); + } + } + this.oscPeriodicWave = this.audioContext.createPeriodicWave(real, imag); + } + + private restartCurrentlyActiveVoices() { + this.voices = this.voices.map((voice) => { + if (!voice.isActive) { + return voice; + } + const noteNumber = voice.noteNumber; + const contextTime = getEarliestSchedulableContextTime(this.audioContext); + + voice.noteOff(contextTime); + + const newVoice = new SynthVoice(this.audioContext, { + noteNumber, + oscParams: this._oscParams, + oscPeriodicWave: this.oscPeriodicWave, + filterParams: this._filterParams, + ampParams: { ...this._ampParams, attack: 0.04 }, + }); + newVoice.output.connect(this.highPassFilterNode); + newVoice.noteOn(contextTime); + + return newVoice; + }); + } + /** * ノートオンをスケジュールします。 * ノートの長さが指定された場合は、ノートオフもスケジュールします。 @@ -836,9 +945,10 @@ export class PolySynth implements Instrument { if (!voice) { voice = new SynthVoice(this.audioContext, { noteNumber, - osc: this.oscParams, - filter: this.filterParams, - amp: this.ampParams, + oscParams: this._oscParams, + oscPeriodicWave: this.oscPeriodicWave, + filterParams: this._filterParams, + ampParams: this._ampParams, }); voice.output.connect(this.highPassFilterNode); voice.noteOn(contextTime); diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 5b5f21788e..3ef1a731ea 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1,5 +1,10 @@ -import { calculateHash, getLast, getNext, getPrev, isSorted } from "./utility"; -import { PolySynth } from "./audioRendering"; +import { + calculateHash, + getLast, + getNext, + getPrev, + isSorted, +} from "@/sing/utility"; import { convertLongVowel, moraPattern } from "@/domain/japanese"; import { Note, @@ -10,9 +15,11 @@ import { PhraseKey, Track, EditorFrameAudioQuery, + PreviewSynthParams, } from "@/store/type"; import { FramePhoneme } from "@/openapi"; import { NoteId, TrackId } from "@/type/preload"; +import { ExhaustiveError } from "@/type/utility"; // TODO: 後でdomain/type.tsに移す export type MeasuresBeats = { @@ -327,6 +334,22 @@ export function decibelToLinear(decibelValue: number) { return Math.pow(10, decibelValue / 20); } +export function getHarmonicsAmount( + oscType: "sawtooth" | "square" | "triangle" | "sine", +) { + if (oscType === "sawtooth") { + return { odd: 1, even: 1 }; + } else if (oscType === "square") { + return { odd: 0, even: 1 }; + } else if (oscType === "triangle") { + return { odd: 0, even: 0.5 }; + } else if (oscType === "sine") { + return { odd: 0, even: 0 }; + } else { + throw new ExhaustiveError(oscType); + } +} + export const DEFAULT_TRACK_NAME = "無名トラック"; export const DEFAULT_TPQN = 480; @@ -1007,38 +1030,19 @@ export const splitLyricsByMoras = ( export const PREVIEW_SOUND_DURATION_SECONDS = 0.15; -export function createSynthForPreview(audioContext: BaseAudioContext) { - const maxHarmonics = 36; - const oddHarmonicsAmount = 0.89; // 1のとき矩形波、0.5のとき三角波 - if (maxHarmonics <= 0 || oddHarmonicsAmount <= 0) { - throw new Error( - "maxHarmonics and oddHarmonicsAmount must be greater than 0.", - ); - } - - const real = new Float32Array(maxHarmonics); - const imag = new Float32Array(maxHarmonics); - for (let i = 0; i <= maxHarmonics; i++) { - real[i] = 0; - if (i === 0 || (i & 1) === 0) { - imag[i] = 0; - } else { - imag[i] = 1 / Math.pow(i, 1 / oddHarmonicsAmount); - } - } - const periodicWave = audioContext.createPeriodicWave(real, imag); - - return new PolySynth(audioContext, { - osc: { +export function createPreviewSynthParams(): PreviewSynthParams { + return { + oscParams: { type: "custom", - periodicWave, + oddHarmonicsAmount: 0.89, + evenHarmonicsAmount: 0, }, - filter: { + filterParams: { cutoff: 1930, resonance: 0, keyTrack: 0, }, - amp: { + ampParams: { attack: 0.002, decay: 0.16, sustain: 0, @@ -1046,7 +1050,7 @@ export function createSynthForPreview(audioContext: BaseAudioContext) { }, lowCutFrequency: 125, volume: 0.1, - }); + }; } /** diff --git a/src/sing/utility.ts b/src/sing/utility.ts index 1836d37b18..86f4ce92ad 100644 --- a/src/sing/utility.ts +++ b/src/sing/utility.ts @@ -1,3 +1,12 @@ +export function clamp(value: number, min: number, max: number) { + if (min > max) { + throw new Error( + `Invalid range: min (${min}) cannot be greater than max (${max}).`, + ); + } + return Math.min(max, Math.max(min, value)); +} + export function round(value: number, digits: number) { const powerOf10 = 10 ** digits; return Math.round(value * powerOf10) / powerOf10; diff --git a/src/store/setting.ts b/src/store/setting.ts index e000302461..3b664b1643 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -48,6 +48,7 @@ export const settingStoreState: SettingStoreState = { enableMorphing: false, enableMultiSelect: false, shouldKeepTuningOnTextChange: false, + showPreviewSoundEditor: false, }, splitTextWhenPaste: "PERIOD_AND_NEW_LINE", splitterPosition: { diff --git a/src/store/singing.ts b/src/store/singing.ts index 88e2985b4e..f0753f1a2a 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -71,7 +71,6 @@ import { tickToSecond, VALUE_INDICATING_NO_DATA, isValidPitchEditData, - createSynthForPreview, calculatePhraseKey, isValidTempos, isValidTimeSignatures, @@ -94,6 +93,7 @@ import { toEntirePhonemeTimings, adjustPhonemeTimingsAndPhraseEndFrames, phonemeTimingsToPhonemes, + createPreviewSynthParams, } from "@/sing/domain"; import { getOverlappingNoteIds } from "@/sing/storeHelper"; import { @@ -522,9 +522,11 @@ let clipper: Clipper | undefined; // NOTE: テスト時はAudioContextが存在しない if (window.AudioContext) { + const previewSynthInitialParams = createPreviewSynthParams(); + audioContext = new AudioContext(); transport = new Transport(audioContext); - synthForPreview = createSynthForPreview(audioContext); + synthForPreview = new PolySynth(audioContext, previewSynthInitialParams); mainChannelStrip = new ChannelStrip(audioContext); limiter = new Limiter(audioContext); clipper = new Clipper(audioContext); @@ -759,6 +761,7 @@ export const singingStoreState: SingingStoreState = { exportState: "NOT_EXPORTING", cancellationOfExportRequested: false, isSongSidebarOpen: false, + previewSynthParams: createPreviewSynthParams(), }; export const singingStore = createPartialStore({ @@ -1553,6 +1556,81 @@ export const singingStore = createPartialStore({ }, }, + SET_PREVIEW_SYNTH_OSC_PARAMS: { + mutation(state, { oscParams }) { + state.previewSynthParams.oscParams = oscParams; + }, + async action({ mutations }, { oscParams }) { + if (synthForPreview == undefined) { + throw new Error("synthForPreview is undefined."); + } + mutations.SET_PREVIEW_SYNTH_OSC_PARAMS({ oscParams }); + + synthForPreview.oscParams = oscParams; + }, + }, + + SET_PREVIEW_SYNTH_FILTER_PARAMS: { + mutation(state, { filterParams }) { + state.previewSynthParams.filterParams = filterParams; + }, + async action({ mutations }, { filterParams }) { + if (synthForPreview == undefined) { + throw new Error("synthForPreview is undefined."); + } + mutations.SET_PREVIEW_SYNTH_FILTER_PARAMS({ filterParams }); + + synthForPreview.filterParams = filterParams; + }, + }, + + SET_PREVIEW_SYNTH_AMP_PARAMS: { + mutation(state, { ampParams }) { + state.previewSynthParams.ampParams = ampParams; + }, + async action({ mutations }, { ampParams }) { + if (synthForPreview == undefined) { + throw new Error("synthForPreview is undefined."); + } + mutations.SET_PREVIEW_SYNTH_AMP_PARAMS({ ampParams }); + + synthForPreview.ampParams = ampParams; + }, + }, + SET_PREVIEW_SYNTH_LOW_CUT_FREQUENCY: { + mutation(state, { lowCutFrequency }) { + state.previewSynthParams.lowCutFrequency = lowCutFrequency; + }, + async action({ mutations }, { lowCutFrequency }) { + if (synthForPreview == undefined) { + throw new Error("synthForPreview is undefined."); + } + mutations.SET_PREVIEW_SYNTH_LOW_CUT_FREQUENCY({ lowCutFrequency }); + + synthForPreview.lowCutFrequency = lowCutFrequency; + }, + }, + + SET_PREVIEW_SYNTH_VOLUME: { + mutation(state, { volume }) { + state.previewSynthParams.volume = volume; + }, + async action({ mutations }, { volume }) { + if (synthForPreview == undefined) { + throw new Error("synthForPreview is undefined."); + } + mutations.SET_PREVIEW_SYNTH_VOLUME({ volume }); + + synthForPreview.volume = volume; + }, + }, + + DEFAULT_PREVIEW_SYNTH_PARAMS: { + getter() { + return createPreviewSynthParams(); + }, + }, + SET_START_RENDERING_REQUESTED: { mutation(state, { startRenderingRequested }) { state.startRenderingRequested = startRenderingRequested; diff --git a/src/store/type.ts b/src/store/type.ts index a57e243881..12299679f9 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -879,6 +879,37 @@ export type SongExportState = | "EXPORTING_LABEL" | "NOT_EXPORTING"; +export type PreviewSynthOscParams = + | { + type: "sawtooth" | "sine" | "square" | "triangle"; + } + | { + type: "custom"; + oddHarmonicsAmount: number; + evenHarmonicsAmount: number; + }; + +export type PreviewSynthFilterParams = { + cutoff: number; + resonance: number; + keyTrack: number; +}; + +export type PreviewSynthAmpParams = { + attack: number; + decay: number; + sustain: number; + release: number; +}; + +export type PreviewSynthParams = { + oscParams: PreviewSynthOscParams; + filterParams: PreviewSynthFilterParams; + ampParams: PreviewSynthAmpParams; + lowCutFrequency: number; + volume: number; +}; + export type SingingStoreState = { tpqn: number; // Ticks Per Quarter Note tempos: Tempo[]; @@ -907,6 +938,7 @@ export type SingingStoreState = { exportState: SongExportState; cancellationOfExportRequested: boolean; isSongSidebarOpen: boolean; + previewSynthParams: PreviewSynthParams; }; export type SingingStoreTypes = { @@ -1243,6 +1275,35 @@ export type SingingStoreTypes = { action(payload: { noteNumber: number }): void; }; + SET_PREVIEW_SYNTH_OSC_PARAMS: { + mutation: { oscParams: PreviewSynthOscParams }; + action(payload: { oscParams: PreviewSynthOscParams }): void; + }; + + SET_PREVIEW_SYNTH_FILTER_PARAMS: { + mutation: { filterParams: PreviewSynthFilterParams }; + action(payload: { filterParams: PreviewSynthFilterParams }): void; + }; + + SET_PREVIEW_SYNTH_AMP_PARAMS: { + mutation: { ampParams: PreviewSynthAmpParams }; + action(payload: { ampParams: PreviewSynthAmpParams }): void; + }; + + SET_PREVIEW_SYNTH_LOW_CUT_FREQUENCY: { + mutation: { lowCutFrequency: number }; + action(payload: { lowCutFrequency: number }): void; + }; + + SET_PREVIEW_SYNTH_VOLUME: { + mutation: { volume: number }; + action(payload: { volume: number }): void; + }; + + DEFAULT_PREVIEW_SYNTH_PARAMS: { + getter: Readonly; + }; + SET_START_RENDERING_REQUESTED: { mutation: { startRenderingRequested: boolean }; }; diff --git a/src/type/preload.ts b/src/type/preload.ts index a144d2c64d..46a0b90cdd 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -357,6 +357,7 @@ export const experimentalSettingSchema = z.object({ enableMorphing: z.boolean().default(false), enableMultiSelect: z.boolean().default(false), shouldKeepTuningOnTextChange: z.boolean().default(false), + showPreviewSoundEditor: z.boolean().default(false), }); export type ExperimentalSettingType = z.infer; diff --git a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap index 4da839ee6a..0a95016071 100644 --- a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap +++ b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap @@ -29,6 +29,7 @@ exports[`0.13.0からマイグレーションできる 1`] = ` "enableMorphing": false, "enableMultiSelect": false, "shouldKeepTuningOnTextChange": false, + "showPreviewSoundEditor": false, }, "hotkeySettings": [ { From 917425ad6b23a97141db0923a651db655128d64e Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Wed, 15 Jan 2025 00:43:38 +0900 Subject: [PATCH 10/14] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sing/PreviewSoundEditorKnob.vue | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/Sing/PreviewSoundEditorKnob.vue b/src/components/Sing/PreviewSoundEditorKnob.vue index ffd18cd432..a148629330 100644 --- a/src/components/Sing/PreviewSoundEditorKnob.vue +++ b/src/components/Sing/PreviewSoundEditorKnob.vue @@ -56,10 +56,7 @@ const lastMousePosX = ref(0); const lastMousePosY = ref(0); const limitedMin = computed(() => { - if (props.logScale && props.min < LOG_SCALE_MIN) { - return props.min; - } - return props.min; + return props.logScale ? Math.max(LOG_SCALE_MIN, props.min) : props.min; }); const limitedValue = computed(() => { @@ -67,10 +64,10 @@ const limitedValue = computed(() => { }); const percentage = computed(() => { - const range = props.max - limitedMin.value; if (props.logScale) { return linearToLog(limitedValue.value, limitedMin.value, props.max); } + const range = props.max - limitedMin.value; return (limitedValue.value - limitedMin.value) / range; }); From 3171b6d9502d65857111e4b1389e3936a4e04d6b Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Wed, 15 Jan 2025 01:12:19 +0900 Subject: [PATCH 11/14] =?UTF-8?q?=E9=9F=B3=E8=89=B2=E3=82=92=E8=AA=BF?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/domain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 3ef1a731ea..a2fd2e188c 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1034,7 +1034,7 @@ export function createPreviewSynthParams(): PreviewSynthParams { return { oscParams: { type: "custom", - oddHarmonicsAmount: 0.89, + oddHarmonicsAmount: 0.83, evenHarmonicsAmount: 0, }, filterParams: { From 0039892991fd06961f6bf2dd4e069b5fcfaf1af9 Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Wed, 15 Jan 2025 21:51:21 +0900 Subject: [PATCH 12/14] =?UTF-8?q?sustain=E3=82=92=E8=AA=BF=E6=95=B4?= =?UTF-8?q?=E3=80=81=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=92=E6=9B=B4=E6=96=B0=20[update=20snaps?= =?UTF-8?q?hots]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/domain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sing/domain.ts b/src/sing/domain.ts index a2fd2e188c..2379c32923 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1045,7 +1045,7 @@ export function createPreviewSynthParams(): PreviewSynthParams { ampParams: { attack: 0.002, decay: 0.16, - sustain: 0, + sustain: 0.33, release: 0.02, }, lowCutFrequency: 125, From c948a966739575cc3c2b19c2f5fbeb6a841b726d Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Wed, 15 Jan 2025 22:22:39 +0900 Subject: [PATCH 13/14] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sing/audioRendering.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/sing/audioRendering.ts b/src/sing/audioRendering.ts index c3269416a1..9775b4725f 100644 --- a/src/sing/audioRendering.ts +++ b/src/sing/audioRendering.ts @@ -868,7 +868,6 @@ export class PolySynth implements Instrument { return; } const MAX_NUMBER = 1e10; - const MIN_NUMBER = 1e-10; const NUM_HARMONICS = 36; const MIN_HARMONICS_AMOUNT = 0; @@ -890,10 +889,10 @@ export class PolySynth implements Instrument { MAX_HARMONICS_AMOUNT, ); const maxHarmonics = Math.pow(MAX_NUMBER, clampedHarmonicsAmount); - if (i > maxHarmonics || clampedHarmonicsAmount < MIN_NUMBER) { - imag[i] = 0; - } else { + if (i <= maxHarmonics) { imag[i] = 1 / Math.pow(i, 1 / clampedHarmonicsAmount); + } else { + imag[i] = 0; } } this.oscPeriodicWave = this.audioContext.createPeriodicWave(real, imag); From 1d2086a8ee0e75dc027436559dd98473149a05ca Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Fri, 28 Feb 2025 19:04:20 +0900 Subject: [PATCH 14/14] =?UTF-8?q?=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/__snapshots__/configManager.spec.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap index 93d5fae857..0cc05fa24b 100644 --- a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap +++ b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap @@ -29,8 +29,8 @@ exports[`0.13.0からマイグレーションできる 1`] = ` "enableMorphing": false, "enableMultiSelect": false, "shouldKeepTuningOnTextChange": false, - "showPreviewSoundEditor": false, "showParameterPanel": false, + "showPreviewSoundEditor": false, }, "hotkeySettings": [ {