Add original synthesized game audio and document sound provenance

This commit is contained in:
2026-10-15 00:03:23 +09:00
parent 05bc16e364
commit 16380eac79
11 changed files with 184 additions and 2 deletions
+23
View File
@@ -0,0 +1,23 @@
export const SOUND_MUTED_KEY = 'ssessesse:sound-muted';
export const SOUND_MASTER_VOLUME = 0.35;
export const SOUND_EVENT_HISTORY = 128;
export const SOUND_MAX_VOICES = 32;
export interface SoundPart {
wave: OscillatorType | 'noise'; from: number; to: number; duration: number; volume: number; delay?: number;
}
// Original synthesis recipes: no recordings, samples, borrowed melodies or voices.
export const SOUNDS = {
click: [{ wave: 'sine', from: 900, to: 540, duration: .055, volume: .18 }],
charge: [{ wave: 'sine', from: 140, to: 480, duration: .48, volume: .2 }, { wave: 'triangle', from: 210, to: 720, duration: .38, volume: .1, delay: .06 }],
blast: [{ wave: 'noise', from: 2200, to: 400, duration: .22, volume: .3 }, { wave: 'sine', from: 320, to: 65, duration: .18, volume: .22 }],
guard: [{ wave: 'triangle', from: 840, to: 650, duration: .2, volume: .2 }, { wave: 'sine', from: 1260, to: 980, duration: .24, volume: .09 }],
beam: [{ wave: 'sawtooth', from: 90, to: 180, duration: .6, volume: .1 }, { wave: 'noise', from: 700, to: 3600, duration: .65, volume: .24 }],
dodge: [{ wave: 'noise', from: 3800, to: 250, duration: .18, volume: .15 }, { wave: 'sine', from: 1200, to: 300, duration: .12, volume: .1, delay: .08 }],
cutscene1: [{ wave: 'sawtooth', from: 65, to: 260, duration: .7, volume: .11 }, { wave: 'noise', from: 300, to: 2300, duration: .6, volume: .18 }],
cutscene2: [{ wave: 'noise', from: 2400, to: 120, duration: .4, volume: .45 }, { wave: 'sine', from: 150, to: 35, duration: .5, volume: .35 }],
cutscene3: [{ wave: 'noise', from: 650, to: 100, duration: .55, volume: .18 }, { wave: 'triangle', from: 100, to: 40, duration: .45, volume: .16 }],
win: [{ wave: 'sine', from: 440, to: 660, duration: .2, volume: .2 }, { wave: 'triangle', from: 660, to: 880, duration: .35, volume: .18, delay: .18 }],
loss: [{ wave: 'triangle', from: 330, to: 165, duration: .38, volume: .16 }, { wave: 'sine', from: 165, to: 82, duration: .45, volume: .16, delay: .24 }],
draw: [{ wave: 'sine', from: 380, to: 380, duration: .18, volume: .14 }, { wave: 'sine', from: 380, to: 380, duration: .2, volume: .1, delay: .25 }],
} satisfies Record<string, readonly SoundPart[]>;
export type SoundId = keyof typeof SOUNDS;
+34
View File
@@ -0,0 +1,34 @@
# 효과음 출처 및 권리 검토
검토일: 2026-10-14
## 적용 소스
모든 효과음은 `constants/sound.ts`의 수치 설정과 `src/client/sound.ts`의 Web Audio 합성 코드로 실행 시 생성한다. 외부 녹음물, 샘플 라이브러리, 음악 파일, 음성, TTS 서비스, 게임에서 추출한 오디오는 사용하지 않았다. 새로운 오디오 의존성도 추가하지 않았다.
| 대상 | 생성 방식 |
| --- | --- |
| 메뉴·행동 선택 | 짧은 사인파 하강음 |
| 기 충전 | 사인파·삼각파 상승음 |
| 파 | 저역 하강음과 필터링한 노이즈 |
| 막기 | 두 주파수의 삼각파·사인파 |
| 에네르기파 | 톱니파와 필터 주파수가 상승하는 노이즈 |
| 순간이동 | 빠르게 하강하는 노이즈·사인파 |
| 컷씬 1단계: 공격 | 상승하는 톱니파·노이즈 |
| 컷씬 2단계: 명중 | 저역 충격음·노이즈 |
| 컷씬 3단계: 후속 반응 | 감쇠하는 저역음·노이즈 |
| 승리·패배·무승부 | 각각 상승·하강·반복하는 짧은 합성 신호 |
기존 게임의 음악·멜로디·시그니처 효과음·캐릭터 음성을 복제하거나 재현하도록 설계하지 않았다. 하스스톤은 캐릭터마다 같은 감정표현 항목을 다른 대사로 표현하는 상호작용만 참고했다. 대사는 프로젝트에서 새로 작성했다.
## 검토 기준과 범위
미국 저작권청의 안내는 음악 저작물과 녹음물이 별개 권리임을 설명한다. 따라서 단순히 녹음을 새로 만들었다는 이유로 기존 곡을 사용할 수 있다고 판단하지 않았다. 이번 변경은 기존 곡 및 녹음물을 입력으로 사용하지 않는 자체 합성으로 구성했다.
- https://www.copyright.gov/engage/musicians/
- https://www.copyright.gov/register/pa-sr.html
- 감정표현 참고: https://hearthstone.blizzard.com/en-us/heroes/hunter
이번 구현에는 별도 제3자 음원 라이선스나 출처 표기 조건이 적용되는 외부 오디오 자산이 없다. 이 기록은 사용 소스에 대한 기술적 검토이며, 모든 관할에서의 비침해나 합성 결과의 독점적 저작권을 보증하는 법률 의견은 아니다. 기존 게임 이름·그래픽 등 사운드 변경 밖의 자산은 이 검토 범위에 포함하지 않는다.
향후 외부 음원으로 교체할 때는 원본 URL, 저작자, 라이선스 전문·버전·확인일, 상업 이용·수정·배포 허용 여부, 표시 의무를 파일별로 기록한다. '무료 다운로드'만으로 사용을 승인하지 않는다.
+3
View File
@@ -1,5 +1,6 @@
import { ACTIONS } from '../../constants/gameplay.js'; import { ACTIONS } from '../../constants/gameplay.js';
import type { TurnReveal } from '../shared/types.js'; import type { TurnReveal } from '../shared/types.js';
import { sound } from './sound.js';
export function createActionRenderer(container: HTMLElement) { export function createActionRenderer(container: HTMLElement) {
let last = '', timer: ReturnType<typeof setTimeout> | undefined; let last = '', timer: ReturnType<typeof setTimeout> | undefined;
@@ -22,6 +23,8 @@ export function createActionRenderer(container: HTMLElement) {
const id = `${reveal.matchId}:${reveal.turnId}`; const id = `${reveal.matchId}:${reveal.turnId}`;
if (id === last || serverNow >= reveal.revealEndsAt) return; if (id === last || serverNow >= reveal.revealEndsAt) return;
reset(); last = id; reset(); last = id;
// Finishing attacks are scored by the cutscene instead of stacking both presentations.
if (reveal.hitIndex === null) reveal.actions.forEach((action, slot) => sound.play(action, `${id}:action:${slot}`, slot === 0 ? -.45 : .45));
// Effects follow the authoritative reveal window, including reconnects. // Effects follow the authoritative reveal window, including reconnects.
container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`); container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`);
container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`); container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`);
+1
View File
@@ -1,4 +1,5 @@
/* Keep the footer in layout during combat so it never covers action buttons. */ /* Keep the footer in layout during combat so it never covers action buttons. */
body.player-app .app-footer #sound-toggle { margin-right: auto; white-space: nowrap; }
body.player-app .app > footer.app-footer { body.player-app .app > footer.app-footer {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
+1 -1
View File
@@ -4,7 +4,7 @@ type FooterDialog = typeof FOOTER_LINKS[number]['id'];
export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) { export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) {
container.classList.add('app-footer'); container.classList.add('app-footer');
container.innerHTML = `<span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) => container.innerHTML = `<button type="button" id="sound-toggle" class="sound-toggle quiet" aria-pressed="true">소리 끄기</button><span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) =>
`<button type="button" class="quiet" id="${id}-open" aria-haspopup="dialog" aria-controls="${id}-dialog">${label}</button>`).join('<span aria-hidden="true">|</span>')}</nav>`; `<button type="button" class="quiet" id="${id}-open" aria-haspopup="dialog" aria-controls="${id}-dialog">${label}</button>`).join('<span aria-hidden="true">|</span>')}</nav>`;
const status = container.querySelector<HTMLElement>('#connection')!; const status = container.querySelector<HTMLElement>('#connection')!;
for (const { id } of FOOTER_LINKS) { for (const { id } of FOOTER_LINKS) {
+5
View File
@@ -4,6 +4,7 @@ import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js'; import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
import { finisherArt } from './fighter-art.js'; import { finisherArt } from './fighter-art.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { sound } from './sound.js';
// Survives view replacement on reconnect, without persisting anything to game state. // Survives view replacement on reconnect, without persisting anything to game state.
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined; let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined;
@@ -44,6 +45,10 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
: elapsed >= timing.fallen ? 'reaction' : elapsed >= timing.fallen ? 'reaction'
: elapsed >= timing.impact ? 'impact' : 'attack'; : elapsed >= timing.impact ? 'impact' : 'attack';
dialog.dataset.stage = stage; dialog.dataset.stage = stage;
if (stage !== 'complete') {
const cue = stage === 'attack' ? 'cutscene1' : stage === 'impact' ? 'cutscene2' : 'cutscene3';
sound.play(cue, `${activeMatch}:${cue}`);
}
if (complete) { if (complete) {
dismiss(); dismiss();
} else { } else {
+2
View File
@@ -17,6 +17,7 @@ import { fighterArt } from './fighter-art.js';
import { createLobbyFeatures } from './lobby-features.js'; import { createLobbyFeatures } from './lobby-features.js';
import { createGlobalSettingsCache } from './global-settings.js'; import { createGlobalSettingsCache } from './global-settings.js';
import { createAppFooter } from './app-footer.js'; import { createAppFooter } from './app-footer.js';
import { installSoundControls } from './sound.js';
import './game.css'; import './game.css';
import './match.css'; import './match.css';
import './home.css'; import './home.css';
@@ -28,6 +29,7 @@ const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElem
const footer = createAppFooter(el('app-footer'), { const footer = createAppFooter(el('app-footer'), {
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'), rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
}); });
installSoundControls();
const globalSettings = createGlobalSettingsCache({ const globalSettings = createGlobalSettingsCache({
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
}); });
+3 -1
View File
@@ -7,6 +7,7 @@ import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js'
import { createFinishCutscene } from './finish-cutscene.js'; import { createFinishCutscene } from './finish-cutscene.js';
import { matchIntroductionMessage } from '../shared/match-introduction.js'; import { matchIntroductionMessage } from '../shared/match-introduction.js';
import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } from '../../constants/emotes.js'; import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } from '../../constants/emotes.js';
import { sound } from './sound.js';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}"> const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<div class="emote-bubble" role="status" hidden></div> <div class="emote-bubble" role="status" hidden></div>
@@ -130,6 +131,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
const f = s.finished; const f = s.finished;
el('.finish-reason').hidden = f.reason === 'hit'; el('.finish-reason').hidden = f.reason === 'hit';
const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss'; const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
if (outcome !== 'aborted') sound.play(outcome, `${s.matchId}:result`);
container.dataset.outcome = outcome; container.dataset.outcome = outcome;
arena.dataset.result = outcome; arena.dataset.result = outcome;
el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome]; el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome];
@@ -180,6 +182,6 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
} }
tick(); tick();
cutscene.update(state); cutscene.update(state);
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; }, }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.innerHTML = ''; current = undefined; },
}; };
} }
+86
View File
@@ -0,0 +1,86 @@
import { SOUNDS, SOUND_EVENT_HISTORY, SOUND_MASTER_VOLUME, SOUND_MAX_VOICES, SOUND_MUTED_KEY, type SoundId, type SoundPart } from '../../constants/sound.js';
export function createSound(options: { context?: () => AudioContext; storage?: Pick<Storage, 'getItem' | 'setItem'> } = {}) {
let context: AudioContext | undefined, master: GainNode | undefined, muted = false;
const events = new Set<string>(), voices = new Set<AudioScheduledSourceNode>();
const storage = () => options.storage ?? globalThis.localStorage;
try { muted = storage()?.getItem(SOUND_MUTED_KEY) === 'true'; } catch { /* Optional preference storage. */ }
function stop() {
for (const voice of voices) { try { voice.stop(); } catch { /* Already ended. */ } }
voices.clear();
}
return {
get muted() { return muted; },
async unlock() {
if (muted) return;
try {
if (!context) {
context = options.context?.() ?? new AudioContext();
master = context.createGain(); master.gain.value = SOUND_MASTER_VOLUME;
const compressor = context.createDynamicsCompressor();
master.connect(compressor); compressor.connect(context.destination);
}
if (context.state === 'suspended') await context.resume();
} catch { /* Audio support/autoplay failures must not interrupt gameplay. */ }
},
setMuted(value: boolean) {
muted = value; if (muted) stop();
try { storage()?.setItem(SOUND_MUTED_KEY, String(value)); } catch { /* Keep the in-memory preference. */ }
},
stop,
play(id: SoundId, key?: string, pan = 0) {
if (key) {
if (events.has(key)) return;
events.add(key);
if (events.size > SOUND_EVENT_HISTORY) events.delete(events.values().next().value!);
}
if (muted || !context || !master || context.state !== 'running' || (typeof document !== 'undefined' && document.hidden)) return;
const ctx = context;
for (const part of SOUNDS[id] as readonly SoundPart[]) {
if (voices.size >= SOUND_MAX_VOICES) break;
const at = ctx.currentTime + (part.delay ?? 0), end = at + part.duration;
const envelope = ctx.createGain(), panner = ctx.createStereoPanner();
panner.pan.value = Math.max(-1, Math.min(1, pan));
envelope.gain.setValueAtTime(0, at);
envelope.gain.linearRampToValueAtTime(part.volume, at + Math.min(.012, part.duration / 4));
envelope.gain.exponentialRampToValueAtTime(.0001, end);
envelope.gain.linearRampToValueAtTime(0, end + .01);
envelope.connect(panner); panner.connect(master);
let source: AudioScheduledSourceNode, filter: BiquadFilterNode | undefined;
if (part.wave === 'noise') {
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * (part.duration + .02)), ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
const noise = ctx.createBufferSource(); noise.buffer = buffer; source = noise;
filter = ctx.createBiquadFilter(); filter.type = 'lowpass';
filter.frequency.setValueAtTime(part.from, at); filter.frequency.exponentialRampToValueAtTime(part.to, end);
source.connect(filter); filter.connect(envelope);
} else {
const oscillator = ctx.createOscillator(); source = oscillator; oscillator.type = part.wave;
oscillator.frequency.setValueAtTime(part.from, at); oscillator.frequency.exponentialRampToValueAtTime(part.to, end);
source.connect(envelope);
}
voices.add(source);
source.onended = () => { voices.delete(source); source.disconnect(); filter?.disconnect(); envelope.disconnect(); panner.disconnect(); };
source.start(at); source.stop(end + .02);
}
},
};
}
export const sound = createSound();
export function installSoundControls() {
const button = document.getElementById('sound-toggle') as HTMLButtonElement;
const refresh = () => { button.textContent = sound.muted ? '소리 켜기' : '소리 끄기'; button.setAttribute('aria-pressed', String(!sound.muted)); };
refresh();
button.onclick = () => { sound.setMuted(!sound.muted); refresh(); void sound.unlock(); };
document.addEventListener('pointerdown', () => { void sound.unlock(); }, { passive: true });
document.addEventListener('keydown', () => { void sound.unlock(); });
document.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target.closest('button, summary') : null;
if (!target || target.id === 'sound-toggle' || target.matches(':disabled')) return;
void sound.unlock().then(() => sound.play('click'));
}, true);
document.addEventListener('visibilitychange', () => { if (document.hidden) sound.stop(); });
window.addEventListener('pagehide', () => sound.stop());
}
+1
View File
@@ -41,6 +41,7 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') {
GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send), GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send),
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send), createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
createAppFooter: () => ({ setConnection() {}, openRules() {} }), createAppFooter: () => ({ setConnection() {}, openRules() {} }),
installSoundControls() {},
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSound } from '../src/client/sound.js';
test('audio starts only after unlock, deduplicates events, stops on mute and persists preference', async () => {
let created = 0, started = 0, stopped = 0;
const values = new Map<string, string>();
const param = () => ({ value: 0, setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} });
const node = () => ({ connect() {}, disconnect() {}, gain: param(), frequency: param(), pan: param(),
start() { started++; }, stop() { stopped++; }, onended: null });
const context = { state: 'suspended', currentTime: 0, sampleRate: 44100, destination: {},
async resume() { this.state = 'running'; }, createGain: node, createOscillator: node,
createStereoPanner: node, createDynamicsCompressor: node,
};
const sound = createSound({ context: () => { created++; return context as unknown as AudioContext; },
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } } });
sound.play('click'); assert.equal(created, 0);
await sound.unlock(); assert.equal(created, 1);
sound.play('click', 'one'); const count = started;
sound.play('click', 'one'); assert.equal(started, count);
sound.play('click', 'two'); assert.ok(started > count);
sound.setMuted(true); assert.ok(stopped > 0);
const mutedCount = started; sound.play('click'); assert.equal(started, mutedCount);
assert.ok([...values.values()].includes('true'));
});