SOOP Common Audio Auto Sync

여러 SOOP 라이브 탭에서 공통으로 재생되는 영상 소리를 찾아 대상 탭을 자동 정렬하고 싱크를 유지합니다.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
  }

  function escapeAttribute(value) {
    return escapeHtml(value).replace(/`/g, '&#96;');
  }

  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();
  }
})();