Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src-tauri/src/services/obs_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions src/renderer/api/modules/shared.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
});
18 changes: 16 additions & 2 deletions src/renderer/api/modules/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,26 @@ export function subscribe<T>(
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 });
Expand Down
35 changes: 34 additions & 1 deletion src/renderer/stores/signals/noteBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -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) => ({
Expand Down Expand Up @@ -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')]);
Expand All @@ -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);
};
9 changes: 5 additions & 4 deletions src/renderer/stores/signals/noteBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading