📄 src/routes/+page.svelte
<script lang="ts">
  import { endSession, startSession } from "$lib/history";
  import { labelsStore } from "$lib/labels";
  import PatchBanner from "$lib/components/PatchBanner.svelte";
  import { birdnet } from "$lib/birdnet.svelte";

  const WINDOW_SAMPLES = 144000;

  let listening = $state(false);

  let audioCtx: AudioContext | null = null;
  let workletNode: AudioWorkletNode | null = null;
  let sourceNode: MediaStreamAudioSourceNode | null = null;
  let stream: MediaStream | null = null;
  let sampleBuffer: Float32Array = new Float32Array(0);
  let wakeLock: WakeLockSentinel | null = null;

  async function acquireWakeLock(): Promise<void> {
    if (!("wakeLock" in navigator)) return;
    try {
      wakeLock = await navigator.wakeLock.request("screen");
    } catch {
      // not supported or permission denied — silent fail
    }
  }

  function releaseWakeLock(): void {
    wakeLock?.release();
    wakeLock = null;
  }

  async function startListening() {
    await acquireWakeLock();
    startSession();
    audioCtx = new AudioContext({ sampleRate: 48000 });
    await audioCtx.audioWorklet.addModule("/audio-processor.js");

    stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        channelCount: 1,
        sampleRate: 48000,
        echoCancellation: false,
        noiseSuppression: false,
        autoGainControl: false,
      },
    });

    sourceNode = audioCtx.createMediaStreamSource(stream);
    workletNode = new AudioWorkletNode(audioCtx, "audio-processor");

    workletNode.port.addEventListener(
      "message",
      (e: MessageEvent<Float32Array>) => {
        const chunk = e.data;
        const merged = new Float32Array(sampleBuffer.length + chunk.length);
        merged.set(sampleBuffer);
        merged.set(chunk, sampleBuffer.length);
        sampleBuffer = merged;

        if (sampleBuffer.length >= WINDOW_SAMPLES && birdnet.workerReady) {
          const samples = sampleBuffer.slice(0, WINDOW_SAMPLES);
          sampleBuffer = sampleBuffer.slice(WINDOW_SAMPLES);
          birdnet.analyze(samples);
        }
      },
    );
    workletNode.port.start();

    sourceNode.connect(workletNode);
    workletNode.connect(audioCtx.destination);
  }

  function stopListening() {
    releaseWakeLock();
    workletNode?.disconnect();
    sourceNode?.disconnect();
    stream?.getTracks().forEach((t) => t.stop());
    audioCtx?.close();
    workletNode = null;
    sourceNode = null;
    stream = null;
    audioCtx = null;
    sampleBuffer = new Float32Array(0);
    birdnet.clearPredictions();
    endSession();
  }

  async function toggleListening() {
    if (listening) {
      stopListening();
      listening = false;
    } else {
      listening = true;
      try {
        await startListening();
      } catch {
        listening = false;
        stopListening();
      }
    }
  }
</script>

<svelte:document
  onvisibilitychange={() => {
    if (document.visibilityState === "visible" && listening) {
      acquireWakeLock();
    }
  }}
/>

<PatchBanner text="BirdGO" />

{#if birdnet.predictions.length > 0}
  <ol>
    {#each birdnet.predictions as p (p.labelIndex)}
      <li style:--pct={p.confidence * 100}>
        <div class="info">
          <span class="name">{$labelsStore[p.labelIndex]?.commonName ?? "Unknown"}</span>
          <span class="pct">{(p.confidence * 100).toFixed(1)}%</span>
        </div>
        <div class="bar"></div>
      </li>
    {/each}
  </ol>
{/if}

<button
  class:listening
  disabled={!birdnet.workerReady}
  onclick={toggleListening}
  aria-label={listening ? "Stop listening" : "Start listening"}
  aria-pressed={listening}
>
  <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
    <path
      d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"
    />
  </svg>
</button>

<style>
  ol {
    list-style: none;
    padding: 1rem;
    padding-top: 1.25rem;
    padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 6rem);
    display: flex;
    flex-direction: column;
    gap: 0.75rem;

    li {
      background: var(--bg-paper);
      border: 2px solid var(--bg-near-black);
      border-radius: 8px;
      padding: 0.75rem 1rem 0.6rem;
      position: relative;
      overflow: hidden;

      &::before {
        content: "";
        position: absolute;
        inset: 5px;
        border-radius: 4px;
        border: 1.5px dashed var(--accent-amber);
        pointer-events: none;
      }

      .info {
        display: flex;
        justify-content: space-between;
        align-items: center;
      }

      .name {
        font-weight: 700;
      }

      .pct {
        font-variant-numeric: tabular-nums;
        color: var(--brand-red);
        font-weight: 600;
      }

      .bar {
        height: 3px;
        background: var(--brand-red);
        width: calc(var(--pct) * 1%);
        margin-top: 0.5rem;
        border-radius: 2px;
      }
    }
  }

  button {
    position: fixed;
    bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 2rem);
    left: 50%;
    transform: translateX(-50%);
    width: 5rem;
    height: 5rem;
    border-radius: 50%;
    border: none;
    background: var(--brand-red);
    color: var(--bg-cream);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 3px 0 var(--bg-near-black);
    transition: box-shadow 0.1s, transform 0.1s;

    svg {
      width: 2rem;
      height: 2rem;
    }

    &:hover:not(:disabled) {
      background: var(--brand-red-deep);
    }

    &:active:not(:disabled) {
      transform: translateX(-50%) translateY(3px);
      box-shadow: none;
    }

    &:disabled {
      opacity: 0.4;
      cursor: default;
    }

    &.listening {
      background: var(--brand-red-deep);

      &::after {
        content: "";
        position: absolute;
        inset: -0.5rem;
        border-radius: 50%;
        border: 2px solid var(--brand-red);
        pointer-events: none;
        animation: pulse 1.5s ease-out infinite;
      }
    }
  }

  @keyframes pulse {
    from {
      transform: scale(1);
      opacity: 0.7;
    }
    to {
      transform: scale(1.6);
      opacity: 0;
    }
  }
</style>