여러 SOOP 라이브 탭에서 공통으로 재생되는 영상 소리를 찾아 대상 탭을 자동 정렬하고 싱크를 유지합니다.
// ==UserScript==
// @name SOOP Common Audio Auto Sync
// @namespace https://chatgpt.com/
// @version 2.1.2
// @description 여러 SOOP 라이브 탭에서 공통으로 재생되는 영상 소리를 찾아 대상 탭을 자동 정렬하고 싱크를 유지합니다.
// @author ChatGPT
// @match https://play.sooplive.com/*
// @run-at document-start
// @grant none
// @inject-into page
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const APP_ID = '__soop_common_audio_auto_sync_v2__';
if (window[APP_ID]) return;
window[APP_ID] = true;
const VERSION = '2.1.1';
const CHANNEL_NAME = 'soop-common-audio-sync:v2';
const STORAGE_KEY = 'soop-common-audio-sync:config:v2';
const PANEL_KEY = 'soop-common-audio-sync:panel:v2';
const TAB_SESSION_KEY = 'soop-common-audio-sync:tab-id:v2';
const PAGE_PATH_RE = /^\/([^/]+)(?:\/(\d+))?/;
const PRESENCE_INTERVAL_MS = 1000;
const PRESENCE_TTL_MS = 5200;
const BUFFER_KEEP_MS = 48000;
const AUDIO_FRAME_RATE = 8000;
const PCM_RATE = 4000;
const FEATURE_FRAME_SAMPLES = 256;
const FEATURE_FRAME_MS = FEATURE_FRAME_SAMPLES / AUDIO_FRAME_RATE * 1000;
const FEATURE_BANDS = 11;
const DEFAULT_SETTINGS = Object.freeze({
searchRangeSec: 12,
featureWindowSec: 8,
coarseStepMs: 32,
coarseRefineStepMs: 8,
fineRangeMs: 180,
fineWindowSec: 2.4,
minCoarseScore: 0.24,
minCoarseGap: 0.018,
minFineCorrelation: 0.12,
minCombinedConfidence: 0.58,
hardSeekThresholdSec: 0.62,
hardSeekConfidence: 0.65,
hardSeekConfirmations: 3,
hardSeekCooldownMs: 2600,
postSeekMuteMs: 1450,
deadbandSec: 0.018,
softCorrectionLimitSec: 0.70,
proportionalGain: 0.11,
maxRateDelta: 0.04,
controlIntervalMs: 180,
matchIntervalMs: 900,
staleAudioMs: 1800,
lostHoldMs: 2500,
minSeekMarginSec: 0.10,
panel: { right: 18, top: 72, collapsed: false, advanced: false, hidden: false },
});
const WORKLET_SOURCE = String.raw`
class SoopFingerprintProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.targetRate = 8000;
this.frameSize = 256;
this.phase = 0;
this.low = 0;
this.lowAlpha = 1 - Math.exp(-2 * Math.PI * 3500 / sampleRate);
this.frame = new Float32Array(this.frameSize);
this.frameIndex = 0;
this.frameStartTime = 0;
this.prevBands = new Float32Array(11);
this.havePrevBands = false;
this.hpPrevInput = 0;
this.hpPrevOutput = 0;
this.freqs = [90, 140, 220, 340, 520, 800, 1200, 1700, 2300, 3000, 3600];
this.coeffs = this.freqs.map((f) => 2 * Math.cos(2 * Math.PI * f / this.targetRate));
this.window = new Float32Array(this.frameSize);
for (let i = 0; i < this.frameSize; i++) {
this.window[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (this.frameSize - 1));
}
}
process(inputs) {
const input = inputs[0];
if (!input || input.length === 0 || !input[0]) return true;
const channelCount = input.length;
const length = input[0].length;
const blockStart = currentFrame / sampleRate;
for (let i = 0; i < length; i++) {
let mono = 0;
for (let ch = 0; ch < channelCount; ch++) {
const value = input[ch]?.[i];
if (Number.isFinite(value)) mono += value;
}
mono /= Math.max(1, channelCount);
this.low += this.lowAlpha * (mono - this.low);
this.phase += this.targetRate;
if (this.phase >= sampleRate) {
this.phase -= sampleRate;
this.pushSample(this.low, blockStart + i / sampleRate);
}
}
return true;
}
pushSample(value, contextTime) {
if (this.frameIndex === 0) this.frameStartTime = contextTime;
this.frame[this.frameIndex++] = value;
if (this.frameIndex >= this.frameSize) {
this.emitFrame();
this.frameIndex = 0;
}
}
emitFrame() {
const frame = this.frame;
let sumSq = 0;
let peak = 0;
for (let i = 0; i < frame.length; i++) {
const x = frame[i];
sumSq += x * x;
peak = Math.max(peak, Math.abs(x));
}
const rms = Math.sqrt(sumSq / frame.length);
const rawBands = new Float32Array(this.coeffs.length);
for (let b = 0; b < this.coeffs.length; b++) {
const coeff = this.coeffs[b];
let s1 = 0;
let s2 = 0;
for (let i = 0; i < frame.length; i++) {
const s0 = frame[i] * this.window[i] + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
const power = Math.max(0, s1 * s1 + s2 * s2 - coeff * s1 * s2);
rawBands[b] = Math.log1p(power * 20);
}
let mean = 0;
for (const v of rawBands) mean += v;
mean /= rawBands.length;
let norm = 0;
for (let i = 0; i < rawBands.length; i++) {
rawBands[i] -= mean;
norm += rawBands[i] * rawBands[i];
}
norm = Math.sqrt(norm) || 1;
const bandBytes = new Int8Array(rawBands.length);
const deltaFloat = new Float32Array(rawBands.length);
let deltaNorm = 0;
let flux = 0;
for (let i = 0; i < rawBands.length; i++) {
const normalized = rawBands[i] / norm;
bandBytes[i] = Math.max(-127, Math.min(127, Math.round(normalized * 100)));
const delta = this.havePrevBands ? normalized - this.prevBands[i] : 0;
deltaFloat[i] = delta;
deltaNorm += delta * delta;
if (delta > 0) flux += delta * delta;
this.prevBands[i] = normalized;
}
this.havePrevBands = true;
deltaNorm = Math.sqrt(deltaNorm) || 1;
flux = Math.sqrt(flux);
const deltaBytes = new Int8Array(deltaFloat.length);
for (let i = 0; i < deltaFloat.length; i++) {
deltaBytes[i] = Math.max(-127, Math.min(127, Math.round(deltaFloat[i] / deltaNorm * 100)));
}
const pcmFloat = new Float32Array(frame.length / 2);
let pcmSq = 0;
let pcmIndex = 0;
for (let i = 0; i < frame.length; i++) {
const input = frame[i];
const hp = input - this.hpPrevInput + 0.96 * this.hpPrevOutput;
this.hpPrevInput = input;
this.hpPrevOutput = hp;
if ((i & 1) === 0) {
pcmFloat[pcmIndex++] = hp;
pcmSq += hp * hp;
}
}
const pcmRms = Math.sqrt(pcmSq / pcmFloat.length);
const pcmBytes = new Int8Array(pcmFloat.length);
if (pcmRms >= 0.00008) {
const scale = Math.min(8000, 38 / pcmRms);
for (let i = 0; i < pcmFloat.length; i++) {
pcmBytes[i] = Math.max(-127, Math.min(127, Math.round(pcmFloat[i] * scale)));
}
}
this.port.postMessage({
type: 'frame',
contextTime: this.frameStartTime,
bands: bandBytes,
delta: deltaBytes,
pcm: pcmBytes,
rms,
peak,
flux,
}, [bandBytes.buffer, deltaBytes.buffer, pcmBytes.buffer]);
}
}
registerProcessor('soop-fingerprint-v2', SoopFingerprintProcessor);
`;
const state = {
tabId: getOrCreateTabId(),
instanceId: randomId('instance'),
page: readPageIdentity(),
video: null,
videoListeners: [],
observer: null,
scanTimer: null,
presenceTimer: null,
pruneTimer: null,
matchTimer: null,
controlTimer: null,
uiTimer: null,
bc: null,
peers: new Map(),
peerStatus: new Map(),
config: normalizeConfig(loadJson(STORAGE_KEY, null)),
panelSettings: { ...DEFAULT_SETTINGS.panel, ...loadJson(PANEL_KEY, {}) },
ui: null,
analyzer: createAnalyzerState(),
localFeatures: [],
localPcm: [],
referenceFeatures: [],
referencePcm: [],
currentReferenceId: null,
lastMatchAt: 0,
matchHistory: [],
latestMatch: null,
smoothedLagSec: null,
previousLagForDerivative: null,
previousLagAt: 0,
lastAppliedRate: 1,
lastHardSeekAt: 0,
suppressUntil: 0,
lostSince: 0,
statusText: '탭 검색 중',
statusKind: 'normal',
statusDetail: '',
lastStatusBroadcastAt: 0,
configReconcileAt: 0,
renderNeeded: true,
flashUntil: 0,
flashText: '',
matchingBusy: false,
fullSearchAt: 0,
gestureHooked: false,
};
function randomId(prefix = 'id') {
const bytes = new Uint32Array(3);
crypto.getRandomValues(bytes);
return `${prefix}-${[...bytes].map((v) => v.toString(36)).join('')}`;
}
function getOrCreateTabId() {
// 탭 복제 시 sessionStorage도 복제될 수 있으므로, 매 로드마다 instance nonce를 붙여 충돌을 방지한다.
let base = '';
try { base = sessionStorage.getItem(TAB_SESSION_KEY) || ''; } catch { /* 무시 */ }
if (!base) {
base = randomId('tab');
try { sessionStorage.setItem(TAB_SESSION_KEY, base); } catch { /* 무시 */ }
}
return `${base}-${Math.random().toString(36).slice(2, 7)}`;
}
function readPageIdentity() {
const match = location.pathname.match(PAGE_PATH_RE);
const channelId = match?.[1] || 'unknown';
const broadcastId = match?.[2] || 'current';
return {
channelId,
broadcastId,
pageKey: `${channelId}/${broadcastId}`,
url: location.href,
title: cleanTitle(document.title) || channelId,
};
}
function cleanTitle(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 100);
}
function loadJson(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch {
return fallback;
}
}
function saveJson(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
function normalizeConfig(input) {
const source = input && typeof input === 'object' ? input : {};
const settings = { ...DEFAULT_SETTINGS, ...(source.settings || {}) };
delete settings.panel;
const members = Array.isArray(source.members)
? source.members.filter((item) => item && typeof item.tabId === 'string').map((item) => ({
tabId: item.tabId,
pageKey: String(item.pageKey || ''),
label: String(item.label || ''),
manualOffsetMs: finiteNumber(item.manualOffsetMs, 0),
}))
: [];
return {
schema: 2,
enabled: Boolean(source.enabled),
members,
referenceTabId: typeof source.referenceTabId === 'string' ? source.referenceTabId : null,
updatedAt: finiteNumber(source.updatedAt, 0),
writer: String(source.writer || ''),
seq: finiteNumber(source.seq, 0),
settings,
};
}
function finiteNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function median(values) {
if (!values.length) return NaN;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
function percentile(values, p) {
if (!values.length) return NaN;
const sorted = [...values].sort((a, b) => a - b);
const index = clamp((sorted.length - 1) * p, 0, sorted.length - 1);
const low = Math.floor(index);
const high = Math.ceil(index);
if (low === high) return sorted[low];
const ratio = index - low;
return sorted[low] * (1 - ratio) + sorted[high] * ratio;
}
function compareConfigVersion(a, b) {
if ((a?.updatedAt || 0) !== (b?.updatedAt || 0)) return (a?.updatedAt || 0) - (b?.updatedAt || 0);
if ((a?.seq || 0) !== (b?.seq || 0)) return (a?.seq || 0) - (b?.seq || 0);
return String(a?.writer || '').localeCompare(String(b?.writer || ''));
}
function updateConfig(mutator, reason = 'config') {
const draft = normalizeConfig(structuredCloneSafe(state.config));
mutator(draft);
draft.members = dedupeMembers(draft.members);
if (draft.referenceTabId && !draft.members.some((m) => m.tabId === draft.referenceTabId)) {
draft.referenceTabId = null;
}
draft.updatedAt = Date.now();
draft.seq = (state.config.seq || 0) + 1;
draft.writer = state.tabId;
state.config = normalizeConfig(draft);
saveJson(STORAGE_KEY, state.config);
postMessage({ type: 'config', config: state.config, reason });
onConfigChanged(reason);
}
function structuredCloneSafe(value) {
try { return structuredClone(value); } catch { return JSON.parse(JSON.stringify(value)); }
}
function dedupeMembers(members) {
const seen = new Set();
const result = [];
for (const item of members || []) {
if (!item?.tabId || seen.has(item.tabId)) continue;
seen.add(item.tabId);
result.push(item);
}
return result;
}
function acceptRemoteConfig(config, reason = 'remote') {
const normalized = normalizeConfig(config);
if (compareConfigVersion(normalized, state.config) <= 0) return false;
state.config = normalized;
saveJson(STORAGE_KEY, state.config);
onConfigChanged(reason);
return true;
}
function createAnalyzerState() {
return {
status: 'off',
detail: '대기',
mode: 'none',
video: null,
context: null,
source: null,
node: null,
silentGain: null,
stream: null,
workletUrl: null,
fallbackExtractor: null,
startedAt: 0,
lastFrameAt: 0,
lastSignalAt: 0,
signalRms: 0,
signalPeak: 0,
clockBaseEpochMs: 0,
clockUpdatedAt: 0,
retryAt: 0,
generation: 0,
trackListeners: [],
startPromise: null,
};
}
function isSelected(tabId = state.tabId) {
return state.config.members.some((member) => member.tabId === tabId);
}
function isReference(tabId = state.tabId) {
return state.config.referenceTabId === tabId;
}
function memberFor(tabId) {
return state.config.members.find((member) => member.tabId === tabId) || null;
}
function referenceMember() {
return memberFor(state.config.referenceTabId);
}
function currentManualOffsetSec() {
return finiteNumber(memberFor(state.tabId)?.manualOffsetMs, 0) / 1000;
}
function postMessage(payload) {
try {
state.bc?.postMessage({ ...payload, from: state.tabId, instanceId: state.instanceId, sentAt: Date.now() });
} catch {
// 탭 종료 과정이나 BroadcastChannel 비지원 시 무시한다.
}
}
function startBroadcastChannel() {
if (!('BroadcastChannel' in window)) {
setStatus('BroadcastChannel 미지원', 'error', '이 브라우저에서는 탭 간 동기화가 불가능합니다.');
return;
}
state.bc = new BroadcastChannel(CHANNEL_NAME);
state.bc.onmessage = (event) => handleChannelMessage(event.data);
}
function handleChannelMessage(message) {
if (!message || message.from === state.tabId) return;
switch (message.type) {
case 'presence':
state.peers.set(message.from, { ...message, receivedAt: Date.now() });
state.renderNeeded = true;
break;
case 'bye':
state.peers.delete(message.from);
state.peerStatus.delete(message.from);
state.renderNeeded = true;
break;
case 'config':
acceptRemoteConfig(message.config, 'broadcast');
break;
case 'audio':
handleRemoteAudio(message);
break;
case 'sync-status':
state.peerStatus.set(message.from, { ...message.status, receivedAt: Date.now() });
state.renderNeeded = true;
break;
case 'command':
handleRemoteCommand(message);
break;
default:
break;
}
}
function handleRemoteCommand(message) {
if (message.command === 'reset-match') {
state.localFeatures.length = 0;
state.localPcm.length = 0;
resetMatching('원격 재탐색');
ensureAnalyzer('원격 재탐색');
} else if (message.command === 'restore-rate') {
restoreNormalRate();
} else if (message.command === 'start-analysis') {
if (isSelected()) ensureAnalyzer('그룹 시작');
} else if (message.command === 'set-ui-hidden') {
setUiHidden(Boolean(message.hidden), { broadcast: false, announce: false });
}
}
function sendPresence() {
state.page = readPageIdentity();
const presence = buildPresence();
state.peers.set(state.tabId, { ...presence, from: state.tabId, receivedAt: Date.now() });
postMessage({ type: 'presence', ...presence });
}
function buildPresence() {
const video = state.video;
return {
pageKey: state.page.pageKey,
channelId: state.page.channelId,
broadcastId: state.page.broadcastId,
title: cleanTitle(document.title) || state.page.channelId,
url: location.href,
selected: isSelected(),
reference: isReference(),
hidden: document.hidden,
videoReady: Boolean(video && video.readyState >= 2 && !video.error),
paused: Boolean(video?.paused),
analyzerStatus: state.analyzer.status,
analyzerMode: state.analyzer.mode,
signalRms: state.analyzer.signalRms,
lastAudioFrameAt: state.analyzer.lastFrameAt,
playbackRate: finiteNumber(video?.playbackRate, 1),
};
}
function prunePeers() {
const now = Date.now();
let changed = false;
for (const [tabId, peer] of state.peers) {
if (tabId === state.tabId) continue;
if (now - peer.receivedAt > PRESENCE_TTL_MS) {
state.peers.delete(tabId);
state.peerStatus.delete(tabId);
changed = true;
}
}
if (changed) state.renderNeeded = true;
reconcileMissingMembers();
}
function reconcileMissingMembers() {
const now = Date.now();
if (now - state.configReconcileAt < 2500) return;
state.configReconcileAt = now;
const active = [...state.peers.entries()].filter(([, peer]) => now - peer.receivedAt <= PRESENCE_TTL_MS);
const replacements = new Map();
const occupied = new Set(state.config.members.map((m) => m.tabId));
for (const member of state.config.members) {
if (state.peers.has(member.tabId)) continue;
const candidates = active.filter(([tabId, peer]) => !occupied.has(tabId) && peer.pageKey === member.pageKey);
if (candidates.length === 1) replacements.set(member.tabId, candidates[0][0]);
}
if (!replacements.size) return;
updateConfig((draft) => {
for (const member of draft.members) {
const replacement = replacements.get(member.tabId);
if (replacement) {
if (draft.referenceTabId === member.tabId) draft.referenceTabId = replacement;
member.tabId = replacement;
}
}
}, '탭 재연결');
}
function onConfigChanged(reason) {
state.renderNeeded = true;
const newReference = state.config.referenceTabId;
if (newReference !== state.currentReferenceId) {
state.currentReferenceId = newReference;
resetMatching('기준 탭 변경');
}
if (!state.config.enabled) {
resetMatching('자동 싱크 정지');
} else if (!isSelected()) {
resetMatching('대상 탭 해제');
}
if (isSelected()) ensureAnalyzer(reason);
if (state.config.enabled) postMessage({ type: 'command', command: 'start-analysis' });
sendPresence();
updateMainStatus();
}
function resetMatching(reason = '재탐색') {
state.referenceFeatures.length = 0;
state.referencePcm.length = 0;
state.matchHistory.length = 0;
state.latestMatch = null;
state.smoothedLagSec = null;
state.previousLagForDerivative = null;
state.previousLagAt = 0;
state.lostSince = 0;
state.fullSearchAt = 0;
if (!isReference()) setStatus(reason, 'work');
restoreNormalRate();
}
function findBestVideo() {
const exact = document.querySelector('video#livePlayer');
if (exact) return exact;
const candidates = [...document.querySelectorAll('video')]
.filter((video) => video.id !== 'pipMedia')
.map((video) => {
const rect = video.getBoundingClientRect();
return { video, area: Math.max(0, rect.width) * Math.max(0, rect.height) };
})
.sort((a, b) => b.area - a.area);
return candidates[0]?.video || null;
}
function scanVideo() {
const found = findBestVideo();
if (found && found !== state.video) bindVideo(found);
if (!found && state.video && !document.contains(state.video)) unbindVideo();
}
function bindVideo(video) {
if (!video || video === state.video) return;
unbindVideo();
state.video = video;
state.localFeatures.length = 0;
state.localPcm.length = 0;
resetMatching('영상 다시 연결');
const rescanEvents = ['loadedmetadata', 'emptied', 'durationchange', 'playing', 'canplay', 'seeked'];
for (const eventName of rescanEvents) {
const handler = () => {
if (eventName === 'emptied') resetAfterTimelineJump('미디어 재생성');
if (eventName === 'seeked' && Date.now() > state.suppressUntil) resetAfterTimelineJump('외부 시킹 감지');
ensureAnalyzer(`video:${eventName}`);
updateMainStatus();
};
video.addEventListener(eventName, handler, { passive: true });
state.videoListeners.push([eventName, handler]);
}
const rateHandler = () => {
if (state.config.enabled && isSelected() && !isReference()) controlSync(true);
};
video.addEventListener('ratechange', rateHandler, { passive: true });
state.videoListeners.push(['ratechange', rateHandler]);
if (isSelected()) ensureAnalyzer('영상 연결');
state.renderNeeded = true;
updateMainStatus();
}
function unbindVideo() {
if (state.video) {
restoreNormalRate();
for (const [eventName, handler] of state.videoListeners) {
try { state.video.removeEventListener(eventName, handler); } catch { /* 무시 */ }
}
}
state.videoListeners = [];
state.video = null;
stopAnalyzer('영상 없음');
}
function restartAnalyzerSoon(reason) {
stopAnalyzer(reason);
state.analyzer.retryAt = Date.now() + 600;
}
function resetAfterTimelineJump(reason) {
state.localFeatures.length = 0;
state.localPcm.length = 0;
state.referenceFeatures.length = 0;
state.referencePcm.length = 0;
state.matchHistory.length = 0;
state.latestMatch = null;
state.smoothedLagSec = null;
state.previousLagForDerivative = null;
state.previousLagAt = 0;
state.suppressUntil = Date.now() + 900;
restoreNormalRate();
restartAnalyzerSoon(reason);
if (state.config.enabled && isReference()) postMessage({ type: 'command', command: 'reset-match' });
setStatus(reason, 'work');
}
async function ensureAnalyzer(reason = '요청') {
if (!isSelected()) return false;
const video = state.video;
if (!video) {
setAnalyzerStatus('waiting-video', '영상 요소 대기');
return false;
}
if (state.analyzer.video === video && ['running', 'suspended', 'starting'].includes(state.analyzer.status)) {
if (state.analyzer.context?.state === 'suspended') attemptResumeAudioContext();
return state.analyzer.status === 'running';
}
if (state.analyzer.startPromise) return state.analyzer.startPromise;
if (Date.now() < state.analyzer.retryAt) return false;
state.analyzer.startPromise = startAnalyzer(video, reason).finally(() => {
state.analyzer.startPromise = null;
});
return state.analyzer.startPromise;
}
async function startAnalyzer(video, reason) {
stopAnalyzer('재시작');
const generation = ++state.analyzer.generation;
state.analyzer.video = video;
state.analyzer.status = 'starting';
state.analyzer.detail = reason;
state.analyzer.startedAt = Date.now();
state.renderNeeded = true;
try {
const capture = video.captureStream || video.webkitCaptureStream;
if (typeof capture !== 'function') {
throw new Error('이 브라우저가 video.captureStream()을 지원하지 않습니다.');
}
const stream = capture.call(video);
state.analyzer.stream = stream;
let audioTrack = stream.getAudioTracks()[0];
if (!audioTrack) {
await waitForAudioTrack(stream, 2600);
audioTrack = stream.getAudioTracks()[0];
}
if (!audioTrack) throw new Error('캡처 스트림에서 오디오 트랙을 찾지 못했습니다.');
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) throw new Error('Web Audio API를 지원하지 않습니다.');
const context = new AudioContextClass({ latencyHint: 'interactive' });
state.analyzer.context = context;
updateAudioClockMapping();
const source = context.createMediaStreamSource(stream);
state.analyzer.source = source;
const silentGain = context.createGain();
silentGain.gain.value = 0;
state.analyzer.silentGain = silentGain;
silentGain.connect(context.destination);
let processorMode = 'audio-worklet';
try {
const blob = new Blob([WORKLET_SOURCE], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
state.analyzer.workletUrl = url;
if (!context.audioWorklet?.addModule) throw new Error('AudioWorklet을 사용할 수 없는 컨텍스트입니다.');
await context.audioWorklet.addModule(url);
if (generation !== state.analyzer.generation) return false;
const node = new AudioWorkletNode(context, 'soop-fingerprint-v2', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
channelCountMode: 'max',
});
state.analyzer.node = node;
node.port.onmessage = (event) => handleWorkletFrame(event.data, generation);
source.connect(node);
node.connect(silentGain);
} catch (workletError) {
processorMode = 'script-processor';
if (state.analyzer.workletUrl) {
URL.revokeObjectURL(state.analyzer.workletUrl);
state.analyzer.workletUrl = null;
}
const node = context.createScriptProcessor(2048, 2, 1);
const extractor = new MainThreadFeatureExtractor(context.sampleRate, (frame) => handleWorkletFrame(frame, generation));
state.analyzer.fallbackExtractor = extractor;
node.onaudioprocess = (event) => extractor.pushAudioBuffer(event.inputBuffer, context.currentTime);
state.analyzer.node = node;
source.connect(node);
node.connect(silentGain);
console.warn('[SOOP Audio Sync] AudioWorklet 실패, ScriptProcessor fallback 사용:', workletError);
}
const trackEvents = ['mute', 'unmute', 'ended'];
for (const eventName of trackEvents) {
const handler = () => {
if (eventName === 'ended') restartAnalyzerSoon('오디오 트랙 종료');
state.renderNeeded = true;
};
audioTrack.addEventListener(eventName, handler, { passive: true });
state.analyzer.trackListeners.push([audioTrack, eventName, handler]);
}
context.addEventListener('statechange', () => {
if (generation !== state.analyzer.generation) return;
if (context.state === 'running') setAnalyzerStatus('running', processorMode);
else if (context.state === 'suspended') setAnalyzerStatus('suspended', '이 탭에서 한 번 클릭 필요');
else if (context.state === 'closed') setAnalyzerStatus('off', 'AudioContext 종료');
});
try { await context.resume(); } catch { /* 사용자 동작 대기 */ }
if (generation !== state.analyzer.generation) return false;
state.analyzer.mode = processorMode;
if (context.state === 'running') {
setAnalyzerStatus('running', processorMode);
} else {
setAnalyzerStatus('suspended', '이 탭에서 한 번 클릭 필요');
hookGestureResume();
}
return context.state === 'running';
} catch (error) {
if (generation === state.analyzer.generation) {
state.analyzer.retryAt = Date.now() + 2500;
setAnalyzerStatus('error', error?.message || String(error));
}
return false;
}
}
function waitForAudioTrack(stream, timeoutMs) {
return new Promise((resolve) => {
const started = Date.now();
const timer = setInterval(() => {
if (stream.getAudioTracks().length || Date.now() - started >= timeoutMs) {
clearInterval(timer);
resolve();
}
}, 120);
});
}
function hookGestureResume() {
if (state.gestureHooked) return;
state.gestureHooked = true;
const resume = () => {
attemptResumeAudioContext();
if (state.analyzer.context?.state === 'running') {
document.removeEventListener('pointerdown', resume, true);
document.removeEventListener('keydown', resume, true);
state.gestureHooked = false;
}
};
document.addEventListener('pointerdown', resume, true);
document.addEventListener('keydown', resume, true);
}
async function attemptResumeAudioContext() {
const context = state.analyzer.context;
if (!context || context.state !== 'suspended') return;
try {
await context.resume();
updateAudioClockMapping();
if (context.state === 'running') setAnalyzerStatus('running', state.analyzer.mode);
} catch {
setAnalyzerStatus('suspended', '이 탭에서 한 번 클릭 필요');
}
}
function stopAnalyzer(detail = '정지') {
const analyzer = state.analyzer;
analyzer.generation += 1;
for (const [track, eventName, handler] of analyzer.trackListeners || []) {
try { track.removeEventListener(eventName, handler); } catch { /* 무시 */ }
}
analyzer.trackListeners = [];
try { analyzer.node?.disconnect(); } catch { /* 무시 */ }
try { analyzer.source?.disconnect(); } catch { /* 무시 */ }
try { analyzer.silentGain?.disconnect(); } catch { /* 무시 */ }
try { analyzer.stream?.getTracks().forEach((track) => track.stop()); } catch { /* 무시 */ }
try { analyzer.context?.close(); } catch { /* 무시 */ }
if (analyzer.workletUrl) {
try { URL.revokeObjectURL(analyzer.workletUrl); } catch { /* 무시 */ }
}
const retryAt = analyzer.retryAt;
const generation = analyzer.generation;
state.analyzer = createAnalyzerState();
state.analyzer.retryAt = retryAt;
state.analyzer.generation = generation;
state.analyzer.status = 'off';
state.analyzer.detail = detail;
state.renderNeeded = true;
}
function setAnalyzerStatus(status, detail) {
state.analyzer.status = status;
state.analyzer.detail = detail || '';
state.renderNeeded = true;
sendPresence();
}
function updateAudioClockMapping() {
const context = state.analyzer.context;
if (!context) return;
let base = NaN;
try {
const stamp = context.getOutputTimestamp?.();
if (stamp && Number.isFinite(stamp.contextTime) && Number.isFinite(stamp.performanceTime)) {
base = performance.timeOrigin + stamp.performanceTime - stamp.contextTime * 1000;
}
} catch { /* fallback 아래 */ }
if (!Number.isFinite(base)) base = Date.now() - context.currentTime * 1000;
state.analyzer.clockBaseEpochMs = base;
state.analyzer.clockUpdatedAt = Date.now();
}
function handleWorkletFrame(frame, generation) {
if (!frame || frame.type !== 'frame' || generation !== state.analyzer.generation) return;
if (Date.now() - state.analyzer.clockUpdatedAt > 1000) updateAudioClockMapping();
const timestamp = state.analyzer.clockBaseEpochMs + finiteNumber(frame.contextTime, 0) * 1000;
if (!Number.isFinite(timestamp)) return;
const feature = {
t: timestamp,
bands: ensureInt8(frame.bands, FEATURE_BANDS),
delta: ensureInt8(frame.delta, FEATURE_BANDS),
rms: finiteNumber(frame.rms, 0),
peak: finiteNumber(frame.peak, 0),
flux: finiteNumber(frame.flux, 0),
};
const pcm = {
t: timestamp,
rate: PCM_RATE,
samples: ensureInt8(frame.pcm, FEATURE_FRAME_SAMPLES / 2),
};
pushTimed(state.localFeatures, feature, BUFFER_KEEP_MS);
pushTimed(state.localPcm, pcm, BUFFER_KEEP_MS);
const now = Date.now();
state.analyzer.lastFrameAt = now;
state.analyzer.signalRms = state.analyzer.signalRms * 0.88 + feature.rms * 0.12;
state.analyzer.signalPeak = Math.max(feature.peak, state.analyzer.signalPeak * 0.93);
if (feature.rms > 0.00012) state.analyzer.lastSignalAt = now;
if (state.analyzer.status !== 'running') setAnalyzerStatus('running', state.analyzer.mode || 'audio');
if (isSelected() && state.config.enabled && isReference()) {
postMessage({
type: 'audio',
feature: {
t: feature.t,
bands: feature.bands,
delta: feature.delta,
rms: feature.rms,
peak: feature.peak,
flux: feature.flux,
},
pcm: { t: pcm.t, rate: pcm.rate, samples: pcm.samples },
});
}
}
function ensureInt8(value, expectedLength) {
if (value instanceof Int8Array) return value;
if (ArrayBuffer.isView(value)) return new Int8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
if (Array.isArray(value)) return Int8Array.from(value.slice(0, expectedLength));
return new Int8Array(expectedLength);
}
function pushTimed(array, item, keepMs) {
if (!Number.isFinite(item.t)) return;
const last = array[array.length - 1];
if (last && item.t < last.t - 5) {
array.push(item);
array.sort((a, b) => a.t - b.t);
} else {
array.push(item);
}
const cutoff = item.t - keepMs;
let removeCount = 0;
while (removeCount < array.length && array[removeCount].t < cutoff) removeCount += 1;
if (removeCount > 0) array.splice(0, removeCount);
}
function handleRemoteAudio(message) {
if (!state.config.enabled || !isSelected() || isReference()) return;
if (message.from !== state.config.referenceTabId) return;
const feature = message.feature;
const pcm = message.pcm;
if (feature && Number.isFinite(feature.t)) {
pushTimed(state.referenceFeatures, {
t: feature.t,
bands: ensureInt8(feature.bands, FEATURE_BANDS),
delta: ensureInt8(feature.delta, FEATURE_BANDS),
rms: finiteNumber(feature.rms, 0),
peak: finiteNumber(feature.peak, 0),
flux: finiteNumber(feature.flux, 0),
}, BUFFER_KEEP_MS);
}
if (pcm && Number.isFinite(pcm.t)) {
pushTimed(state.referencePcm, {
t: pcm.t,
rate: finiteNumber(pcm.rate, PCM_RATE),
samples: ensureInt8(pcm.samples, FEATURE_FRAME_SAMPLES / 2),
}, BUFFER_KEEP_MS);
}
}
class MainThreadFeatureExtractor {
constructor(sourceRate, callback) {
this.sourceRate = sourceRate;
this.callback = callback;
this.targetRate = AUDIO_FRAME_RATE;
this.phase = 0;
this.low = 0;
this.lowAlpha = 1 - Math.exp(-2 * Math.PI * 3500 / sourceRate);
this.frame = new Float32Array(FEATURE_FRAME_SAMPLES);
this.index = 0;
this.frameStartTime = 0;
this.prevBands = new Float32Array(FEATURE_BANDS);
this.havePrev = false;
this.hpPrevInput = 0;
this.hpPrevOutput = 0;
this.freqs = [90, 140, 220, 340, 520, 800, 1200, 1700, 2300, 3000, 3600];
this.coeffs = this.freqs.map((f) => 2 * Math.cos(2 * Math.PI * f / this.targetRate));
this.window = new Float32Array(FEATURE_FRAME_SAMPLES);
for (let i = 0; i < this.window.length; i++) this.window[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (this.window.length - 1));
}
pushAudioBuffer(buffer, contextEndTime) {
const channels = [];
for (let ch = 0; ch < buffer.numberOfChannels; ch++) channels.push(buffer.getChannelData(ch));
const length = buffer.length;
const blockStart = contextEndTime - length / this.sourceRate;
for (let i = 0; i < length; i++) {
let mono = 0;
for (const channel of channels) mono += channel[i] || 0;
mono /= Math.max(1, channels.length);
this.low += this.lowAlpha * (mono - this.low);
this.phase += this.targetRate;
if (this.phase >= this.sourceRate) {
this.phase -= this.sourceRate;
if (this.index === 0) this.frameStartTime = blockStart + i / this.sourceRate;
this.frame[this.index++] = this.low;
if (this.index >= this.frame.length) {
this.emit();
this.index = 0;
}
}
}
}
emit() {
const frame = this.frame;
let sumSq = 0;
let peak = 0;
for (const x of frame) {
sumSq += x * x;
peak = Math.max(peak, Math.abs(x));
}
const rms = Math.sqrt(sumSq / frame.length);
const raw = new Float32Array(FEATURE_BANDS);
for (let b = 0; b < FEATURE_BANDS; b++) {
let s1 = 0;
let s2 = 0;
const coeff = this.coeffs[b];
for (let i = 0; i < frame.length; i++) {
const s0 = frame[i] * this.window[i] + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
raw[b] = Math.log1p(Math.max(0, s1 * s1 + s2 * s2 - coeff * s1 * s2) * 20);
}
let mean = raw.reduce((a, b) => a + b, 0) / raw.length;
let norm = 0;
for (let i = 0; i < raw.length; i++) {
raw[i] -= mean;
norm += raw[i] * raw[i];
}
norm = Math.sqrt(norm) || 1;
const bands = new Int8Array(FEATURE_BANDS);
const deltaRaw = new Float32Array(FEATURE_BANDS);
let deltaNorm = 0;
let flux = 0;
for (let i = 0; i < FEATURE_BANDS; i++) {
const v = raw[i] / norm;
bands[i] = clamp(Math.round(v * 100), -127, 127);
const d = this.havePrev ? v - this.prevBands[i] : 0;
deltaRaw[i] = d;
deltaNorm += d * d;
if (d > 0) flux += d * d;
this.prevBands[i] = v;
}
this.havePrev = true;
deltaNorm = Math.sqrt(deltaNorm) || 1;
const delta = new Int8Array(FEATURE_BANDS);
for (let i = 0; i < FEATURE_BANDS; i++) delta[i] = clamp(Math.round(deltaRaw[i] / deltaNorm * 100), -127, 127);
const pcmRaw = new Float32Array(FEATURE_FRAME_SAMPLES / 2);
let pcmSq = 0;
let p = 0;
for (let i = 0; i < frame.length; i++) {
const hp = frame[i] - this.hpPrevInput + 0.96 * this.hpPrevOutput;
this.hpPrevInput = frame[i];
this.hpPrevOutput = hp;
if ((i & 1) === 0) {
pcmRaw[p++] = hp;
pcmSq += hp * hp;
}
}
const pcmRms = Math.sqrt(pcmSq / pcmRaw.length);
const pcm = new Int8Array(pcmRaw.length);
if (pcmRms >= 0.00008) {
const scale = Math.min(8000, 38 / pcmRms);
for (let i = 0; i < pcm.length; i++) pcm[i] = clamp(Math.round(pcmRaw[i] * scale), -127, 127);
}
this.callback({
type: 'frame',
contextTime: this.frameStartTime,
bands,
delta,
pcm,
rms,
peak,
flux: Math.sqrt(flux),
});
}
}
function maybeRunMatching() {
if (state.matchingBusy || !state.config.enabled || !isSelected() || isReference()) return;
if (Date.now() < state.suppressUntil) return;
const now = Date.now();
if (now - state.lastMatchAt < state.config.settings.matchIntervalMs) return;
state.lastMatchAt = now;
if (!audioBuffersReady()) {
updateMainStatus();
return;
}
state.matchingBusy = true;
try {
const initialOrLost = !state.latestMatch || now - state.fullSearchAt > 14000 || (state.latestMatch.confidence || 0) < 0.48;
const centerLagMs = !initialOrLost && Number.isFinite(state.latestMatch?.lagMs) ? state.latestMatch.lagMs : 0;
const rangeMs = initialOrLost ? state.config.settings.searchRangeSec * 1000 : 1450;
if (initialOrLost) state.fullSearchAt = now;
const coarse = estimateCoarseLag(centerLagMs, rangeMs);
if (!coarse) {
registerLostMatch('공통 음원 후보 없음');
return;
}
const fine = estimateFineLag(coarse.lagMs);
const combined = combineMatch(coarse, fine);
registerMatch(combined);
} catch (error) {
console.warn('[SOOP Audio Sync] matching error:', error);
registerLostMatch('오디오 비교 오류');
} finally {
state.matchingBusy = false;
}
}
function audioBuffersReady() {
const now = Date.now();
const localLast = state.localFeatures.at(-1)?.t || 0;
const refLast = state.referenceFeatures.at(-1)?.t || 0;
if (now - localLast > state.config.settings.staleAudioMs || now - refLast > state.config.settings.staleAudioMs) return false;
const localSpan = localLast - (state.localFeatures[0]?.t || localLast);
const refSpan = refLast - (state.referenceFeatures[0]?.t || refLast);
return state.localFeatures.length >= 150 && state.referenceFeatures.length >= 150 && localSpan >= 4500 && refSpan >= 4500;
}
function estimateCoarseLag(centerLagMs, rangeMs) {
const settings = state.config.settings;
const local = state.localFeatures;
const remote = state.referenceFeatures;
const localEnd = (local.at(-1)?.t || 0) - 180;
const localStart = localEnd - settings.featureWindowSec * 1000;
const localWindow = local.filter((frame) => frame.t >= localStart && frame.t <= localEnd);
if (localWindow.length < 70) return null;
const coarseResults = [];
const step = Math.max(16, settings.coarseStepMs);
const startLag = Math.round((centerLagMs - rangeMs) / step) * step;
const endLag = Math.round((centerLagMs + rangeMs) / step) * step;
for (let lag = startLag; lag <= endLag + 0.1; lag += step) {
const scored = scoreLagCandidate(localWindow, remote, lag);
if (scored) coarseResults.push({ lagMs: lag, ...scored });
}
if (!coarseResults.length) return null;
coarseResults.sort((a, b) => b.score - a.score);
const initialBest = coarseResults[0];
const refine = [];
const refineStep = Math.max(8, settings.coarseRefineStepMs);
for (let lag = initialBest.lagMs - step; lag <= initialBest.lagMs + step; lag += refineStep) {
const scored = scoreLagCandidate(localWindow, remote, lag);
if (scored) refine.push({ lagMs: lag, ...scored });
}
refine.sort((a, b) => b.score - a.score);
const best = refine[0] || initialBest;
const all = coarseResults.concat(refine);
const outside = all.filter((item) => Math.abs(item.lagMs - best.lagMs) >= 240).sort((a, b) => b.score - a.score);
const secondScore = outside[0]?.score ?? -1;
const scores = coarseResults.map((item) => item.score);
const med = median(scores);
const variance = scores.reduce((sum, value) => sum + (value - med) ** 2, 0) / Math.max(1, scores.length);
const z = (best.score - med) / Math.max(0.015, Math.sqrt(variance));
return {
lagMs: best.lagMs,
score: best.score,
gap: best.score - secondScore,
z,
pairs: best.pairs,
coverage: best.coverage,
activity: best.activity,
};
}
function scoreLagCandidate(localFrames, remoteFrames, lagMs) {
const pairScores = [];
let activitySum = 0;
let attempted = 0;
const expectedPairs = Math.max(1, Math.floor(localFrames.length / 2));
for (let i = 1; i < localFrames.length; i += 2) {
const local = localFrames[i];
const remote = nearestTimed(remoteFrames, local.t - lagMs, 23);
if (!remote) continue;
attempted += 1;
const minRms = Math.min(local.rms, remote.rms);
if (minRms < 0.00010) continue;
const bandSim = cosineInt8(local.bands, remote.bands);
const deltaSim = cosineInt8(local.delta, remote.delta);
const signSim = signAgreement(local.delta, remote.delta);
const fluxActivity = clamp(Math.min(local.flux, remote.flux) / 0.32, 0, 1);
const rmsActivity = clamp((minRms - 0.00010) / 0.012, 0.12, 1);
const weight = 0.35 + 0.65 * Math.max(fluxActivity, rmsActivity * 0.45);
const score = 0.22 * bandSim + 0.64 * deltaSim + 0.14 * signSim;
pairScores.push({ score, weight });
activitySum += weight;
}
if (pairScores.length < 28) return null;
pairScores.sort((a, b) => a.score - b.score);
const dropBottom = Math.floor(pairScores.length * 0.24);
const dropTop = Math.floor(pairScores.length * 0.04);
const kept = pairScores.slice(dropBottom, Math.max(dropBottom + 1, pairScores.length - dropTop));
let weighted = 0;
let totalWeight = 0;
for (const item of kept) {
weighted += clamp(item.score, -0.45, 1) * item.weight;
totalWeight += item.weight;
}
const robustMean = weighted / Math.max(1e-6, totalWeight);
const medianScore = median(pairScores.map((item) => item.score));
// 전체 비교 창 중 실제로 겹친 비율을 사용한다. 버퍼가 아직 짧은데 먼 지연 후보가
// 일부 구간만 우연히 일치하는 것을 높은 신뢰도로 오인하지 않게 한다.
const coverage = pairScores.length / expectedPairs;
const activity = activitySum / pairScores.length;
const score = (0.68 * robustMean + 0.32 * medianScore) * (0.68 + 0.32 * Math.sqrt(clamp(coverage, 0, 1)));
return { score, pairs: pairScores.length, coverage, activity };
}
function nearestTimed(array, target, toleranceMs) {
if (!array.length) return null;
let low = 0;
let high = array.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
if (array[mid].t < target) low = mid + 1;
else high = mid - 1;
}
const a = array[low];
const b = array[low - 1];
let best = null;
if (a && (!best || Math.abs(a.t - target) < Math.abs(best.t - target))) best = a;
if (b && (!best || Math.abs(b.t - target) < Math.abs(best.t - target))) best = b;
return best && Math.abs(best.t - target) <= toleranceMs ? best : null;
}
function cosineInt8(a, b) {
const length = Math.min(a?.length || 0, b?.length || 0);
if (!length) return 0;
let dot = 0;
let aa = 0;
let bb = 0;
for (let i = 0; i < length; i++) {
const x = a[i];
const y = b[i];
dot += x * y;
aa += x * x;
bb += y * y;
}
return aa > 0 && bb > 0 ? dot / Math.sqrt(aa * bb) : 0;
}
function signAgreement(a, b) {
const length = Math.min(a?.length || 0, b?.length || 0);
if (!length) return 0;
let same = 0;
let used = 0;
for (let i = 0; i < length; i++) {
if (Math.abs(a[i]) < 7 && Math.abs(b[i]) < 7) continue;
used += 1;
if ((a[i] >= 0) === (b[i] >= 0)) same += 1;
}
return used ? same / used * 2 - 1 : 0;
}
function estimateFineLag(coarseLagMs) {
const settings = state.config.settings;
if (state.localPcm.length < 50 || state.referencePcm.length < 50) return null;
const fineRangeMs = settings.fineRangeMs;
const sampleRate = PCM_RATE;
const fineSamples = Math.round(fineRangeMs * sampleRate / 1000);
const durationSamples = Math.round(settings.fineWindowSec * sampleRate);
const localLast = state.localPcm.at(-1)?.t || 0;
const remoteLast = state.referencePcm.at(-1)?.t || 0;
// 참가 탭이 기준보다 앞선 경우(음수 lag), 최신 참가 오디오는 아직 기준 탭에 도착하지 않았다.
// 두 타임라인 모두 실제 데이터가 존재하는 과거 구간으로 비교 창을 이동한다.
const remoteCoverageLimitedEnd = remoteLast + coarseLagMs - fineRangeMs - 160;
const localEnd = Math.min(localLast - 160, remoteCoverageLimitedEnd);
const localStart = localEnd - durationSamples / sampleRate * 1000;
const localRendered = renderPcm(state.localPcm, localStart, durationSamples, sampleRate);
const remoteStart = localStart - coarseLagMs - fineRangeMs;
const remoteRendered = renderPcm(state.referencePcm, remoteStart, durationSamples + fineSamples * 2 + 8, sampleRate);
if (localRendered.coverage < 0.78 || remoteRendered.coverage < 0.72) return null;
let best = { score: -1, shift: 0, coverage: 0 };
const candidates = [];
for (let shift = -fineSamples; shift <= fineSamples; shift += 2) {
const scored = blockRobustCorrelation(localRendered, remoteRendered, fineSamples - shift, durationSamples);
if (!scored) continue;
candidates.push({ shift, ...scored });
if (scored.score > best.score) best = { shift, ...scored };
}
if (!candidates.length) return null;
const refined = [];
for (let shift = best.shift - 4; shift <= best.shift + 4; shift++) {
if (shift < -fineSamples || shift > fineSamples) continue;
const scored = blockRobustCorrelation(localRendered, remoteRendered, fineSamples - shift, durationSamples);
if (scored) refined.push({ shift, ...scored });
}
refined.sort((a, b) => b.score - a.score);
if (refined[0]) best = refined[0];
const second = candidates
.filter((item) => Math.abs(item.shift - best.shift) >= Math.round(0.014 * sampleRate))
.sort((a, b) => b.score - a.score)[0];
const secondScore = second?.score ?? -1;
return {
lagMs: coarseLagMs + best.shift / sampleRate * 1000,
correlation: best.score,
gap: best.score - secondScore,
coverage: best.coverage,
};
}
function renderPcm(chunks, startMs, sampleCount, sampleRate) {
const values = new Float32Array(sampleCount);
const mask = new Uint8Array(sampleCount);
const endMs = startMs + sampleCount / sampleRate * 1000;
for (const chunk of chunks) {
const chunkDuration = chunk.samples.length / chunk.rate * 1000;
if (chunk.t + chunkDuration < startMs || chunk.t > endMs) continue;
for (let i = 0; i < chunk.samples.length; i++) {
const t = chunk.t + i / chunk.rate * 1000;
const index = Math.round((t - startMs) * sampleRate / 1000);
if (index >= 0 && index < sampleCount) {
values[index] = chunk.samples[i] / 127;
mask[index] = 1;
}
}
}
let present = 0;
for (const value of mask) present += value;
return { values, mask, coverage: present / sampleCount };
}
function blockRobustCorrelation(local, remote, remoteOffset, length) {
const blocks = 6;
const blockLength = Math.floor(length / blocks);
const scores = [];
let totalPresent = 0;
for (let block = 0; block < blocks; block++) {
const start = block * blockLength;
const end = block === blocks - 1 ? length : start + blockLength;
let dot = 0;
let aa = 0;
let bb = 0;
let present = 0;
for (let i = start; i < end; i++) {
const j = remoteOffset + i;
if (!local.mask[i] || !remote.mask[j]) continue;
const x = local.values[i];
const y = remote.values[j];
dot += x * y;
aa += x * x;
bb += y * y;
present += 1;
}
totalPresent += present;
if (present < (end - start) * 0.62 || aa < 1e-4 || bb < 1e-4) continue;
scores.push(dot / Math.sqrt(aa * bb));
}
if (scores.length < 4) return null;
scores.sort((a, b) => b - a);
const kept = scores.slice(0, Math.max(3, scores.length - 1));
const score = kept.reduce((sum, value) => sum + value, 0) / kept.length;
return { score, coverage: totalPresent / length };
}
function combineMatch(coarse, fine) {
const settings = state.config.settings;
const coarseScoreConfidence = clamp((coarse.score - 0.16) / 0.46, 0, 1);
const coarseGapConfidence = clamp((coarse.gap - 0.008) / 0.075, 0, 1);
const zConfidence = clamp((coarse.z - 1.3) / 5.0, 0, 1);
const coverageConfidence = clamp((coarse.coverage - 0.55) / 0.40, 0, 1);
let fineConfidence = 0.12;
let fineGapConfidence = 0;
let lagMs = coarse.lagMs;
let fineUsed = false;
if (fine && fine.correlation >= settings.minFineCorrelation) {
const corrConfidence = clamp((fine.correlation - 0.08) / 0.46, 0, 1);
fineGapConfidence = clamp((fine.gap - 0.003) / 0.060, 0, 1);
// 반복음·지속음은 상관계수 자체는 높아도 여러 지연 후보가 똑같이 높다.
// 따라서 피크 유일성(gap)이 없으면 정밀 결과를 자동 보정에 사용하지 않는다.
fineConfidence = corrConfidence * (0.18 + 0.82 * fineGapConfidence);
if (fine.gap >= 0.004 && fineConfidence >= 0.30) {
lagMs = fine.lagMs;
fineUsed = true;
}
}
let confidence = 0.27 * coarseScoreConfidence
+ 0.18 * coarseGapConfidence
+ 0.12 * zConfidence
+ 0.08 * coverageConfidence
+ 0.35 * fineConfidence;
// 파형 상관이 높고 피크가 유일하면, 스트리머 음성이 스펙트럼 특징을 가린 경우에도
// 정밀 파형 검증을 우선 신뢰한다.
if (fineUsed && fineConfidence >= 0.62 && (fineGapConfidence >= 0.20 || coarse.gap >= settings.minCoarseGap)) {
confidence = Math.max(confidence, 0.52 + 0.40 * fineConfidence);
} else {
if (coarse.score < settings.minCoarseScore) confidence *= 0.72;
if (coarse.gap < settings.minCoarseGap) confidence *= 0.78;
}
return {
lagMs,
confidence: clamp(confidence, 0, 1),
coarse,
fine,
fineUsed,
measuredAt: Date.now(),
};
}
function registerMatch(match) {
const settings = state.config.settings;
state.matchHistory.push(match);
if (state.matchHistory.length > 8) state.matchHistory.shift();
const recent = state.matchHistory.filter((item) => Date.now() - item.measuredAt < 6500 && item.confidence >= 0.42);
const lagValues = recent.map((item) => item.lagMs);
const stableMedian = median(lagValues);
const deviations = lagValues.map((value) => Math.abs(value - stableMedian));
const spread = median(deviations);
const stableCount = recent.filter((item) => Math.abs(item.lagMs - stableMedian) <= 95).length;
const consistency = clamp(1 - finiteNumber(spread, 999) / 180, 0, 1);
match.stableLagMs = stableMedian;
match.stableCount = stableCount;
match.spreadMs = spread;
match.confidence = clamp(match.confidence * (0.78 + 0.22 * consistency), 0, 1);
match.stableConfidence = median(recent.map((item) => item.confidence));
state.latestMatch = match;
state.lostSince = 0;
if (match.confidence >= settings.minCombinedConfidence && Number.isFinite(stableMedian)) {
const targetLagSec = stableMedian / 1000 - currentManualOffsetSec();
if (state.smoothedLagSec === null || Math.abs(targetLagSec - state.smoothedLagSec) > 0.8) {
state.smoothedLagSec = targetLagSec;
} else {
state.smoothedLagSec = state.smoothedLagSec * 0.64 + targetLagSec * 0.36;
}
setStatus(
Math.abs(state.smoothedLagSec) <= settings.deadbandSec ? '싱크 고정' : '공통 음원 추적 중',
Math.abs(state.smoothedLagSec) <= settings.deadbandSec ? 'ok' : 'work',
`신뢰도 ${Math.round(match.confidence * 100)}%`,
);
} else {
setStatus('후보 검증 중', 'work', `신뢰도 ${Math.round(match.confidence * 100)}%`);
}
state.renderNeeded = true;
broadcastSyncStatus();
}
function registerLostMatch(detail) {
if (!state.lostSince) state.lostSince = Date.now();
if (Date.now() - state.lostSince > state.config.settings.lostHoldMs) {
state.latestMatch = null;
state.smoothedLagSec = null;
restoreNormalRate();
setStatus('공통 음원 찾는 중', 'warn', detail);
} else {
setStatus('일시적으로 음원 가림', 'work', detail);
}
broadcastSyncStatus();
}
function controlSync(force = false) {
const settings = state.config.settings;
const video = state.video;
if (!video) return;
if (!state.config.enabled || !isSelected() || isReference()) {
restoreNormalRate();
return;
}
const nowEpoch = Date.now();
const referencePeer = state.peers.get(state.config.referenceTabId);
const lastReferenceAudioAt = state.referenceFeatures.at(-1)?.t || 0;
const lastLocalAudioAt = state.localFeatures.at(-1)?.t || 0;
if (!referencePeer || nowEpoch - referencePeer.receivedAt > PRESENCE_TTL_MS) {
state.latestMatch = null;
state.smoothedLagSec = null;
state.matchHistory.length = 0;
restoreNormalRate();
setStatus('기준 탭 연결 끊김', 'error');
return;
}
if (nowEpoch - lastReferenceAudioAt > settings.staleAudioMs || nowEpoch - lastLocalAudioAt > settings.staleAudioMs) {
restoreNormalRate();
setStatus('오디오 입력 일시 중단', 'warn');
return;
}
if (Date.now() < state.suppressUntil) {
restoreNormalRate();
return;
}
if (video.paused || video.ended || video.readyState < 2 || video.seeking) {
restoreNormalRate();
setStatus(video.seeking ? '시킹 완료 대기' : '영상 재생 대기', 'warn');
return;
}
const match = state.latestMatch;
const lag = state.smoothedLagSec;
if (!match || !Number.isFinite(lag) || match.confidence < settings.minCombinedConfidence) {
restoreNormalRate();
return;
}
const absLag = Math.abs(lag);
const stableEnoughForSeek = match.stableCount >= settings.hardSeekConfirmations
&& finiteNumber(match.stableConfidence, match.confidence) >= settings.hardSeekConfidence
&& finiteNumber(match.spreadMs, 999) <= 100;
if (absLag >= settings.hardSeekThresholdSec
&& stableEnoughForSeek
&& Date.now() - state.lastHardSeekAt >= settings.hardSeekCooldownMs) {
if (safeRelativeSeek(lag, `오디오 오차 ${formatSignedMs(lag * 1000)}`)) return;
}
if (absLag <= settings.deadbandSec) {
restoreNormalRate();
setStatus('싱크 고정', 'ok', `${formatSignedMs(lag * 1000)} · ${Math.round(match.confidence * 100)}%`);
return;
}
const now = Date.now();
let derivative = 0;
if (Number.isFinite(state.previousLagForDerivative) && now - state.previousLagAt > 200) {
derivative = (lag - state.previousLagForDerivative) / ((now - state.previousLagAt) / 1000);
}
state.previousLagForDerivative = lag;
state.previousLagAt = now;
const boundedLag = clamp(lag, -settings.softCorrectionLimitSec, settings.softCorrectionLimitSec);
const rateDelta = clamp(boundedLag * settings.proportionalGain + derivative * 0.018, -settings.maxRateDelta, settings.maxRateDelta);
setPlaybackRate(1 + rateDelta);
setStatus(rateDelta > 0 ? '가속하여 따라잡는 중' : '감속하여 늦추는 중', 'work', `${formatSignedMs(lag * 1000)} · ${Math.round(match.confidence * 100)}%`);
if (force) broadcastSyncStatus(true);
}
function getLatestSeekRange(video) {
if (!video?.seekable?.length) return null;
const index = video.seekable.length - 1;
const start = video.seekable.start(index);
const end = video.seekable.end(index);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
return { start, end };
}
function safeRelativeSeek(deltaSec, reason) {
const video = state.video;
const range = getLatestSeekRange(video);
if (!video || !range || !Number.isFinite(video.currentTime)) return false;
const margin = state.config.settings.minSeekMarginSec;
const desired = video.currentTime + deltaSec;
const target = clamp(desired, range.start + margin, range.end - margin);
const actualDelta = target - video.currentTime;
if (Math.abs(actualDelta) < 0.06) return false;
try {
state.lastHardSeekAt = Date.now();
state.suppressUntil = Date.now() + state.config.settings.postSeekMuteMs;
restoreNormalRate();
// fastSeek는 키프레임 근사 위치로 이동할 수 있으므로 정확한 공통 음원 정렬에는 사용하지 않는다.
video.currentTime = target;
state.localFeatures.length = 0;
state.localPcm.length = 0;
state.referenceFeatures.length = 0;
state.referencePcm.length = 0;
state.matchHistory.length = 0;
state.latestMatch = null;
state.smoothedLagSec = null;
restartAnalyzerSoon('시킹 후 오디오 파이프라인 재연결');
setStatus('큰 오차 즉시 보정', 'work', reason);
broadcastSyncStatus(true);
return true;
} catch (error) {
setStatus('시킹 실패', 'error', error?.message || String(error));
return false;
}
}
function setPlaybackRate(rate) {
const video = state.video;
if (!video) return;
const safe = clamp(rate, 0.94, 1.06);
if (Math.abs(video.playbackRate - safe) < 0.0005) return;
try {
video.preservesPitch = true;
video.webkitPreservesPitch = true;
video.playbackRate = safe;
state.lastAppliedRate = safe;
} catch {
// 플레이어가 재생속도 변경을 거부하면 다음 틱에 다시 시도한다.
}
}
function restoreNormalRate() {
const video = state.video;
if (!video) return;
if (Math.abs(video.playbackRate - 1) < 0.0005) {
state.lastAppliedRate = 1;
return;
}
try { video.playbackRate = 1; } catch { /* 무시 */ }
state.lastAppliedRate = 1;
}
function updateMainStatus() {
if (!state.config.enabled) {
setStatus('자동 싱크 정지', 'normal');
return;
}
if (!isSelected()) {
setStatus('이 탭은 대상 아님', 'normal');
return;
}
if (!state.video) {
setStatus('영상 요소 찾는 중', 'warn');
return;
}
if (state.analyzer.status === 'suspended') {
setStatus('오디오 분석 권한 필요', 'warn', '이 탭에서 화면을 한 번 클릭하세요.');
return;
}
if (state.analyzer.status === 'error') {
setStatus('오디오 분석 실패', 'error', state.analyzer.detail);
return;
}
if (state.analyzer.status !== 'running') {
setStatus('오디오 분석 준비 중', 'work', state.analyzer.detail);
return;
}
if (isReference()) {
setStatus('기준 탭 · 변경하지 않음', 'ok', '다른 대상 탭이 이 소리를 추적합니다.');
return;
}
const reference = state.peers.get(state.config.referenceTabId);
if (!reference || Date.now() - reference.receivedAt > PRESENCE_TTL_MS) {
setStatus('기준 탭 연결 끊김', 'error');
restoreNormalRate();
return;
}
if (Date.now() - state.analyzer.lastFrameAt > state.config.settings.staleAudioMs) {
setStatus('이 탭의 오디오 대기', 'warn');
return;
}
if (!state.latestMatch) setStatus('공통 음원 찾는 중', 'work');
}
function setStatus(text, kind = 'normal', detail = '') {
if (state.flashUntil > Date.now()) return;
state.statusText = text;
state.statusKind = kind;
state.statusDetail = detail;
state.renderNeeded = true;
}
function flash(text, kind = 'work', duration = 1500) {
state.flashText = text;
state.flashUntil = Date.now() + duration;
state.statusText = text;
state.statusKind = kind;
state.renderNeeded = true;
}
function broadcastSyncStatus(force = false) {
const now = Date.now();
if (!force && now - state.lastStatusBroadcastAt < 700) return;
state.lastStatusBroadcastAt = now;
postMessage({
type: 'sync-status',
status: {
role: isReference() ? 'reference' : isSelected() ? 'participant' : 'none',
state: state.statusText,
kind: state.statusKind,
detail: state.statusDetail,
lagMs: Number.isFinite(state.smoothedLagSec) ? state.smoothedLagSec * 1000 : null,
confidence: state.latestMatch?.confidence ?? null,
rate: state.video?.playbackRate ?? 1,
analyzerStatus: state.analyzer.status,
analyzerMode: state.analyzer.mode,
signalRms: state.analyzer.signalRms,
},
});
}
function formatSignedMs(ms) {
if (!Number.isFinite(ms)) return '—';
const rounded = Math.abs(ms) < 0.05 ? 0 : ms;
const sign = rounded > 0 ? '+' : rounded < 0 ? '−' : '';
const value = Math.abs(rounded);
return `${sign}${value < 100 ? value.toFixed(1) : value.toFixed(0)}ms`;
}
function formatConfidence(value) {
return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—';
}
function formatSignal(rms) {
if (!Number.isFinite(rms) || rms <= 0.00005) return '무음';
const db = 20 * Math.log10(rms);
return `${Math.round(db)}dB`;
}
function getAllPeersSorted() {
const now = Date.now();
const peers = [...state.peers.entries()]
.filter(([, peer]) => now - peer.receivedAt <= PRESENCE_TTL_MS)
.map(([tabId, peer]) => ({ tabId, ...peer }));
if (!peers.some((peer) => peer.tabId === state.tabId)) peers.push({ tabId: state.tabId, ...buildPresence(), receivedAt: now });
return peers.sort((a, b) => {
if (a.tabId === state.tabId) return -1;
if (b.tabId === state.tabId) return 1;
return `${a.channelId}/${a.broadcastId}`.localeCompare(`${b.channelId}/${b.broadcastId}`);
});
}
function showUiVisibilityToast(hidden) {
if (!document.documentElement) return;
const toastId = `${APP_ID}-visibility-toast`;
document.getElementById(toastId)?.remove();
const toast = document.createElement('div');
toast.id = toastId;
toast.textContent = hidden
? '싱크 패널 숨김 · 오른쪽 화면 가장자리의 S 탭을 누르면 다시 열립니다.'
: '싱크 패널 표시됨';
toast.style.cssText = [
'position:fixed',
'top:18px',
'left:50%',
'transform:translateX(-50%)',
'z-index:2147483647',
'padding:9px 13px',
'border:1px solid rgba(255,255,255,.16)',
'border-radius:9px',
'background:rgba(15,17,21,.96)',
'box-shadow:0 8px 26px rgba(0,0,0,.42)',
'color:#f4f7fb',
'font:600 12px/1.3 Pretendard,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'pointer-events:none',
'opacity:1',
'transition:opacity .22s ease',
].join(';');
document.documentElement.appendChild(toast);
const visibleMs = hidden ? 2600 : 1300;
setTimeout(() => { toast.style.opacity = '0'; }, visibleMs);
setTimeout(() => toast.remove(), visibleMs + 300);
}
function clampUiToViewport() {
if (!state.ui || state.ui.host.dataset.hidden === 'true') return;
const host = state.ui.host;
const rect = host.getBoundingClientRect();
if (!rect.width || !rect.height) return;
host.style.left = 'auto';
host.style.top = `${clamp(rect.top, 0, Math.max(0, innerHeight - 40))}px`;
host.style.right = `${clamp(innerWidth - rect.right, 0, Math.max(0, innerWidth - 80))}px`;
}
function setUiHidden(hidden, { broadcast = false, announce = false } = {}) {
if (!state.ui) return;
const next = Boolean(hidden);
const host = state.ui.host;
const current = host.dataset.hidden === 'true';
if (current === next) {
if (broadcast) postMessage({ type: 'command', command: 'set-ui-hidden', hidden: next });
return;
}
if (next) savePanelPosition();
host.dataset.hidden = String(next);
state.panelSettings = { ...state.panelSettings, hidden: next };
saveJson(PANEL_KEY, state.panelSettings);
state.renderNeeded = true;
if (!next) requestAnimationFrame(() => { clampUiToViewport(); renderUi(true); });
if (announce) showUiVisibilityToast(next);
if (broadcast) postMessage({ type: 'command', command: 'set-ui-hidden', hidden: next });
}
function toggleUiVisibility({ broadcast = true, announce = true } = {}) {
if (!state.ui) return;
setUiHidden(state.ui.host.dataset.hidden !== 'true', { broadcast, announce });
}
function createUi() {
if (state.ui || !document.documentElement) return;
const host = document.createElement('div');
host.id = 'soop-common-audio-sync-host';
host.dataset.collapsed = String(Boolean(state.panelSettings.collapsed));
host.dataset.advanced = String(Boolean(state.panelSettings.advanced));
host.dataset.hidden = String(Boolean(state.panelSettings.hidden));
const top = clamp(finiteNumber(state.panelSettings.top, DEFAULT_SETTINGS.panel.top), 0, Math.max(0, innerHeight - 60));
const right = clamp(finiteNumber(state.panelSettings.right, DEFAULT_SETTINGS.panel.right), 0, Math.max(0, innerWidth - 80));
host.style.cssText = `position:fixed;top:${top}px;right:${right}px;z-index:2147483647;font-family:Pretendard,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;`;
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { all: initial; }
:host([data-hidden="true"]) { right:0 !important; left:auto !important; }
:host([data-hidden="true"]) .panel { display:none !important; }
.restoreTab {
display:none;
width:12px;
height:48px;
padding:0;
border:1px solid rgba(255,255,255,.15);
border-right:0;
border-radius:9px 0 0 9px;
color:rgba(255,255,255,.78);
background:rgba(18,21,26,.55);
box-shadow:0 5px 18px rgba(0,0,0,.28);
backdrop-filter:blur(8px);
cursor:pointer;
align-items:center;
justify-content:center;
overflow:hidden;
white-space:nowrap;
opacity:.81;
user-select:none;
transition:width .18s ease, opacity .18s ease, background .18s ease, box-shadow .18s ease;
}
:host([data-hidden="true"]) .restoreTab { display:flex; }
.restoreTab::before { content:'S'; flex:0 0 auto; font-size:9px; font-weight:900; line-height:1; }
.restoreTab span { max-width:0; margin-left:0; overflow:hidden; opacity:0; font-size:9px; font-weight:750; transition:max-width .18s ease, margin-left .18s ease, opacity .14s ease; }
.restoreTab:hover, .restoreTab:focus-visible {
width:67px;
opacity:.94;
background:rgba(18,21,26,.94);
box-shadow:0 7px 24px rgba(0,0,0,.42);
outline:none;
}
.restoreTab:hover span, .restoreTab:focus-visible span { max-width:48px; margin-left:5px; opacity:1; }
.restoreTab:active { transform:translateX(1px); }
* { box-sizing: border-box; }
button, input, select { font: inherit; }
.panel {
width: 390px;
color: #f4f7fb;
background: rgba(16,18,22,.972);
border: 1px solid rgba(255,255,255,.14);
border-radius: 13px;
box-shadow: 0 14px 42px rgba(0,0,0,.48);
overflow: hidden;
user-select: none;
backdrop-filter: blur(12px);
}
.header { height: 40px; display:flex; align-items:center; gap:8px; padding:0 8px 0 12px; cursor:move; background:rgba(255,255,255,.048); border-bottom:1px solid rgba(255,255,255,.09); }
.title { font-size:12px; font-weight:800; letter-spacing:.35px; }
.version { color:rgba(255,255,255,.36); font-size:9px; }
.headerControls { margin-left:auto; display:flex; align-items:center; gap:2px; }
.header button { width:27px; height:27px; border:0; border-radius:7px; background:transparent; color:rgba(255,255,255,.75); cursor:pointer; }
.header button:hover { background:rgba(255,255,255,.1); }
.body { padding:10px; }
:host([data-collapsed="true"]) .body { display:none; }
:host([data-collapsed="true"]) .panel { width:242px; }
.summary { display:flex; align-items:center; gap:8px; }
.mainToggle { min-width:92px; height:32px; border:1px solid rgba(255,255,255,.15); border-radius:8px; color:rgba(255,255,255,.76); background:rgba(255,255,255,.065); font-size:11px; font-weight:780; cursor:pointer; }
.mainToggle[data-on="true"] { color:#06130a; background:#78e69c; border-color:#78e69c; }
.statusBox { min-width:0; flex:1; }
.status { display:block; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; font-size:11px; color:rgba(255,255,255,.65); }
.status[data-kind="ok"] { color:#7de6a0; }
.status[data-kind="work"] { color:#88c8ff; }
.status[data-kind="warn"] { color:#ffd27e; }
.status[data-kind="error"] { color:#ff8d8d; }
.detail { display:block; margin-top:2px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; color:rgba(255,255,255,.36); font-size:9px; }
.metrics { margin-top:8px; display:grid; grid-template-columns:repeat(4,1fr); gap:5px; }
.metric { min-width:0; padding:6px 7px; border-radius:7px; background:rgba(255,255,255,.04); }
.metric label { display:block; color:rgba(255,255,255,.39); font-size:8px; }
.metric b { display:block; margin-top:3px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; font-size:10px; font-variant-numeric:tabular-nums; }
.sectionHead { margin:10px 1px 6px; display:flex; align-items:center; justify-content:space-between; color:rgba(255,255,255,.48); font-size:9px; }
.sectionHead button { border:0; background:transparent; color:#8ec9ff; font-size:9px; cursor:pointer; }
.peerList { display:flex; flex-direction:column; gap:5px; max-height:230px; overflow:auto; padding-right:1px; }
.peerList::-webkit-scrollbar { width:5px; }
.peerList::-webkit-scrollbar-thumb { background:rgba(255,255,255,.16); border-radius:99px; }
.peer { display:grid; grid-template-columns:22px 22px minmax(0,1fr) auto; align-items:center; gap:5px; min-height:48px; padding:5px 7px; border:1px solid rgba(255,255,255,.075); border-radius:8px; background:rgba(255,255,255,.025); }
.peer[data-self="true"] { border-color:rgba(96,175,255,.30); }
.check, .radio { display:flex; align-items:center; justify-content:center; }
.check input, .radio input { width:15px; height:15px; margin:0; accent-color:#49a8ff; cursor:pointer; }
.peerInfo { min-width:0; }
.peerName { display:flex; align-items:center; gap:4px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; font-size:10px; font-weight:700; }
.tag { flex:0 0 auto; padding:1px 4px; border-radius:5px; background:rgba(255,255,255,.08); color:rgba(255,255,255,.58); font-size:8px; font-weight:600; }
.tag.ref { background:rgba(112,229,151,.14); color:#7de6a0; }
.peerSub { margin-top:3px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; color:rgba(255,255,255,.39); font-size:8px; }
.peerValue { min-width:62px; text-align:right; font-size:9px; font-variant-numeric:tabular-nums; }
.peerValue strong { display:block; font-size:11px; }
.peerValue span { display:block; margin-top:2px; color:rgba(255,255,255,.39); }
.actions { margin-top:8px; display:grid; grid-template-columns:repeat(3,1fr); gap:5px; }
.action { height:31px; border:1px solid rgba(255,255,255,.12); border-radius:7px; color:rgba(255,255,255,.84); background:rgba(255,255,255,.06); font-size:9px; font-weight:650; cursor:pointer; }
.action:hover { background:rgba(255,255,255,.12); }
.action:disabled { opacity:.35; cursor:default; }
.advancedToggle { margin-top:8px; width:100%; height:27px; border:0; border-top:1px solid rgba(255,255,255,.075); background:transparent; color:rgba(255,255,255,.43); font-size:9px; cursor:pointer; }
.advanced { display:none; padding-top:7px; border-top:1px solid rgba(255,255,255,.075); }
:host([data-advanced="true"]) .advanced { display:block; }
.settingGrid { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
.setting { padding:6px; border-radius:7px; background:rgba(255,255,255,.035); }
.setting label { display:block; color:rgba(255,255,255,.44); font-size:8px; }
.setting input { width:100%; height:25px; margin-top:4px; padding:0 6px; border:1px solid rgba(255,255,255,.12); border-radius:5px; outline:none; color:#fff; background:rgba(0,0,0,.18); font-size:9px; }
.nudge { margin-top:7px; display:grid; grid-template-columns:repeat(5,1fr); gap:4px; }
.nudge button { height:27px; border:1px solid rgba(255,255,255,.10); border-radius:6px; color:rgba(255,255,255,.76); background:rgba(255,255,255,.045); font-size:8px; cursor:pointer; }
.help { margin-top:7px; color:rgba(255,255,255,.34); font-size:8px; line-height:1.45; }
</style>
<div class="panel">
<div class="header" id="dragHandle">
<span class="title">SOOP COMMON AUDIO SYNC</span><span class="version">v${VERSION}</span>
<span class="headerControls">
<button id="hide" title="패널 숨기기 · 오른쪽 화면 가장자리의 S 탭으로 다시 표시">×</button>
<button id="collapse" title="접기/펼치기">−</button>
</span>
</div>
<div class="body">
<div class="summary">
<button class="mainToggle" id="toggle">자동 싱크 OFF</button>
<div class="statusBox"><span class="status" id="status">탭 검색 중</span><span class="detail" id="detail"></span></div>
</div>
<div class="metrics">
<div class="metric"><label>현재 오차</label><b id="lag">—</b></div>
<div class="metric"><label>신뢰도</label><b id="confidence">—</b></div>
<div class="metric"><label>재생속도</label><b id="rate">1.000x</b></div>
<div class="metric"><label>입력 신호</label><b id="signal">—</b></div>
</div>
<div class="sectionHead"><span>대상 탭 선택 · ◎ 기준 탭</span><button id="clear">선택 초기화</button></div>
<div class="peerList" id="peerList"></div>
<div class="actions">
<button class="action" id="analyze">이 탭 분석 시작</button>
<button class="action" id="rescan">정밀 재탐색</button>
<button class="action" id="restore">모두 1.000x</button>
</div>
<button class="advancedToggle" id="advancedToggle">고급 설정 / 현재 탭 미세 보정</button>
<div class="advanced">
<div class="settingGrid">
<div class="setting"><label>초기 검색 범위(±초)</label><input id="searchRange" type="number" min="3" max="30" step="1"></div>
<div class="setting"><label>최대 속도 보정(%)</label><input id="maxRate" type="number" min="0.5" max="6" step="0.5"></div>
<div class="setting"><label>즉시 시킹 기준(ms)</label><input id="seekThreshold" type="number" min="300" max="2000" step="50"></div>
<div class="setting"><label>필요 신뢰도(%)</label><input id="minConfidence" type="number" min="35" max="90" step="1"></div>
</div>
<div class="nudge">
<button data-offset="-50">−50ms</button><button data-offset="-10">−10ms</button><button data-offset="0">0ms</button><button data-offset="10">+10ms</button><button data-offset="50">+50ms</button>
</div>
<div class="help">오차 +는 이 탭이 기준보다 뒤처진 상태입니다. 공통 음원이 가려지거나 다른 영상이면 보정을 멈추고 1.000x로 복귀합니다.</div>
</div>
</div>
</div>
<button class="restoreTab" id="restoreTab" type="button" title="싱크 패널 다시 열기" aria-label="싱크 패널 다시 열기"><span>SYNC 열기</span></button>
`;
document.documentElement.appendChild(host);
host.addEventListener('click', (event) => event.stopPropagation());
host.addEventListener('pointerdown', (event) => event.stopPropagation());
state.ui = {
host,
shadow,
toggle: shadow.getElementById('toggle'),
status: shadow.getElementById('status'),
detail: shadow.getElementById('detail'),
lag: shadow.getElementById('lag'),
confidence: shadow.getElementById('confidence'),
rate: shadow.getElementById('rate'),
signal: shadow.getElementById('signal'),
peerList: shadow.getElementById('peerList'),
analyze: shadow.getElementById('analyze'),
rescan: shadow.getElementById('rescan'),
restore: shadow.getElementById('restore'),
clear: shadow.getElementById('clear'),
hide: shadow.getElementById('hide'),
restoreTab: shadow.getElementById('restoreTab'),
collapse: shadow.getElementById('collapse'),
dragHandle: shadow.getElementById('dragHandle'),
advancedToggle: shadow.getElementById('advancedToggle'),
searchRange: shadow.getElementById('searchRange'),
maxRate: shadow.getElementById('maxRate'),
seekThreshold: shadow.getElementById('seekThreshold'),
minConfidence: shadow.getElementById('minConfidence'),
};
state.ui.toggle.addEventListener('click', toggleGroup);
state.ui.analyze.addEventListener('click', async () => {
if (!isSelected()) {
updateConfig((draft) => draft.members.push(memberDescriptorFor(state.tabId)), '현재 탭 선택');
}
await ensureAnalyzer('사용자 분석 시작');
attemptResumeAudioContext();
});
state.ui.rescan.addEventListener('click', () => {
state.localFeatures.length = 0;
state.localPcm.length = 0;
resetMatching('정밀 재탐색');
postMessage({ type: 'command', command: 'reset-match' });
flash('모든 대상 탭 재탐색', 'work');
});
state.ui.restore.addEventListener('click', () => {
restoreNormalRate();
postMessage({ type: 'command', command: 'restore-rate' });
flash('모든 탭 1.000x 복귀', 'ok');
});
state.ui.clear.addEventListener('click', () => {
updateConfig((draft) => {
draft.enabled = false;
draft.members = [];
draft.referenceTabId = null;
}, '선택 초기화');
});
state.ui.peerList.addEventListener('change', handlePeerListChange);
state.ui.hide.addEventListener('click', (event) => {
event.stopPropagation();
setUiHidden(true, { broadcast: true, announce: true });
});
state.ui.restoreTab.addEventListener('click', (event) => {
event.stopPropagation();
setUiHidden(false, { broadcast: true, announce: true });
});
state.ui.collapse.addEventListener('click', (event) => {
event.stopPropagation();
host.dataset.collapsed = String(host.dataset.collapsed !== 'true');
state.ui.collapse.textContent = host.dataset.collapsed === 'true' ? '+' : '−';
savePanelPosition();
});
state.ui.advancedToggle.addEventListener('click', () => {
host.dataset.advanced = String(host.dataset.advanced !== 'true');
savePanelPosition();
});
for (const input of [state.ui.searchRange, state.ui.maxRate, state.ui.seekThreshold, state.ui.minConfidence]) {
input.addEventListener('change', saveAdvancedSettings);
}
for (const button of shadow.querySelectorAll('[data-offset]')) {
button.addEventListener('click', () => setManualOffset(Number(button.dataset.offset)));
}
makeDraggable(host, state.ui.dragHandle);
state.ui.collapse.textContent = host.dataset.collapsed === 'true' ? '+' : '−';
state.renderNeeded = true;
renderUi(true);
}
function memberDescriptorFor(tabId) {
const peer = state.peers.get(tabId) || (tabId === state.tabId ? buildPresence() : null);
return {
tabId,
pageKey: peer?.pageKey || '',
label: peer ? `${peer.channelId}/${peer.broadcastId}` : tabId,
manualOffsetMs: finiteNumber(memberFor(tabId)?.manualOffsetMs, 0),
};
}
function handlePeerListChange(event) {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const tabId = target.dataset.tabId;
if (!tabId) return;
if (target.dataset.action === 'member') {
updateConfig((draft) => {
const exists = draft.members.some((member) => member.tabId === tabId);
if (target.checked && !exists) draft.members.push(memberDescriptorFor(tabId));
if (!target.checked) {
draft.members = draft.members.filter((member) => member.tabId !== tabId);
if (draft.referenceTabId === tabId) draft.referenceTabId = null;
if (draft.members.length < 2) draft.enabled = false;
}
}, '대상 탭 변경');
} else if (target.dataset.action === 'reference') {
updateConfig((draft) => {
if (!draft.members.some((member) => member.tabId === tabId)) draft.members.push(memberDescriptorFor(tabId));
draft.referenceTabId = tabId;
}, '기준 탭 변경');
}
}
function toggleGroup() {
const activeMembers = state.config.members.filter((member) => state.peers.has(member.tabId));
if (!state.config.enabled) {
if (activeMembers.length < 2) {
flash('대상 탭을 2개 이상 선택하세요', 'warn');
return;
}
if (!state.config.referenceTabId || !activeMembers.some((member) => member.tabId === state.config.referenceTabId)) {
flash('기준 탭(◎)을 지정하세요', 'warn');
return;
}
}
updateConfig((draft) => { draft.enabled = !draft.enabled; }, '자동 싱크 토글');
if (state.config.enabled) {
ensureAnalyzer('자동 싱크 시작');
postMessage({ type: 'command', command: 'start-analysis' });
}
}
function saveAdvancedSettings() {
updateConfig((draft) => {
draft.settings.searchRangeSec = clamp(finiteNumber(state.ui.searchRange.value, 12), 3, 30);
draft.settings.maxRateDelta = clamp(finiteNumber(state.ui.maxRate.value, 4) / 100, 0.005, 0.06);
draft.settings.hardSeekThresholdSec = clamp(finiteNumber(state.ui.seekThreshold.value, 620) / 1000, 0.3, 2.0);
draft.settings.minCombinedConfidence = clamp(finiteNumber(state.ui.minConfidence.value, 58) / 100, 0.35, 0.90);
}, '고급 설정 변경');
resetMatching('설정 변경');
}
function setManualOffset(valueMs) {
if (!isSelected()) {
flash('먼저 이 탭을 대상으로 선택하세요', 'warn');
return;
}
updateConfig((draft) => {
const member = draft.members.find((item) => item.tabId === state.tabId);
if (member) member.manualOffsetMs = valueMs;
}, '수동 미세 보정');
resetMatching(`수동 오프셋 ${formatSignedMs(valueMs)}`);
}
function renderUi(force = false) {
if (!state.ui) return;
if (!force && !state.renderNeeded) return;
state.renderNeeded = false;
if (state.flashUntil && state.flashUntil <= Date.now()) {
state.flashUntil = 0;
if (state.config.enabled && isSelected() && !isReference() && state.latestMatch) controlSync(true);
else updateMainStatus();
}
const ui = state.ui;
ui.toggle.textContent = state.config.enabled ? '자동 싱크 ON' : '자동 싱크 OFF';
ui.toggle.dataset.on = String(state.config.enabled);
ui.status.textContent = state.statusText;
ui.status.dataset.kind = state.statusKind;
ui.detail.textContent = state.statusDetail || state.analyzer.detail || '';
ui.lag.textContent = Number.isFinite(state.smoothedLagSec) ? formatSignedMs(state.smoothedLagSec * 1000) : '—';
ui.confidence.textContent = formatConfidence(state.latestMatch?.confidence);
ui.rate.textContent = `${finiteNumber(state.video?.playbackRate, 1).toFixed(3)}x`;
ui.signal.textContent = `${formatSignal(state.analyzer.signalRms)} · ${state.analyzer.mode === 'audio-worklet' ? 'AW' : state.analyzer.mode === 'script-processor' ? 'SP' : '—'}`;
ui.analyze.textContent = state.analyzer.status === 'running' ? '이 탭 분석 중' : state.analyzer.status === 'suspended' ? '클릭해 분석 허용' : '이 탭 분석 시작';
ui.searchRange.value = String(state.config.settings.searchRangeSec);
ui.maxRate.value = String(state.config.settings.maxRateDelta * 100);
ui.seekThreshold.value = String(Math.round(state.config.settings.hardSeekThresholdSec * 1000));
ui.minConfidence.value = String(Math.round(state.config.settings.minCombinedConfidence * 100));
const peers = getAllPeersSorted();
ui.peerList.innerHTML = peers.map((peer) => renderPeerRow(peer)).join('');
}
function renderPeerRow(peer) {
const selected = isSelected(peer.tabId);
const reference = isReference(peer.tabId);
const status = peer.tabId === state.tabId
? {
state: state.statusText,
lagMs: Number.isFinite(state.smoothedLagSec) ? state.smoothedLagSec * 1000 : null,
confidence: state.latestMatch?.confidence,
rate: state.video?.playbackRate,
analyzerStatus: state.analyzer.status,
signalRms: state.analyzer.signalRms,
}
: state.peerStatus.get(peer.tabId);
const label = `${escapeHtml(peer.channelId || 'unknown')} / ${escapeHtml(peer.broadcastId || 'current')}`;
const tags = `${peer.tabId === state.tabId ? '<span class="tag">이 탭</span>' : ''}${reference ? '<span class="tag ref">기준</span>' : ''}`;
let sub = status?.state || analyzerLabel(peer.analyzerStatus);
if (peer.hidden) sub += ' · 백그라운드';
const lagText = reference ? '기준' : Number.isFinite(status?.lagMs) ? formatSignedMs(status.lagMs) : '—';
const confidenceText = reference ? formatSignal(peer.signalRms) : Number.isFinite(status?.confidence) ? `${formatConfidence(status.confidence)} · ${finiteNumber(status.rate, 1).toFixed(3)}x` : analyzerLabel(peer.analyzerStatus);
return `
<div class="peer" data-self="${peer.tabId === state.tabId}">
<label class="check" title="자동 싱크 대상"><input type="checkbox" data-action="member" data-tab-id="${escapeAttribute(peer.tabId)}" ${selected ? 'checked' : ''}></label>
<label class="radio" title="기준 탭"><input type="radio" name="reference" data-action="reference" data-tab-id="${escapeAttribute(peer.tabId)}" ${reference ? 'checked' : ''}></label>
<div class="peerInfo"><div class="peerName">${label}${tags}</div><div class="peerSub">${escapeHtml(sub || '')}</div></div>
<div class="peerValue"><strong>${escapeHtml(lagText)}</strong><span>${escapeHtml(confidenceText)}</span></div>
</div>`;
}
function analyzerLabel(status) {
return ({
running: '오디오 분석 중',
suspended: '탭에서 클릭 필요',
starting: '분석 준비 중',
error: '분석 실패',
'waiting-video': '영상 대기',
off: '분석 꺼짐',
})[status] || '대기';
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
}
function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, '`');
}
function savePanelPosition() {
if (!state.ui) return;
const hidden = state.ui.host.dataset.hidden === 'true';
const rect = hidden ? null : state.ui.host.getBoundingClientRect();
state.panelSettings = {
right: rect?.width ? Math.max(0, innerWidth - rect.right) : finiteNumber(state.panelSettings.right, DEFAULT_SETTINGS.panel.right),
top: rect?.height ? Math.max(0, rect.top) : finiteNumber(state.panelSettings.top, DEFAULT_SETTINGS.panel.top),
collapsed: state.ui.host.dataset.collapsed === 'true',
advanced: state.ui.host.dataset.advanced === 'true',
hidden,
};
saveJson(PANEL_KEY, state.panelSettings);
}
function makeDraggable(host, handle) {
let drag = null;
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0 || event.target.closest('button')) return;
const rect = host.getBoundingClientRect();
drag = { id: event.pointerId, dx: event.clientX - rect.left, dy: event.clientY - rect.top };
host.style.right = 'auto';
host.style.left = `${rect.left}px`;
handle.setPointerCapture(event.pointerId);
event.preventDefault();
});
handle.addEventListener('pointermove', (event) => {
if (!drag || drag.id !== event.pointerId) return;
const rect = host.getBoundingClientRect();
host.style.left = `${clamp(event.clientX - drag.dx, 0, Math.max(0, innerWidth - rect.width))}px`;
host.style.top = `${clamp(event.clientY - drag.dy, 0, Math.max(0, innerHeight - 40))}px`;
});
const finish = (event) => {
if (!drag || drag.id !== event.pointerId) return;
drag = null;
try { handle.releasePointerCapture(event.pointerId); } catch { /* 무시 */ }
const rect = host.getBoundingClientRect();
host.style.left = 'auto';
host.style.right = `${Math.max(0, innerWidth - rect.right)}px`;
savePanelPosition();
};
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
window.addEventListener('resize', () => {
if (host.dataset.hidden === 'true') return;
clampUiToViewport();
}, { passive: true });
}
function periodicTick() {
if (isSelected()) ensureAnalyzer('주기 확인');
maybeRunMatching();
controlSync(false);
updateMainStatusIfNeeded();
broadcastSyncStatus();
}
function updateMainStatusIfNeeded() {
if (state.flashUntil > Date.now()) return;
if (!state.config.enabled || !isSelected() || isReference() || !state.latestMatch) updateMainStatus();
}
function handleStorageEvent(event) {
if (event.key === STORAGE_KEY && event.newValue) {
try { acceptRemoteConfig(JSON.parse(event.newValue), 'storage'); } catch { /* 무시 */ }
}
}
function handleBeforeUnload(event) {
if (event?.type === 'pagehide' && event.persisted) return;
restoreNormalRate();
postMessage({ type: 'bye' });
stopAnalyzer('페이지 종료');
try { state.bc?.close(); } catch { /* 무시 */ }
}
function start() {
createUi();
startBroadcastChannel();
scanVideo();
sendPresence();
state.currentReferenceId = state.config.referenceTabId;
state.observer = new MutationObserver(scanVideo);
state.observer.observe(document.documentElement, { childList: true, subtree: true });
state.scanTimer = setInterval(scanVideo, 850);
state.presenceTimer = setInterval(sendPresence, PRESENCE_INTERVAL_MS);
state.pruneTimer = setInterval(prunePeers, 1200);
state.matchTimer = setInterval(maybeRunMatching, 280);
state.controlTimer = setInterval(() => controlSync(false), state.config.settings.controlIntervalMs);
state.uiTimer = setInterval(() => renderUi(false), 250);
setInterval(periodicTick, 1000);
window.addEventListener('storage', handleStorageEvent);
window.addEventListener('beforeunload', handleBeforeUnload, { once: true });
window.addEventListener('pagehide', handleBeforeUnload, { once: true });
document.addEventListener('visibilitychange', () => {
sendPresence();
attemptResumeAudioContext();
if (!document.hidden) resetMatching('탭 복귀 재검증');
}, { passive: true });
document.addEventListener('pointerdown', () => attemptResumeAudioContext(), true);
onConfigChanged('초기화');
window.SOOPCommonAudioSync = Object.freeze({
version: VERSION,
getState: () => ({
tabId: state.tabId,
page: { ...state.page },
config: structuredCloneSafe(state.config),
analyzer: {
status: state.analyzer.status,
detail: state.analyzer.detail,
mode: state.analyzer.mode,
signalRms: state.analyzer.signalRms,
lastFrameAt: state.analyzer.lastFrameAt,
},
latestMatch: state.latestMatch ? structuredCloneSafe(state.latestMatch) : null,
smoothedLagSec: state.smoothedLagSec,
playbackRate: state.video?.playbackRate,
peers: getAllPeersSorted().map((peer) => ({ ...peer, status: state.peerStatus.get(peer.tabId) || null })),
}),
enable: () => {
if (!state.config.enabled) toggleGroup();
},
disable: () => {
if (state.config.enabled) toggleGroup();
},
reset: () => {
resetMatching('콘솔 재탐색');
postMessage({ type: 'command', command: 'reset-match' });
},
restoreRate: restoreNormalRate,
showUi: () => setUiHidden(false, { broadcast: true, announce: true }),
hideUi: () => setUiHidden(true, { broadcast: true, announce: true }),
toggleUi: () => toggleUiVisibility({ broadcast: true, announce: true }),
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
})();