fix: [sound-logo] 시퀀서/AI BGM 상호 배타적 재생 구조 적용

- AI BGM 있으면 시퀀서(수동/프리셋) 음표 제외
- 시퀀서 모드에서는 AI BGM 제외
- TTS 음성은 양쪽 모두 공통 재생
- exportWav도 동일 로직 적용 + 오프라인 컴프레서 추가
This commit is contained in:
김보곤
2026-03-08 14:53:05 +09:00
parent 7fd6b904f6
commit a14cfaae18

View File

@@ -1488,9 +1488,6 @@ function soundLogo() {
if (this.notes.length === 0 && !this.voiceBuffer && !this.bgmBuffer) {
return this.toast('재생할 콘텐츠가 없습니다.\n음표를 추가하거나 음성/배경음악을 먼저 생성해 주세요.', 'warn');
}
if (this.notes.length === 0 && !this.voiceBuffer && this.bgmBuffer) {
// BGM만 있는 경우 — 허용하되 안내
}
this.isPlaying = true;
this.pickerIdx = -1;
@@ -1500,18 +1497,29 @@ function soundLogo() {
let t = ctx.currentTime + 0.05;
const startTime = t;
this.notes.forEach((n, i) => {
const noteStart = t;
this.playNote(n, ctx, noteStart);
// AI BGM이 있으면 시퀀서(수동/프리셋) 제외, 없으면 시퀀서 재생
const useBgm = !!this.bgmBuffer;
// Highlight
const delayMs = (noteStart - startTime) * 1000;
setTimeout(() => { this.playingIdx = i; }, delayMs);
if (!useBgm) {
// 시퀀서 모드: 수동/프리셋 음표 재생
this.notes.forEach((n, i) => {
const noteStart = t;
this.playNote(n, ctx, noteStart);
const delayMs = (noteStart - startTime) * 1000;
setTimeout(() => { this.playingIdx = i; }, delayMs);
t += n.duration || 0.2;
});
} else {
// BGM 모드: AI 배경음악 재생
const bgmSrc = ctx.createBufferSource();
bgmSrc.buffer = this.bgmBuffer;
const bgmGain = ctx.createGain();
bgmGain.gain.value = this.bgmVolume;
bgmSrc.connect(bgmGain).connect(this.getMasterNode());
bgmSrc.start(startTime);
}
t += n.duration || 0.2;
});
// Voice overlay
// Voice(TTS)는 공통 — 양쪽 모드 모두 재생
if (this.voiceBuffer) {
const voiceSrc = ctx.createBufferSource();
voiceSrc.buffer = this.voiceBuffer;
@@ -1521,22 +1529,12 @@ function soundLogo() {
voiceSrc.start(startTime + this.voiceDelay);
}
// BGM overlay
if (this.bgmBuffer) {
const bgmSrc = ctx.createBufferSource();
bgmSrc.buffer = this.bgmBuffer;
const bgmGain = ctx.createGain();
bgmGain.gain.value = this.bgmVolume;
bgmSrc.connect(bgmGain).connect(this.getMasterNode());
bgmSrc.start(startTime);
}
// Draw waveform
this.drawWaveform(ctx);
const synthMs = (t - startTime) * 1000 + (this.adsr.release || 500);
const synthMs = !useBgm ? (t - startTime) * 1000 + (this.adsr.release || 500) : 0;
const voiceMs = this.voiceBuffer ? (this.voiceDelay + this.voiceBuffer.duration) * 1000 + 200 : 0;
const bgmMs = this.bgmBuffer ? this.bgmBuffer.duration * 1000 + 200 : 0;
const bgmMs = useBgm ? this.bgmBuffer.duration * 1000 + 200 : 0;
const totalMs = Math.max(synthMs, voiceMs, bgmMs);
setTimeout(() => {
this.isPlaying = false;
@@ -1606,64 +1604,76 @@ function soundLogo() {
}
const sampleRate = 44100;
const synthDur = this.getTotalDuration() + this.adsr.release / 1000 + 0.5;
const useBgm = !!this.bgmBuffer;
const synthDur = !useBgm ? this.getTotalDuration() + this.adsr.release / 1000 + 0.5 : 0;
const voiceDur = this.voiceBuffer ? this.voiceDelay + this.voiceBuffer.duration + 0.5 : 0;
const bgmDur = this.bgmBuffer ? this.bgmBuffer.duration + 0.5 : 0;
const totalDur = Math.max(synthDur, voiceDur, bgmDur);
const bgmDur = useBgm ? this.bgmBuffer.duration + 0.5 : 0;
const totalDur = Math.max(synthDur, voiceDur, bgmDur, 0.5);
const offline = new OfflineAudioContext(2, sampleRate * totalDur, sampleRate);
// 오프라인 컴프레서
const offComp = offline.createDynamicsCompressor();
offComp.threshold.value = -6;
offComp.knee.value = 10;
offComp.ratio.value = 12;
offComp.attack.value = 0.003;
offComp.release.value = 0.15;
offComp.connect(offline.destination);
let t = 0.05;
this.notes.forEach(n => {
if (n.type !== 'rest') {
const freqs = n.type === 'chord'
? (n.chord || []).map(c => this.noteFreq[c]).filter(Boolean)
: [this.noteFreq[n.note]].filter(Boolean);
const vel = (n.velocity || 0.8) * this.volume;
const a = this.adsr.attack / 1000;
const d = this.adsr.decay / 1000;
const s = this.adsr.sustain;
const r = this.adsr.release / 1000;
const dur = n.duration || 0.2;
if (!useBgm) {
// 시퀀서 모드: 수동/프리셋 음표만
this.notes.forEach(n => {
if (n.type !== 'rest') {
const freqs = n.type === 'chord'
? (n.chord || []).map(c => this.noteFreq[c]).filter(Boolean)
: [this.noteFreq[n.note]].filter(Boolean);
freqs.forEach(freq => {
const osc = offline.createOscillator();
const gain = offline.createGain();
osc.type = this.synth;
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(vel, t + a);
gain.gain.linearRampToValueAtTime(vel * s, t + a + d);
gain.gain.setValueAtTime(vel * s, t + dur);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur + r);
osc.connect(gain).connect(offline.destination);
osc.start(t);
osc.stop(t + dur + r + 0.05);
});
}
t += n.duration || 0.2;
});
const vel = (n.velocity || 0.8) * this.volume;
const a = this.adsr.attack / 1000;
const d = this.adsr.decay / 1000;
const s = this.adsr.sustain;
const r = this.adsr.release / 1000;
const dur = n.duration || 0.2;
// Voice overlay in offline context
freqs.forEach(freq => {
const osc = offline.createOscillator();
const gain = offline.createGain();
osc.type = this.synth;
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(vel, t + a);
gain.gain.linearRampToValueAtTime(vel * s, t + a + d);
gain.gain.setValueAtTime(vel * s, t + dur);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur + r);
osc.connect(gain).connect(offComp);
osc.start(t);
osc.stop(t + dur + r + 0.05);
});
}
t += n.duration || 0.2;
});
} else {
// BGM 모드: AI 배경음악만
const bgmSrc = offline.createBufferSource();
bgmSrc.buffer = this.bgmBuffer;
const bgmGain = offline.createGain();
bgmGain.gain.value = this.bgmVolume;
bgmSrc.connect(bgmGain).connect(offComp);
bgmSrc.start(0.05);
}
// Voice(TTS)는 공통
if (this.voiceBuffer) {
const voiceSrc = offline.createBufferSource();
voiceSrc.buffer = this.voiceBuffer;
const voiceGain = offline.createGain();
voiceGain.gain.value = this.voiceVolume;
voiceSrc.connect(voiceGain).connect(offline.destination);
voiceSrc.connect(voiceGain).connect(offComp);
voiceSrc.start(0.05 + this.voiceDelay);
}
// BGM overlay in offline context
if (this.bgmBuffer) {
const bgmSrc = offline.createBufferSource();
bgmSrc.buffer = this.bgmBuffer;
const bgmGain = offline.createGain();
bgmGain.gain.value = this.bgmVolume;
bgmSrc.connect(bgmGain).connect(offline.destination);
bgmSrc.start(0.05);
}
const buffer = await offline.startRendering();
const wav = this.bufferToWav(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });