SOOP Common Audio Auto Sync

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 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();
  }
})();