📄 src/routes/+page.svelte
<script lang="ts">
  import BirdNetWorker from "$lib/birdnet.worker.ts?worker";
  import type { Prediction, WorkerOutMessage } from "$lib/types";

  const WINDOW_SAMPLES = 144000;

  let listening = $state(false);
  let workerReady = $state(false);
  let predictions = $state<Prediction[]>([]);

  let worker: Worker | null = null;
  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);

  $effect(() => {
    worker = new BirdNetWorker();
    worker.addEventListener("message", (e: MessageEvent<WorkerOutMessage>) => {
      const msg = e.data;
      if (msg.type === "ready") {
        workerReady = true;
      } else if (msg.type === "results") {
        predictions = msg.predictions;
      }
    });
    worker.postMessage({ type: "init" });

    return () => {
      worker?.terminate();
      worker = null;
    };
  });

  async function startListening() {
    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 && worker && workerReady) {
        const window = sampleBuffer.slice(0, WINDOW_SAMPLES);
        sampleBuffer = sampleBuffer.slice(WINDOW_SAMPLES);
        worker.postMessage({ type: "analyze", samples: window }, [window.buffer]);
      }
    });
    workletNode.port.start();

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

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

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

{#if predictions.length > 0}
  <ol>
    {#each predictions as p (p.scientificName)}
      <li style:--pct={p.confidence * 100}>
        <span>{p.commonName}</span>
        <span>{(p.confidence * 100).toFixed(1)}%</span>
      </li>
    {/each}
  </ol>
{/if}

<button
  class:listening
  disabled={!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: calc(4rem);
    padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 6rem);
    display: flex;
    flex-direction: column;
    gap: 0.5rem;

    li {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 0.75rem 1rem;
      border-radius: 0.5rem;
      background: linear-gradient(
        to right,
        color-mix(in srgb, var(--accent) 25%, var(--nav-bg)) calc(var(--pct) * 1%),
        var(--nav-bg) calc(var(--pct) * 1%)
      );

      span:first-child {
        font-weight: 500;
      }

      span:last-child {
        font-variant-numeric: tabular-nums;
        opacity: 0.8;
      }
    }
  }

  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: color-mix(in srgb, var(--accent) 20%, var(--bg));
    color: var(--text);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background 0.2s, transform 0.1s;

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

    &:hover:not(:disabled) {
      background: color-mix(in srgb, var(--accent) 30%, var(--bg));
    }

    &:active:not(:disabled) {
      transform: translateX(-50%) scale(0.95);
    }

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

    &.listening {
      background: var(--accent);

      &::after {
        content: "";
        position: absolute;
        inset: -0.5rem;
        border-radius: 50%;
        border: 2px solid var(--accent);
        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>