diff --git a/.planning/phases/04-power-user-features/04-RESEARCH.md b/.planning/phases/04-power-user-features/04-RESEARCH.md
new file mode 100644
index 0000000..ca5f3a4
--- /dev/null
+++ b/.planning/phases/04-power-user-features/04-RESEARCH.md
@@ -0,0 +1,541 @@
+# Phase 4: Power User Features - Research
+
+**Researched:** 2026-03-26
+**Domain:** BPF filter integration, pcap file reading, timestamp-based aggregation
+**Confidence:** HIGH
+
+## Summary
+
+Phase 4 adds two additive CLI features to an already working pipeline: a `--filter` flag that scopes live capture (or offline replay) using BPF syntax, and a `--read` flag that replaces the live capture source with an existing pcap file. Both features wire into the existing classify → aggregate → synthesis pipeline without touching audio synthesis, classification rules, or MP3 encoding.
+
+The key insight from reading the actual library source in the module cache is that go-pcap's `Handle.SetBPFFilter(expr string) error` is available as a first-class method; it compiles a tcpdump-syntax BPF expression into kernel BPF instructions and attaches them to the socket. For pcap file reading, `gopacket/pcapgo.NewReader(r io.Reader)` reads standard pcap (v2.4) files and returns a `*Reader` whose `ReadPacketData()` method satisfies `gopacket.PacketDataSource` — so it plugs directly into `gopacket.NewPacketSource`, exactly as the live `go-pcap` handle does. Timestamp-based windowing for offline mode replaces the real-time `time.Ticker` with arithmetic over `gopacket.CaptureInfo.Timestamp`.
+
+**Primary recommendation:** Add `SetBPFFilter` to `OpenCapture` and add a new `ReadPcapFile` function in `capture/capture.go` that returns the same `<-chan gopacket.Packet` channel as `StartCapture`. Add a `AggregatePcap` function in `aggregate/window.go` that uses packet timestamps instead of a ticker. Wire both paths in `main.go`.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-01:** Use packet timestamps from the pcap file to assign packets to 500ms time windows. Processing is fast — a 10-minute pcap produces a 10-minute MP3 in seconds.
+- **D-02:** Preserve time gaps as silence. Windows with zero packets during gaps produce silent audio sections. MP3 duration faithfully matches the pcap file's time span.
+- **D-03:** `--read` and `-i` are mutually exclusive. Error if both provided. `--read` replaces `-i` as the packet source.
+- **D-04:** `--filter` works with both `-i` (live capture) and `--read` (pcap file). BPF filter applies to whichever packet source is active.
+- **D-05:** `--read` without `-o` derives output filename from input: `capture.pcap` → `capture.mp3`.
+- **D-06:** Bookend messages: `"Reading ..."` at start, then protocol summary + `"Saved"` line at end. No progress bar.
+- **D-07:** `--verbose` works with `--read` — per-window protocol activity lines scroll by quickly. Consistent behavior regardless of packet source.
+
+### Claude's Discretion
+- BPF filter validation approach (pre-validate before opening capture vs let go-pcap/gopacket reject it)
+- Pcap file format detection and error messages for corrupt/unreadable files
+- Implementation of timestamp-based windowing (new aggregation path vs adapter that feeds existing Aggregate())
+- How to handle pcap files with no packets (reuse existing zero-packet guard from OUT-03)
+
+### Deferred Ideas (OUT OF SCOPE)
+None — discussion stayed within phase scope
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CAPT-05 | User can filter captured traffic using BPF syntax via `--filter` flag | `Handle.SetBPFFilter(expr)` on go-pcap handle; `filter.NewExpression(expr).Compile()` for pre-validation without a live socket; errors surface before capture begins |
+| CAPT-06 | User can sonify a pcap file instead of live traffic via `--read` flag | `pcapgo.NewReader(f)` + `gopacket.NewPacketSource` gives same packet channel interface; `CaptureInfo.Timestamp` provides per-packet timestamps for window assignment per D-01/D-02 |
+
+
+## Standard Stack
+
+### Core (all already in go.mod — no new dependencies)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `github.com/packetcap/go-pcap` | v0.0.0-20251215 | BPF filter on live handle | Already used; `Handle.SetBPFFilter(string) error` is a first-class method |
+| `github.com/gopacket/gopacket/pcapgo` | included in gopacket v1.5.0 | Pcap file reading | Part of gopacket; `pcapgo.NewReader(io.Reader)` is the canonical pure-Go pcap file reader |
+| `github.com/gopacket/gopacket` | v1.5.0 | Packet decoding from pcapgo reader | Already used; `gopacket.NewPacketSource(pcapgoReader, linkType)` identical to live path |
+| `github.com/spf13/cobra` | v1.10.2 | New `--filter` and `--read` flags | Already used |
+
+**No new dependencies needed.** `pcapgo` is a sub-package of `gopacket` already in go.mod.
+
+**Installation:** None required. All libraries are already present in go.mod/go.sum.
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `golang.org/x/net/bpf` | via go.sum (transitive) | BPF instruction assembly inside go-pcap | Not called directly; go-pcap's `SetBPFFilter` uses it internally |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `pcapgo.NewReader` (already in gopacket) | External libpcap CGo path | Libpcap can read pcap files but adds CGo and system library dependency — no benefit |
+| In-place BPF filter on `--read` | Software filter in classify stage | go-pcap's `SetBPFFilter` only works on live sockets; for pcap file reading the filter must be applied in software after decoding. See Pitfall 2 below. |
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```
+capture/
+├── capture.go # OpenCapture (add filter param), StartCapture (add filter param), ReadPcapFile (new)
+aggregate/
+├── window.go # Aggregate (unchanged), AggregatePcap (new timestamp-based variant)
+cmd/netsynth/
+└── main.go # New --filter and --read Cobra flags; branch run() into live vs pcap paths
+```
+
+### Pattern 1: BPF Filter on Live Handle
+**What:** Call `handle.SetBPFFilter(expr)` immediately after `pcap.OpenLive` succeeds, before starting the read loop. The filter is installed in the kernel; packets not matching the expression are silently dropped by the kernel before they reach user space.
+
+**When to use:** When `-i` is active (live capture mode) and `--filter` is provided.
+
+**Validated API (verified by reading `/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/pcap.go`):**
+```go
+// Source: go-pcap pcap.go SetBPFFilter method
+func (h *Handle) SetBPFFilter(expr string) error {
+ expr2 := strings.TrimSpace(expr)
+ if expr2 == "" {
+ return nil
+ }
+ e := filter.NewExpression(expr2)
+ if e == nil {
+ return fmt.Errorf("no expression received for filter '%s'", expr)
+ }
+ f := e.Compile()
+ instructions, err := f.Compile()
+ if err != nil {
+ return fmt.Errorf("failed to compile filter into instructions: %v", err)
+ }
+ raw, err := bpf.Assemble(instructions)
+ if err != nil {
+ return fmt.Errorf("bpf assembly failed: %v", err)
+ }
+ return h.SetRawBPFFilter(raw)
+}
+```
+
+**Usage in OpenCapture (updated signature):**
+```go
+// capture/capture.go
+func OpenCapture(ctx context.Context, iface string, filter string) (*pcap.Handle, error) {
+ handle, err := pcap.OpenLive(ctx, iface, 65535, false, 0, false)
+ if err != nil { ... }
+ if filter != "" {
+ if err := handle.SetBPFFilter(filter); err != nil {
+ handle.Close()
+ return nil, fmt.Errorf("invalid BPF filter %q: %w", filter, err)
+ }
+ }
+ return handle, nil
+}
+```
+
+**Error message pattern for invalid BPF:** The error from `SetBPFFilter` surfaces as `"failed to compile filter into instructions: parse error"` for unrecognized tokens, or `"failed to compile filter into instructions: unknown host: abc"` for unresolvable hostnames. Wrapping with `fmt.Errorf("invalid BPF filter %q: %w", expr, err)` gives the user the expression and the underlying reason.
+
+### Pattern 2: BPF Filter for Pcap File (Software Filter)
+**What:** `go-pcap`'s `SetBPFFilter` attaches to a Linux raw socket — it cannot be called on a pcapgo file reader. For `--read` mode, BPF filtering must happen in the classify goroutine or as an adapter between the packet channel and the classify channel.
+
+**Recommended approach (Claude's Discretion):** Apply a software BPF filter in a thin wrapper over the packet channel. Use `go-pcap`'s `filter` sub-package directly to pre-compile the expression and match each packet's raw bytes:
+
+```go
+// Option A (simpler): validate expression early, then filter packets in a goroutine
+// using filter.NewExpression(expr).Compile() which returns a Filter that has a
+// Match([]byte) bool method. Verify Match signature from filter package source.
+```
+
+**Important caveat:** After examining `filter/compile.go`, the `Filter` interface has a `Compile() ([]bpf.Instruction, error)` method for kernel assembly but the per-packet matching for user-space is via `golang.org/x/net/bpf.VM`. This adds a dependency on `bpf.NewVM(instructions)` and `vm.Run(packet)` for software filtering.
+
+**Simpler alternative:** For `--read` mode, skip hardware BPF entirely and apply the filter as a software drop in the classify stage: decode the packet normally, then check if it matches. But this defeats the "filter before classification" separation.
+
+**Most practical approach for this phase:** Use `bpf.NewVM` from `golang.org/x/net/bpf` (already in go.sum as a transitive dependency) to run BPF programs in user space against pcap file packets.
+
+```go
+// Source: golang.org/x/net/bpf VM interface (standard, HIGH confidence)
+import "golang.org/x/net/bpf"
+
+instructions, err := filter.NewExpression(expr).Compile().Compile()
+vm, err := bpf.NewVM(instructions)
+// Per packet:
+ok, err := vm.Run(packetData)
+// ok > 0 means packet passes the filter
+```
+
+### Pattern 3: Pcap File Reading
+**What:** Use `gopacket/pcapgo.NewReader(io.Reader)` to open a pcap file, then `gopacket.NewPacketSource(reader, reader.LinkType())` to get the same packet-producing interface as live capture.
+
+**Validated API (verified by reading `/home/dev/go/pkg/mod/github.com/gopacket/gopacket@v1.5.0/pcapgo/read.go`):**
+```go
+// Source: gopacket/pcapgo read.go
+import (
+ "os"
+ "github.com/gopacket/gopacket"
+ "github.com/gopacket/gopacket/pcapgo"
+)
+
+func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("cannot open pcap file %q: %w", path, err)
+ }
+ r, err := pcapgo.NewReader(f)
+ if err != nil {
+ f.Close()
+ return nil, fmt.Errorf("invalid pcap file %q: %w", path, err)
+ }
+ // r.LinkType() returns layers.LinkType — same as handle.LinkType() in live mode
+ packetSource := gopacket.NewPacketSource(r, r.LinkType())
+ packetSource.NoCopy = false // pcapgo allocates per packet; NoCopy=false is correct here
+ packets := make(chan gopacket.Packet, 512)
+ go func() {
+ defer close(packets)
+ defer f.Close()
+ for pkt := range packetSource.Packets() {
+ packets <- pkt
+ }
+ }()
+ return packets, nil
+}
+```
+
+**Key observation:** `pcapgo.NewReader` reads and validates the pcap file header immediately. If the file is corrupt, truncated, or not a pcap file, `NewReader` returns an error before any packets are read. This is the natural pre-flight validation.
+
+**CaptureInfo.Timestamp:** Every packet from pcapgo has `pkt.Metadata().CaptureInfo.Timestamp` set to the recorded timestamp. This is the time value used for D-01 window assignment.
+
+### Pattern 4: Timestamp-Based Aggregation for Pcap Mode
+**What:** Replace the `time.Ticker` in `Aggregate()` with arithmetic over packet timestamps. Compute the current window index as `int(pkt.Timestamp.Sub(firstPacket).Milliseconds()) / windowMs` and flush when the index advances.
+
+**When to use:** When `--read` mode is active. Reuse existing `Aggregate()` for live mode unchanged.
+
+**New function in aggregate/window.go:**
+```go
+// AggregatePcap reads ClassifiedPackets that include a Timestamp field,
+// assigns each to a windowMs window relative to the first packet timestamp,
+// fills gap windows with empty snapshots per D-02, and emits all windows.
+// Returns a []WindowSnapshot directly (not a channel) because pcap processing
+// is synchronous and all snapshots are available before encode.RunSynthesis.
+func AggregatePcap(events []classify.ClassifiedPacket, timestamps []time.Time, windowMs int) []classify.WindowSnapshot
+```
+
+**Alternative:** Pass timestamps alongside ClassifiedPackets by adding a `Timestamp time.Time` field to `classify.ClassifiedPacket`. This keeps the pipeline shape consistent.
+
+**Recommended approach (Claude's Discretion):** Add `Timestamp time.Time` to `ClassifiedPacket`. The classify goroutine already reads `pkt.Metadata().CaptureInfo.Timestamp` — adding it to the struct costs one field. Then a new `AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int) []classify.WindowSnapshot` function collects all events, assigns them to windows, and fills gaps. This reuses the existing classify stage unchanged and requires only a new aggregation function.
+
+**Gap-filling algorithm (D-02):**
+```go
+// For each window index from 0 to maxWindowIndex (inclusive):
+// if window has packets → emit WindowSnapshot with counts
+// if window is empty → emit WindowSnapshot with TotalPackets=0, empty Counts
+// This preserves silence for pcap files with quiet periods.
+```
+
+### Pattern 5: Filename Derivation (D-05)
+```go
+// capture.pcap -> capture.mp3
+func deriveOutputPath(readPath string) string {
+ ext := filepath.Ext(readPath)
+ return strings.TrimSuffix(readPath, ext) + ".mp3"
+}
+```
+
+### Pattern 6: Mutual Exclusion Validation (D-03)
+```go
+// In run(), before any capture:
+if readPath != "" && ifaceName != "" {
+ return fmt.Errorf("--read and -i are mutually exclusive; use one or the other")
+}
+if readPath == "" && ifaceName == "" {
+ return fmt.Errorf("interface required: use -i or --read ")
+}
+```
+
+### Anti-Patterns to Avoid
+- **Calling SetBPFFilter on a pcapgo.Reader:** This method only exists on `go-pcap`'s live `Handle`. Will not compile.
+- **Using NoCopy=true with pcapgo:** Unlike go-pcap which copies internally (making NoCopy safe), pcapgo's `ZeroCopyReadPacketData` reuses a buffer that is invalidated on the next read. Use `ReadPacketData` (copy-safe) or `NoCopy=false` on PacketSource.
+- **Deriving pcap timestamps from wall clock:** When `--read` is active, use `pkt.Metadata().CaptureInfo.Timestamp`, not `time.Now()`, or windows will all collapse into a single instant.
+- **Omitting filter validation before capture starts:** If `--filter` is invalid, the error should appear before the "Starting capture..." message. Call `handle.SetBPFFilter` before logging capture start; or pre-validate using `filter.NewExpression(expr).Compile().Compile()` before opening the handle.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| BPF expression compilation | Custom parser for tcpdump syntax | `handle.SetBPFFilter(expr)` (go-pcap) | BPF syntax has 20+ primitives, direction qualifiers, boolean operators; already implemented and tested |
+| Pcap file header parsing | Read magic bytes manually | `pcapgo.NewReader(r)` | Handles big/little endian, microsecond/nanosecond timestamps, gzip-compressed pcap; error on unknown magic |
+| Software BPF matching | Byte comparisons per-packet | `bpf.NewVM(instructions)` (golang.org/x/net/bpf) | Runs the same BPF bytecode the kernel uses; already in go.sum as transitive dep |
+| BPF expression validation | String parsing | `filter.NewExpression(expr).Compile().Compile()` | Validates the full pipeline (parse → AST → BPF instructions) without needing an open socket |
+
+**Key insight:** The BPF filter path in go-pcap is already a pure-Go parser + compiler; the same compiled instructions can be run in user space via `bpf.NewVM` without duplicating any filter logic.
+
+## Runtime State Inventory
+
+Step 2.5: SKIPPED — this is a new feature addition phase, not a rename/refactor/migration.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go toolchain | Build | Yes | go1.24.1 linux/arm64 | — |
+| C compiler (CGo for go-lame) | MP3 encoding | Yes (verified in Phase 2) | — | — |
+| `golang.org/x/net/bpf` | Software BPF VM for `--read --filter` | Yes (in go.sum as transitive dep) | v0.39.0 | — |
+| `gopacket/pcapgo` | Pcap file reading | Yes (sub-package of gopacket v1.5.0, already in go.mod) | v1.5.0 | — |
+| Test pcap files | Integration tests | Not present — must create | — | Use `tcpdump -w test.pcap` or generate a minimal pcap in test setup |
+
+**Missing dependencies with no fallback:** None.
+
+**Missing dependencies with fallback:** None — all needed libraries are already in go.mod/go.sum.
+
+**Note on test pcap files:** Unit tests for `ReadPcapFile` and `AggregatePcap` will need either (a) a small pcap fixture committed to the repo, or (b) a test helper that writes a minimal valid pcap file using `pcapgo.NewWriter`. Option (b) is preferred (no binary test fixtures in repo).
+
+## Common Pitfalls
+
+### Pitfall 1: SetBPFFilter Error After Capture Has Started
+**What goes wrong:** If BPF filter validation is deferred until after the "Starting capture on eth0..." message is printed, users see a misleading output before the error appears.
+**Why it happens:** `OpenCapture` is called, then filter is applied, but the start message was already printed in `main.go` before calling `StartCapture`.
+**How to avoid:** In `OpenCapture`, apply `SetBPFFilter` before returning the handle, and print the "Starting capture..." message only after the handle is successfully opened and filtered. Or pre-validate the BPF expression string using `filter.NewExpression(expr).Compile().Compile()` before `OpenCapture`.
+**Warning signs:** Error message appears after "Starting capture..." log line.
+
+### Pitfall 2: BPF VM Not Available at Filter Validation Time
+**What goes wrong:** For `--read` mode, trying to call `handle.SetBPFFilter` fails because there is no live handle. Without a check, the filter flag is silently ignored.
+**Why it happens:** The BPF filter API is on `*pcap.Handle`, which doesn't exist in offline mode.
+**How to avoid:** Pre-validate the BPF expression string once at startup using `filter.NewExpression(expr).Compile().Compile()` (pure parse + compile, no socket needed). Then for live mode use `handle.SetBPFFilter`; for pcap mode use `bpf.NewVM(instructions)` per packet.
+**Warning signs:** `--filter` flag is provided with `--read` but no filtering occurs.
+
+### Pitfall 3: NoCopy=true With pcapgo
+**What goes wrong:** `NoCopy=true` on `PacketSource` combined with `pcapgo.Reader.ZeroCopyReadPacketData` means packet data is overwritten on the next read. If the classify goroutine lags behind, it reads stale/corrupted data.
+**Why it happens:** go-pcap copies data internally (making NoCopy safe), but pcapgo's zero-copy mode reuses a buffer. The `NoCopy = true` comment in `StartCapture` notes "safe: go-pcap copies internally" — this does not apply to pcapgo.
+**How to avoid:** Use `NoCopy = false` (the default) when creating `PacketSource` from a pcapgo reader. Use `ReadPacketData` (not `ZeroCopyReadPacketData`) internally.
+**Warning signs:** Occasional garbage in decoded packet fields during pcap replay.
+
+### Pitfall 4: Empty Pcap File Produces Zero Snapshots
+**What goes wrong:** `ReadPcapFile` on a pcap with no packets returns an empty packet channel. `AggregatePcap` returns `[]WindowSnapshot{}`. `encode.RunSynthesis` receives an empty slice and the existing OUT-03 guard fires: "no packets captured."
+**Why it happens:** The zero-packet guard in `RunSynthesis` already handles this — but the error message ("no packets captured") is slightly wrong for the pcap case.
+**How to avoid:** Check `len(collectedSnapshots) == 0` before calling `RunSynthesis` in the pcap path and return a more informative error: `"pcap file %q contains no packets"`. Reuse the guard, improve the message.
+**Warning signs:** User runs `netsynth --read empty.pcap` and gets "no packets captured" without any file path context.
+
+### Pitfall 5: Timestamp Monotonicity in Pcap Files
+**What goes wrong:** Some pcap files have non-monotonic timestamps (e.g., pcap files merged from multiple captures). Window assignment via `pkt.Timestamp.Sub(firstPacket)` produces negative or very large window indices for out-of-order packets.
+**Why it happens:** The pcap format does not guarantee timestamp order.
+**How to avoid:** Track `minTimestamp` rather than assuming the first packet has the earliest timestamp. Or use `max(currentWindowIndex, computedWindowIndex)` to clamp backward jumps. For this phase, a simple guard is sufficient: skip packets with timestamps before the first observed timestamp.
+**Warning signs:** Panic on negative slice index, or extremely long MP3 output from merged pcap.
+
+### Pitfall 6: go-pcap SetBPFFilter Returns nil for Empty String
+**What goes wrong:** If `filter != ""` check is omitted, passing an empty `--filter ""` to `SetBPFFilter` does nothing (the method returns nil for empty string). This is actually correct behavior, but test cases must confirm empty filter == no filter applied.
+**Why it happens:** `SetBPFFilter` explicitly returns nil for whitespace-only strings (`strings.TrimSpace(expr) == ""`).
+**How to avoid:** Pass `filter` through only when it is non-empty. Document the empty-filter-is-no-filter behavior.
+
+## Code Examples
+
+Verified patterns from module cache source:
+
+### BPF Pre-validation (no socket required)
+```go
+// Source: go-pcap filter package (filter/expression.go, filter/compile.go)
+// Validates the BPF expression string before opening any capture handle.
+import "github.com/packetcap/go-pcap/filter"
+
+func validateBPFFilter(expr string) error {
+ if strings.TrimSpace(expr) == "" {
+ return nil // empty is valid (means no filter)
+ }
+ e := filter.NewExpression(expr)
+ if e == nil {
+ return fmt.Errorf("invalid BPF filter expression: %q", expr)
+ }
+ compiled := e.Compile()
+ if _, err := compiled.Compile(); err != nil {
+ return fmt.Errorf("invalid BPF filter %q: %v", expr, err)
+ }
+ return nil
+}
+```
+
+### Opening pcapgo Reader
+```go
+// Source: gopacket/pcapgo read.go (NewReader, LinkType)
+import (
+ "os"
+ "github.com/gopacket/gopacket"
+ "github.com/gopacket/gopacket/pcapgo"
+)
+
+f, err := os.Open(path)
+if err != nil {
+ return nil, fmt.Errorf("cannot open %q: %w", path, err)
+}
+r, err := pcapgo.NewReader(f)
+if err != nil {
+ f.Close()
+ return nil, fmt.Errorf("not a valid pcap file %q: %w", path, err)
+}
+lt := r.LinkType() // layers.LinkType, same type as handle.LinkType() returns
+packetSource := gopacket.NewPacketSource(r, lt)
+packetSource.NoCopy = false // do NOT set true with pcapgo
+```
+
+### Timestamp-Based Window Assignment
+```go
+// Source: gopacket CaptureInfo struct (gopacket/packet.go)
+// pkt.Metadata().CaptureInfo.Timestamp is a time.Time set by pcapgo.
+
+func assignWindow(pktTime, firstTime time.Time, windowMs int) int {
+ elapsed := pktTime.Sub(firstTime)
+ if elapsed < 0 {
+ return 0 // clamp backward jumps
+ }
+ return int(elapsed.Milliseconds()) / windowMs
+}
+```
+
+### Gap-Filling for Silence (D-02)
+```go
+// Emit a WindowSnapshot for every window index from 0 to maxIdx (inclusive).
+// Windows with no packets get TotalPackets=0 and empty Counts map (silent audio).
+snapshots := make([]classify.WindowSnapshot, maxWindowIdx+1)
+for i := range snapshots {
+ snapshots[i] = classify.WindowSnapshot{
+ Counts: make(map[classify.TrafficClass]int64),
+ WindowIndex: i,
+ }
+}
+for _, ev := range buffered {
+ idx := assignWindow(ev.Timestamp, firstTime, windowMs)
+ snapshots[idx].Counts[ev.Class]++
+ snapshots[idx].TotalPackets++
+}
+```
+
+### Output Filename Derivation (D-05)
+```go
+// Source: stdlib path/filepath
+import "path/filepath"
+
+func deriveOutputPath(readPath string) string {
+ ext := filepath.Ext(readPath)
+ base := strings.TrimSuffix(readPath, ext)
+ return base + ".mp3"
+}
+// "capture.pcap" -> "capture.mp3"
+// "/tmp/net.pcap.gz" -> "/tmp/net.pcap.mp3" (intentional: only strips last ext)
+```
+
+### Software BPF Matching via bpf.VM
+```go
+// Source: golang.org/x/net/bpf (in go.sum as transitive dep via go-pcap)
+import (
+ "golang.org/x/net/bpf"
+ gpcapfilter "github.com/packetcap/go-pcap/filter"
+)
+
+func compileSoftwareBPF(expr string) (*bpf.VM, error) {
+ e := gpcapfilter.NewExpression(expr)
+ instructions, err := e.Compile().Compile()
+ if err != nil {
+ return nil, err
+ }
+ return bpf.NewVM(instructions)
+}
+
+// Per packet:
+result, err := vm.Run(pkt.Data())
+if result > 0 {
+ // packet passes the filter
+}
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `google/gopacket` pcapgo | `gopacket/gopacket` pcapgo (community fork) | 2022 (fork), actively maintained | pcapgo API identical; import path differs |
+| libpcap for pcap file reading | `pcapgo.NewReader` (pure Go) | Available in gopacket since v1.0 | No system library needed; works without libpcap installed |
+
+**No deprecations affecting this phase.**
+
+## Open Questions
+
+1. **Software BPF for `--read --filter`: bpf.VM vs skip filtering**
+ - What we know: `go-pcap`'s `SetBPFFilter` is hardware-only; for pcap files we need software matching.
+ - What's unclear: Whether `bpf.VM` from `golang.org/x/net/bpf` correctly evaluates the same instructions that `SetBPFFilter` would compile for all filter expressions (hostname resolution differs between kernel and userspace).
+ - Recommendation: Test with `port 53`, `tcp`, and `host 10.0.0.1` filter expressions against a known pcap fixture. If hostname resolution in `filter.NewExpression` proves unreliable for user-space matching, document the limitation clearly in the error message.
+
+2. **ClassifiedPacket Timestamp field**
+ - What we know: `aggregate.Aggregate` uses a `<-chan classify.ClassifiedPacket` with no timestamp. D-01 requires timestamp-based windowing for pcap mode.
+ - What's unclear: Whether to add `Timestamp time.Time` to `ClassifiedPacket` (shared struct change) or handle timestamps externally.
+ - Recommendation (Claude's Discretion): Add `Timestamp time.Time` to `ClassifiedPacket`. It is zero-valued in live mode (no behaviour change there) and populated from `pkt.Metadata().CaptureInfo.Timestamp` in the classify goroutine for pcap mode. This is the least invasive change.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | go test (stdlib) |
+| Config file | none |
+| Quick run command | `go test ./capture/... ./aggregate/... ./cmd/netsynth/...` |
+| Full suite command | `go test ./...` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CAPT-05 | `--filter "port 53"` with live capture applies BPF to handle | unit | `go test ./capture/... -run TestOpenCaptureWithFilter` | No — Wave 0 |
+| CAPT-05 | Invalid BPF filter returns error before capture starts | unit | `go test ./capture/... -run TestBPFFilterValidation` | No — Wave 0 |
+| CAPT-05 | BPF pre-validation function rejects bad expressions | unit | `go test ./capture/... -run TestValidateBPFFilter` | No — Wave 0 |
+| CAPT-06 | `ReadPcapFile` returns packet channel from valid pcap | unit | `go test ./capture/... -run TestReadPcapFile` | No — Wave 0 |
+| CAPT-06 | `ReadPcapFile` returns error on missing/corrupt file | unit | `go test ./capture/... -run TestReadPcapFileErrors` | No — Wave 0 |
+| CAPT-06 | `AggregatePcap` assigns packets to correct windows | unit | `go test ./aggregate/... -run TestAggregatePcap` | No — Wave 0 |
+| CAPT-06 | `AggregatePcap` fills gap windows with empty snapshots (D-02) | unit | `go test ./aggregate/... -run TestAggregatePcapGaps` | No — Wave 0 |
+| CAPT-06 | `--read` and `-i` mutually exclusive | unit | `go test ./cmd/netsynth/... -run TestFlagMutualExclusion` | No — Wave 0 |
+| CAPT-05+06 | `--filter` with `--read` applies software BPF to pcap packets | unit | `go test ./capture/... -run TestSoftwareBPFFilter` | No — Wave 0 |
+| D-05 | Output filename derived from input when `-o` absent | unit | `go test ./cmd/netsynth/... -run TestDeriveOutputPath` | No — Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `go test ./capture/... ./aggregate/...`
+- **Per wave merge:** `go test ./...`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `capture/capture_test.go` — add `TestOpenCaptureWithFilter`, `TestBPFFilterValidation`, `TestValidateBPFFilter`, `TestReadPcapFile`, `TestReadPcapFileErrors`, `TestSoftwareBPFFilter`
+- [ ] `capture/testdata/` — small pcap fixture for `TestReadPcapFile` (or a test helper that generates one using `pcapgo.NewWriter`)
+- [ ] `aggregate/window_test.go` — add `TestAggregatePcap`, `TestAggregatePcapGaps`
+- [ ] `cmd/netsynth/main_test.go` — add `TestFlagMutualExclusion`, `TestDeriveOutputPath`
+
+**Existing test infrastructure:** `go test ./...` is already green (verified). All Wave 0 gaps are test additions, not framework setup.
+
+## Project Constraints (from CLAUDE.md)
+
+These directives apply to all phase work:
+
+| Directive | Applies To |
+|-----------|------------|
+| Language: Go only | All new files |
+| Single binary output | No new system library dependencies |
+| MP3 output format | No change — RunSynthesis unchanged |
+| Non-interactive model: run → Ctrl+C → file saved | `--read` mode: run → completes automatically → file saved |
+| Use `gopacket/gopacket` (community fork, NOT `google/gopacket`) | import path in pcapgo: `github.com/gopacket/gopacket/pcapgo` |
+| Use `packetcap/go-pcap` for live capture | unchanged for live path; pcapgo for file path |
+| Use `sjzar/go-lame` for MP3 | unchanged |
+| Use `spf13/cobra` for CLI | `--filter` and `--read` flags added via cobra |
+| `go-audio/generator` is archived — do not use | not relevant to this phase |
+| Do not use `faiface/beep`, `dasa.cc/snd`, or `viert/go-lame` | not relevant to this phase |
+| GSD workflow: use Write/Edit only within a GSD command | enforced by workflow |
+
+## Sources
+
+### Primary (HIGH confidence)
+- Module cache: `/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/pcap.go` — `SetBPFFilter` signature and implementation verified
+- Module cache: `/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/filter/expression.go` — `NewExpression`, `Compile` verified
+- Module cache: `/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/filter/compile_cases_test.go` — error strings for invalid BPF expressions verified (`"parse error"`, `"unknown host: ..."`)
+- Module cache: `/home/dev/go/pkg/mod/github.com/gopacket/gopacket@v1.5.0/pcapgo/read.go` — `NewReader`, `ReadPacketData`, `LinkType`, `CaptureInfo.Timestamp` verified
+- Project source: `capture/capture.go`, `aggregate/window.go`, `cmd/netsynth/main.go`, `classify/types.go` — integration points verified by reading existing code
+- `go test ./...` — full test suite passes (verified live on machine)
+
+### Secondary (MEDIUM confidence)
+- `golang.org/x/net/bpf` VM interface for software BPF matching — package is in go.sum (transitive), API is stable stdlib-adjacent, `bpf.NewVM(instructions)` and `vm.Run(data)` are well-documented
+
+### Tertiary (LOW confidence)
+- None — all claims verified from module cache source directly
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — verified from go.mod and module cache source; no new dependencies required
+- Architecture: HIGH — API signatures verified from module cache source, not from documentation or training data
+- Pitfalls: HIGH — derived from direct source reading (NoCopy comment in pcap_linux.go, error string tests in compile_cases_test.go)
+- BPF VM for software filtering: MEDIUM — API is in go.sum, standard package, but `vm.Run` return semantics not verified by reading source; test against real pcap to confirm
+
+**Research date:** 2026-03-26
+**Valid until:** 2026-09-26 (stable stdlib-adjacent packages; go-pcap minor version could change API but no indication of breaking changes planned)