diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 0ca90d5f..ba98d028 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -571,6 +571,12 @@ impl ObsBridgeService { result = listener.accept() => { match result { Ok((stream, addr)) => { + // 작은 키 이벤트 연속 전송의 Nagle 지연 방지 + if let Err(error) = stream.set_nodelay(true) { + log::warn!( + "[ObsBridge] TCP_NODELAY 설정 실패 from {addr}: {error}" + ); + } let bridge = Arc::clone(self); tokio::spawn(async move { bridge.handle_connection(stream, addr).await; diff --git a/src/renderer/api/modules/shared.test.ts b/src/renderer/api/modules/shared.test.ts new file mode 100644 index 00000000..e39116ea --- /dev/null +++ b/src/renderer/api/modules/shared.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { invoke, listen } = vi.hoisted(() => ({ + invoke: vi.fn(), + listen: vi.fn(() => Promise.resolve(vi.fn())), +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke })); +vi.mock('@tauri-apps/api/event', () => ({ listen })); + +import { subscribe } from './shared'; + +describe('subscribe', () => { + beforeEach(() => { + listen.mockReset(); + listen.mockResolvedValue(vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('이벤트 구독 등록 실패를 이벤트 이름과 함께 기록한다', async () => { + const error = new Error('registration failed'); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + listen.mockRejectedValueOnce(error); + + const unsubscribe = subscribe('keys:state', vi.fn()); + + await expect(unsubscribe.ready).rejects.toBe(error); + expect(consoleError).toHaveBeenCalledWith( + '[API] Failed to subscribe to event "keys:state":', + error, + ); + }); + + it('이벤트 구독 해제 실패를 이벤트 이름과 함께 기록한다', async () => { + const error = new Error('unlisten failed'); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const unlisten = vi.fn(() => { + throw error; + }); + listen.mockResolvedValueOnce(unlisten); + const unsubscribe = subscribe('keys:state', vi.fn()); + await unsubscribe.ready; + + unsubscribe(); + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + '[API] Failed to unsubscribe from event "keys:state":', + error, + ); + }); + }); +}); diff --git a/src/renderer/api/modules/shared.ts b/src/renderer/api/modules/shared.ts index 632b7bbc..a3d73e49 100644 --- a/src/renderer/api/modules/shared.ts +++ b/src/renderer/api/modules/shared.ts @@ -13,12 +13,26 @@ export function subscribe( const ready = registration.then(() => undefined); let unsubscribed = false; - void ready.catch(() => undefined); + void ready.catch((error) => { + console.error(`[API] Failed to subscribe to event "${event}":`, error); + }); const unsubscribe = () => { if (unsubscribed) return; unsubscribed = true; - void registration.then((unlisten) => unlisten()).catch(() => undefined); + void registration.then( + (unlisten) => { + void Promise.resolve() + .then(() => unlisten()) + .catch((error) => { + console.error( + `[API] Failed to unsubscribe from event "${event}":`, + error, + ); + }); + }, + () => undefined, + ); }; return Object.assign(unsubscribe, { ready }); diff --git a/src/renderer/stores/signals/noteBuffer.test.ts b/src/renderer/stores/signals/noteBuffer.test.ts index 1446e74d..69ffbe34 100644 --- a/src/renderer/stores/signals/noteBuffer.test.ts +++ b/src/renderer/stores/signals/noteBuffer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { NOTE_SETTINGS_CONSTRAINTS } from '@src/types/settings/noteSettingsConstraints'; import { createNoteBuffer } from './noteBuffer'; const layoutFor = (trackKey: string) => ({ @@ -49,11 +50,38 @@ describe('NoteBuffer 시각 저장', () => { buffer.updateTrackLayouts([layoutFor('Z')]); buffer.allocate('Z', 'note-1', 500); - expect(buffer.maybeRebaseEpoch(2_000_000)).toBe(false); + expect(buffer.maybeRebaseEpoch(524_288)).toBe(false); expect(buffer.timeEpoch).toBe(0); expect(buffer.noteInfo[0]).toBe(500); }); + it('Float32 간격이 설정 가능한 최소 노트 길이보다 작다', () => { + const rebaseLimitMs = 2 ** 19; + const float32SpacingMs = 2 ** (Math.floor(Math.log2(rebaseLimitMs)) - 23); + const minimumNoteLengthMs = + (NOTE_SETTINGS_CONSTRAINTS.shortNoteMinLengthPx.min * 1000) / + NOTE_SETTINGS_CONSTRAINTS.speed.max; + + expect(bufferAtEpochBoundary(rebaseLimitMs)).toBe(false); + expect(bufferAtEpochBoundary(rebaseLimitMs + 1)).toBe(true); + expect(float32SpacingMs).toBe(0.0625); + expect(minimumNoteLengthMs).toBeGreaterThan(float32SpacingMs); + }); + + it('이전 한도에서도 최소 노트 길이가 0으로 양자화되지 않는다', () => { + const buffer = createNoteBuffer(); + buffer.updateTrackLayouts([layoutFor('Z')]); + const startTime = 2 ** 21; + const minimumNoteLengthMs = + (NOTE_SETTINGS_CONSTRAINTS.shortNoteMinLengthPx.min * 1000) / + NOTE_SETTINGS_CONSTRAINTS.speed.max; + + buffer.allocate('Z', 'note-1', startTime); + buffer.finalize('note-1', startTime + minimumNoteLengthMs); + + expect(buffer.noteInfo[1] - buffer.noteInfo[0]).toBeGreaterThan(0); + }); + it('장시간 유휴 후 첫 할당은 자동 재기준화되고 sentinel 0을 피한다', () => { const buffer = createNoteBuffer(); buffer.updateTrackLayouts([layoutFor('Z')]); @@ -79,3 +107,8 @@ describe('NoteBuffer 시각 저장', () => { expect(buffer.noteInfo[1]).toBeCloseTo(50, 3); }); }); + +const bufferAtEpochBoundary = (nowMs: number): boolean => { + const buffer = createNoteBuffer(); + return buffer.maybeRebaseEpoch(nowMs); +}; diff --git a/src/renderer/stores/signals/noteBuffer.ts b/src/renderer/stores/signals/noteBuffer.ts index abd1f322..32b5408d 100644 --- a/src/renderer/stores/signals/noteBuffer.ts +++ b/src/renderer/stores/signals/noteBuffer.ts @@ -3,10 +3,11 @@ import { toRgbHexColor } from '@utils/color/colorUtils'; const MAX_NOTES = 2048; -// GPU 시각의 Float32 정밀도 유지 한도. performance.now()가 수일 누적되면 -// 간격이 32~64ms로 벌어져 짧은 노트 길이가 양자화되므로, 이 한도를 넘으면 -// epoch를 현재로 옮겨 절대값을 작게 유지 (2^21ms ≈ 35분, 해당 구간 정밀도 0.125ms) -const EPOCH_REBASE_LIMIT_MS = 2_097_152; +// GPU 시각은 Float32라 절대값이 커질수록 간격이 벌어진다. 노트 길이보다 간격이 +// 넓어지면 시작·종료가 같은 값으로 뭉개져 길이가 0이 되므로 epoch를 주기적으로 옮긴다. +// 최소 노트 길이는 1px / 9999px/s ≈ 0.10001ms 이고 2^19ms 구간의 간격은 0.0625ms라, +// 이 한도에서는 설정 범위 전체가 안전하다. 한도를 올리면 정밀도 테스트가 깨진다 +const EPOCH_REBASE_LIMIT_MS = 524_288; // 셰이더 sentinel(startTime 0.0 = 빈 슬롯, endTime 0.0 = 활성)과의 우연 충돌 방지 const EPOCH_ZERO_NUDGE = 1e-4;