From 47c64f419263cd087415c2ba0cc235f73cca4356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 27 Jul 2026 01:18:51 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20OBS=20=EB=B8=8C=EB=A6=BF=EC=A7=80=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=EC=97=90=20TCP=5FNODELAY=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=B4=20=ED=82=A4=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=EC=86=A1=20=EC=A7=80=EC=97=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/obs_bridge.rs | 6 ++++++ 1 file changed, 6 insertions(+) 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; From 64cd58968279d9e01bc5da1f62293ca41e85163f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 27 Jul 2026 01:18:51 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=85=B8=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EC=9E=AC=EA=B8=B0=EC=A4=80=ED=99=94=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=EB=82=AE=EC=B6=B0=20=EC=A7=A7=EC=9D=80=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=EA=B0=80=20=EC=96=91=EC=9E=90=ED=99=94?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stores/signals/noteBuffer.test.ts | 35 ++++++++++++++++++- src/renderer/stores/signals/noteBuffer.ts | 9 ++--- 2 files changed, 39 insertions(+), 5 deletions(-) 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; From 9671d60479effc0e436bf5db86a2fe5999cec570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 27 Jul 2026 01:18:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EB=93=B1=EB=A1=9D=EA=B3=BC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/api/modules/shared.test.ts | 60 +++++++++++++++++++++++++ src/renderer/api/modules/shared.ts | 18 +++++++- 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/renderer/api/modules/shared.test.ts 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 });