diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0b6e0e..7009c01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,11 @@ name: ci +# Every branch, not only main: work happens on long-lived feature branches +# here, and before 0.24.0 these checks first ran at release time — where a +# missing optional dependency failed the publish after every binary had built. on: push: - branches: [main] + branches: ["**"] pull_request: jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19e150e..8e90ed0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,78 @@ permissions: contents: write jobs: + # One-file binaries of the Python core (ADR 0019): the web front end, + # the device profiles and the example configs travel inside. ffmpeg is the + # only runtime dependency, on PATH. Each binary is smoke-tested on its own + # runner before it may be published. + binaries: + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-22.04, name: linux-x64 } + - { os: ubuntu-22.04-arm, name: linux-arm64 } + - { os: macos-latest, name: macos-arm64 } + - { os: windows-latest, name: windows-x64 } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Build + shell: bash + run: | + pip install ".[pi]" pyinstaller + pyinstaller --noconfirm --clean packaging/adhush.spec + - name: Smoke test the binary + shell: bash + run: | + set -e + bin="$PWD/dist/adhush"; [ "${{ runner.os }}" = Windows ] && bin="$PWD/dist/adhush.exe" + "$bin" --version + mkdir smoke && cd smoke + "$bin" init --config adhush.toml + test -f adhush.toml && test -d profiles + "$bin" doctor --config adhush.toml || true + python - <<'PY' + import sys; sys.path.insert(0, "..") + from adhush.capture.file_replay import write_fixture + from tests.synth import RATE, synthesize + frames, ts, audio, labels = synthesize([("program", 6.0), ("boundary", 1.0), ("ad", 12.0), ("program", 6.0)]) + write_fixture("fixture.npz", frames=frames, frame_ts=ts, audio=audio, audio_rate=RATE) + print("fixture ok") + PY + "$bin" replay fixture.npz | tail -5 + - name: Package (Linux / macOS) + if: runner.os != 'Windows' + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + mkdir -p out + if [ "${{ runner.os }}" = macOS ]; then + (cd dist && zip -q "../out/adhush-${version}-${{ matrix.name }}.zip" adhush) + else + tar -C dist -czf "out/adhush-${version}-${{ matrix.name }}.tar.gz" adhush + fi + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $version = "$env:GITHUB_REF_NAME".TrimStart("v") + New-Item -ItemType Directory -Force out | Out-Null + Compress-Archive -Path dist\adhush.exe, dist\adhushw.exe -DestinationPath "out\adhush-$version-${{ matrix.name }}.zip" + - uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.name }} + path: out/* + if-no-files-found: error + build-and-publish: + needs: binaries + # A binary runner failing must not hold the APK, the wheel and the web app + # hostage: publish what built, and the missing archive is simply absent. + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -29,10 +100,47 @@ jobs: run: | version="${GITHUB_REF_NAME#v}" cp platforms/android/app/build/outputs/apk/debug/app-debug.apk "dist/adhush-${version}-android-debug.apk" - - name: Bundle the web front end + - name: "Bundle the web front end (also the iOS install, via Add to Home Screen)" run: | version="${GITHUB_REF_NAME#v}" (cd platforms/web && zip -r "../../dist/adhush-web-${version}.zip" .) + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + pattern: binary-* + path: dist/bin + merge-multiple: true + - name: Raspberry Pi bundle (arm64 binary, listener config, guides) + run: | + version="${GITHUB_REF_NAME#v}" + mkdir -p dist/bin pi + if [ ! -f "dist/bin/adhush-${version}-linux-arm64.tar.gz" ]; then + echo "no arm64 binary this time: skipping the Pi bundle"; exit 0 + fi + tar -C pi -xzf "dist/bin/adhush-${version}-linux-arm64.tar.gz" + cp config/adhush-listener.example.toml pi/ + cp docs/print/AdHush-beginner-guide-listener-NESPi4.pdf docs/print/AdHush-beginner-guide-NESPi4.pdf pi/ 2>/dev/null || true + cat > pi/README.txt <<'TXT' + AdHush for Raspberry Pi 4 / 5 (64-bit Raspberry Pi OS, bookworm or newer) + + sudo apt install -y ffmpeg + chmod +x adhush + ./adhush init --listener # writes config/adhush.toml and the profiles + nano config/adhush.toml # the TV's serial port or address + ./adhush doctor # what is missing, if anything + ./adhush probe --active # the set should mute and unmute + ./adhush service install # start at login, restart on failure + + The illustrated build guides are in this folder as PDFs. Everything else: + https://github.com/socrtwo/adhush/blob/main/docs/build-guide-tv-listener.md + TXT + (cd pi && zip -q -r "../dist/adhush-${version}-raspberry-pi.zip" .) + - name: Gather the binaries that built + run: | + mkdir -p dist/bin + mv dist/bin/* dist/ 2>/dev/null || true + rmdir dist/bin 2>/dev/null || true + ls -la dist - name: Release notes from the changelog run: | version="${GITHUB_REF_NAME#v}" @@ -40,13 +148,29 @@ jobs: $0 ~ "^## \\[" v "\\]" {on=1; next} on && /^## \[/ {exit} on {print}' CHANGELOG.md > dist/notes.md - echo >> dist/notes.md - echo "Platform matrix: docs/release.md" >> dist/notes.md + cat >> dist/notes.md <<'TXT' + + ## Downloads by platform + + | Platform | File | + |---|---| + | Android (phone app) | `adhush--android-debug.apk` — sideload | + | iOS / any phone (web app) | open `http://:8675/` in Safari or Chrome → Add to Home Screen; the page is in `adhush-web-.zip` | + | Windows 10/11 x64 | `adhush--windows-x64.zip` — `adhush.exe` (terminal) and `adhushw.exe` (silent, for the logon task) | + | macOS (Apple silicon) | `adhush--macos-arm64.zip` — Intel Macs: the wheel | + | Linux x64 / ChromeOS (Intel) | `adhush--linux-x64.tar.gz` | + | Linux arm64 / ChromeOS (ARM) | `adhush--linux-arm64.tar.gz` | + | Raspberry Pi 4 / 5 | `adhush--raspberry-pi.zip` — binary, listener config, the PDF guides | + | Any Python 3.11+ | `adhush--py3-none-any.whl` | + + Platform matrix and what each build can do: docs/release.md + TXT - uses: softprops/action-gh-release@v2 with: body_path: dist/notes.md + prerelease: ${{ contains(github.ref_name, '-') }} files: | dist/*.whl dist/*.tar.gz - dist/adhush-web-*.zip + dist/*.zip dist/adhush-*-android-debug.apk diff --git a/.gitignore b/.gitignore index 1729d58..90e8b10 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ captures/ config/adhush.toml config/profiles/*.local.toml .DS_Store +data/clock.tsv +data/jingles.tsv diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c087fc..b1e122d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,468 @@ All notable changes to this project are documented here. Format follows Keep a Changelog; versioning follows SemVer. +## [0.28.5] - 2026-09-20 +### Changed +- Android: the remote is its own tab (Home, Remote, TV, Methods, Log; + Help moves to the ? in the toolbar) and is drawn like a TV remote: a + dark body with rounded ends, power, input and mute on top, a number + pad, the volume and channel rockers either side of a round D-pad with + OK in the middle, then menu, transport, the colour keys and the rest, + every key a dark pill or circle with one line of white text. +- Android: no button anywhere wraps its label any more. Every button is + one line tall and its text shrinks to fit; the Log page's buttons read + Save, Share and Clear, the memory buttons Share memory and Import + memory, and the Home page's action row is Test TV and Set-up wizard. + +## [0.28.4] - 2026-09-19 +### Changed +- Android buttons change only when something real happened; the press + animation of 0.28.3 is gone. A button that started a job (a model + download, Test TV, an AI test, an import, saving the log) stays blue + and disabled with the job's progress as its label until the job ends, + so it cannot be pressed twice; then green with a tick or red with a + cross, and the label the outcome calls for. +### Fixed +- Model downloads can no longer leave a short file installed as if it + were whole. The bytes go to a `.part` file with the expected size kept + beside it; a download that stops short is an error saying how far it + got, the button reads Resume with the percentage, and the next press + continues from there. Only a part of exactly the expected size is moved + into place, and a model counts as installed only when every byte is + there. The speech model is unpacked into a folder of its own and moved + into place whole. Delete partial model downloads in the ⋮ menu throws a + stopped download away. + +## [0.28.3] - 2026-09-19 +### Added +- Android: every button answers the finger. It dips when pressed, turns + green with a tick for a second and a half when what it asked for went + through, red with a cross when it did not (nothing running, the set did + not answer, an override in the way). The service reports each control + action's outcome to the buttons — Is an ad, Show's back, Not an ad, the + timed ducks, +30 s, every remote key — and the local ones (Save, Test + TV, downloads, share, import, forget, save the log) report their own. + Is an ad, Show's back, Not an ad and the ducks are bright only when they + would do something right now. A Help topic explains the colours. + +## [0.28.2] - 2026-09-19 +### Added +- Android, stream learning: a player that blocks capture is named as such + within eight seconds, on the Home card and the notification, instead + of a log line after forty. Digital silence from the first second means + the player opted out of being heard; a black picture as well is the + protected-window flag behind Android's "App content hidden from + screenshare" message. Both cases say what to do instead: the channel's + website in Chrome, or the microphone with the normal Start. A black + picture with audio still heard is reported too, since the AD badge + cannot be read from it. Help explains the message. + +## [0.28.1] - 2026-09-19 +### Fixed +- **The first evening's loop** (ADR 0027, amended). A taught break that + ran into the show, two mis-taps of ten and forty seconds, and a jingle + "opener" cut from the show's own bed fed each other: remembered breaks + matched the show, a closer learned from the show ended every duck in + three seconds, and each three-second duck taught more jingles. Now, in + both cores: a break shorter than 20 s teaches the jingle learner + nothing; two hearings of a sting within half an hour count once, so one + session cannot promote it; a taught break under half a minute is a slip + of the finger and one over six minutes is cut there; a remembered break + whose duck ends within ten seconds on programme evidence is a false + match, and two of them forget the record. +- Android: pressing Download twice started two writers on the same model + file and the second one deleted the finished model. One download at a + time, and a finished model is never deleted. +### Added +- Android: **Forget what it learned…** (Methods page, and the ⋮ menu): + tick remembered breaks, jingles, the break clock or scripts and they are + gone. **Save to a file** on the Log page (and the ⋮ menu) writes the log + through the system file picker, beside Share. +### Changed +- Android: the set-up wizard is a button on the Home page next to Start, + Stop, Test TV and Remote, labelled as the wizard on its own screen, and + never opens by itself. The Home page's timed ducks are a row of chips; + Is an ad and Show's back sit side by side; the test output appears only + once there is some. The remote is laid out like one: power, input and + mute on top, volume and channel rockers either side of a real D-pad, + then the number pad, transport and picture keys; every key is at least + 56 dp tall. Buttons and cards are taller and rounder throughout, and + the app has proper dark-mode colours. + +## [0.28.0] - 2026-09-18 +### Added +- **Jingle families** (ADR 0027): a learned sting is also recognised + transposed by up to two semitones and stretched by up to ten per cent + in tempo, at a higher bar, so a channel's per-show re-cuts of its house + sting count as the sting they resemble. The vote reason names the + variant. Both cores. +- **Segment stingers** (`stinger`, ADR 0027): the whoosh or hit a channel + drops on the cut into a segment or a break — a short noisy burst over + the bed, then the level moves. Default weight, inert until it fires, + decays over 2.5 s; ignores our own duck. On by default in the Python + core; on the phone it rides on the Loudness method. +- **The hour of day** (ADR 0027): every jingle remembers the local hours + it opened breaks in; heard again at one of those hours it is trusted a + break early and matched a little more loosely. `jingles.tsv` becomes v2 + with an `hours` column; v1 files still load, and the shared memory + carries the hours between phones. + +## [0.27.0] - 2026-09-18 +### Added +- **The other TV families** (ADR 0026): Android TV / Google TV (paired + once with the six-character code, the phone's own certificate kept), + Hisense VIDAA (its MQTT broker; the four-digit code on newer sets), + and Philips JointSpace 1 and 6 (a PIN once on the Android-based sets). + All three read the volume back, so they duck to an exact level. The + wizard's Find my TV tries them like the rest and shows one code row + when a set asks for one; the remote works over all three. + +## [0.26.0] - 2026-09-18 +### Added +- **Any brand of TV from the phone** (ADR 0025). The wizard's Find my TV + asks every set on the Wi-Fi who it is (SSDP, then each brand's own + question) and proves each way in until one works: a Sharp's control + port, a Sony Bravia (with its pre-shared key), an LG webOS (allow it on + the set once), any DLNA renderer over UPnP, a Samsung's remote channel + (allow once), a Roku TV, a Vizio SmartCast (a PIN once). Paths that read + the volume back duck to an exact level; key-only paths step the volume, + proven by a MUTE fired twice and your yes/no. Then the serial cable, then + infrared brand by brand — Sharp, Samsung, LG/Vizio, Sony, Philips, + Panasonic, Toshiba — until the set muted. The TV page's fourth radio + carries what was found; the remote works over Roku, Samsung and Vizio too. +- Core: `VolumeDevice` and `LevelController` (the Sharp controller made + general), infrared encoders for NEC, Samsung, Sony SIRC, RC-5 and + Panasonic, `MiniHttp`, a hundred-line `MiniWebSocket`, and `TvFinder`. + +## [0.25.0] - 2026-09-18 +### Added +- Android wizard, **Find my TV** (ADR 0022, amended): the TV step tries + every way in and reports each — the Wi-Fi (the typed address, or a scan + of the phone's subnet for a Sharp on the control port), a serial cable + through the USB adapter (asking for USB access if needed), and the + infrared blaster (fired twice, then a Yes/No on whether the sound + dipped). The first that worked is chosen, network first, and the found + address is saved. + +## [0.24.0] - 2026-09-16 +### Added +- **What comskip, MythTV and the Auto-Cut VCRs knew** (ADR 0023), all + inert until they have evidence, all in the default set: + `rating_bug` — the parental-rating box after a break is positive + programme evidence (Python HDMI path and the phone camera); + `ad_units` — gaps on the 15-second ad-unit grid hold a mute through + the middle of a pod (both cores; on the phone it rides on Quiet gaps); + uniform separators in `black_frame` — a white flash or colour card + counts like black; `cutscene` — captured intro/outro frames + (`adhush cutscene add`), an "in" frame ducks alone, an "out" frame is + the show back; `stereo_width` — the mono/stereo switch, measured by the + capture backends before their downmix (`capture.stereo`); `watermark` + — ATSC A/335 / DVB-TA presence lost when a local spot replaces the + marked picture (experimental: written from the spec's shape, not a + live feed). +- **Three feeds** (ADR 0024): `schedule` — XMLTV programme boundaries + (a grace window after a start dilutes mutes; `ad_free` channels never + mute); `ts_stream` capture + `scte35` — an HDHomeRun or DVB transport + stream's in-band splice cues, parsed in-process, authoritative where + they survive; `crowd` — an opt-in shared feed of break times on the + same channel with k-anonymous hashing, `adhush crowd serve` for the + server. +- Programme evidence comes from any detector that has it + (`Detector.program_present`); wall time reaches every detector + (`Detector.tick`); cues are a third event type (`CueEvent`). + +### Fixed +- The release run built every binary and then refused to publish: strict + `mypy` cannot find Pillow, which `adhush cutscene add --image` imports + optionally inside a `try`/`except` and which CI does not install. The + missing import is now ignored by configuration, and `ci.yml` runs the + Python checks on **every branch push** instead of only `main` and pull + requests, so the next such drift appears on the push rather than at the tag. + +## [0.23.0] - 2026-09-16 +### Added +- Android **set-up wizard** (ADR 0022): offered once on first run and always + on the Home page. It asks how the phone reaches the TV (and asks the set + its volume), where the phone sits, the channel, ticker and captions; + listens to fifteen seconds of the show and ten of the room with the TV + muted; looks through the camera for eight seconds; then suggests every + method, the duck level, the camera target and zoom and the local AI size + with a reason each, writes them, and opens the Methods page for review. + Numbers only, never audio or pictures. +- `docs/ideas-backlog.md`: fourteen ranked ideas from a search of comskip, + MythTV, the ATSC/DVB watermark specs, patents and peer projects. +### Fixed +- Every count of the phone's methods now says ten: the Help "Choosing + methods" topic (eight), its lists of weak and strong methods (the clock + and the jingle were missing), the Help header, the Android README's + screens section (six) and the local-AI print guide (eight; PDF + regenerated). + +## [0.22.0] - 2026-09-16 +### Added +- **`aspect_change`** (ADR 0021, after comskip): the Python core's HDMI and + screen-capture paths now watch the picture's shape. Dark, flat bars at + the edges give the active picture's aspect ratio; the programme's shape + is a rolling mode, and a spot that arrives pillarboxed or letterboxed + votes for as long as it holds the other shape. Inert while the shape is + the programme's, so it never dilutes the other detectors; never mutes + alone. On by default and in the HDMI example configs; the synthetic + fixtures gained a 4:3 ad for its ground truth. +- **Crest factor in loudness** (ADR 0021, after beepscore): the + peak-to-RMS ratio rides in the same short-term window against the same + slow baseline, and a drop of 4 dB under the programme's — a spot + compressed flat, however loud — adds up to half a vote. Python and the + phone; `crest_drop_db = 0` turns it off. + +## [0.21.0] - 2026-09-15 +### Added +- **Method 10, break jingles** (ADR 0020, after AdVent): the channel's own + sting into and out of every break is learned from the breaks the + detectors already end — three breaks opened by the same three seconds of + sound promote it — and then ducks the set the moment it is heard, ahead + of every other method. A closer heard while ducked counts as the + programme coming back. Python core (`jingle`, on in the listener example) + and the phone; `jingles.tsv` travels with the shared memory. +- **The ad badge** during stream learning on Android: ML Kit reads the + corners of the player once a second and "Ad", "Ad 1 of 3", "AD 0:15" or + "Your video will resume" is ground truth for the break. +- **Learned break lengths**: the break clock keeps a histogram of how long + this channel's breaks run; a mute's ceiling becomes the 90th percentile + plus 30 s once five breaks are known, and the status line shows "about + m:ss left" during an automatic duck. +- **A countdown you can extend**: manual ducks of 150, 180, 210, 240, 270 + and 300 s next to the 30–120 s ones, a `+30 s` button (phone Home, the + remote, the notification, the web page, IPC `duck_for` with + `extend: true`) and a live `m:ss left` on the status line. `+30 s` on an + automatic duck holds it as a timed one. +- A remote-control icon in the toolbar of every phone screen. + +## [0.20.0] - 2026-09-15 +### Added +- **A release for every platform** (ADR 0019): one-file binaries of the + core for Windows x64, macOS (Apple silicon), Linux x64 and Linux arm64, + each smoke-tested on its own runner; a Raspberry Pi bundle with the arm64 + binary, the listener config and the PDF guides; the Android APK, the web + app (which is also the iOS install, through Add to Home Screen) and the + wheel as before. `adhush init` writes a starter config and the profile + library next to a binary. The web front end and the profiles travel + inside the binary; ffmpeg is the one thing to install. + +## [0.19.0] - 2026-09-15 +### Added +- Android **Learn from a stream** (ADR 0018): with Android's playback + capture the phone hears the live stream it is playing (MS NOW, Xfinity + Stream, any channel's web player) and learns every break into the same + memory it uses at the TV — fingerprints, and words with Speech on; no TV + is touched. Started from the Methods page; the notification counts the + breaks. `docs/print/AdHush-guide-stream-learning.pdf` is the guide. +- Android **memory that moves**: *Share this phone's memory* zips the + breaks, scripts and clock; *Import memory from a file* merges another + phone's without doubling what is known. + +## [0.18.0] - 2026-09-15 +### Added +- **Method 9, the break clock** (ADR 0017): learns which minutes of the + hour the breaks land on from every confirmed break, votes once a minute + has been watched in three hours, never ducks alone. Phone and Python; + `[detect.clock]`, `clock.tsv`. +- **Timed manual duck**: 30, 60, 90 or 120 seconds, then back up on its + own; works from any state, extends when already ducked, "Show's back" + ends it, nothing is learned. Home page, the remote, the web page + (`duck_for` IPC command; `timed_s` in status). +- **Remote control**: every key of the Sharp handset through the control + port (`RCKY`), on a phone screen that also carries Start, Stop, Is an + ad, Show's back, Not an ad and the timed ducks; the same grid on the web + page (`remote` IPC command, `send_key` on controllers, + `commands.remote_key` in the Sharp profile). + +## [0.17.0] - 2026-09-15 +### Added +- **Three sizes of local AI** on Android (method 8): Qwen 2.5 0.5B + (550 MB), Qwen 2.5 1.5B (1.6 GB) and Qwen 3 4B (2.7 GB, the newer + `.litertlm` bundle). Each size keeps its own file; pick one under + Methods, download, switch on. `docs/print/AdHush-guide-local-AI-OnePlus.pdf` + walks through it. +- **Duck compensation** (ADR 0016, borrowed from admuffs): with a room + microphone the loudness detector is told about every duck, waits one + window, measures how far the room dropped and judges the ducked ad on the + original scale — so a ducked commercial no longer reads as "programme + resumed" and the set is not ducked again and again. When the ducked set + is buried under the room (near the silence gate, or flat fan noise), the + loudness and silence detectors go inert and the other methods carry the + unmute; the reason says `ducked_buried`. Python listener and phone alike. + +## [0.16.0] - 2026-09-15 +### Added +- Android **method 7, Ask Claude** (ADR 0015): the last 40 seconds of words + go to Claude with one question — is a commercial playing? — and the + one-line answer is a vote strong enough to duck on its own. Haiku 4.5 by + default (Sonnet 5 / Opus 5 selectable), tie-breaker cadence by default + (ask only when the other methods are unsure or while ducked), a confident + answer saved as a script. Opt-in, API key in encrypted settings, text only + ever leaves the phone. **Test Claude with a sample** shows its answers. +- Android **method 8, Local AI**: the same question answered by + Qwen2.5-0.5B running on the phone through MediaPipe's LLM Inference + (about 550 MB, downloaded once, resumable). Free and private. +- Android camera: **the news ticker instead of the bug** — a target choice + under Channel bug; setup finds the lower-third band's edges and watches + it with the same sighting and whole-TV rules. +- Android speech: a **better speech model** choice (Vosk medium, 128 MB). +- Android: the status line and the Methods page show what each AI last + said; warnings when a judge has no words (speech and captions both off), + no key, or no model. + +## [0.15.1] - 2026-09-15 +### Added +- `rs232_sharp`: **ducking** over the cable (`duck_level`, `normal_volume`, + `duck_state_file`, crash recovery at the next start, restore on close), + the same behaviour `network_ip` gained in 0.15.0. +- The listener guide now wires the TV by its **RS-232C serial socket** + (USB null-modem cable, no login, no Wi-Fi dependence), with Wi-Fi as the + alternative; `config/adhush-listener.example.toml` defaults to the cable. + The illustrated NESPi 4 edition + (`docs/print/AdHush-beginner-guide-listener-NESPi4.pdf`) is rewritten to + match and gains an optional SSD-in-the-cartridge page. +- Android: version number only. + +## [0.15.0] - 2026-09-14 +### Added +- **The Pi listener** (ADR 0014): the Python core learns what the phone + learned. A Raspberry Pi with a webcam and a microphone on a shelf, turning + a Sharp down over Wi-Fi: `config/adhush-listener.example.toml` and a + beginner's guide, `docs/build-guide-tv-listener.md`. +- `network_ip`: one persistent connection (login once, late replies drained, + reconnect and resend on a hang-up), a set that closes before the login + prompt reported as **busy** rather than a wrong password, and **ducking** + (`duck_level`, `normal_volume`, `duck_state_file`) through the profile's + new `volume_set` / `volume_query` commands; a run that died ducked is + repaired at the next start. The Sharp LC-46LE830U profile carries the VOLM + commands. +- Logo detector: `require_sighting` (the bug must be seen before it can be + missed; "Not an ad" demands a fresh sighting), `search_px` (the box slides + and the best match counts), `stale_s` (no frame = inert). Camera capture + drops frames that show only part of a screen ("whole TV or nothing"). +- Detectors can be **inert** (`voting`), and fusion normalises over the + detectors present this tick; detectors are told when the user says it was + the show (`user_says_program`). +- **Teach mode** in the core: `confirm_ad` outside a mute ducks now and + holds; the new `show_back` command restores and learns the bracketed + break. `[fusion] not_ad_quiet_s`: after "Not an ad" nothing may mute for a + minute. Status carries `teaching`, `quiet_s` and `camera`; the web page has + **▶ Show's back** and shows both. +- Android: version number only (no code changes since 0.14.1). + +## [0.14.1] - 2026-09-14 +### Fixed +- Android: the bottom tab bar covered the last part of every page (the + Save/Test row on TV, the last method cards, the end of the log). Android + ignores `paddingBottom` when `padding` is also set on the same view, so + the room left under each page was never applied. Each page now pads its + sides explicitly and scrolls clear of the bar. +- Android: **Test TV while AdHush is running** failed with "hung up during + the login handshake". The Sharp allows one control connection at a time + and the running service holds it, so the test's second connection was + closed before the login prompt. The test now runs through the service's + own connection while it is running, and a set that hangs up before + asking for a login is reported as busy rather than as a wrong password. +- Android: pressing **Is an ad** (button or tile) before the microphone had + ever been allowed crashed the app: Android refuses a microphone-type + foreground service without the permission. The service now logs and + stops instead, and the app's control buttons say "not running" when + nothing is running. + +## [0.14.0] - 2026-09-14 +### Fixed +- Android: the set ducked again seconds after every **Not an ad** during a + show. The camera's logo detector called the bug "gone" without ever having + seen it (a template that did not match the live picture), and its vote is + strong enough to duck alone. Now the bug must be **sighted** once before + its absence counts; **Not an ad** and **Show's back** tell every detector it + was wrong (the camera then needs a fresh sighting, a matched script is + dropped) and open a **one-minute quiet period** in which nothing may duck. +- Android camera: the logo box is **slid over a small window** and the best + match counts, so a screen edge found a few pixels off in a hand no longer + reads as "logo gone" (the likely cause of the mismatch above). +- Android camera: a TV that runs off the edge of the picture, or a lit shape + that is not TV-shaped, is **"whole TV not in view"** — inert, never a duck. +- Android: a blank TV address was tried as ":10002" every five seconds; the + app now refuses to start (and says so) until one is typed. The encrypted + settings falling back to plain ones is now logged. +- Android speech: stopping while the microphone was still feeding the + recogniser threw `RejectedExecutionException` on the mic thread (two + crashes in the log). The feed is guarded and the mic is stopped first. + +### Added +- Android **Methods** page: all six ways to spot a commercial are switches — + quiet gaps, loudness jumps, remembered breaks, channel bug (camera), spoken + words (speech), **on-screen captions** (new, ADR 0013). At least one must be + on; the app refuses to start otherwise. A coloured dot on each card and a + chip row on Home show which methods are running right now; buttons of a + running method are tinted; Start turns into a green "Running", Stop turns + red. +- Android **captions** (sixth method): the camera reads the closed-caption + band off the screen with the on-device text recogniser and feeds the words + to the same script learning and matching as speech. Nothing is misheard + over the room's fans. +- Android **Test mode** card on Home: Test TV with its byte-by-byte result + printed right there (still on the TV page too). +- Android **Help** page and ⓘ buttons on every card: what the "bug" is, what + a "script" is, ducking vs muting, teaching, test mode, and each method. +- Android **splash screen** (the app's mark on a deep blue field) and colour: + the status card is green for the show, red while ducked, orange while + teaching, grey when stopped; each method has its own colour. +- Android camera setup in **colour**, with **pinch-to-zoom** (and a slider) + that zooms the camera itself so the bug is big; the zoom is saved and used + by the service. The TV outline turns red with a warning when the whole TV + is not in the picture. The status line says SEEN / GONE / not seen yet. +- Android status line reports what the camera can see ("bug seen", "whole + TV not in view", "looking for the bug") and the quiet period after Not an + ad. + +## [0.13.0] - 2026-09-14 +### Fixed +- Android: the app could vanish without a word. The service installed a + crash handler that killed the process silently on any uncaught error. + The app-wide handler now writes the stack trace to the error log, + restores the TV volume, and lets Android report the crash. +- Android speech: the 40 MB model was loaded on the main thread inside the + service start, long enough to stall it; it now loads in the background + and attaches when ready. Closing the recogniser now waits for the block + being processed, instead of racing a native call. + +### Added +- Android **error log**: a rolling file every status line, caught error and + crash goes to, shown on the Log tab, with **Share error log**. + +### Changed +- Android interface rebuilt on Material 3: a top bar with the live status, + four tabs (Home, TV, Senses, Log), cards, labelled text fields with a + password toggle, switches, and one primary action per card. All the same + functions, arranged the way a phone app is expected to be. + +## [0.12.0] - 2026-09-14 +### Added +- Android **transcript detector**, the fifth way (ADR 0012): offline speech + recognition (Vosk, small English model downloaded once), commercials + found by **repetition** in the last three hours of words (a run of ten or + more words heard again minutes apart), teach-mode windows saved as + scripts, live matching that tolerates misheard words with a rolling + grace, and legal/sales boilerplate that ducks on its own. `Learn scripts + now` button and a ten-minute automatic pass. + +## [0.11.0] - 2026-09-13 +### Changed +- Android camera: a **setup screen** replaces the blind 45-second button — + the live picture with the screen and logo boxes drawn on it, a magnified + crop of the chosen box, a live match score, and drag-to-draw to override + the automatic choice. The first real run found the finder picking a + persistent news graphic rather than the bug, unverifiable without seeing + it. +- Hand-held phones: the screen is re-found in every frame with edges refined + to the pixel (the coarse box was only good to 8 px), capture is 1280 × 720, + a blur guard makes a smeared frame inert rather than "absent", and absence + must last 2.5 s. Frames come out upright. + ## [0.10.0] - 2026-09-12 ### Added - Android **camera logo watching** (ADR 0011): the back camera feeds two diff --git a/README.md b/README.md index 92321b4..f0a58d7 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,231 @@ # AdHush -Detects television commercials and mutes them automatically. - -AdHush watches a video and/or audio stream, runs several independent commercial -detectors over it, fuses their votes, and issues a mute command to the display -through whatever control path that display supports — infrared, HDMI-CEC, -RS-232, network API, or the host's own audio mixer. - -**Status: all five roadmap phases implemented.** Working today: capture from -HDMI-UVC, screen grab, camera-at-the-screen (with screen auto-crop), -microphone, line-in, or file replay; the `black_frame`, `silence`, -`loudness`, `logo_absence` (calibrated via `adhush calibrate`), `scene_cut`, -and `fingerprint` detectors; vote fusion with hysteresis and the ad state -machine with fingerprint promotion; eight control backends (`rs232_sharp`, -`ir_lirc`, `ir_pigpio`, `cec`, `ir_blaster_net`, `network_ip`, `local_audio`, -`relay_hdmi`) with `adhush probe` to discover which can drive your set; a -device-profile library (Sharp reference, Samsung, LG, Sony BRAVIA, Vizio, -Roku TV, passthrough box); a localhost HTTP+SSE API with a dependency-free -web front end (`platforms/web`); the inline HDMI passthrough box -(`docs/hardware-passthrough-box.md` + `scripts/install-pi.sh`); and the full -CLI. Repeat ads are recognized from their first seconds and muted for their -learned duration. Remaining ideas live under "Beyond the roadmap" in -`docs/roadmap.md`. +Turns the television down during the commercials, and back up when the +show returns. Nothing is uploaded, nothing is recorded, and no capture +path is circumvented: it watches and listens the way a person in the room +does, decides, and presses the volume for you. + +**Status 0.28.5: nineteen roadmap phases in, running in real living +rooms.** Two implementations share one design, and `docs/adr/` records +every decision behind both: + +- **The Python core** (Windows, macOS, Linux, ChromeOS, Raspberry Pi) + captures from an HDMI splitter and USB dongle, a transport stream from an + HDHomeRun or DVB tuner, the screen, a camera at the set, a microphone or + a line-in. Eighteen detectors vote — black frames and silence, loudness + and the crest factor, the channel bug, scene cuts, aspect change, the + rating box, ad-unit lengths, captured cutscenes, the stereo switch, the + ATSC watermark, fingerprints of ads seen before, the break clock, the + channel's break jingle and its re-cuts, the segment stinger, an XMLTV + schedule, SCTE-35 cues and a shared crowd feed — and the set is driven over RS-232, IP, HDMI-CEC, infrared, + a network blaster, the host's own mixer or a relay. +- **The Android app** is a phone by the TV, no cables: the same core + ported to Kotlin with ten methods, among them offline speech and + on-screen captions matched against learned scripts, a cloud and an + on-device AI judge, the channel bug or a news ticker through the camera, + and a jingle it learns by itself. It ducks Sharp, Samsung, LG, Sony, + Roku TV, Vizio, Android TV and Google TV, Hisense, Philips and any DLNA + set; a three-minute wizard finds the set, measures the room and the + camera, and suggests the settings. + +## Get it + +Every release on the [releases page](https://github.com/socrtwo/adhush/releases) +carries a build for each platform. Pick yours: + +| You have | Download | Then | +|---|---|---| +| **An Android phone** | `adhush--android-debug.apk` | sideload it, press the wizard; `platforms/android/README.md` | +| **An iPhone or iPad** | nothing to install: open the core's address in Safari, Share → *Add to Home Screen* | the page is a PWA; it needs a core running somewhere on the network | +| **Windows 10/11** | `adhush--windows-x64.zip` | unzip; `adhush.exe init`, edit `config\adhush.toml`, `adhush.exe doctor`, `adhush.exe run`; `adhush.exe service install` starts it at logon (silently, via `adhushw.exe`) | +| **A Mac (Apple silicon)** | `adhush--macos-arm64.zip` | unzip; `./adhush init` … `./adhush service install`. Intel Mac: the wheel | +| **Linux (x64 or arm64)** | `adhush--linux-x64.tar.gz` / `-linux-arm64.tar.gz` | untar; the same five commands. glibc 2.35+ (Ubuntu 22.04, Debian bookworm) | +| **A Raspberry Pi 4 / 5** | `adhush--raspberry-pi.zip` | the arm64 binary, the listener config and the illustrated PDF guides in one folder, with a README | +| **A Chromebook** | the Linux binary for its chip, in the Linux container | open the served page in Chrome; its Mini window floats above everything | +| **Python 3.11+ anywhere** | `adhush--py3-none-any.whl` | `pip install "adhush--py3-none-any.whl[pi]"` | + +The one thing every desktop and Pi needs besides the binary is **ffmpeg** on +PATH for live capture (`apt install ffmpeg`, `brew install ffmpeg`, +`winget install ffmpeg`); `adhush doctor` says when it is missing. The +binaries are unsigned: Windows SmartScreen and macOS Gatekeeper ask once. +Which platform gets what is in `docs/release.md`. ## Quick start +From a binary, in any folder you like: + ```sh -pip install -e . # numpy only; add [pi] for pyserial +adhush init # writes config/adhush.toml and the profile library (--listener for the shelf build) +adhush doctor # what is missing, if anything +adhush probe --active # which control paths can drive your set (the sound should dip) +adhush run # live detection + control; serves the web app at :8675 +adhush service install # start at login, restart on failure +``` + +From a checkout: + +```sh +pip install -e . # numpy only; add [pi] for pyserial cp config/adhush.example.toml config/adhush.toml # then edit -adhush doctor # verify environment and config -adhush probe # discover which control paths work -adhush calibrate # learn the logo template (logo on screen) -adhush run # live detection + mute control +adhush calibrate # learn the logo template (logo on screen) adhush replay clip.mp4 --labels labels.json # offline scoring adhush learn clip.mp4 --labels ads.json # seed the ad fingerprint store +adhush cutscene add --kind in --image brb.png # a "we'll be right back" card the channel shows +adhush crowd serve # the opt-in shared feed of break times, for the house +make lint type test # ruff, mypy --strict, pytest ``` `replay` accepts any media file ffmpeg can decode, or an `.npz` fixture, and -reports precision/recall separately for mute-onset and unmute-onset against a -JSON label file (`[{"start_ts": 16.0, "duration_s": 30.0}, ...]`). - -## Keep it running, see it at a glance (0.6.0) - -``` -adhush run # with [ipc] enabled: serves the web app at :8675 and - # opens the always-on-top mini window (✗ ✓ ■) -adhush service install # start at login on Linux/ChromeOS, macOS, Windows -``` - -Phones and tablets open `http://:8675/` and add it to the home -screen. Which platform gets what — and what mobile does *not* get yet — is in -`docs/release.md`. - -## Design goals - -1. **Detector plurality.** No single heuristic is reliable. Every detector is a - plugin returning a confidence vote; fusion decides. -2. **Device breadth.** Control backends and device profiles are data, not code - branches. Adding a new make/model should mean adding a profile file. -3. **Small-target friendly.** The reference deployment is a Raspberry Pi 4 with - 2 GB RAM. Everything in the core loop is budgeted against that. -4. **Fail quiet, not loud.** An uncertain detector must never leave the set - muted through program content. Recovery is aggressive. - -## Detectors +reports precision and recall separately for mute-onset and unmute-onset +against a JSON label file (`[{"start_ts": 16.0, "duration_s": 30.0}, ...]`). +A late unmute is the worse failure, and the tests hold it to a tighter +tolerance than a late mute. + +## Keep it running, see it at a glance + +`adhush run` with `[ipc] enabled` serves the web app and opens the +always-on-top mini window: ✗ ✓ ▶ ■, timed ducks of 30 s to 5 min with +`+30 s`, a countdown, and a full TV remote. Phones and tablets open +`http://:8675/` and add it to the home screen. + +## How it decides + +Every detector is a plugin that returns a confidence vote with a +machine-readable reason, never touches a controller, and never reads +another detector. Fusion weighs the votes over the mass of the detectors +that are *voting*: a detector with nothing to say stays inert and neither +adds evidence nor dilutes it. No single default-weight detector can mute +the set alone; two that agree can. A few whose evidence identifies a break +outright — a learned jingle, a captured cutscene, a splice cue, a +fingerprint match, other devices in the same break — may act alone. A +state machine adds dwell and hysteresis, a ceiling learned from how long +this channel's breaks run, and *positive programme evidence* (the bug back, +the closing sting, the rating box, a title card, an in-network cue) that +ends a hold early. `docs/architecture.md` and +`docs/detection-strategies.md` have the whole of it. + +### The detectors (Python core) | Detector | Signal | |---|---| -| `logo_absence` | Network bug vanishes from a configured ROI (typically lower right) | -| `loudness` | Short-term LUFS jumps above rolling program baseline | -| `black_frame` | Black/near-black runs at pod boundaries | +| `black_frame` | Black — or any uniform-colour — frame runs at pod boundaries | | `silence` | Audio gaps at pod boundaries | +| `loudness` | Short-term LUFS above a rolling programme baseline; a crest-factor drop (a spot compressed flat) adds half a vote | +| `logo_absence` | The network bug vanishes from its corner; its return is programme evidence | | `scene_cut` | Shot-change rate spike | -| `aspect_change` | Letterbox/pillarbox transition | -| `caption_gap` | Closed-caption stream discontinuity | -| `fingerprint` | Perceptual video + audio hash match against previously seen ads | - -The fingerprint path is what makes repeat ads instant: the first seconds of an -ad are hashed, stored with the observed duration, and matched on later airings. -A confirmed match mutes for the learned duration, snapped to the nearest -15/30/45/60-second slot, with detector-driven early unmute as a safety net. - -## Control backends - -`rs232_sharp`, `network_ip` (TCP APIs like Sony Simple IP, HTTP APIs like -Roku ECP), `cec`, `ir_lirc`, `ir_pigpio` (raw NEC / extended NEC / Samsung / -Sharp / SIRC / RC-5 / raw-timing waveforms on a GPIO pin), `ir_blaster_net` -(Global Caché iTach, Broadlink), `local_audio` (host mute for screen/app -mode), `relay_hdmi` (the passthrough box's GPIO relay physically opening the -audio path — wired to fail unmuted). `adhush probe` reports which backends -can drive your set, in the profile's preference order. - -Discrete mute-on / mute-off is strongly preferred over toggle. Toggle-only -devices desynchronize; profiles must declare which they support so fusion can -compensate with state verification where available. +| `fingerprint` | Perceptual video and audio hashes of ads seen before; a match mutes for the learned duration | +| `aspect_change` | The active picture's shape leaves the programme's: pillarboxed or letterboxed spots (HDMI and screen paths) | +| `rating_bug` | The parental-rating box ("TV-14") flashed after every break: the show is back | +| `ad_units` | Separators on the 15/30/45/60/90/120-second ad-unit grid hold a mute through the middle of a pod | +| `cutscene` | Captured intro/outro frames: "we'll be right back" ducks, the title card unmutes | +| `stereo_width` | The mix switches between mono and stereo (HDMI, line-in, transport stream) | +| `watermark` | The ATSC A/335 / DVB-TA video watermark lost when a local spot replaces the picture (experimental) | +| `clock` | The break clock: which minutes of the hour the breaks land on, and how long they run | +| `jingle` | The channel's own sting into and out of every break, learned from three breaks; re-cuts a few semitones or a little slower count as the same family; remembers the hours it plays | +| `stinger` | The whoosh or hit on the cut into a segment: a short noisy burst over the bed, then the level moves | +| `schedule` | XMLTV programme boundaries: a grace window after a start; ad-free channels never mute | +| `scte35` | In-band splice cues from a transport stream (`ts_stream`): authoritative where the feed still carries them | +| `crowd` | Other AdHush devices on the same channel reporting a break (opt-in, hashed, `adhush crowd serve`) | +| `caption_gap` | Closed-caption stream discontinuity — planned, not yet built | + +Everything new since 0.21.0 is inert until it has evidence, so the default +configuration enables it all without weakening the rest. + +### The methods (Android app) + +| # | Method | Uses | +|---|---|---| +| 1 | Quiet gaps | microphone; gaps on the ad-unit grid hold a duck through a pod | +| 2 | Loudness jumps | microphone, with the crest factor | +| 3 | Remembered breaks | audio fingerprints of taught breaks | +| 4 | Channel bug, or the news ticker | camera, whole TV in view; the rating box brings the show back | +| 5 | Spoken words | microphone and an offline recogniser, matched against learned scripts | +| 6 | On-screen captions | camera, the caption band read as text | +| 7 | Ask Claude | the words, judged over the API (opt-in, paid) | +| 8 | Local AI | the words, judged by a Qwen model on the phone in three sizes | +| 9 | Break clock | the minute of the hour and how long breaks run | +| 10 | Break jingle | the channel's sting and its re-cuts, learned by itself, with the hours it plays; the segment stinger rides on Loudness | + +The phone also learns a channel's commercials from its live stream before +the first evening at the TV, reads a streaming player's "AD" badge, moves +its memory between phones, and carries a full remote. + +## Control paths + +**Python core:** `rs232_sharp`, `network_ip` (TCP APIs such as Sony +Simple IP, HTTP APIs such as Roku ECP), `cec`, `ir_lirc`, `ir_pigpio` (NEC, +extended NEC, Samsung, Sharp, SIRC, RC-5 and raw timings on a GPIO pin), +`ir_blaster_net` (Global Caché iTach, Broadlink), `local_audio` (the host's +mute, for screen and app mode) and `relay_hdmi` (the passthrough box's +relay, wired to fail unmuted). Device profiles under `config/profiles/` +(Sharp, Samsung, LG, Sony, Vizio, Roku TV, generic) are data, not code; +`adhush probe` reports which backends can drive your set. Discrete +mute-on and mute-off are preferred to a toggle, and ducking to a level is +preferred to muting, so the microphone keeps hearing the set. + +**Android app:** the wizard's *Find my TV* asks every set on the Wi-Fi +who it is and proves each way in until one works: + +| Set | How | Volume | +|---|---|---| +| Sharp | its control port, a serial cable, or infrared | exact | +| Sony Bravia | JSON-RPC with the pre-shared key from its menu | exact | +| LG webOS | allow AdHush on the set once | exact | +| Philips JointSpace | version 1 directly; version 6 with a PIN once | exact | +| Hisense VIDAA | the set's own broker; four digits once on newer sets | exact | +| Android TV / Google TV | a six-character code once | exact, from the set's reports | +| Any DLNA set | UPnP rendering control, no pairing | exact | +| Samsung | allow AdHush on the set once | stepped keys | +| Roku TV | no pairing | stepped keys | +| Vizio SmartCast | a PIN once | stepped keys | +| Anything with a remote | the phone's infrared blaster: Sharp, Samsung, LG/Vizio, Sony, Philips, Panasonic, Toshiba codes | stepped keys | ## Capture paths -- **HDMI passthrough** — splitter/extractor feeding a USB UVC capture dongle. -- **Camera + microphone** — device camera pointed at the screen, no cabling. -- **Audio only** — microphone or line-in; a reduced but useful detector set. -- **Screen capture** — for streaming apps on desktop, ChromeOS, and Web. - -## Android app (0.7.0) - -`platforms/android` is a phone-only build for the Sharp LC-46LE830U: the -detector core ported to Kotlin, listening through the microphone, ducking the -set's volume over its IP port during commercials and restoring it after. No -Pi, no cables. CI builds a debug APK (`adhush--android-debug.apk` on each -release) to sideload; see `platforms/android/README.md` and -`docs/android-app-design.md`. It has not yet been run on a phone. +- **HDMI passthrough** — a splitter or extractor feeding a USB UVC dongle. +- **Transport stream** — an HDHomeRun or DVB tuner over HTTP or UDP, with + its SCTE-35 cues parsed in-process. +- **Camera and microphone** — a device camera pointed at the screen, no + cabling; the phone's way. +- **Audio only** — a microphone or line-in; a reduced but useful set. +- **Screen capture** — for streaming apps on desktop, ChromeOS and the web. -## Platform roadmap - -Linux/Raspberry Pi (reference) -> Windows -> macOS -> Linux desktop -> -ChromeOS -> Android -> iOS -> Web. See `docs/roadmap.md`. - -## Reference hardware - -Raspberry Pi 4, 2 GB. See `docs/hardware-pi4.md` for the wiring, IR LED driver -circuit, and capture-dongle notes. - -## Building the passthrough box +## Design goals -- `docs/hardware-passthrough-box.md` — topology, parts, fail-unmuted wiring. -- `docs/build-guide-beginner.md` — step-by-step build a 12-year-old can - follow: no soldering, low voltage only, ~$120–160 in parts. -- `docs/build-guide-microcontrollers.md` — using an Arduino, ESP32/ESP8266, - or Pico as the mute actuator (with firmware sketches and matching config), - plus the minimum requirements for the box. +1. **Detector plurality.** No single heuristic is reliable. Every detector + is a plugin returning a confidence vote; fusion decides. +2. **Device breadth.** Control backends and device profiles are data, not + code branches. A new make or model is a profile file, or a driver the + wizard proves live. +3. **Small-target friendly.** The reference deployment is a Raspberry Pi 4 + with 2 GB of memory. Everything in the core loop is budgeted against it. +4. **Fail quiet, not loud.** An uncertain detector must never leave the set + muted through the programme. Recovery is aggressive, a late unmute is the + worse failure, and the remote always wins. +5. **Nothing leaves the house.** No audio or video is uploaded by default. + The cloud judge sends text, only while its switch is on; the crowd feed + sends a hashed channel and timestamps, only when opted in. + +## Documentation + +- `docs/architecture.md`, `docs/detection-strategies.md` — how the pieces fit. +- `docs/release.md` — the platform matrix and what each build can do. +- `docs/roadmap.md` — the eighteen phases and what is left; `docs/ideas-backlog.md` — what the wider world does that AdHush does not yet. +- `docs/adr/` — twenty-six architecture decision records, one per idea. +- `platforms/android/README.md` — the phone app, screen by screen. +- `docs/hardware-pi4.md`, `docs/hardware-passthrough-box.md` — the Pi and the passthrough box. +- `docs/build-guide-beginner.md`, `docs/build-guide-tv-listener.md`, `docs/build-guide-nespi4.md`, `docs/build-guide-microcontrollers.md` — illustrated builds, from a shelf listener to a relay box. +- `docs/print/` — the same guides as printable PDFs, plus the local-AI and stream-learning guides for the phone. +- `docs/device-support.md` — contributing a profile for a set that is not listed. + +## Contributing + +`CLAUDE.md` is the working agreement: implement in roadmap order, keep a +module inside its docstring, give every detector a labelled replay test, +and report mute-onset and unmute-onset separately. `make lint type test` +runs ruff, strict mypy and pytest; the Android core's tests run with +`./gradlew :core:test` under `platforms/android`, and CI runs both on +every push. ## License diff --git a/config/adhush-listener.example.toml b/config/adhush-listener.example.toml new file mode 100644 index 0000000..6599d99 --- /dev/null +++ b/config/adhush-listener.example.toml @@ -0,0 +1,90 @@ +# The TV listener: a Raspberry Pi with a USB webcam and microphone on a shelf +# facing the TV, turning a Sharp AQUOS down during commercials through the +# set's RS-232C serial port (a USB null-modem cable) -- or over Wi-Fi. +# No HDMI hardware, no relay, no soldering (docs/build-guide-tv-listener.md). +# Copy to config/adhush.toml and edit the [control] section. + +[capture] +backend = "camera" +device = "/dev/video0" # the webcam; `adhush doctor` lists what it found +width = 1280 +height = 720 +fps = 10 # a bug on a screen does not need 30 frames a second +audio_device = "alsa:plughw:1,0" # the webcam's or USB microphone's card number, from `adhush doctor` +autocrop = true # find the TV in the picture; half a TV is dropped, never "logo gone" + +[device] +profile = "sharp-lc46le830u" + +[detect] +# The listener's four ways (docs/build-guide-tv-listener.md, "The methods"): +enabled = ["silence", "loudness", "logo_absence", "fingerprint", "clock", "jingle", "ad_units", "stereo_width", "schedule", "crowd", "stinger"] # clock: the learned break clock (ADR 0017); jingle: the channel's break sting (ADR 0020) + +[detect.logo_absence] +absence_frames = 25 # 2.5 s at 10 fps: the bug must be gone this long +template = "data/logos/logo.npz" # written by `adhush calibrate` +present_threshold = 0.4 +search_px = 6 # slide the box a little: a webcam's screen edge is never exact +require_sighting = true # the bug must be seen once before it can be missed +stale_s = 2.0 # no whole screen for this long = the camera has no say + +[detect.loudness] +window_s = 1.5 +delta_lufs = 2.5 +max_elevated_s = 180 + +[detect.silence] +dbfs_threshold = -50.0 # the room survey on the phone read a -56 dBFS floor with the fans on +min_run_ms = 400 + +[fingerprint] +enabled = true +store = "data/fingerprints/ads.sqlite" +learn = true + +[fusion] +mute_confidence = 0.72 +unmute_confidence = 0.45 +mute_dwell_ms = 900 +unmute_dwell_ms = 400 +max_mute_s = 240 +not_ad_quiet_s = 60 # after "Not an ad", a minute in which nothing may duck + +[control] +backend = "rs232_sharp" # the cable; or "network_ip" for Wi-Fi (section below) +verify_with_audio = true + +# The serial cable: USB-to-RS-232 null-modem cable (FTDI chip) from a front USB +# port of the Pi to the TV's 9-pin RS-232C socket. No login, no Wi-Fi needed. +# The user running AdHush must be in the "dialout" group to open the port. +[control.rs232_sharp] +port = "/dev/ttyUSB0" # `ls /dev/ttyUSB*` shows it once the cable is in +baud = 9600 # 8 data bits, no parity, 1 stop bit (the manual, p. 58) +timeout_s = 1.0 +duck_level = 4 # turn the set down to this during a commercial (3-5 is inaudible — + # to the microphone too: loudness then goes inert and the camera decides + # the unmute; 8-10 lets the mic follow the ducked ad, ADR 0016) +normal_volume = 19 # used only if the set will not say its own volume +duck_state_file = "data/preduck-volume.txt" # so a crash never leaves the set quiet + +# The Wi-Fi alternative: set backend = "network_ip" above and fill this in. +[control.network_ip] +host = "192.168.1.12" # the TV's address (MENU > Initial Setup > Internet Setup) +port = 10002 +login_id = "admin" # the IP Control login you set on the TV, or blank +login_password = "" # never commit a real password; config/adhush.toml is git-ignored +duck_level = 4 +normal_volume = 19 +duck_state_file = "data/preduck-volume.txt" + +[ipc] +enabled = true +host = "0.0.0.0" # the phone page (the three buttons) needs the Pi reachable on the Wi-Fi +port = 8675 +token = "" + +[ui] +overlay = false # no screen on the shelf + +[log] +level = "info" diff --git a/config/adhush-passthrough.example.toml b/config/adhush-passthrough.example.toml index ff549f8..bde1a39 100644 --- a/config/adhush-passthrough.example.toml +++ b/config/adhush-passthrough.example.toml @@ -18,7 +18,8 @@ profile = "passthrough-box" [detect] # The box sees clean HDMI video, so the full detector set applies. Calibrate # the logo template once per channel lineup: adhush calibrate -enabled = ["black_frame", "silence", "loudness", "logo_absence", "scene_cut", "fingerprint"] +enabled = ["black_frame", "silence", "loudness", "logo_absence", "scene_cut", "fingerprint", "clock", "jingle", "aspect_change", + "rating_bug", "ad_units", "cutscene", "stereo_width", "watermark", "schedule", "scte35", "crowd", "stinger"] # the last nine are inert until they have evidence (ADR 0023/0024/0027) [control] backend = "relay_hdmi" diff --git a/config/adhush.example.toml b/config/adhush.example.toml index ee133e8..5f5895d 100644 --- a/config/adhush.example.toml +++ b/config/adhush.example.toml @@ -1,6 +1,8 @@ # Copy to config/adhush.toml and edit. [capture] +# backend = "ts_stream" reads an MPEG transport stream: url = "http://192.168.1.50:5004/auto/v7" (HDHomeRun) or "udp://:1234" (ADR 0024) +# stereo = true measures the mix's width before the mono downmix on hdmi_uvc, line_in and ts_stream (ADR 0023) backend = "hdmi_uvc" # hdmi_uvc | camera | microphone | line_in | screen | file_replay device = "/dev/video0" width = 1280 @@ -12,7 +14,8 @@ audio_device = "default" profile = "sharp-lc46le830u" [detect] -enabled = ["black_frame", "silence", "loudness", "logo_absence", "scene_cut", "fingerprint"] +enabled = ["black_frame", "silence", "loudness", "logo_absence", "scene_cut", "fingerprint", "clock", "jingle", "aspect_change", + "rating_bug", "ad_units", "cutscene", "stereo_width", "watermark", "schedule", "scte35", "crowd", "stinger"] # the last nine are inert until they have evidence (ADR 0023/0024/0027) [detect.logo_absence] roi = { x = 0.84, y = 0.80, w = 0.14, h = 0.14 } # default comes from the device profile @@ -30,10 +33,59 @@ high_cpm = 40.0 # ...1.0 confidence here window_s = 1.5 # short window: quick unmute matters more than R128's 3 s delta_lufs = 2.5 max_elevated_s = 180 # elevated longer than any ad pod: the baseline is wrong, let it follow +crest_drop_db = 4.0 # a spot compressed this far flatter than the show adds half a vote; 0 = off (ADR 0021) + +[detect.aspect_change] # HDMI and screen paths: the picture's shape leaves the programme's (ADR 0021) +frame_aspect = 1.7778 # the shape of the whole captured frame (16:9) +bar_luma = 24 # a row or column this dark on average is a bar... +bar_spread = 8 # ...if it is also this flat +confirm_s = 1.0 # a new shape must hold this long before it counts +baseline_s = 600 # the programme's shape is the mode over this long +max_change_s = 360 # changed longer than any break: the programme changed shape [detect.black_frame] luma_threshold = 16 min_run_frames = 3 +uniform_spread = 6.0 # a white flash or colour card counts like black; 0 = black only (ADR 0023) + +[detect.rating_bug] # the parental-rating box after a break: the show is back (ADR 0023) +roi = { x = 0.0, y = 0.02, w = 0.22, h = 0.16 } +hold_s = 20 + +[detect.ad_units] # separators on the 15/30/60 s ad-unit grid hold a mute (ADR 0023) +tolerance_s = 1.2 +hold_s = 35 + +[detect.cutscene] # adhush cutscene add --kind in|out --image still.png (ADR 0023) +directory = "data/cutscenes" + +[detect.stereo_width] # the mix switches mono <-> stereo; needs capture.stereo = true (ADR 0023) +min_delta = 0.15 + +[detect.watermark] # ATSC A/335 / DVB-TA presence; experimental, inert on an unmarked channel (ADR 0023) +lines = 2 + +[detect.schedule] # XMLTV from Schedules Direct or tv_grab_*; empty file = off (ADR 0024) +file = "" +channel = "" # the XMLTV channel id or display name you watch +start_grace_s = 90 +ad_free = [] # channels that never carry ads: ["PBS"] + +[detect.scte35] # splice cues from a ts_stream capture (ADR 0024) +max_break_s = 360 + +[detect.crowd] # other AdHush devices on the same channel; opt-in (ADR 0024) +url = "" # "http://pi.local:8676" — adhush crowd serve +channel = "" # hashed with the salt before it leaves the device +report = true +min_reports = 2 + +[detect.stinger] # the whoosh or hit on the cut into a segment (ADR 0027); default weight, inert until it fires +min_flatness = 0.25 # a burst is noisy, not tonal +burst_above_db = 6.0 # over the two seconds before it +level_step_db = 3.0 # and the level has moved afterwards ... +loud_burst_db = 10.0 # ... or the burst itself was this far above +hold_s = 2.5 [detect.silence] dbfs_threshold = -50.0 diff --git a/config/profiles/sharp-lc46le830u.example.toml b/config/profiles/sharp-lc46le830u.example.toml index 593a666..dea9244 100644 --- a/config/profiles/sharp-lc46le830u.example.toml +++ b/config/profiles/sharp-lc46le830u.example.toml @@ -36,11 +36,26 @@ timeout_s = 1.0 # no flow control (p. 58) # Command framing is identical to serial: four ASCII command characters, four # parameter characters left-aligned and space-padded, terminated by CR. # Put host, port, and any login_id / login_password in your own config/adhush.toml -# -- never here. The TV drops an idle connection after 3 minutes, which is -# harmless because each command opens its own. +# -- never here. One connection is kept open and reopened when the set drops it +# (it allows a single control connection; a second one is hung up before the +# login prompt). Set duck_level in your own config to turn the set *down* to a +# whisper instead of muting it (VOLM 0-60, p. 59): a microphone then keeps +# hearing the set through the break, which is how the listener knows when the +# show is back. [network_ip] transport = "tcp" +[network_ip.commands.volume_set] +send = "VOLM{level:<4}\r" # VOLM19 +expect = "OK" + +[network_ip.commands.volume_query] +send = "VOLM? \r" # the LC-46LE830U answers with the number + +[network_ip.commands.remote_key] +send = "RCKY{code:<4}\r" # a remote-control key by code (p. 59); the web page's remote +expect = "OK" + [network_ip.commands.mute_on] send = "MUTE1 \r" expect = "OK" diff --git a/docs/adr/0012-android-transcript-detector.md b/docs/adr/0012-android-transcript-detector.md new file mode 100644 index 0000000..ee74c76 --- /dev/null +++ b/docs/adr/0012-android-transcript-detector.md @@ -0,0 +1,49 @@ +# ADR 0012 — Android: the fifth detector hears the words + +Status: Accepted. 2026-09-14. + +## Context + +The owner asked for a detector that listens to the audio for an hour or +indefinitely, identifies the commercials in the transcript, and afterwards +ducks the set when the same words are heard. The earlier assessment (a +brand-name list would mute the news; no public script database exists) +still holds; this is the version that works around both. + +## Decision + +- **Recognition on the phone.** Vosk with its small English model (about + 40 MB, downloaded once into app-private storage). The microphone's 48 kHz + blocks are averaged to 16 kHz; finished utterances yield words with media + time stamps. Nothing leaves the phone. +- **Commercials are found by repetition.** A spot airs several times an + hour and its words recur verbatim; programme speech never repeats a run + of ten words. `RepeatLearner` indexes every 4-word key with its time; a + key heard again at least two minutes later starts a run, consecutive + agreeing words extend it (one-word slips allowed), and runs of at least + ten words become scripts. Every later airing folds into the same script. + It runs every ten minutes and on demand. +- **Teach mode feeds it too.** The words heard between *Is an ad* and + *Show's back* are saved as a script directly. +- **Live matching tolerates errors.** `TranscriptMatcher` aligns the last + four words against the scripts like the audio matcher aligns chroma + blocks; a misheard word breaks four keys but not the alignment tally; + three aligned hits confirm. The vote is 1.0 while hits keep coming, + rolling an 8 s grace past the last one (ads have music and pauses). +- **Boilerplate on its own.** "Side effects may include", "ask your + doctor", "call now", "results may vary" and the like essentially never + occur in programming; hearing one holds the duck for 15 s. +- **Weight.** Three default weights, like the logo: a known script mutes + alone. + +## Consequences + +- Utterance-level latency: a match arrives when the recogniser finishes a + phrase, typically two to five seconds into it. +- Battery: continuous recognition is the costliest thing the app does; + it is opt-in. +- The room matters: the survey put the TV about 4 dB above the fans, poor + conditions for word recognition; louder TV, closer phone. +- Recurring programme lines (a show's opening) could be learned if they + repeat within the window verbatim for ten words; *Not an ad* does not yet + forget a script — a later addition if it happens. diff --git a/docs/adr/0013-android-captions-and-whole-tv-gating.md b/docs/adr/0013-android-captions-and-whole-tv-gating.md new file mode 100644 index 0000000..55efb42 --- /dev/null +++ b/docs/adr/0013-android-captions-and-whole-tv-gating.md @@ -0,0 +1,63 @@ +# ADR 0013 — Android: captions as the sixth method; the camera may only doubt what it has seen + +Status: Accepted. 2026-09-14. + +## Context + +A log from 0.13 showed the set ducked again seconds after every *Not an ad* +during a show. The camera's logo detector (ADR 0011) voted "absent" at +full confidence from the first frame: its template did not match the live +picture (the phone in a hand eight feet away puts the screen box a few +pixels from where calibration had it, which is most of a small logo), and +nothing required the detector to have *seen* the logo before missing it. +Its vote carries three default weights, so it ducked alone, and *Not an +ad* only reset the fusion hysteresis, so the same vote ducked again after +the dwell. The owner also asked for captions as a sixth way of spotting a +commercial, and for the app not to duck when the camera is not looking at +the whole TV. + +## Decision + +- **Sighting before absence.** `LogoAbsenceDetector` starts "unknown" + (score 0) with `requireSighting`; it may vote absent only after one frame + whose raw correlation clears the presence threshold. Until then it is + inert: no vote, no share of the normaliser. `Detector.voting` is the + generic form of the inert state; the engine filters on it. +- **The user's word reaches the detectors.** `Detector.userSaysProgramme` + is called on *Not an ad* and *Show's back*. The logo detector drops its + absence and demands a fresh sighting; the transcript detector drops its + held script. The engine also opens a quiet period (`NOT_AD_QUIET_S`, + 60 s) during which the decision is forced to "programme": the evidence is + still shown, nothing acts on it. +- **A search window.** The ROI is slid over ±6 normalised pixels (stride 2) + and the best correlation counts; the box therefore follows the bug in a + hand-held frame instead of assuming calibration's pixel. +- **Whole TV or nothing.** `Vision.screenComplete`: a screen box that + touches the frame edge (2 px margin) or whose aspect is outside 1.15–2.6 + is a partial or false screen; the detector is inert with reason + `partial_screen`, and the finder does not learn from such frames. +- **Captions are a `TranscriptDetector` named "captions".** `CaptionSource` + cuts the lower 35 % of the found (whole) screen out of each frame, scales + it so a caption row is about 20 px tall, and runs ML Kit's bundled Latin + text recogniser once a second. Only words new since the previous reading + are fed (captions scroll: the new line's head is the old line's tail). + The words carry media time and enter the same three-hour history, + `RepeatLearner` and `TranscriptMatcher` as speech, in a second detector + instance that shares the script store; both carry the logo weight. +- **Methods are switches.** `Assembly.engine` takes `silence`, `loudness`, + `fingerprints` flags and the optional logo, transcript and captions + detectors, and refuses an empty set; the app refuses to start with no + method on or a blank IP address. + +## Consequences + +- A template that never matches can no longer duck the set; it shows + "looking for the bug" in the status line instead, which is the cue to + redo the setup. +- Captions cannot arrive by cable: Sharp's RS-232C and IP protocols carry + commands and one-word replies only, and infrared is one-way. Reading them + off the picture is the only path from outside the set and needs the + whole TV in view; at eight feet it needs the camera zoomed. +- The Python core does not gain captions or the inert-vote generalisation + in this release; the Kotlin core is ahead on the camera-and-phone paths + that only exist there. diff --git a/docs/adr/0014-pi-listener-parity-with-the-phone.md b/docs/adr/0014-pi-listener-parity-with-the-phone.md new file mode 100644 index 0000000..f3d0049 --- /dev/null +++ b/docs/adr/0014-pi-listener-parity-with-the-phone.md @@ -0,0 +1,64 @@ +# ADR 0014 — The Pi listener learns what the phone learned + +Status: Accepted. 2026-09-14. + +## Context + +The Android app went through a week of living-room testing (ADR 0009 to +0013) that the Python core never had: a hand-held camera, a Sharp that +allows one control connection, a set that must be turned *down* rather +than muted so the microphone keeps hearing it, a "Not an ad" that has to +mean something, and a logo detector that must not call the bug gone before +it has ever seen it. The owner asked for the Raspberry Pi version "in that +light", plus a beginner's build guide for a Pi on a shelf with a webcam and +a microphone, controlling the same Sharp over Wi-Fi. + +## Decision + +Port the rules, not the code, into the Python core (0.15.0): + +- **Inert votes.** `Detector.voting` (default True) lets a detector cast no + vote; `Fusion.combine` normalises over the detectors present this tick + (`max(present_mass / 2, 0.30)`), so an inert detector neither adds + evidence nor dilutes it. With every detector voting the numbers are + unchanged, so the Kotlin conformance fixture still holds. +- **The user's word reaches the detectors.** `Detector.user_says_program`; + the logo detector drops its absence and (with `require_sighting`) demands + a fresh sighting. `reject_ad` also opens `fusion.not_ad_quiet_s` (60 s) + in which the decision is forced to program. +- **Logo detector.** `require_sighting` (score starts at 0; absence counts + only after one raw correlation clears the threshold), `search_px` (the + ROI slid over ±px of a 320-wide screen, best correlation wins, box + arithmetic identical to `extract_roi`), `stale_s` (no frame for this long + = inert with reason `no_screen`). `describe()` feeds the status line. +- **Whole screen or nothing.** `capture.camera.screen_complete`: a screen + box touching the frame edge or outside 1.15–2.6 aspect is a partial or + false screen; such frames are dropped (not cropped) and the box is looked + for again half a second later, so the logo detector goes stale rather + than absent. +- **Teach mode.** `confirm_ad` outside AD promotes to AD with a user hold + (`fp_hold` semantics, no program evidence); the new `show_back` command + cancels it and learns the bracketed segment through the existing learner; + outside teach mode it stands down without learning. Status carries + `teaching`, `quiet_s` and `camera`; the web page has ▶ Show's back. +- **Network control.** `PersistentTcp` keeps one connection, logs in once, + drains late replies, reconnects on EOF and resends once, and names a set + that closes before the login prompt as *busy* (one control connection) + rather than a wrong password. `duck_level` with the profile's + `volume_set` / `volume_query` commands turns the set down and back, + remembering the pre-duck volume in `duck_state_file`; `recover_on_start` + repairs a run that died ducked. The Sharp profile gains the VOLM commands. +- **Config.** `config/adhush-listener.example.toml` is the shelf setup; + `docs/build-guide-tv-listener.md` is the guide. + +## Consequences + +- The Pi listener behaves like the phone on the four methods it shares + (quiet gaps, loudness, remembered breaks, channel bug). Speech and + captions stay phone-only for now; both are portable (Vosk and Tesseract + exist for Python) and are the obvious next port. +- `confirm_ad` changed meaning outside AD: it used to be a no-op, now it + mutes. Old clients that sent it only while muted see no difference. +- Per-command connections are gone from `network_ip`; sets that preferred + them (none known) would need a `persistent = false` option, not added + until one appears. diff --git a/docs/adr/0015-ai-judges-and-the-news-ticker.md b/docs/adr/0015-ai-judges-and-the-news-ticker.md new file mode 100644 index 0000000..21a35d6 --- /dev/null +++ b/docs/adr/0015-ai-judges-and-the-news-ticker.md @@ -0,0 +1,64 @@ +# ADR 0015 — Two AI judges as methods seven and eight; the news ticker instead of the bug + +Status: Accepted. 2026-09-15. + +## Context + +The word-based methods (ADR 0012, 0013) recognise commercials they have +heard before, or stock legal phrases. The owner asked for a method that +judges *any* stretch of speech — "listen to MSNOW and ask a cheap Claude +whether this is content or a commercial" — and, for a new phone, the same +with a language model running on the device. Separately, on news channels +the corner bug is small and translucent while the lower-third ticker is a +large, high-contrast band that is present throughout programming and gone +during commercials. + +## Decision + +- **One judge detector, two brains.** `JudgeDetector` (core) collects the + words the speech and caption detectors already produce, and on a cadence + hands the last `windowS` (40 s) to a `TranscriptJudge` on a thread the + app supplies. The answer is one line, `COMMERCIAL 0.9: why` or + `SHOW 0.8: why` (`Verdict.parse` is tolerant of prose and JSON). A + commercial answer is a vote at the logo weight held for `holdS` (15 s); + the user's "Not an ad" drops it. The default cadence is **tie-breaker**: + ask only when fusion is in the grey zone (0.2–0.72) or the set is + ducked, and otherwise at most once a minute; "always" asks every 10 s. + A confident commercial over 15+ words is saved to the shared script + store (deduplicated through `TranscriptMatcher.identify`) and the script + detectors reload, so the next airing is recognised offline for free. +- **Method 7, `judge_claude`:** the official Anthropic Java SDK + (`com.anthropic:anthropic-java`), one `messages.create` per question with + the instruction as a cached system block and `max_tokens` 64. Haiku 4.5 + by default; Sonnet 5 and Opus 5 selectable. Opt-in, with the API key in + the encrypted settings; text only ever leaves the phone, never audio. +- **Method 8, `judge_local`:** MediaPipe's LLM Inference task + (`com.google.mediapipe:tasks-genai`) running Qwen2.5-0.5B-Instruct + (8-bit, ~550 MB, Apache-2.0, LiteRT community build, no login) from + app-private storage; the Qwen chat template is applied in the prompt; + the download resumes with a `Range` request. Gemma 3 1B would be the + better small model but is gated behind a Hugging Face login, which an + app cannot satisfy. +- **The ticker.** `LogoFinder.bandResult` finds rows in the bottom 40 % of + the normalised screen where a strong edge persists across at least half + the width — the band's top and bottom lines — and builds a full-width + template between them. The existing `LogoAbsenceDetector` runs on it + under the name `ticker_absence` with a 4-px search window; the same + sighting, whole-screen and stale rules apply. Camera setup gains a + target choice and saves to `ticker.tsv`. +- **Speech model choice.** The Vosk medium model + (`vosk-model-en-us-0.22-lgraph`, 128 MB) is offered next to the small one, + because the judges are only as good as the words they read. + +## Consequences + +- Both judges need words: without speech or captions on, the app says so + and they sit idle. +- Cost of method 7 at Haiku rates: about $0.0004 a call, ~$0.13 an hour in + "always" mode, a tenth of that as a tie-breaker. Method 8 costs CPU and + battery instead. +- The judges are late by nature (utterance finalisation + window + answer): + they start ducks well; the unmute is better left to the bug, the ticker + or a remembered break. +- The Pi listener has no speech recogniser yet, so neither judge runs + there; that is the next port (Vosk has a Python package and Pi wheels). diff --git a/docs/adr/0016-what-we-took-from-admuffs.md b/docs/adr/0016-what-we-took-from-admuffs.md new file mode 100644 index 0000000..8e62f15 --- /dev/null +++ b/docs/adr/0016-what-we-took-from-admuffs.md @@ -0,0 +1,69 @@ +# ADR 0016 — What we took from admuffs: duck compensation and the mute paradox + +Status: Accepted. 2026-09-15. + +## Context + +The owner asked whether [admuffs](https://github.com/ablanquart/admuffs) +(a C++ Raspberry Pi commercial muter with an IR pHAT) had methods worth +borrowing. Most of its design is already here in another shape: a shared +audio bus (our shared decoded frame), a loudness source with a baseline +that will not follow an ad (ours freezes and has a `max_elevated_s` +escape), a weighted fusion with hysteresis (our Schmitt trigger and dwell +timers), a failsafe ceiling (`max_mute_s`) and a reset after it (our +RECOVERY state), a JSON IR database (our profiles), and a web remote (our +web page and IPC). Its ACRCloud fingerprinting is a paid cloud service +where ours learns locally. + +One thing it names and handles that we did not: **the mute paradox**. A +room microphone hears the set get quieter the moment we duck it. The +loudness detector then reads "quieter than the baseline" as *programme +resumed*, the machine unmutes into the commercial, the mic hears the ad +again, and the set is ducked once more — the "repeated ducking" the owner +saw in September logs. admuffs answers it three ways: measure the actual +attenuation after a volume drop and compensate the loudness source; discard +audio "programme" verdicts while muted with a room mic; and never outlive +the failsafe. + +## Decision + +- **Detectors are told about ducks.** `Detector.audio_ducked(ts, ducked)` + (Python) / `audioDucked` (Kotlin) is a new optional hook. The engine calls + it after every successful mute/unmute, override or user action — only + when the audio is a microphone in the room (Python: `capture.backend` is + `camera` or `microphone`; the phone always is). A line tap hears the + broadcast whatever the set does, so it is never told. +- **Loudness compensates.** On a duck the detector freezes its vote for one + window plus a second (so the pre-duck ad is not "gone" before the room + has settled), remembers the pre-duck short-term level, then measures the + post-duck level and adds the difference (clamped to 30 dB) to every later + reading. The ducked ad is judged on the original scale; a ducked + programme reads as programme, so the unmute is signal-driven as before. + The baseline is not updated by a window straddling a volume change. +- **Buried means inert, not stuck.** If the post-duck level sits within + 6 dB of the silence gate, or the settle window is spectrally flat noise + (the silence detector's own room-versus-broadcast test), the ducked set is + below the room and no offset can recover it. The detector goes inert + (`voting = False`, reason `ducked_buried`) until the volume is back, and + the other methods — camera, fingerprints, scripts, judges — decide the + unmute, with `max_mute_s` as the ceiling. This is admuffs's "discard + programme verdicts" rule, applied per detector and only when it is true. +- **Silence is inert while ducked.** A ducked set heard through a room mic + cannot show a real gap: every quiet moment is our own doing. +- **Not taken.** Volume *normalisation* (levelling ads instead of ducking) + is a different product; a controller that reads audio levels would break + ADR 0003. Recording IR codes from the real remote is useful for the + smaller Sharp and is a candidate for `adhush probe`. ACRCloud is not + needed: our fingerprints are local and free. + +## Consequences + +- A room-mic setup with loudness as its main audio method no longer + oscillates after a duck. The Python listener and the phone behave the + same; both have replay tests for the compensated and the buried case. +- The owner's room (loud fans, duck level 4, "inaudible") is the buried + case: loudness will report `ducked_buried` and the camera bug, the + fingerprints and the AI judges carry the unmute. A duck level of 8–10 + keeps the set audible to the mic and lets loudness time the unmute. +- Reason strings gain `duck_settling`, `duck_offset_db=` and + `ducked_buried`, so a log shows which path a duck took. diff --git a/docs/adr/0017-break-clock-timed-duck-remote.md b/docs/adr/0017-break-clock-timed-duck-remote.md new file mode 100644 index 0000000..30eeb15 --- /dev/null +++ b/docs/adr/0017-break-clock-timed-duck-remote.md @@ -0,0 +1,63 @@ +# ADR 0017 — The break clock, timed manual ducks, and a remote with everything on it + +Status: Accepted. 2026-09-15. + +## Context + +Three requests from the owner. Do commercials on a news channel run at +predictable minutes, and can that be a method? Can the set be turned down +by hand for 30, 60, 90 or 120 seconds? Can a full TV remote live inside +AdHush, with every AdHush control on the same screen? + +Cable news is scheduled to a *format clock*: each hour's segments and +breaks sit at planned minutes, and the same programme keeps the same clock +for months. But the clock differs by show and daypart, is not published, +drifts with live news, and breaks are not all the same length (roughly two +to four minutes; the top-of-hour pod is the longest, and a network break +often runs into a local one). So a fixed table would be wrong within a +week, while a learned one is right most of the time. + +## Decision + +- **Method 9, the break clock** (`ClockDetector`, Kotlin and Python). Sixty + counters, one per minute of the hour: how many watched hours had a break + in that minute, and how many hours that minute was watched at all. Every + break that ends through the normal paths (fusion, a fingerprint match, + teach mode) marks the minutes it covered, if its length is plausible + (15 s to 5 min); "Not an ad" and timed ducks teach nothing. The vote is + the fraction of watched hours that were a break, scaled so 60 % reads + as certainty, and the detector is **inert** (`voting = false`) until the + minute has been watched in three distinct hours — so learning never + dilutes fusion. Default weight (0.15): alone it can never reach the mute + threshold; with loudness or a gap half sure, it tips the balance. It + needs wall-clock time; a replay has none, so it stays inert there. The + counters live in `clock.tsv` (phone) or `[detect.clock] file`. +- **Timed manual duck.** `duckFor(seconds)` / `duck_for` turns the set down + now — from any state, including the recovery pause after a duck that + just ended (`userMute` on the state machine) — and back up when the + time runs out, whatever the detectors say meanwhile; "Show's back" ends + it early; pressed while already ducked, it extends. Nothing is learned + from it. Android: four buttons on Home and on the remote; Python: the + `duck_for` IPC command and four buttons on the web page; the status + carries `timed_s`. +- **The remote.** Sharp's control port accepts every key of the handset as + `RCKY nn` (codes from the LE-series manual, the same over serial and IP). + `RemoteKey` (Kotlin) and `control/remote_keys.py` (Python) carry the + table; `SharpIpClient.press`, `MuteController.send_key`, the `remote` + IPC command and a `commands.remote_key` template in the Sharp profile + drive it. Android gets `RemoteActivity`: the TV keys in a grid plus + Start, Stop, Is an ad, Show's back, Not an ad and the timed ducks. While + the service runs the keys go through its connection (the Sharp allows one); + otherwise the screen opens its own and closes it on leaving. The web page + gets the same grid. Infrared control knows only volume and mute, and says + so. + +## Consequences + +- A third light-weight audio-independent vote exists. It is honest about + what it knows: the status line shows ":42 a break in 5 of 6 hours" or + ":17 learning (1 of 3 hours)". +- Controllers gain an optional `send_key`; every existing backend keeps + working (the default raises a clear ControlError). +- The Python `IMPLEMENTED_DETECTORS` set includes `clock`; replay tests + that pin the default detector set list it. diff --git a/docs/adr/0018-learn-from-a-stream.md b/docs/adr/0018-learn-from-a-stream.md new file mode 100644 index 0000000..04846c9 --- /dev/null +++ b/docs/adr/0018-learn-from-a-stream.md @@ -0,0 +1,56 @@ +# ADR 0018 — Learn the commercials from a stream; a memory that moves between phones + +Status: Accepted. 2026-09-15. + +## Context + +The owner bought a month of MS NOW's live stream and asked whether its +commercials could be sampled as examples, in the background, for any +channel that streams live with commercials, with no commands to type. + +A channel's live stream carries the network's own national spots as the +same recordings cable plays; the local avails are filled differently +(dynamic ad insertion on the stream, the cable company's local ads on the +set). The stream runs tens of seconds behind cable, so it can teach but +cannot be the live sensor. Two places could listen: a PC running the +Python core with a loopback audio device, or the phone hearing its own +playback. The Python core's fingerprint matcher is video-first (frame +hashes from the camera, audio only to corroborate), so a memory made from +sound alone means nothing to it today; the phone's matcher is audio-only, +and the phone is where the owner's ducking happens. + +## Decision + +- **Stream learning on the phone.** A `StreamSource` captures the phone's + own playback through Android's `AudioPlaybackCapture` (Android 10+, the + "record or cast" consent from `MediaProjectionManager`), in the same + 48 kHz mono float blocks as the microphone. The service runs the usual + engine on it with a `VirtualController` (no TV), fingerprints forced on, + speech and the AI judges as configured, the camera and the break clock + off (the delay would shift every minute). Every break the engine ends + is learned exactly as at the TV; ✓ / ▶ on the notification teach by + hand. The notification reads `STREAM LEARNING · m:ss listened · +n + breaks · +m scripts`. Thirty seconds of digital silence means the player + blocks capture, and the status says so. The foreground service declares + the `mediaProjection` type for this mode only. +- **One memory.** Stream learning writes to the same `ads.tsv`, + `scripts.tsv` and (never, see above) `clock.tsv` the TV mode reads, so + nothing is exported or imported between the two modes. +- **A memory that moves.** `Memory.export` zips the three files as + `adhush-memory.zip` for sharing; `Memory.import` merges a zip into this + phone's files: breaks whose block sequence is already known and scripts + whose words are already known are skipped, the clock counters are + summed. Import needs the service stopped, because stores are read at + start. +- **Not the Pi.** The Python listener gains nothing until it has an + audio-only matcher like the phone's (`AudioMatcher`); that port is the + next candidate on the roadmap, and this ADR does not pretend the zip is + useful there. + +## Consequences + +- The phone can arrive at the TV already knowing the channel's national + spots, learned from clean audio; local spots are still learned at the TV. +- Playback capture depends on the player allowing it: Chrome does, + some apps do not. The guide says to use the web player. +- The Moto's memory can move to the OnePlus and back. diff --git a/docs/adr/0019-one-file-binaries-and-the-release-matrix.md b/docs/adr/0019-one-file-binaries-and-the-release-matrix.md new file mode 100644 index 0000000..30b420e --- /dev/null +++ b/docs/adr/0019-one-file-binaries-and-the-release-matrix.md @@ -0,0 +1,49 @@ +# ADR 0019 — One-file binaries for every desktop, and a release per platform + +Status: Accepted. 2026-09-15. + +## Context + +Until 0.19.0 a release carried a wheel, the Android APK and the web page. +Windows, macOS, Linux, ChromeOS and the Raspberry Pi all meant "install +Python 3.11, then the wheel", which is a real barrier for the people the +build guides are written for. The owner asked for a release version of +AdHush for each platform. + +## Decision + +- **PyInstaller one-file binaries** of the Python core, built by the release + workflow on GitHub's own runners: Windows x64, macOS arm64, Linux x64 and + Linux arm64 (Ubuntu 22.04 runners, so the Linux binaries run on any + distribution with glibc 2.35 or newer: Ubuntu 22.04+, Debian bookworm, + Raspberry Pi OS bookworm, the ChromeOS Linux container). The web front + end, the device profiles and the example configs travel inside the + binary (`util/resources.bundled()` finds them in a binary or a + checkout). ffmpeg stays a runtime dependency on PATH; `adhush doctor` + says so when it is missing. Windows also gets `adhushw.exe`, a + windowless twin, which `adhush service install` uses for the logon task. +- **`adhush init`** writes a starter config and the profile library into + the current folder, so a binary is usable without a checkout: + `init`, edit, `doctor`, `probe`, `run`, `service install`. +- **A Raspberry Pi bundle**: the arm64 binary, the listener config and the + PDF guides in one zip, with a six-line README. `scripts/install-pi.sh` + (the pip route) stays for people who want a venv. +- **iOS** has no native app and will not get one without an Apple developer + account; the web page is a PWA (manifest, service worker, icons) that + installs from Safari's Add to Home Screen, and the release notes say so + plainly. +- **Every binary is smoke-tested on its runner** before publishing: + `--version`, `init`, `doctor`, and an offline `replay` of a synthesised + fixture, which exercises numpy, the detectors and the bundled profiles. +- **Intel Macs** get the wheel; GitHub's Intel macOS runners are being + retired and a build nobody can verify is worse than an honest table row. + +## Consequences + +- One tag produces nine downloads. The release notes carry a table of + which file is for which platform; `docs/release.md` has the matrix. +- Release builds take longer (four extra runners) and can fail on a + runner AdHush's own CI never exercises; the smoke tests are there to + catch that before a broken binary is published. +- Tags with a hyphen (`v0.20.0-rc1`) publish as pre-releases, so a + workflow change can be tried without a real release. diff --git a/docs/adr/0020-jingles-badges-break-lengths-and-the-extendable-countdown.md b/docs/adr/0020-jingles-badges-break-lengths-and-the-extendable-countdown.md new file mode 100644 index 0000000..cba9f39 --- /dev/null +++ b/docs/adr/0020-jingles-badges-break-lengths-and-the-extendable-countdown.md @@ -0,0 +1,124 @@ +# ADR 0020 — Break jingles, the ad badge, learned break lengths, and a countdown you can extend + +Status: Accepted. 2026-09-15. + +## Context + +After the review of seven peer projects (AdMuteTV, Mx5-MuteTimer, DTC, +MuteAd, muteButton, AdVent, AutoAdMuter) the owner asked for the four ideas +worth keeping: + +1. **The channel's break jingle** (AdVent). Many channels play the same + short sting — a whoosh, a three-note ident — going into every break and + often coming out of it. Hearing it is the earliest evidence there is, + earlier than a loudness jump or a logo going away. +2. **The ad badge** (badge OCR). A live stream in a browser labels its + commercials on screen: "Ad", "Ad 1 of 3", "AD 0:15", "Your video will + resume". During stream learning (ADR 0018) the phone can read that text + and use it as ground truth. +3. **How long breaks run** (DTC). Breaks on one channel cluster around a + few lengths. Knowing them puts a ceiling on a mute and tells the owner + roughly how long is left. +4. **A countdown you can extend** (Mx5-MuteTimer). A manual duck shows the + seconds left and has a `+30 s` button. And since real breaks run to five + minutes, the manual buttons must go past 120 s. + +Constraints from CLAUDE.md hold: detectors return a confidence vote with a +reason string and never call a controller; the Kotlin core and the Python +core implement the same ideas; device-specific behaviour stays out of +Python. + +## Decision + +### Method 10: break jingles (`Jingle.kt`, `detect/jingle.py`) + +The detector keeps 400 s of chroma-bit blocks (one per 0.5 s, the same +`chromaBits` the fingerprints use, so no new DSP). When a break ends — the +engine's `learnBreak(startTs, endTs)` on every unmute the user did not +override — it cuts two candidates: the *opener*, blocks from 4 s before the +break started to 2 s after, and the *closer*, 2 s before it ended to 4 s +after. A candidate is compared with every known candidate of the same kind +by sliding a 3-s core across it and taking the best aligned bit agreement +(`sameSting`, threshold 0.72; 0.5 is chance). A match increments `hits`; +otherwise the candidate is stored, up to 80. At `hits ≥ 3` — three +distinct breaks opened by the same sound — the candidate is **promoted** +and starts voting. + +Live, every new block slides the promoted openers over the last 3 s. A hit +votes 1.0 with reason `jingle_open id=… agree=… age_s=…` for `holdS` +20 s; a promoted closer heard while ducked sets `programPresent` for 10 s, +which the engine treats like the channel bug coming back. An opener carries +`LOGO_WEIGHT`, so it may duck alone: that is the point of it. The detector +is **inert** (`voting = false`) until it has a promoted opener — a learner +with nothing to say must not dilute the normaliser the other detectors +share (`fusion.py`). **Not an ad** counts a `falseHit` against the opener +that fired; when false hits reach hits, the jingle is demoted to a +candidate again. Storage is `jingles.tsv` (`j\tid\tkind\thits\tfalse\tcreated\tb,b,b`), +and it travels with the memory export (ADR 0018). + +A channel with no sting simply never promotes anything; the method costs +one chroma frame per half second. + +### The ad badge (`Badge.kt`, app `BadgeReader.kt`) + +Stream learning only. The phone's MediaProjection already exists for +playback capture; `BadgeReader` adds a 640×360 virtual display, stacks the +four corner strips of each frame and runs ML Kit text recognition once a +second. The core's `BadgeDetector.observeText(ts, text)` matches a +whole-word `Ad`/`Advertisement`, `Ad 1 of 3`, `AD 0:15`, or the resume +phrases. A badge seen votes 1.0 (LOGO_WEIGHT) for 3 s after the last +sighting; scans continuing with no badge for more than 2 s is +`programPresent`. No scans for 10 s makes it inert — a player without a +badge must not vote either way. The frames never leave the phone and are +never stored; only the text is looked at, then dropped. + +### Learned break lengths (`Clock.kt`, `detect/clock.py`) + +The break clock (ADR 0017) now also keeps a histogram of break lengths in +30-s bins up to 6 min, from the same confirmed breaks that feed the +minute-of-hour counts (`clock.tsv` v2, `d\t\t` lines; v1 files +still load). With at least five samples: + +- `ceilingS(hardMax) = clamp(p90 + 30 s, 90 s, hardMax)` becomes the state + machine's mute ceiling at every MUTE. A channel whose breaks run + 2–3 min stops being held for the configured 5-min maximum when the + detectors lose the thread. +- `remainingS(elapsed) = p75 − elapsed` is what the status line shows as + "about m:ss left" during an automatic duck. It is a guess and is + labelled as one. + +The hard maximum in config is still the ceiling until the histogram has +learned something, and always the upper bound. + +### Manual ducks: 30 s to 300 s, and `+30 s` + +The manual buttons go 30/60/90/120 and 150/180/210/240/270/300 s +everywhere (phone Home, the phone remote, the web page, the IPC `duck_for` +command). `Engine.extendDuck(now, s)` adds to a running timed duck, capped +at 10 min; the same call on a duck the detectors started converts it into a +timed one, so `+30 s` also means "hold this longer than you think". The +status line counts down `m:ss left`, the Android notification carries a +`+30 s` action, and the IPC command is `{"type":"duck_for","seconds":30,"extend":true}`. +A timed duck learns nothing (`Source.TIMED`), as before. + +### Where the remote is + +Unchanged from ADR 0017, made easier to find: on the phone, Home → +*Manual duck and the remote* → **Open the remote control**, and now the +remote icon in the toolbar of every screen. On the web page it is the +Remote card below the controls. + +## Consequences + +- Ten methods on the phone; the Python core gains `jingle` in + `PHASE3_DETECTORS` and the listener example enables it. +- Replay tests and the CLI flow tests point the jingle and clock stores at + temp files; a fixture with identical program audio before every break + would otherwise "learn" the programme as a sting. `data/clock.tsv` and + `data/jingles.tsv` are ignored by git. +- The badge reader is a second consumer of the projection; both stop with + stream learning. It is an Android-only method and is not counted in the + ten, since it never runs at the TV. +- Break lengths change unmute behaviour only by lowering a ceiling. They + never end a mute early on their own; a late unmute is still the worse + failure and the detectors still decide. diff --git a/docs/adr/0021-aspect-change-and-the-crest-factor.md b/docs/adr/0021-aspect-change-and-the-crest-factor.md new file mode 100644 index 0000000..7e5ec17 --- /dev/null +++ b/docs/adr/0021-aspect-change-and-the-crest-factor.md @@ -0,0 +1,86 @@ +# ADR 0021 — Aspect-ratio change on the HDMI path, and the crest factor in loudness + +Status: Accepted. 2026-09-16. + +## Context + +The 0.21.0 review of peer projects left two cheap cues on the table, and +the owner asked for them in this order: + +1. **Aspect-ratio change** (comskip's `aspect ratio` detect method, in use on + recordings for twenty years). A channel keeps one picture shape for + hours; many commercials arrive in another — a 4:3 spot pillarboxed, a + film clip letterboxed inside a 16:9 spot. The scaffold has carried an + empty `detect/aspect_change.py` since Phase 1. +2. **Crest factor** (beepscore's analysis of ad audio). Commercials are + mastered with heavy compression: their peak-to-RMS ratio sits several dB + under the programme's even when their loudness does not. The loudness + detector only looked at level. + +Constraints from CLAUDE.md hold: detectors vote with a reason string and +never call a controller; the Kotlin and Python cores implement the same +ideas; the Pi budget is 60 % of a core for all video detectors together. + +## Decision + +### `aspect_change` (Python only: HDMI and screen capture) + +On every fourth of a second the detector takes the shared luma frame, +averages it down to about 180 rows, and counts dark, flat rows from the +top and bottom and columns from the sides (`bars`: mean ≤ 24, standard +deviation ≤ 8 — a dark, busy scene is not a bar). What is left is the +active picture; its shape is `frame_aspect × (active_w / w) / (active_h / h)`. +A frame with less than a quarter of its height or width active is a black +frame, `black_frame`'s business, and teaches nothing. + +A new shape must hold for `confirm_s` (1 s) before it counts. Confirmed +shapes, rounded to 0.1, feed a rolling mode over `baseline_s` (10 min); +the mode is the programme's shape once `min_baseline_s` (30 s) has been +seen. While the picture holds another shape the detector votes 1.0 with +`aspect_changed from=… to=… bars=… age_s=…`, and the baseline **freezes**, +as the loudness baseline freezes while elevated: a three-minute break in +4:3 cannot outvote the programme. A change that outlasts any break +(`max_change_s`, 6 min) is the programme changing shape — a film started — +and the history restarts from it. + +It is **inert unless changed** (`voting` is false otherwise). The +programme's own shape is not evidence of anything — most spots keep it — +so a standing 0 vote would only dilute the detectors that do see something +through fusion's present-mass normaliser. Like the jingle, it speaks only +when it has something to say. It carries the default weight and never +mutes alone; a news channel cutting to archive footage changes shape too. +What it adds is a vote that is *sustained* for the length of the spot, +which the transient boundary signals lack. + +It is not ported to Kotlin: the phone's camera path has its own crop and +the phone never sees an HDMI signal. + +### Crest factor inside `loudness` (Python and Kotlin) + +Every audio block's crest factor, 20·log10(peak / RMS), joins the same +short-term window as its K-weighted mean square, and the window's mean +crest is tracked against a slow baseline taken and updated exactly when the +loudness baseline is — so it freezes while elevated, skips the duck-settle +window and ignores the start-up ramp for the same reasons. A drop of +`crest_drop_db` (4 dB) × 1.4 below the baseline is a full crest vote, and +the crest vote adds `CREST_SHARE` = **half** a vote to the loudness +confidence, capped at 1.0. Half, never whole: a music bed inside the +programme is compressed too, and must not mute by itself; a spot mixed no +hotter than the show still shows, and a hot spot saturates sooner. Setting +`crest_drop_db = 0` turns the cue off. The reason string gains +`crest_db=… crest_drop_db=…`. + +The Kotlin port is line for line, with the conformance fixture regenerated +(the fixture's steady tones have a constant crest, so its values did not +move). + +## Consequences + +- Seven video-capable detectors in the Python core; `aspect_change` is in + the default `enabled` set and the HDMI/passthrough example configs, not + in the audio-only listener's. The synthetic fixtures gained an `ad_43` + kind (an ad with black pillars) so the detector has labelled ground truth. +- The loudness detector's reason string is longer; nothing that parses it + exists outside the tests. +- Cost: one 180-row luma reduction four times a second, and one pass over + each audio block for its peak. Both are far inside the Pi budget. diff --git a/docs/adr/0022-the-set-up-wizard.md b/docs/adr/0022-the-set-up-wizard.md new file mode 100644 index 0000000..5bd9d1e --- /dev/null +++ b/docs/adr/0022-the-set-up-wizard.md @@ -0,0 +1,104 @@ +# ADR 0022 — The set-up wizard, and a wording audit + +Status: Accepted. 2026-09-16. + +## Context + +The phone app has ten methods, three connections, a duck level, a camera +zoom, two AI judges in three sizes and a room that is different in every +house. The right settings depend on numbers nobody knows without measuring: +how far the show sits above the fans, whether the camera can see the whole +set from where the phone lives, how much memory the phone has. The Room +survey (ten minutes) measures the first of those but leaves the reading to +the owner. The owner asked for an optional wizard that asks a few +questions, tests the microphone and the camera, suggests a configuration, +applies it, and lands on the page where it can be changed. + +The same request asked for a review of the app's text: one release had +said "six" or "seven" methods when there were eight or nine. + +## Decision + +`SetupWizardActivity`, seven steps, offered once on first run (*Not now* +keeps the defaults) and always available from the Home page: + +1. **Welcome** — the show on at normal volume, the phone where it will live. +2. **Your TV** — network, serial or infrared; for the network, address and + login, and *Ask the TV its volume* opens a `SocketTransport` and sends + `VOLM?`. The answer becomes the Normal volume. +3. **Your room** — where the phone sits (facing the set, or not), the + channel, whether it is a news channel with a ticker, whether captions + are shown. +4. **Listen: the show** — fifteen seconds through `MicSource`: dBFS, + spectral flatness and crest factor per block. +5. **Listen: the room** — ten seconds with the set muted: the room's own + level and flatness. +6. **The camera** — eight seconds through `CameraSource` with the preview: + how often `Vision.findScreen` finds a lit screen, whether + `screenComplete` holds, and how wide the set is in the picture. +7. **Suggested set-up** — every decision with its reason, then *Apply and + review* writes `Settings` and opens the Methods page through + `MainActivity.EXTRA_PAGE`, where every switch is editable. A running + `MainActivity` reloads its fields in `onNewIntent`. + +The rules behind the suggestion (`makePlan`), all from measurements the +app already understands: + +- **Contrast** = show median dBFS − room median dBFS. ≥ 20 dB: quiet gaps + and loudness on, duck to 4. 10–20: both on, duck to 6. 6–10: quiet gaps + off (the room fills the gaps), duck to 9 so loudness can still hear the + ducked set and time the unmute (ADR 0016). < 6: quiet gaps off, duck to + 10, and a follow-up to move the phone. +- **Steady room noise** (room median > −50 dBFS and flatness ≥ 0.2) is + named, since it is what buries a ducked set. +- **Camera** on when a screen was found in at least half the looks and + complete in 70 % of those; ticker instead of bug on a news channel; + zoom so the set fills about 80 % of the picture, capped at 4× and the + lens's range. Captions on when they are shown and the set is at least + 35 % of the picture wide (or zoomed). A missing template becomes the + follow-up "Camera setup → Watch 45 s". +- **Speech** on when its model is installed and contrast ≥ 10 dB. +- **Local AI** size from `ActivityManager.totalMem`: ≥ 11 GB large, + ≥ 5.5 GB medium, else small; on only when installed and a words method + is on. **Claude** on as a tie-breaker when a key is stored and a words + method is on. +- Remembered breaks, the break clock and jingles are always on: they cost + nothing and learn by themselves. + +The wizard keeps numbers only. No audio, no picture is stored; the camera +preview is shown and dropped. The service is stopped while it runs, as +Camera setup does, because two things cannot hold the microphone or the +camera. + +### The wording audit + +Every place that counts the methods now says ten: the Help "Choosing +methods" topic (it said eight and omitted the clock and the jingle from the +strong/weak lists), the Help file's header comment (six), the Android +README's screens section (six), and the local-AI print guide (eight, HTML +and PDF regenerated). The Python README says seven detectors plus the +clock and the jingle, which is the Python count. Method numbering in the +app (1–10) matches the Android README table. + +## Amended 0.25.0 — Find my TV + +The TV step no longer trusts a typed address alone. *Find my TV* tries +all three paths and reports each: the Wi-Fi (the typed address, else a +scan of the phone's own /24 on the Sharp control port with a 300 ms +connect and a `VOLM?` to every listener until one answers as a Sharp), +the serial cable (adapter present, USB permission asked for through the +same broadcast the Home page uses, then `VOLM?` over the cable), and the +infrared blaster (present, fired MUTE twice two seconds apart, then a +Yes/No from the owner, since infrared is one-way). The first that worked +is chosen, network before cable before blaster, and the radio group still +lets the owner override. The address a scan finds is written into the +settings with the rest. + +## Consequences + +- A first run starts in the wizard; nothing changes for an existing + install until the owner opens it. +- The wizard's thresholds are the ADR 0016 posture written down as + numbers; if the room survey's verdicts change, these should follow. +- The wizard does not replace Camera setup: it decides *whether* the + camera is worth switching on and points at Camera setup for the template. diff --git a/docs/adr/0023-what-comskip-and-mythtv-knew.md b/docs/adr/0023-what-comskip-and-mythtv-knew.md new file mode 100644 index 0000000..fb9d2be --- /dev/null +++ b/docs/adr/0023-what-comskip-and-mythtv-knew.md @@ -0,0 +1,126 @@ +# ADR 0023 — What comskip, MythTV and the Auto-Cut VCRs knew + +Status: Accepted. 2026-09-16. + +## Context + +The September 2026 search (`docs/ideas-backlog.md`) found five cues that +twenty years of recorded-TV commercial flagging rely on and AdHush did +not: the parental-rating box, ad-unit lengths, uniform separators that are +not black, captured intro/outro frames, and the stereo/mono switch. A +sixth, the ATSC 3.0 / DVB-TA video watermark, is newer and designed for +exactly the set-top-box-behind-HDMI case this project lives in. The owner +asked for all six. + +The rules stay: a detector votes with a reason, never calls a controller, +never reads another detector; every one has a labelled replay test; the +Pi budget holds; a late unmute is the worse failure. + +## Decisions + +### `rating_bug` — the parental-rating box is programme evidence + +US networks flash "TV-14" in the upper left at the start of a programme +and after every break; commercials never carry one. MythTV scores the +block; AdHush makes it **positive programme evidence**, the thing that +may end a hold early. No OCR: inside a small upper-left ROI the detector +takes the bounding box of bright pixels and asks whether it is box-shaped +(wider than tall, mostly filled since the text is dark, a sensible share +of the ROI), whether it *appeared* (the ROI had been box-free for +`appear_after_s`, so a bright corner that is always there is scenery) +and whether it stayed `min_present_s`. Then `program_present` holds for +`hold_s`. It votes 0 with weight while present and is inert otherwise; +it never votes for an ad. Python for the HDMI path; on the phone +`RatingBugDetector` takes the screen box the logo detector already finds +and looks in the screen's own corner. + +`Detector.program_present` is now a base-class property, and the engines +(Python and Kotlin) take programme evidence from **any** detector that +has it: the logo, the closing jingle, the badge, the rating box, an +"out" cutscene, an in-network cue, other devices' break ends. + +### `ad_units` — ad-unit length quantisation + +Spots are sold in 15/30/45/60/90/120-second units, so inside a break the +separators between them fall on a fifteen-second grid. The detector may +not read other detectors, so it finds cheap separators of its own on the +shared frame (a short black run) and the audio block (a short silence; +on the phone, the rolling-floor silence test of method 1), keeps their +times, and votes while the recent gaps fit the grid: a half vote for one +fitted gap, a full vote for two in a row, held `hold_s` after the last +separator. Default weight — never alone — but it *holds* a mute through +the sag in the middle of a pod when the boundary signals have decayed and +loudness is only halfway sure. Inert without a fit; "Not an ad" clears +it. `wants_video` lets it take frames when there are any without +needing them, so the audio-only listener runs it too. + +### Uniform separators in `black_frame` + +A frame whose raw-pixel luma spread (a strided sample, so a busy dark +scene is not averaged flat) is at most `uniform_spread` counts like a +black frame: a white flash, a colour card, a grey slate — the separators +European networks and some streamers use instead of black, comskip's +`non_uniformity`. The reason string says `uniform_run` instead of +`black_run`. The synthetic ad frames gained a gradient so a fixture's +flat "ad" is no longer a card. + +### `cutscene` — intro/outro template frames + +Comskip keeps up to eight user-captured frames and correlates every frame +against them. AdHush keeps templates in `data/cutscenes/.npz` (a +32×18 luma thumbnail, its perceptual hash, a kind), written by +`adhush cutscene add --kind in|out --image still.png` or `--from +fixture.npz --ts 12.3`. A frame matches when its phash is within +`max_hamming` bits *and* the thumbnails' normalised correlation clears +`min_correlation`, so a flat frame cannot match a flat template on hash +alone. An "in" match (a break starts: "we'll be right back") votes 1.0 +for `hold_s` and carries the alone weight; an "out" match (the title card) +is programme evidence for `close_hold_s`. The jingle's visual twin. + +### `stereo_width` — the channel-count switch + +Mitsubishi's Auto-Cut recorders skipped ads on one cue: the programme was +mono and the spots stereo, and the flip was the boundary; comskip still +penalises a channel-count change. On the captured feed the equivalent is +the mix's stereo width, side/mid RMS, measured by the capture backend +*before* its mono downmix (`AudioEvent.width`; HDMI, line-in and the +transport stream ask ffmpeg for two channels when `capture.stereo` is +on; a room microphone stays mono, its width would be the room). The +detector keeps a slow baseline that freezes while switched, exactly as +loudness does, and votes 1.0 while the width has been on the other side +of `min_delta` for `confirm_s`; a switch that outlasts any break becomes +the new baseline. Inert without width and inert at the programme's own +width — the aspect-change posture. + +### `watermark` — ATSC A/335 / DVB-TA presence (experimental) + +The watermark lives in the luma of the top one or two lines: symbols +eight pixels wide across a 1920-pixel frame, high or low, a run-in +pattern first. It survives set-top boxes and HDMI because it is picture, +and A/336 and HbbTV-TA use it to tell a TV behind a box where the breaks +are. AdHush does not decode the messages (that needs the broadcaster's +recovery server); it uses **presence**. A network feed that carries the +mark loses it the moment a locally inserted spot replaces the picture. +The detector reads the top lines, decides whether they hold a +symbol-aligned two-level pattern (the run-in seen, or a bimodal line +whose steps sit on symbol boundaries), keeps a baseline of "present" +over ten minutes, and votes 1.0 while the mark has been absent for +`confirm_s` on a channel where it is normally present. Inert on a channel +with no mark — most cable channels today — which is why it is on by +default at no cost. Geometry, run-in and thresholds are configuration +because this was written from the spec's published shape and not +verified against a live ATSC 3.0 feed. `describe` shows the last bits so +an owner can see what a channel emits. + +## Consequences + +- Fifteen Python detectors, every new one inert until it has evidence, + so the default `enabled` list carries them all without diluting the + fusion normaliser. The replay tests pin the new names. +- `black_frame` reasons changed shape; nothing outside the tests parsed + them. +- Line-in and HDMI audio now ask ffmpeg for two channels; a mono card + gets a copied channel and a width of 0, harmless. +- On the phone: ad units ride on Quiet gaps and the rating box on the + camera method; no new switches, no new count of methods. The two are + in the status reasons, not the chips. diff --git a/docs/adr/0024-three-feeds.md b/docs/adr/0024-three-feeds.md new file mode 100644 index 0000000..ac7f7dc --- /dev/null +++ b/docs/adr/0024-three-feeds.md @@ -0,0 +1,88 @@ +# ADR 0024 — Three feeds: the schedule, SCTE-35, and the crowd + +Status: Accepted. 2026-09-16. + +## Context + +The last three items of the backlog are not detectors that look at the +picture or listen to the sound; they are *feeds*: an EPG that says what +is on, the splice cues a cable stream carries in-band, and other AdHush +devices watching the same channel. Each is authoritative in its own way +and useless without configuration, so each is off — inert — until it is +pointed at something. + +## Decisions + +### `schedule` — XMLTV programme boundaries + +`util/xmltv.py` reads the XMLTV that Schedules Direct's grabbers, +`tv_grab_*` and most EPG tools write; `detect/schedule.py` is fed wall +time through the new `Detector.tick(wall)` (which now reaches every +detector, not only the break clock). Two uses, both mild by design: + +- For `start_grace_s` after a scheduled start the detector votes 0 with + weight: a break in the first minute and a half of a show is unlikely, + so the mute votes are *diluted* then — never vetoed, since a schedule + is a minute out as often as not and a break across the top of the hour + must still mute once the evidence is there. +- On a channel listed in `ad_free` (PBS, BBC) the engine never mutes at + all (`ad_free_now`), whatever the detectors think, unless the owner + holds a duck by hand. + +It never claims the programme is *present*: an EPG cannot see a break +end. The current title is in the status. + +### `ts_stream` + `scte35` — the cues in the stream + +A new capture backend, `ts_stream`, reads an MPEG transport stream over +HTTP (an HDHomeRun's `http://:5004/auto/v`) or UDP (a DVB +dongle's streamer, an IPTV multicast). One reader thread fans the bytes +out three ways: to an ffmpeg for the frames, to an ffmpeg for the audio +(stereo, so width is measured), and to `util/mpegts.py`, a demuxer that +follows PAT → PMT to the SCTE-35 PID (stream type 0x86), reassembles +the sections and parses `splice_insert` and `time_signal` with +segmentation descriptors of the ad and break types. Cues arrive on the +event bus as `CueEvent`s (`CaptureCaps.cues`; `run_live` pumps a third +stream; `Pipeline.process` hands them to `Detector.observe_cue`). + +`scte35` votes 1.0 from the out-of-network cue until the in-network cue, +the announced duration plus five seconds, or `max_break_s`, whichever is +first, and counts the in cue as programme evidence for `in_hold_s`. It +carries the alone weight: where the stream carries cues they are the +authority every other detector only estimates. Where a provider strips +them (most streaming apps, some cable boxes) the detector simply never +sees one. + +### `crowd` — a shared feed of break times + +The SponsorBlock idea for live TV. `adhush crowd serve` runs a server of +a few dozen lines: devices POST `/report` (a sha256 of the channel name +under a shared salt, a kind, a wall-clock time, a random device id) and +GET `/recent?prefix=<4 hex>` to receive every report whose hash starts +with that prefix — k-anonymity: the server never learns which channel a +device watches, only a sixteenth of the space, and the device keeps the +reports for its own full hash. Reports live ten minutes in memory; +nothing is written to disk. + +`detect/crowd.py` polls on its own thread (`tick` only schedules; the +decision loop never waits on the network), and votes 1.0 with the alone +weight when at least `min_reports` *other* devices have reported a start +within `window_s` and no end since; an end from others is programme +evidence for a few seconds. The engine reports this device's own +confirmed breaks — start at MUTE, end when a real break ends, never a +timed duck — when `report` is on. Off by default: it needs a `url` and a +`channel`. + +## Consequences + +- Three more names in the default `enabled` list, all inert until + configured; the listener example carries `schedule` and `crowd` too. +- A new capture backend and a new event type; `run_live` grew a third + pump thread. `file_replay` has no cues and no wall clock, so the feeds + stay inert in every replay test; their own tests build a schedule file, + a transport stream and a server in-process. +- The crowd server is a LAN or self-hosted service. Nothing in AdHush + points at a public one, and the salt is configuration, so two + households sharing a server also share the salt. +- `adhush crowd serve` binds all interfaces on purpose; put it behind + the same firewall as the rest of the house. diff --git a/docs/adr/0025-any-brand-of-tv.md b/docs/adr/0025-any-brand-of-tv.md new file mode 100644 index 0000000..a9a5bc7 --- /dev/null +++ b/docs/adr/0025-any-brand-of-tv.md @@ -0,0 +1,102 @@ +# ADR 0025 — Any brand of TV, from the phone + +Status: Accepted. 2026-09-18. + +## Context + +The phone app spoke one language: Sharp's, over its network port, its +RS-232C port, or its infrared codes. The Python core has profiles for +Samsung, LG, Sony, Vizio and Roku TV, but the owner's request was for the +*wizard* on the phone to check the TV and set itself up whatever the brand +is. The set-up wizard's "Find my TV" (ADR 0022, amended) had just learned +to search the Wi-Fi for a Sharp; this is the same idea for everyone else. + +Two facts shape the design. Most sets can be reached one of two ways: a +path that *reads the volume back* (then AdHush ducks to an exact level and +notices the remote) or a path that only *sends keys* (then AdHush steps the +volume and keeps count, as it already does over infrared). And every brand +has a question that needs no key or pairing to answer — a Roku's device +info, a Samsung's `/api/v2/`, a Sony's interface information, a Vizio's +power state, an LG's open port, a Sharp's `VOLM?`, and the UPnP +description every DLNA-capable set publishes — so the phone can find out +*what* it is talking to before it asks for anything. + +## Decision + +### A generic level controller + +`VolumeDevice` (get, set, mute, a maximum) and `LevelController` on top of +it are the Sharp controller made general; `SharpIpClient` now implements +`VolumeDevice` and `SharpController` is `LevelController` with the Sharp +client underneath, so nothing that worked changed. One-way paths stay +`KeySender`s under the existing `StepVolumeController`. + +### Drivers, in the core module, pure JVM + +| Path | Speaks | Reads volume back | Needs once | +|---|---|---|---| +| `sharp` | its control port | yes | login, if the set has one | +| `sony` | JSON-RPC on port 80 | yes | the pre-shared key the owner set in the TV's menu | +| `lg` | SSAP over a WebSocket on 3000/3001 | yes | "Allow" on the set; the client key is kept | +| `upnp` | UPnP RenderingControl (SOAP) | yes | nothing | +| `samsung` | the remote channel over a WebSocket on 8002/8001 | no | "Allow" on the set; the token is kept | +| `roku` | ECP on port 8060 | no | nothing | +| `vizio` | SmartCast HTTPS on 7345 | no | a PIN the set shows; the token is kept | + +Two helpers make this possible without a networking library in the app: +`MiniHttp` (one request, headers, a timeout, and a trust-all TLS context +for a set's self-signed certificate on the LAN only) and `MiniWebSocket` +(a text-frame RFC 6455 client in a hundred lines: masked frames, pings +answered, tested against a loopback server). Every parser is a function of +a string and is unit-tested; nothing else in the tests touches a network. + +### Finding the set: `TvFinder` + +One SSDP search names the makers, models and renderers in the house. +Every address it names is then asked the brand questions, in parallel; +with nothing on SSDP the phone's own /24 is swept for the brand ports. +The result is a `FoundTv` per address with its brand, model and the paths +that answered, exact paths ordered first. + +### The wizard, brand by brand + +Find my TV lists what it found, then proves the paths in order. An exact +path proves itself by reading the volume (a Sony needs its key typed; an +LG makes the set prompt for permission and waits up to a minute). A key +path cannot prove itself, so the wizard fires MUTE twice over it two +seconds apart and asks the owner — the same yes/no as infrared — one path +at a time until one is confirmed (a Vizio first shows a PIN, which the +wizard asks for and pairs with). Then the serial cable, then the blaster: +infrared now cycles through the code sets brand by brand, the detected +brand's first, until the owner says the set muted. The first thing that +worked is chosen, network before cable before blaster, and the TV page's +fourth radio carries it: "Another brand, found by the wizard: Samsung +UN55TU8000 — Samsung remote channel". + +### Infrared for the others + +`Ir.kt` gained encoders for NEC, Samsung's NEC variant, Sony SIRC-12, +Philips RC-5 (Manchester) and Panasonic's Kaseikyo, and a table of the +four keys for Sharp, Samsung, LG (and Vizio, which answers LG's codes), +Sony, Philips, Panasonic and Toshiba. The Sharp set still takes the +address and codes typed on the TV page. + +### The remote + +Roku, Samsung and Vizio have key maps, so the remote screen works over +them; a Sharp keeps its RCKY keys; the exact paths without a key protocol +(Sony, LG, UPnP) answer only volume and mute from the remote. + +## Consequences + +- `usesCleartextTraffic` is on: Roku, Sony and UPnP speak plain HTTP on + the LAN. Nothing in the app talks plain HTTP to the internet. +- Tokens and keys live in the encrypted settings with the Sharp password. +- Not one of these paths has been exercised against real hardware from + this session; the parsers and the WebSocket client are tested, the + protocols are written from their published shape, and the wizard's live + test is the verification. Where a set does not answer, the wizard says + so and moves to the next path. +- Not done: Android TV / Google TV's pairing protocol (Sony's newer sets, + TCL and Hisense without Roku), Hisense VIDAA, and Philips JointSpace. + Those sets are usually reachable over UPnP or infrared meanwhile. diff --git a/docs/adr/0026-the-other-families.md b/docs/adr/0026-the-other-families.md new file mode 100644 index 0000000..74a451b --- /dev/null +++ b/docs/adr/0026-the-other-families.md @@ -0,0 +1,56 @@ +# ADR 0026 — The other families: Android TV, Hisense VIDAA, Philips JointSpace + +Status: Accepted. 2026-09-18. + +## Context + +ADR 0025 left three families out because each has its own pairing: +Android TV / Google TV (Sony's newer Bravias, TCL and Hisense with Google +TV, Chromecast with Google TV, Philips' Android sets, the Shield), Hisense's +own VIDAA, and Philips JointSpace. The owner asked for them too. + +## Decision + +Three more `VolumeDevice` drivers in the core, each with the protocol +plumbing it needs and nothing more, all pure JVM and unit-tested: + +- **Android TV** (`AndroidTv.kt`, `MiniProto.kt`, `ClientIdentity.kt`). The + remote protocol v2 authenticates the *client* by certificate, so the + phone makes a self-signed one once (BouncyCastle, the core's first + dependency) and keeps it in the encrypted settings as a PKCS#12 blob. + Pairing on port 6467: request, options, configuration, then the set + shows six characters and the secret is SHA-256 over both RSA keys and + the code's last four hex digits. The session on 6466 answers the set's + configure and ping messages and reads its volume reports, so the level + is known and set by stepping the volume keys. Messages are + protocol-buffers, written and read by a fifty-line codec. +- **Hisense VIDAA** (`Hisense.kt`, `MiniMqtt.kt`). The set runs an MQTT + broker on 36669 over TLS; the client connects with Hisense's service + name and password, subscribes to the mobile topics, asks the set's + state, and — on a newer set — types back the four digits it shows. + Volume is read (`getvolume`) and set (`changevolume`) exactly; keys go + through `sendkey`. Sets from about 2022 on demand a client certificate + Hisense issues; AdHush does not carry it, and the wizard says so when + the handshake is refused. MQTT 3.1.1 is a hundred lines, tested against + a loopback broker. +- **Philips JointSpace** (`Philips.kt`, `Digest.kt`). Version 1 on port + 1925 needs nothing: `/1/audio/volume` reads and writes. Version 6 on + 1926 uses HTTP digest authentication with a paired device id and key: + the set shows a PIN and the grant is signed with Philips' published + secret. Digest is checked against the RFC 2617 example. + +`TvFinder` probes the three ports; the wizard's Find my TV tries the +paths like the others, and when one wants a code it shows a single code +row (the same row a Vizio's PIN uses) and waits up to two minutes on its +own thread for the owner to type it. The remote screen works over all +three. + +## Consequences + +- BouncyCastle (bcpkix) is in the core, for one certificate. Nothing + else uses it. +- A lesson kept in a comment: inside `Socket().apply { }` the name + `port` is the socket's own, not the class's — the MQTT client connected + to port 0 until the loopback test caught it. +- None of the three has met real hardware from this session; the framing, + hashing and parsing are tested, and the wizard's live test is the proof. diff --git a/docs/adr/0027-families-stingers-and-hours.md b/docs/adr/0027-families-stingers-and-hours.md new file mode 100644 index 0000000..7026ee7 --- /dev/null +++ b/docs/adr/0027-families-stingers-and-hours.md @@ -0,0 +1,99 @@ +# ADR 0027 — Jingle families, segment stingers, and the hour of day + +Status: Accepted. 2026-09-18. + +## Context + +The owner watches a news channel whose every segment opens and closes +with music and sound effects that are *related* — the same house style, +re-cut per show and per hour — but not identical. Method 10 (ADR 0020) +learns one sting per channel and recognises it by aligned chroma-bit +agreement, so a re-cut a semitone up, or a version played a little +slower under a different presenter, is a different candidate that must +open three breaks of its own before it votes. The segment *stingers* — +a whoosh or hit on the cut, with no tune to learn — never promote at all. +And the sting the morning show uses at nine is not the one the evening +show uses at nine at night, yet the detector treats every hour alike. + +## Decision + +Three additions, in both cores, none of them a new configuration step. + +1. **Family matching** in the jingle detector. A stored sting is compared + with the live audio as heard, and also under every transposition of up + to `pitch_shifts` semitones either way (the twelve chroma bits rotate; + pitch class *c* lives in bit 11 − *c*, so up is a rotate towards bit 0) + and under a tempo stretch of ±`tempo_tolerance` (the stored blocks + resampled by nearest index). A variant match is docked + `family_penalty` before it is compared with the threshold, so the sting + as heard always wins a tie and a chance match under a variant is rarer + than one under the identity. A variant hit counts for the jingle it + resembles, in learning and live: one memory covers the family. The vote + reason names the variant (`family=+1st/1.10x`). Defaults ±2 semitones, + ±10 % tempo, penalty 0.05. + +2. **A stinger detector** (`stinger`, Python `detect/stinger.py`, Kotlin + `Stinger.kt`), default weight, inert until it fires. Per 100 ms block + it measures level and spectral flatness. A *burst* is up to + `max_burst_s` of blocks that are noisy rather than tonal (flatness ≥ + `min_flatness`) and `burst_above_db` louder than the median of the two + seconds before. It is a stinger when the second after it sits + `level_step_db` away from the bed before it, or the burst itself was + `loud_burst_db` above. Applause and traffic fail the length; a plain + level step without a burst is loudness's business. The vote is 1.0 at + confirmation and decays to nothing over `hold_s`, so it tips a balance + the other detectors already lean towards and never ducks alone. It goes + quiet for `duck_guard_s` after our own volume change (ADR 0016). On the + phone it rides on the Loudness method, as ad units ride on Quiet gaps. + +3. **The hour of day.** Every jingle remembers the local hours it opened + (or closed) a break in, from the engine's wall clock at the start of the + break — a replay has none and learns none. Heard again at one of those + hours it is trusted a break early (two openings instead of three) and + matched `hour_bonus` more loosely. The TSV gains an eighth column, + `hours` (`# adhush jingles v2`); v1 rows load without it, and the shared + memory carries the hours between phones. + +## Consequences + +- One more default detector (`stinger`), so the replay pins and the + Android method-toggle test change; the example configs list it. +- Fifteen variant comparisons per promoted jingle per half second: a few + thousand integer operations, well inside the budget. +- `JingleDetector.learn_break` takes an optional `wall_start`; the engine + passes the wall time it already keeps for the break clock. Kotlin + `learnBreak(start, end, wallStart)` likewise, and the engine ticks the + jingle detector with the wall clock as it ticks the break clock. +- A sting learned at nine in the morning that is also the nine-at-night + sting simply collects both hours; the mechanism only ever admits a + jingle earlier, never later. + +## Amendment (0.28.1, 2026-09-19) + +The first evening at a news channel showed the three additions feeding on +polluted memories. A taught break that ran into the show, two mis-taps of +ten and forty seconds, and a jingle "opener" cut from the show's own bed +fed each other: remembered breaks matched the show; a closer learned from +the show said *programme present* three seconds into every duck; and each +three-second duck was "a real break" that taught more openers, which the +hour-of-day rule then trusted after two. The set ducked for seconds every +ten to twenty seconds. + +Four rules, in both cores, all default-on: + +- The jingle learner ignores a break shorter than `min_break_s` (20 s). +- Two hearings of a sting closer together than `hit_spacing_s` (30 min, + by the wall clock) count as one hit: one session cannot promote it. Each + jingle keeps the wall time of its last counted hit (TSV column 9). +- A taught break shorter than `min_material_s` (30 s) is a slip of the + finger and teaches nothing; longer than `max_material_s` (6 min) it is + cut there, since the viewer forgot to press Show's back. +- A remembered break whose duck ends sooner than `false_match_s` (10 s) + was ended by the show's own evidence: a false match. `false_match_limit` + (2) of them forget the record, as "Not an ad" does at once. + +Family matching still counts a variant hearing as a hit for the sting it +resembles; the spacing rule, not the family rule, is what stops one +evening's bed music from becoming a jingle. The phone also gained a +*Forget what it learned* button, because a bad evening is cheaper to erase +than to unlearn. diff --git a/docs/build-guide-tv-listener.md b/docs/build-guide-tv-listener.md new file mode 100644 index 0000000..3a8641a --- /dev/null +++ b/docs/build-guide-tv-listener.md @@ -0,0 +1,318 @@ +# Build the AdHush TV listener — beginner's guide + +This is the Raspberry Pi version of what the phone app does: a little +computer on a shelf that **watches and listens** to the TV, and turns the +TV down to a whisper when a commercial starts, then back up when the show +returns. It gives the TV its orders through one cable plugged into the +TV's **serial port** (the 9-pin socket on the back of the Sharp), so it +works even when the Wi-Fi is having a bad day. No HDMI boxes, no relay, no +scissors, no soldering: everything plugs together. + +It is written so a 12-year-old can follow it. Ask an adult for the two +steps marked 👨‍🔧. Everything runs on low-voltage USB power; nothing here +touches a wall outlet except the Pi's own power brick. + +**Time:** an afternoon. **Difficulty:** if you can type commands carefully +and follow a recipe, you can do this. + +## What you're building + +``` + ┌──────────┐ serial cable: "turn down to 4" ┌────────────────┐ + │ TV │ <═══════════════════════════════════│ Raspberry Pi │ + │ (Sharp) │ │ + webcam 👁 │ + │ │ ───── light and sound ────────────> │ + microphone 👂│ + └──────────┘ └────────────────┘ +``` + +The webcam looks at the TV and watches the little channel logo in the +corner (broadcasters call it the **bug**). The microphone listens for the +quiet gaps and loudness jumps around commercials, and remembers the sound +of every break you teach it. When it is sure, it tells the TV to turn down. + +## The parts (about $95–130, less if you already own a Pi) + +| # | Part | What it does | Rough price | +|---|------|--------------|-------------| +| 1 | Raspberry Pi 4 (2 GB or more) + official USB-C power supply | The brain | $45–60 | +| 2 | microSD card, 32 GB | The brain's memory | $8 | +| 3 | USB webcam, 1080p, with a **built-in microphone** (most have one) | The eyes and the ears | $20–30 | +| 4 | **USB-to-serial null-modem cable** with an FTDI chip (a USB plug on one end, a 9-pin **female** plug on the other, labelled "null modem" or "crossover"), long enough to reach from the shelf to the back of the TV | The voice: how the Pi tells the TV to turn down | $15–20 | +| 5 | *(optional)* USB microphone, if the webcam has none or hears badly | Better ears | $10–15 | +| 6 | A shelf or small tripod 6–10 feet in front of the TV | Somewhere to stand | — | + +You also need, just for setup (borrow them): a keyboard, a mouse, a monitor +with HDMI, and your home Wi-Fi password (the Pi still uses Wi-Fi for the +phone page, just not for the TV). The TV must be a Sharp AQUOS with an +**RS-232C** socket on the back: a 9-pin, D-shaped, **male** connector (pins, +not holes). The cable end that goes there must be **female** and **cross- +wired** ("null modem"). A plain straight serial cable looks the same and +does nothing, which is the one mistake this build can make. + +## Step 1 — Set up the Pi (👨‍🔧 with an adult) + +1. On a computer, install **Raspberry Pi Imager** (from raspberrypi.com), + put in the microSD card, and write **Raspberry Pi OS (64-bit)** to it — + the plain, recommended one at the top of the list, **not** *Legacy* and + **not** *Lite*. In the imager's settings gear, set a username/password + and your Wi-Fi. +2. Put the card in the Pi, plug in keyboard, mouse, monitor and the webcam, + then the power. Wait for the desktop. +3. Open the black **Terminal** window and type these lines, pressing Enter + after each (typing must be exact — capital letters matter): + + ``` + git clone https://github.com/socrtwo/adhush + cd adhush + sudo scripts/install-pi.sh + cp config/adhush-listener.example.toml config/adhush.toml + ``` + + The last line makes your settings file. It is *yours*: it is never sent + anywhere, and git ignores it, so the TV password you will type into it + stays on the Pi. + +## Optional — put an SSD in the NESPi 4 cartridge (👨‍🔧 with an adult) + +Building the Pi into a Retroflag NESPi 4 case? Its front slot takes a +"cartridge" that holds a 2.5-inch SATA SSD. AdHush does not need one: it +stores only numbers, and a microSD card has room for years of them. But an +SSD boots faster, shrugs off the thousands of tiny writes a box makes over +a year, and is the part most likely to survive the case's POWER button +cutting the power mid-write. If you have one, here is how to use it. Do +this **after** Step 1 has worked from the microSD card, so you always have +a card that boots. (The illustrated version is Step 2½ of +`docs/print/AdHush-beginner-guide-listener-NESPi4.pdf`.) + +**You need:** a 2.5-inch **SATA** SSD, 7 mm thick (the normal laptop kind; +120 GB is plenty). Not an M.2 or NVMe stick — those do not fit the +cartridge. The cartridge shell comes in the case box. + +1. **Put the SSD in the cartridge.** Open the cartridge shell (the small + screws), slide the SSD onto the connector inside until it clicks, and + screw the shell shut. Push the cartridge into the slot behind the big + front flap. Nothing else to wire: the cartridge plugs into the Pi + through the case's own connection. +2. **Copy the card onto the SSD.** With the Pi running from the microSD + card (Step 1 done, AdHush installed), open the menu → **Accessories → + SD Card Copier**. *Copy From Device:* the microSD card. *Copy To + Device:* the SSD (it shows up as a USB drive with the SSD's name). Tick + **New Partition UUIDs** and press **Start**. It takes a few minutes. + Everything comes along — AdHush, your settings file, and any breaks it + has learned. +3. **Tell the Pi to boot from USB.** In the Terminal: + + ``` + sudo raspi-config + ``` + + Go to **Advanced Options → Boot Order → NVMe/USB Boot**, then Finish. + If it offers to update the bootloader, say yes. (Raspberry Pi 4 boards + made before late 2020 need this update once; a fresh Raspberry Pi OS + does it for you.) +4. **Switch over.** Type `sudo poweroff`, wait for the green light to stop + blinking, press POWER, take the microSD card out of the slot on the + right side, and press POWER again. The Pi now starts from the SSD. + Keep the card somewhere safe: it is your spare. +5. **Check.** Type `findmnt /` — it should say `/dev/sda2`, not + `/dev/mmcblk0p2`. Then carry on with Step 2 below. + +Two things to know: + +- The cartridge uses a USB 3.0 link, and USB 3.0 can put noise on 2.4 GHz + Wi-Fi. The TV is on a cable, so it does not care; if the *phone page* + becomes hard to reach after adding the SSD, join your router's 5 GHz + network, or plug an Ethernet cable into the back of the case. +- With SAFE SHUTDOWN off, the POWER button cuts the power like pulling the + plug. The listener is meant to stay on, but when you do want it off, + type `sudo poweroff` first and press POWER when the green light has + stopped blinking. + +| Problem | Try | +|---|---| +| It still boots from the card | Take the card out; re-check Boot Order (step 3) | +| Rainbow screen or "no boot device" | Reseat the cartridge, then the SSD inside it; put the card back to prove the Pi is fine | +| Very slow or freezes with the SSD | A few USB-to-SATA bridges misbehave with the Pi 4. Ask an adult to search "Raspberry Pi 4 usb-storage quirks" for the one-line fix in `/boot/firmware/cmdline.txt` | + +## Step 2 — Find the eyes and ears + +Type: + +``` +adhush doctor +``` + +It prints a list of **video devices** (the webcam, usually `/dev/video0`) +and **sound cards** with numbers. Find the webcam's microphone (or the USB +microphone) in the sound-card list — it says something like +`card 1: Webcam`. Now open the settings file: + +``` +nano config/adhush.toml +``` + +and check two lines near the top: + +```toml +device = "/dev/video0" # the webcam +audio_device = "alsa:plughw:1,0" # change the 1 to your microphone's card number +``` + +Save with **Ctrl+O**, Enter, then leave with **Ctrl+X**. + +## Step 3 — Let the TV take orders (👨‍🔧 with an adult) + +Plug the **9-pin end** of the serial cable into the **RS-232C** socket on +the back of the TV (it only fits one way; tighten the two thumbscrews if it +has them). Plug the **USB end** into a USB port on the Pi — on the NESPi 4, +one of the two ports under the small front flap. The webcam takes the +other. If you also want a separate USB microphone, put a small USB hub in +one port; there are only two. + +Now ask the Pi where the cable landed: + +``` +ls /dev/ttyUSB* +``` + +It should answer `/dev/ttyUSB0`. (If it says "No such file", the cable is +not plugged in, or the Pi does not know its chip — FTDI ones just work.) + +Linux only lets certain users talk to serial ports. Put yourself on that +list once, then reboot so it takes: + +``` +sudo usermod -aG dialout $USER +sudo reboot +``` + +Back in the Terminal, open the settings file (`nano config/adhush.toml`) +and check the `[control.rs232_sharp]` part. It already says the right +things; make sure the port matches what `ls` showed: + +```toml +[control] +backend = "rs232_sharp" + +[control.rs232_sharp] +port = "/dev/ttyUSB0" # what `ls /dev/ttyUSB*` showed +baud = 9600 +duck_level = 4 # how quiet a commercial gets (4 is a whisper) +``` + +Save (Ctrl+O, Enter, Ctrl+X). Now test it — this is the same thing the +listener does when a commercial comes on: + +``` +adhush probe --active +``` + +It asks the TV its volume, turns it down to 4, waits two seconds, and +turns it back up. **If the sound dipped and came back, the TV path works.** + +- *"cannot open serial port"* or *"Permission denied"*: the `dialout` step + above has not taken yet — reboot. +- *"serial port /dev/ttyUSB0 not present"*: the cable is not plugged into + the Pi, or it is on `ttyUSB1` — fix the port line. +- *"timeout"* (the TV says nothing): the cable is straight, not null-modem, + or it is not in the RS-232C socket. The TV must be on (standby is fine). +- *"rejected VOLM"* / *ERR*: wrong socket, or a second control cable is + fighting it. + +**Rather use Wi-Fi?** The TV can also take the same orders over the network +(MENU → Initial Setup → Internet Setup → Network Setup → IP Control Setup → +Enable). Set `backend = "network_ip"` and fill in the `[control.network_ip]` +part of the settings file with the TV's address and login instead. The +phone app uses that path; the Sharp allows only one such connection at a +time, so the app must be stopped while the Pi is running. + +## Step 4 — Point the eyes + +Put the Pi and webcam on the shelf, six to ten feet from the TV, roughly +straight in front of it. The **whole TV must be in the webcam's picture** +with a little room around it. If the TV runs off the edge of the picture, +AdHush will not use the camera at all (it never guesses from half a TV), +so step back or turn the webcam. + +With a **show** on — not a commercial — teach it what the channel's bug +looks like: + +``` +adhush calibrate +``` + +It watches for 20 seconds and saves the bug. Do it again if a commercial +came on in the middle. The bug is expected in the **bottom-right** corner; +if your channel keeps it somewhere else, change the `roi` line under +`[detect.logo_absence]` in the settings file (`x` and `y` are how far +across and down, from 0 to 1; `w` and `h` are its size). Set it up once per +channel. + +## Step 5 — Wake it up + +``` +adhush run +``` + +Watch TV. When a commercial break starts, the sound drops to a whisper; +when the show returns, it comes back. The first day it mostly listens and +learns. It gets noticeably smarter about breaks it has heard before. + +**On your phone:** open a browser and go to `http://` + the Pi's address + +`:8675` (the Pi prints its address when it starts, and `hostname -I` shows +it). The page shows SHOW / MUTED / TEACHING live, and has the three +buttons that make it smart: + +- **✓ Is an ad** — press the moment a commercial break starts. The TV + turns down and stays down. +- **▶ Show's back** — press the second your show returns. The sound comes + back and everything in between is remembered as a break. +- **✗ Not an ad** — press if it turned your show down by mistake. The + sound comes back at once, whatever caused it is dropped, the camera has + to see the bug again before it may say the bug is gone, and for one + minute nothing may turn the TV down. + +Do the ✓ / ▶ pair for five or six breaks. After that it recognises those +commercials by itself within a few seconds. + +**To make it start by itself** whenever it is plugged in: +`sudo systemctl enable --now adhush` + +## The methods + +The listener uses four of the phone app's six ways of spotting a commercial: + +| Method | What it uses | Can turn the TV down alone? | +|---|---|---| +| Quiet gaps | microphone: the short silence between show and ad | no — needs a second opinion | +| Loudness jumps | microphone: the sound jumping up and staying up | no — needs a second opinion | +| Remembered breaks | microphone: fingerprints of the breaks you taught it | yes | +| Channel bug | webcam: the logo corner, whole TV in view | yes | + +Spoken words and on-screen captions (the phone's methods 5 and 6) are not +on the Pi yet. Because the Pi stands still on a shelf, the camera method is +steadier here than in a hand. + +## Words used here + +- **Bug** — the small channel logo in a corner of the picture during a + show. It is taken off the screen during commercials. +- **Duck / turn down** — the TV goes to volume 4 instead of muting, so the + microphone can still hear when the show is back. +- **Remembered break** — the sound of a break you bracketed with ✓ and ▶, + stored as numbers (never a recording). + +## If something's wrong + +| Problem | Try | +|---|---| +| `adhush probe --active` says *cannot open serial port* or *Permission denied* | Run the `dialout` line from Step 3 and reboot | +| It says *serial port … not present* | The USB end is not in the Pi, or it is `ttyUSB1`: check `ls /dev/ttyUSB*` and the port line | +| It says *timeout* — the TV says nothing | Straight cable instead of null-modem, or not in the RS-232C socket. The TV must be on | +| Using Wi-Fi instead and it says *hung up before asking for a login* | Something else is connected — stop the phone app (Stop button) and try again | +| The phone page says *camera: whole TV not in view* | Move the webcam back or turn it until the whole TV, with a border, is in the picture | +| The phone page says *camera: looking for the bug* for minutes | Redo `adhush calibrate` during a show, or fix the `roi` corner | +| It turns the show down by mistake | Press **✗ Not an ad**. If it keeps happening on one channel, recalibrate | +| It never turns anything down | Teach it five breaks with ✓ / ▶. Check the microphone card number (Step 2) | +| Sound stuck quiet after the Pi lost power | Start `adhush run` again: it puts the volume back on its own. Or use the remote | +| `adhush run` errors | Read the message; `adhush doctor` lists what is missing | diff --git a/docs/detection-strategies.md b/docs/detection-strategies.md index 42fe06f..1c1f995 100644 --- a/docs/detection-strategies.md +++ b/docs/detection-strategies.md @@ -24,7 +24,40 @@ Ads cut far faster than program content. A rolling shot-length estimate is a good soft signal, especially where logo detection fails. ## Aspect change -Letterbox to full-frame transitions mark many pod boundaries. +Letterbox to full-frame transitions mark many pod boundaries, and a spot +that arrives in another shape (4:3 pillarboxed, a film clip letterboxed) +holds it for its whole length. Dark, flat rows and columns at the edges are +bars; what is left is the active picture, and its aspect ratio against a +rolling mode of the programme's is the vote — sustained while the shape is +changed, silent otherwise, never alone (ADR 0021). HDMI and screen paths. + +## The rating box +The parental-rating box after a break is the programme back, never an ad. +A bright, filled, box-shaped blob appearing in the upper left where none +was — positive programme evidence for twenty seconds (ADR 0023). + +## Ad units +Separators (black, silence) that fall on the 15/30/45/60/90/120-second +grid are a break's own rhythm; the detector holds a mute through the sag +between spots (ADR 0023). + +## Cutscenes +Captured frames the channel shows around every break, correlated every +frame; the jingle's visual twin (ADR 0023). + +## Stereo width and the watermark +A mono/stereo switch of the mix, and the loss of the ATSC/DVB video +watermark when a local spot replaces the picture (ADR 0023). + +## Feeds +An XMLTV schedule, SCTE-35 cues from a transport stream, and other +devices' breaks on the same channel (ADR 0024). + +## Crest factor +Commercials are compressed harder than programme audio: their peak-to-RMS +ratio sits several dB lower even when they are no louder. Tracked inside +the loudness detector against the same slow baseline; a drop is worth up +to half a vote (ADR 0021). ## Caption gap Discontinuity in the CC stream is a reliable corroborator where captions exist. diff --git a/docs/ideas-backlog.md b/docs/ideas-backlog.md new file mode 100644 index 0000000..d69f8e4 --- /dev/null +++ b/docs/ideas-backlog.md @@ -0,0 +1,74 @@ +# Ideas backlog — from the September 2026 search + +**Status (0.24.0):** items 1–7 and 10–12 are built (ADR 0023, ADR 0024); +5 (caption style), 8 (packshots), 9 (subtitle PIDs), 13 (Android TV +accessibility) and 14 (smoothing) remain. + +What the wider world does that AdHush does not yet, ranked by what it +would buy this project. Each entry says what it is, why it would help, how +hard it is, and where it came from. Everything AdHush already has (black +frames, silence, loudness and crest, logo, scene cuts, aspect change, +fingerprints, speech and caption scripts, the AI judges, the break clock +and lengths, jingles, the AD badge, the ticker, timed ducks) is left out. + +1. **Parental-rating bug as an unmute cue.** US networks flash the TV-14 / + TV-PG box at programme start and after every break. MythTV scores it; + for AdHush it is a near-perfect *unmute-onset* signal, the failure this + project fears most. An ROI template or OCR on the decoded frame; easy. + MythTV `ClassicCommDetector.cpp`. +2. **ATSC 3.0 / DVB-TA video watermark (A/335) on the HDMI path.** A + payload in the luma of the first two lines, designed to survive set-top + boxes and HDMI, used by A/336 and HbbTV-TA to tell TVs about ad breaks. + Where a broadcaster emits it, an authoritative in/out marker without a + tuner. A spec-driven decoder of two lines; medium; only where deployed. + atsc.org A/335, dvb.org targeted-advertising watermarking. +3. **Ad-unit length quantisation.** Segments between boundary events that + snap to 15/30/60 s, and a block of 60–395 s built from such units, + score higher inside a candidate break (Comskip, MythTV). Different from + the break-length histogram: it scores *inside* a break. Post-processing + on existing detectors; easy. +4. **Audio channel-count or stereo-width switch.** Comskip penalises + channel-count transitions; Japanese "Auto-Cut" VCRs detected ads purely + by mono→stereo flips (US patent 5692093). On HDMI, the audio InfoFrame + (5.1→2.0); on a mic or screen path, L–R correlation width. Easy on + HDMI. +5. **Caption *style* changes.** Roll-up vs pop-on vs paint-on, a window + that moves, a caption gap longer than N seconds; Comskip's "wrong type" + modifiers. XDS carries the programme name. Medium; the caption region + tracking half exists. +6. **Uniform-colour frames, not only black.** White or single-colour + separators, and broadcasters that deliberately drop black frames. + Generalise the black-frame run to low-variance frames (Comskip's + `non_uniformity`). Trivial. +7. **Intro/outro template ("cutscene") matching.** Comskip keeps up to + eight captured frames (bumpers, "we'll be right back", the title card) + and correlates every frame. Sharp break-start *and* return timing per + channel; easy, and the jingle's visual twin. +8. **Overlay-text density / packshot detector.** Ads end on a static + product shot with a logo, URL or price; many small text patches across + the frame (IEEE 5302320). Reuse the OCR's bounding boxes: count, area, + position. Medium. +9. **Subtitle-PID absence (DVB) and audio-description dropout (US SAP).** + Only with a transport-stream input; see 10. +10. **Optional transport-stream input (HDHomeRun or a DVB dongle) for + SCTE-35 and EIT.** In-band `splice_insert` / `time_signal` cues; + TSDuck parses them. A new capture path, not a detector; hard-ish but + authoritative on cable local insertion. +11. **EPG programme boundaries (XMLTV, Schedules Direct).** Force + "programme" at the scheduled start, gate the break clock per + programme, mark ad-free channels. Easy. +12. **Crowdsourced live break timestamps (the SponsorBlock model).** + Users on the same channel, NTP-synced, share mute/unmute events with + k-anonymity hashing. High payoff, hard: opt-in, privacy, a server. +13. **Accessibility-service ad detection on Android TV / Fire TV.** Watch + the UI tree for "Ad" / "Skip ad" nodes instead of camera OCR; a + companion app. Medium. +14. **Neighbour-block smoothing.** MythTV nudges zero-score blocks toward + their neighbours; a small HMM or hysteresis over fusion to stop + flapping. Trivial. + +Skipped after looking: Nielsen watermarks (a commercial flag exists but +there is no open decoder), PDC/VPS pause codes (unused by commercial +broadcasters), DNS blockers (cannot see server-side inserted ads), 3:2 +cadence and HDR/SDR switches (no evidence), and tiny image classifiers of +one player's ad UI (the AD badge already covers it). diff --git a/docs/print/AdHush-beginner-guide-listener-NESPi4.pdf b/docs/print/AdHush-beginner-guide-listener-NESPi4.pdf new file mode 100644 index 0000000..0c3da14 Binary files /dev/null and b/docs/print/AdHush-beginner-guide-listener-NESPi4.pdf differ diff --git a/docs/print/AdHush-guide-local-AI-OnePlus.pdf b/docs/print/AdHush-guide-local-AI-OnePlus.pdf new file mode 100644 index 0000000..767f569 Binary files /dev/null and b/docs/print/AdHush-guide-local-AI-OnePlus.pdf differ diff --git a/docs/print/AdHush-guide-stream-learning.pdf b/docs/print/AdHush-guide-stream-learning.pdf new file mode 100644 index 0000000..c2f1268 Binary files /dev/null and b/docs/print/AdHush-guide-stream-learning.pdf differ diff --git a/docs/print/README.md b/docs/print/README.md index 77c0c6e..e647846 100644 --- a/docs/print/README.md +++ b/docs/print/README.md @@ -11,7 +11,26 @@ guide re-cut for a Pi 4 B inside a Retroflag NESPi 4 case (`docs/build-guide-nes an extra case-assembly step, relay power from a USB-A breakout because the case's plug covers header pins 1–8, and one wire leaving the case. -Regenerate either PDF with any headless Chromium: +`beginner-guide-listener-nespi4.html` / `AdHush-beginner-guide-listener-NESPi4.pdf` +is the **TV listener** (`docs/build-guide-tv-listener.md`) for a Pi 4 in the +NESPi 4 case: a webcam and a microphone on a shelf, the Sharp turned down +through its RS-232C socket by a USB null-modem cable (Wi-Fi as the +alternative), an optional SSD in the cartridge, nothing leaving the case but +that one cable. Same fonts and +page style; the case diagram is shared with the NESPi 4 box guide. + +`beginner-guide-local-ai-oneplus.html` / `AdHush-guide-local-AI-OnePlus.pdf` +is the **Local AI** guide for the Android app (method 8) at about a +15-year-old's reading level: the three sizes of Qwen the app can download +(0.5B, 1.5B, Qwen 3 4B), what each needs, how to pick, download, test and +switch on a OnePlus Ace 6 Ultra, and what to do when it misbehaves. + +`beginner-guide-stream-learning.html` / `AdHush-guide-stream-learning.pdf` +teaches the phone from a channel's live stream (Android 10+ playback +capture): what a stream can and cannot teach, starting it, teaching by hand, +moving the memory to another phone, and which streams work. + +Regenerate any PDF with any headless Chromium: ``` chromium --headless --no-pdf-header-footer \ diff --git a/docs/print/beginner-guide-listener-nespi4.html b/docs/print/beginner-guide-listener-nespi4.html new file mode 100644 index 0000000..19c9150 --- /dev/null +++ b/docs/print/beginner-guide-listener-nespi4.html @@ -0,0 +1,769 @@ + + + + +Build the AdHush TV Listener — NESPi 4 edition + + + + + +

AdHush · the TV listener

+

Build the AdHush TV Listener

+

A Raspberry Pi 4 in a NESPi 4 case, with a webcam and a microphone, +that sits on a shelf, watches and listens to your TV, and turns the sound down +to a whisper when a commercial starts — then back up when your show comes back. It gives the TV +its orders through one cable into the TV’s serial socket, so it works even when the +Wi-Fi is having a bad day. No HDMI boxes, no relay, no scissors, no soldering.

+NESPi 4 edition · Sharp TV by serial cable · AdHush 0.15 + +
+Read this first +

Everything here runs on low-voltage USB power. The only thing that plugs into a wall outlet is the +Pi’s own power brick, and you never open that. Ask an adult for the two steps marked +ADULT CHECK. Typing must be exact: capital letters and spaces matter.

+

Time: an afternoon. Difficulty: if you can follow a recipe and type carefully, you can do this.

+
+ + +
+
●

The big picture

+

The webcam looks at the TV and watches the little channel logo in the corner. TV people call +that logo the bug, because it sits there all through the show like a bug on the glass. +During a commercial the bug is taken off the screen. The microphone listens for the quiet gaps and +the loudness jumps around commercials, and remembers the sound of every break you teach it. When +the Pi is sure, it tells the TV down the cable: turn down to 4. When the show is back: turn +back up.

+
+ + + + + + + + + BUG + the TV (a Sharp) + the bug lives in a corner during the show + + a shelf, 6–10 feet from the TV + + Pi in the NESPi 4 + + + + webcam = eyes + + mic = ears + + light: is the bug there? + + sound: gaps, loudness, breaks + + + serial cable: “turn down to 4” … “back up to 19” + USB end in the Pi, 9-pin end in the TV’s RS-232C socket + + +
One cable carries the orders. Wi-Fi is still used for the phone page, but the TV never depends on it.
+
+
+ + +
+
●

The parts (about $125–160, less if you already own a Pi)

+ + + + + + + + + + + +
#PartWhat it doesRough price
1Raspberry Pi 4 (2 GB or more) and the official USB-C power supplyThe brain$45–60
2Retroflag NESPi 4 case (comes with a fan, heatsinks and screws)The body — and it looks great on a shelf$30–40
3microSD card, 32 GBThe brain’s memory$8
4USB webcam, 1080p, with a built-in microphone (most have one)The eyes and the ears$20–30
5USB-to-serial null-modem cable with an FTDI chip: a USB plug on one end, a 9-pin female plug on the other, marked “null modem” or “crossover”; long enough to reach from the shelf to the back of the TVThe voice: how the Pi tells the TV to turn down$15–20
6Optional: a small USB microphone (and a small USB hub, since the case has two ports), if the webcam has none or hears badlyBetter ears$10–15
7A shelf, table or small tripod 6–10 feet in front of the TVSomewhere to stand—
+

Just for setup, borrow: a keyboard, a mouse, a monitor or TV with HDMI (and a micro-HDMI cable +for the Pi), and your home Wi-Fi password (the Pi still uses Wi-Fi for the phone page, not for the TV). The TV must +be a Sharp AQUOS with an RS-232C socket on the back: 9 pins, D-shaped, male (pins, not +holes). The cable end that goes there must be female and cross-wired (“null modem”). +A plain straight serial cable looks the same and does nothing — the one mistake this build can make.

+
+ + + + 1 · Pi 4 + + 2 · NESPi 4 case + + 3 · microSD + + 4 · webcam + + 5 · serial cable + + power supply + + Wi-Fi password + + +
+
+ + +
+
1

Wake up the Pi  ADULT CHECK

+
    +
  1. On a computer, install Raspberry Pi Imager from raspberrypi.com. Put in the microSD card.
  2. +
  3. Choose Raspberry Pi OS (64-bit) — the plain, recommended one at the top of the list. +Not the one called Legacy (its Python is too old) and not Lite +(no desktop, so no Terminal window to type in).
  4. +
  5. In the imager’s settings gear, set a username and password and type in your Wi-Fi name and password. Write the card.
  6. +
  7. Put the card in the Pi. Plug in the keyboard, mouse, monitor and the webcam, then the power. Wait for the desktop.
  8. +
  9. Open the black Terminal window and type these four lines, pressing Enter after each:
  10. +
+
git clone https://github.com/socrtwo/adhush
+cd adhush
+sudo scripts/install-pi.sh
+cp config/adhush-listener.example.toml config/adhush.toml
+
+What that last line did +

It made your settings file, config/adhush.toml. It stays on the Pi and is never sent +anywhere, which matters because the TV’s password will go in it later. You will open it twice, in Steps 3 and 4, +with the little text editor nano. In nano, Ctrl+O then Enter saves, and Ctrl+X leaves.

+
+
+ + + + + Imagera computer + writes + microSD + goes into + the Piin Step 2, the case + monitor + keyboard + mouse + + just for setup — borrow them + + +
+
+ + +
+
2

Build the Pi into the NESPi 4  ADULT CHECK

+

The case comes with its own little manual and screws; follow that for where each screw goes. Here is what +matters for the listener. Good news: unlike the older AdHush box, nothing leaves the case — no wires, +no relay — so the lid can be screwed down as soon as it is built.

+
+ + + FRONT + + + cartridge slot (under the big flap) + leave it empty — no SSD needed + + + 2 USB — webcam and mic go here + + + POWER + RESET + BACK + + + vent slots — keep them clear on the shelf + + + 2× micro-HDMI (setup monitor) + + 3.5 mm + + USB-C in + + Ethernet + no USB-A ports back here — they are all on the front + RIGHT SIDE + + + microSD slot — reachable without opening the case + INSIDE: the one connector to get right + + + + + + + + the case’s 8-pin plug goes on pins 1–8, not one column over + 2 + 20 + nothing else touches the header in this build + + +
Where everything ends up. The case’s own 8-pin plug is the only thing on the Pi’s header pins.
+
+
    +
  1. Stick the heatsinks from the case box onto the Pi’s two biggest chips. The listener watches +and listens all evening, so they matter.
  2. +
  3. Follow the case manual to screw the Pi in and plug its USB and Ethernet ports into the case’s +adapter board. Plug the fan into the fan header on the case board.
  4. +
  5. Push the case’s 8-pin plug onto header pins 1–8: the two rows of four at the +corner nearest the microSD slot. The manual shows which way round. Count the pins: it must not sit one column over.
  6. +
  7. Set the case board’s SAFE SHUTDOWN switch to OFF. The ON position needs a helper +script that no longer works on current Raspberry Pi OS. OFF turns the POWER button into a plain power switch — +fine for a box that stays on.
  8. +
  9. Leave the cartridge empty for now. AdHush needs very little storage; if you want to use the +slot for an SSD anyway, the next page shows how — after the box works from the card.
  10. +
  11. Close the case and screw the lid down. Put the microSD card in the slot on the right side.
  12. +
+
+ + +
+
2½

Optional: an SSD in the cartridge  ADULT CHECK

+

The NESPi 4’s front slot takes a cartridge that holds a 2.5-inch SATA SSD. AdHush does not need one +— it stores only numbers, and a microSD card has room for years of them. But an SSD boots faster, shrugs off the +thousands of tiny writes a box makes over a year, and is the part most likely to survive the POWER button cutting the +power mid-write. If you have one, this is how to use it. Do it after Step 1 works from the card, so you always +have a card that boots.

+
+You need +

A 2.5-inch SATA SSD, 7 mm thick — the normal laptop kind; 120 GB is plenty. Not an M.2 or +NVMe stick: those do not fit the cartridge. The cartridge shell comes in the case box.

+
+
+ + + + 1 · SSD into the cartridge, cartridge into the slot + + + + SSD + + slide onto the connector, screw shut + + + + + cartridge + into the slot behind the big flap + nothing to wire — the case connects it + 2 · copy the card onto the SSD, then take the card out + + + SD Card Copier + Copy From Device:microSD card (mmcblk0) + Copy To Device:the SSD (sda) + + New Partition UUIDs + Start + then sudo raspi-config → Advanced Options → Boot Order → NVMe/USB Boot + sudo poweroff, POWER off, card out, POWER on + + + card out — keep it as a spare + + +
+
    +
  1. Put the SSD in the cartridge. Open the cartridge shell (the small screws), slide the SSD onto the connector +inside until it clicks, and screw the shell shut. Push the cartridge into the slot behind the big front flap.
  2. +
  3. Copy the card onto the SSD. With the Pi running from the microSD card, open the menu → +Accessories → SD Card Copier. Copy From: the microSD card. Copy To: the SSD (it shows up as a +USB drive with the SSD’s name). Tick New Partition UUIDs, press Start, wait a few minutes. +Everything comes along — AdHush, your settings file, and any breaks it has learned.
  4. +
  5. Tell the Pi to boot from USB. In the Terminal, sudo raspi-config → Advanced Options +→ Boot Order → NVMe/USB Boot, then Finish. If it offers to update the bootloader, say yes (Pi 4 boards made +before late 2020 need that once).
  6. +
  7. Switch over. Type sudo poweroff, wait for the green light to stop blinking, press POWER, take the +microSD card out of the slot on the right side, and press POWER again. The Pi now starts from the SSD. Keep the card: it is your spare.
  8. +
  9. Check. Type findmnt / — it should say /dev/sda2, not /dev/mmcblk0p2. +Then carry on with Step 3.
  10. +
+
+Two things to know about the SSD +

Wi-Fi. The cartridge uses a USB 3.0 link, and USB 3.0 can put noise on 2.4 GHz Wi-Fi. The TV is on a cable, +so it does not care; if the phone page becomes hard to reach after adding the SSD, join your router’s 5 GHz network, +or plug an Ethernet cable into the back of the case.

+

Switching off. With SAFE SHUTDOWN off, the POWER button cuts the power like pulling the plug. The listener is +meant to stay on, but when you do want it off, type sudo poweroff first and press POWER once the green light has +stopped blinking.

+

If it still boots from the card: take the card out and re-check Boot Order. Rainbow screen or +“no boot device”: reseat the cartridge, then the SSD inside it; put the card back to prove the Pi is fine. +Very slow or freezes: a few USB-to-SATA bridges misbehave with the Pi 4 — an adult can search +“Raspberry Pi 4 usb-storage quirks” for the one-line fix in /boot/firmware/cmdline.txt.

+
+
+ + +
+
3

Plug in the eyes and ears

+

Plug the webcam into one of the two USB ports under the case’s small front flap. The other port is +for the serial cable (Step 4). If you also want a separate USB microphone, put a small USB hub in one of the ports. +Then ask the Pi what it found:

+
adhush doctor
+

It prints a list of video devices (the webcam is usually /dev/video0) and a list of +sound cards with numbers, something like card 1: Webcam. Find the line for your microphone +and remember its number. Now open your settings file:

+
nano config/adhush.toml
+

Near the top, check these two lines. Change the 1 to your microphone’s card number if it is different:

+
device = "/dev/video0"             # the webcam
+audio_device = "alsa:plughw:1,0"   # the microphone: card 1, device 0
+

Save with Ctrl+O, Enter, then leave with Ctrl+X.

+
+ + + + front of the case + + the two USB ports + + + webcam (eyes + ears) + + + serial cable to the TV (Step 4) + + + $ adhush doctor + video devices: /dev/video0 + sound cards: + card 0: Headphones + card 1: Webcam <- this one + + + +
The number after “card” is what goes in the settings file.
+
+
+ + +
+
4

Let the TV take orders  ADULT CHECK

+

Plug the 9-pin end of the serial cable into the RS-232C socket on the back of the TV (it only +fits one way; tighten the two thumbscrews if it has them). Plug the USB end into the free front USB port on the +case. That is the whole wiring.

+
+ + + + back of the TV + + + RS-232C — 9 pins (male) + + + female end, + cross-wired + + + USB end + + front of the case + + + webcam · serial cable + two ports: a mic needs a small hub + a plain straight serial cable looks identical and does nothing — it must say null modem / crossover + + +
+

Now ask the Pi where the cable landed, and put yourself on the list of people allowed to use serial ports +(Linux keeps one); the reboot makes it take:

+
ls /dev/ttyUSB*              # should answer /dev/ttyUSB0
+sudo usermod -aG dialout $USER
+sudo reboot
+

After the reboot, open the settings file (nano config/adhush.toml) and check the [control] part. +It already says the right things; make sure the port matches what ls showed:

+
[control]
+backend = "rs232_sharp"
+
+[control.rs232_sharp]
+port = "/dev/ttyUSB0"      # what ls /dev/ttyUSB* showed
+baud = 9600
+duck_level = 4             # how quiet a commercial gets (4 is a whisper)
+

Save (Ctrl+O, Enter, Ctrl+X). Now test it — this is the exact thing the listener does +when a commercial comes on:

+
adhush probe --active   # asks the volume, turns the TV down to 4, waits, turns it back up
+
+What you should hear — and what the messages mean +

The sound dips for two seconds and comes back: the TV path works. That is the whole point of this step.

+

“cannot open serial port” / “Permission denied”: the dialout line has not taken yet — reboot.

+

“serial port /dev/ttyUSB0 not present”: the USB end is not in the Pi, or it landed on ttyUSB1 — fix the port line.

+

“timeout” (the TV says nothing): a straight cable instead of null-modem, or the plug is not in the RS-232C socket. The TV must be on; standby is fine.

+

“rejected VOLM” / ERR: wrong socket, or a second control cable is fighting it.

+
+
+Rather use Wi-Fi? +

The TV takes the same orders over the network: MENU → Initial Setup → Internet Setup → Network Setup → IP Control +Setup → Enable. Set backend = "network_ip" and fill in the [control.network_ip] part of the settings file +with the TV’s address and login instead. The phone app uses that path; the Sharp allows only one such connection at a time, +so the app must be stopped while the Pi is running. The cable has no such limit and no password.

+
+
+ + +
+
5

Point the eyes

+

Put the case and webcam on the shelf, six to ten feet from the TV, roughly straight in front of it. The +whole TV must be in the webcam’s picture with a little room around it. This is a rule the listener +never breaks: if the TV runs off the edge of the picture, it will not use the camera at all. It never guesses from half a TV.

+
+ + + + + BUG + GOOD — whole TV, room around it + the camera sees the bug’s corner → it can help + + + + + + NOT GOOD — the TV runs off the edge + “whole TV not in view” → the camera stays out of it + + +
The phone page (Step 6) tells you which of these the camera sees: “bug seen”, “looking for the bug”, or “whole TV not in view”.
+
+

Now, with a show on — not a commercial — teach it what the channel’s bug looks like:

+
adhush calibrate        # run this while a SHOW is on, not an ad
+

It watches for twenty seconds and saves the bug. If a commercial came on in the middle, just run it again. +Set-up is once per channel.

+
+If the bug is not in the bottom-right corner +

The listener expects the bug in the bottom-right. If your channel keeps it somewhere else, open the +settings file and find the roi line under [detect.logo_absence]. x and y say +how far across and down the corner is, from 0 to 1; w and h are its size. Bottom-left would be +x = 0.02, y = 0.80; top-right would be x = 0.84, y = 0.06. Then run adhush calibrate again.

+
+
+ + +
+
6

Wake it up

+
adhush run              # start watching and listening!
+

Watch TV. When a commercial break starts, the sound drops to a whisper; when the show returns, it comes back. +The first day it mostly listens and learns. It gets noticeably smarter about breaks it has heard before.

+

On your phone: open a browser and go to http:// + the Pi’s address + :8675 +(the Pi prints its address when it starts; hostname -I in the Terminal shows it too). The page shows +SHOW, MUTED or TEACHING live, what the camera can see, and three buttons that make the listener smart:

+
+ + + + + + + + your show — volume 19 + ads — volume 4 + show again — 19 + bug gone / break recognised + bug back / break over + + + slow to turn down, quick to turn back up — on purpose. + Missing the first second of an ad is fine. + Missing the first second of your show is not. + It never mutes: at volume 4 the microphone still hears the TV, + which is how it knows the show is back. + + + + SHOW + camera: bug seen + + ✓ Is an ad + + ▶ Show’s back + + ✗ Not an ad + + the phone page + + + +
+
    +
  • ✓ Is an ad — press the moment a commercial break starts. The TV turns down and stays down.
  • +
  • ▶ Show’s back — press the second your show returns. The sound comes back and everything in +between is remembered as a break (as numbers, never a recording).
  • +
  • ✗ Not an ad — press if it turned your show down by mistake. The sound comes back at once, +whatever caused it is dropped, the camera has to see the bug again before it may say the bug is gone, and for one +minute nothing may turn the TV down.
  • +
+

Do the ✓ / ▶ pair for five or six breaks. After that it recognises those commercials by itself within a few seconds.

+
+Make it start by itself +

Once it works, type sudo systemctl enable --now adhush. From then on the listener starts whenever the case +is switched on, and if it ever loses power while the TV is turned down, it puts the volume back the next time it starts.

+
+
+ + +
+
●

How it decides

+

The listener uses four ways of spotting a commercial. Two are hints that need a second opinion; two are strong +enough to turn the TV down on their own.

+ + + + + + + + +
MethodWhat it usesCan turn the TV down alone?
Quiet gapsmicrophone: the short silence broadcasters leave between the show and each adno — needs a second opinion
Loudness jumpsmicrophone: the sound jumping up and staying up (ads are mixed louder)no — needs a second opinion
Remembered breaksmicrophone: fingerprints of the breaks you taught it with ✓ and ▶yes
Channel bugwebcam: the logo corner — only while the whole TV is in view and the bug has been seen at least onceyes
+

The phone app has two more (spoken words and on-screen captions); they are not on the Pi yet. Because the +Pi stands still on a shelf, the camera method is steadier here than in a hand.

+

Words used in this guide

+
    +
  • Bug — the small channel logo in a corner of the picture during a show. It is taken off the screen during commercials.
  • +
  • Duck / turn down — the TV goes to volume 4 instead of muting, so the microphone can still hear when the show is back.
  • +
  • Remembered break — the sound of a break you bracketed with ✓ and ▶, stored as numbers (never a recording).
  • +
  • Settings file — config/adhush.toml, the one file you edit. It stays on the Pi.
  • +
+
+ + +
+
?

If something’s wrong

+ + + + + + + + + + + + + + + + +
ProblemTry
adhush probe --active says cannot open serial port or Permission deniedRun the dialout line from Step 4 and reboot
It says serial port … not presentThe USB end is not in the Pi, or it is ttyUSB1: check ls /dev/ttyUSB* and the port line
It says timeout — the TV says nothingStraight cable instead of null-modem, or not in the RS-232C socket. The TV must be on
Using Wi-Fi instead and it says hung up before asking for a loginSomething else is connected — press Stop in the phone app and try again
The phone page says camera: whole TV not in viewMove the shelf back or turn the webcam until the whole TV, with a border, is in the picture (Step 5)
The phone page says camera: looking for the bug for minutesRun adhush calibrate again during a show, or fix the corner in the settings file (Step 5)
It turns the show down by mistakePress ✗ Not an ad. If it keeps happening on one channel, calibrate again
It never turns anything downTeach it five breaks with ✓ / ▶. Check the microphone card number (Step 3)
Sound stuck quiet after the Pi lost powerSwitch the case back on: the listener puts the volume back by itself. Or use the remote
The case gets hotMake sure the fan is plugged in (Step 2) and the vent slots on the back are not against a wall
install-pi.sh says the Python version is too oldThe Legacy image got written. Re-flash with Raspberry Pi OS (64-bit), Step 1
adhush run errorsRead the message; adhush doctor lists what is missing
+
AdHush is free software (MIT). This guide matches AdHush 0.15 and config/adhush-listener.example.toml; +the same steps in plain text are in docs/build-guide-tv-listener.md. Nothing you build here records +sound or pictures — the listener keeps only numbers about them, and nothing leaves the Pi except the +“turn down” and “turn up” orders down the cable to the TV.
+
+ + + diff --git a/docs/print/beginner-guide-local-ai-oneplus.html b/docs/print/beginner-guide-local-ai-oneplus.html new file mode 100644 index 0000000..3482359 --- /dev/null +++ b/docs/print/beginner-guide-local-ai-oneplus.html @@ -0,0 +1,421 @@ + + + + +Local AI on your OnePlus: three sizes of Qwen — AdHush + + + + + +

AdHush · the Android app · Method 8

+

Local AI on your OnePlus: three sizes of Qwen

+

AdHush can put one question, over and over, to a language model that lives on the phone: +is this a commercial or the show? The phone listens to the TV, turns the speech into words, hands the last +40 seconds of them to the model, and receives a one-line answer whose confidence becomes a vote; a confident +“commercial” turns the TV down. Nothing leaves the phone, and it costs nothing. This guide +explains the three sizes of model the app can install, how to choose between them, and how to tell whether the one +you chose is earning its keep.

+OnePlus Ace 6 Ultra (Dimensity 9500, 12 or 16 GB) · AdHush 0.17 · Qwen 0.5B, 1.5B and 4B + +
+Read this first +

The model files are large, from half a gigabyte to nearly three, so download them on Wi-Fi with the +phone plugged in, and keep the app open until the log says installed. An interrupted download resumes from +where it stopped when you press the button again. The phone runs warmer while the AI is on, which is normal, and it +never uploads anything: the words, the question and the answer all stay inside the phone.

+

Time: 20 minutes plus the download. Needs: the AdHush app already talking to your +TV (the TV page shows a green Test), about 3 GB free storage for the biggest size, and the Speech method.

+
+ +
+
●

How it works, in one picture

+
+ + + + + + the TV + + sound + + Speech + (Vosk, offline) + sound → words + + words + the last 40 s + + Qwen + 0.5B / 1.5B / 4B + on the phone + + + COMMERCIAL 0.9: + “prices, a phone number” + → turn the TV down + + volume down to 4 · back up to 19 when the show returns + + +
The model is a judge, not a spy. It sees words, never sound, and its answer is one vote among the app’s ten methods.
+
+

The model does not run continuously. In tie-breaker mode it is consulted only when the other +methods cannot decide, or while the TV is ducked and the app needs to know whether the show is back; in +always mode it is consulted every ten seconds regardless. A confident commercial answer is also saved +as a script, so the next time that ad airs the app recognises the words itself without asking anyone.

+
+ +
+
●

The three sizes

+

“B” means billions of parameters, the adjustable weights inside the model, and more of them means +better judgement at the price of a bigger download, more memory and slower answers. All three are Qwen models from +Alibaba, converted for phones by the LiteRT community on Hugging Face under the Apache-2.0 licence, which allows this +use without an account or a login.

+ + + + + + + +
SizeDownloadMemory it needsAn answer takesWhat it is like
Qwen 2.5 — 0.5B550 MBabout 1 GB free1–2 secondsQuick and cheap. Catches obvious ads. Hedges on tricky ones and is easily fooled by garbled speech.
Qwen 2.5 — 1.5B1.6 GBabout 2.5 GB free3–5 secondsThe sweet spot on this phone. Noticeably sharper; still fast enough to catch an ad early.
Qwen 3 — 4B2.7 GBabout 3 GB free (more on the CPU)6–12 secondsThe best judgement. Slower, so use it as a tie-breaker. Uses the newer .litertlm file format.
+

Why 4B and not 3B? No phone-ready Qwen chat model exists at exactly 3 billion +parameters: the ready-made bundles jump from 1.5B straight to Qwen 3 at 4B, so that is the “large” choice. +Your OnePlus has 12 or 16 GB of memory, which is plenty for it, whereas a phone with 8 GB should stay on 0.5B or 1.5B.

+

The times are for a Dimensity 9500 that is awake and cool; a hot phone slows down noticeably. Once the file is on +the phone none of this needs the internet.

+
+ +
+
1

Give the AI something to hear

+

The model judges words, so something has to produce words first, and on this phone that is Method 5, +Speech: an offline recogniser that turns what the microphone hears into text.

+
    +
  1. Open AdHush and tap Methods at the bottom.
  2. +
  3. Find card 5. Speech. Choose a speech model: small (40 MB, quick) or medium (128 MB, +hears better over a noisy room). With loud fans in the room, choose medium.
  4. +
  5. Tap Download the speech model and wait for installed.
  6. +
  7. Switch on Use speech. If the phone asks for the microphone, say yes.
  8. +
+

Captions work too: if the TV shows closed captions and the camera is set up, Method 6 +reads the words off the screen and feeds the same judge, which is cleaner than speech in a noisy room but needs the +camera aimed first. Speech is the easier place to start.

+
+ +
+
2

Pick a size and download it

+
    +
  1. On the Methods page, scroll to card 8. Local AI (on the phone).
  2. +
  3. Under Which model, pick a size. Start with Qwen 2.5 1.5B on this phone. You can +come back for the others; every size keeps its own file.
  4. +
  5. Tap the big button. It says Download Qwen 2.5 — 1.5 billion (1599 MB).
  6. +
  7. Watch the Log tab. It prints the percentage every few seconds. On home Wi-Fi the 1.5B file takes +two to five minutes; the 4B file about twice that.
  8. +
  9. When it says installed — switch on Local AI and Start, the button changes to +Local AI model: … installed.
  10. +
+

Lost Wi-Fi halfway? Tap the button again. The app keeps the part it has and continues from there.

+
+ +
+
3

Switch it on and test it

+
    +
  1. On card 8, switch on Use the local AI.
  2. +
  3. Tap Test the local AI with a sample. The app feeds it two made-up transcripts, one from a show +and one from an ad, and prints the answers in the Log with the time each took.
  4. +
  5. A good answer looks like COMMERCIAL 0.9: prices and a phone number for the ad and +SHOW 0.8: a discussion between hosts for the show. The number is how sure it is, from 0 to 1.
  6. +
+

The first answer after a Start is slow because the model is being loaded into memory; later answers are faster. +If the test prints failed to load, the fix-it table at the end covers the likely causes.

+
+ +
+
4

Decide how often to ask

+

Also on the Methods page, under How often to ask:

+ + + + + + +
ChoiceWhat happensBest with
Tie-breakerAsk only when the other methods are unsure, or while the TV is ducked. Otherwise at most once a minute.1.5B and 4B. Keeps the phone cool.
AlwaysAsk every ten seconds, whatever the others think.0.5B, or when the AI is your main method.
+

Type your channel’s name in the Channel box on card 7 if you have not already, because the +judge is told which channel it is listening to, and knowing that it is a news channel helps it tell an anchor reading a +headline from a pitchman reading a price.

+
+ +
+
5

Start, and watch it think

+
    +
  1. Go to Home and tap Start. Put the phone where the microphone can hear the TV.
  2. +
  3. The status line shows the app’s state and, when the AI has spoken, its last answer, for example +local: COMMERCIAL 0.85.
  4. +
  5. On the Methods page, card 8’s dot turns the indigo colour while the AI is running. The chips on Home show +which methods are on.
  6. +
  7. When the AI is right, do nothing. When it ducks the show by mistake, tap ✗ Not an ad: the app +brings the sound back and ignores every method for a minute.
  8. +
  9. When it misses an ad, tap ✓ Is an ad at the start and ▶ Show’s back +at the end. The app learns that break by sound and by its words, so it needs no AI next time.
  10. +
+

On the Log tab every AI answer appears with its reason, and reading a dozen of them +during an evening is the quickest way to judge whether the size you chose is sharp enough for your channel.

+
+ +
+
6

Switching sizes

+

Each size is its own file, so switching is simply Methods → card 8 → pick another size → download +it if the button offers to → Stop and Start on Home; the previous file stays on +the phone. All three files together take about 4.9 GB, which this phone has room for, and nothing else in the app +gets slower because of them.

+

To reclaim the space, Android’s Settings → Apps → AdHush → Storage → Clear data +removes the model files, but it also removes the app’s settings, learned ads and scripts, so only do that when +you want a fresh start.

+
+ +
+
?

Which size should I use?

+
    +
  1. Start with 1.5B in tie-breaker mode. On a Dimensity 9500 it answers in a few seconds and is +sharp enough for most channels.
  2. +
  3. Drop to 0.5B if you want the AI in always mode, or if the phone is running hot with the +camera on as well.
  4. +
  5. Move up to 4B if the log shows the AI hedging (answers near 0.5) or calling the show a commercial. +Keep it in tie-breaker mode and keep the phone plugged in.
  6. +
  7. Remember that it is one vote among eight: the fingerprints, the camera bug and the scripts do most of the daily +work, and the AI earns its place on the ads none of them has met yet.
  8. +
+
+ +
+
+Fix-it table + + + + + + + + + + + + +
What you seeWhat to do
The Log says download failed … press again to resumeCheck Wi-Fi and tap the button again. It continues from where it stopped.
Card 8 says no model or the status line says local: no modelThe download did not finish, or a different size is selected than the one you downloaded. Pick the size you have, or download the one selected.
local AI failed to load after StartNot enough free memory: close other apps, or choose a smaller size. With the 4B file this can also mean the app’s runtime cannot read the newer format yet; use 1.5B and tell the project.
The AI never answers; the status line has no local: partIt has no words. Check that Speech (or Captions) is on and its model is installed, and that the microphone permission was given.
Answers take much longer than the table saysThe phone is hot or on battery saver. Plug it in, give it air, or use tie-breaker mode or a smaller size.
It calls the show a commercialTap ✗ Not an ad. If it keeps happening, move up a size, or set the AI to tie-breaker so the other methods must agree.
It misses adsTeach them with ✓ and ▶; try the medium speech model so the words are cleaner; try a bigger size.
The phone is warmNormal while the AI runs. Tie-breaker mode and a smaller size keep it cooler. The 8,600 mAh battery is big, but plugging in is still better for an evening.
+
+
AdHush is free software (MIT). This guide matches AdHush 0.17 on Android. The models are +litert-community/Qwen2.5-0.5B-Instruct, litert-community/Qwen2.5-1.5B-Instruct and +litert-community/Qwen3-4B-Instruct-2507 on Hugging Face, Apache-2.0. The app downloads them straight +from there; nothing else is ever sent or fetched. The phone in the title is the OnePlus Ace 6 Ultra; any Android +phone with 6 GB or more can run the two smaller sizes.
+
+ + + diff --git a/docs/print/beginner-guide-stream-learning.html b/docs/print/beginner-guide-stream-learning.html new file mode 100644 index 0000000..f3f81e6 --- /dev/null +++ b/docs/print/beginner-guide-stream-learning.html @@ -0,0 +1,349 @@ + + + + +Learn the commercials from a stream — AdHush + + + + + +

AdHush · the Android app · teaching it from a stream

+

Learn the commercials from a stream

+

Cable channels play the same national commercials on their live streams as on cable. If your phone +listens to that stream for a few evenings, it fills its memory with those spots before you ever sit +down in front of the TV, and it learns from clean audio rather than a noisy living room. This guide shows how to +make the phone listen to its own playback while a stream plays, how to move what it learned to +another phone, and what a stream can and cannot teach.

+Android 10 or newer · AdHush 0.19 · MS NOW, Xfinity Stream, or any channel with a live stream + +
+Read this first +

Nothing is recorded. The phone keeps fingerprints (numbers that describe a few seconds of sound) and the words it +heard, never the audio itself, and nothing leaves the phone. You will be asked by Android whether AdHush may +“record or cast” the screen; that permission is how Android lets one app hear another app’s sound, +and AdHush uses only the sound. Keep the phone plugged in: a few hours of listening is a few hours of screen-on time.

+

Time: ten minutes to set up, then evenings of listening in the background. +Needs: the AdHush app, a channel you can stream (a membership, your cable login, or a free stream), and Android 10 or newer.

+
+ +
+
●

What a stream can teach, and what it cannot

+ + + + + + + + +
On the streamOn cableWhat the phone learns
The channel’s own national spotsThe same recordingsYes. Fingerprints and, with Speech on, the words. Recognised next time they air on cable.
Ads inserted by the streaming serviceYour cable company’s local ads insteadLearned, but never seen on cable. Harmless: they simply never match.
The show itselfThe same show, about half a minute earlierNothing; and the stream is too late to be the live sensor.
The break clock (method 9)—Not from a stream: the delay would shift every minute. The clock learns only at the TV.
+

The stream is a teacher, not a sensor. When you are done teaching, you Stop, and the normal AdHush at the TV uses +everything the stream taught it. The two share one memory.

+
+ +
+
1

Get the stream playing on the phone

+
    +
  1. Open the channel’s live stream in Chrome: the MS NOW site with your membership, the +Xfinity Stream site with your cable login, or any network’s live page. Chrome lets other apps hear its sound.
  2. +
  3. Apps work too when they allow it. If an app blocks capture, AdHush hears silence and will tell you within a +minute; switch to the web player in Chrome.
  4. +
  5. Turn the phone’s volume down or off if you like: capture takes the sound before the speaker, so a silent +phone still teaches.
  6. +
+

Android pauses most web video when you leave the browser. Keep the player on screen, or use the +player’s picture-in-picture button so it floats while you do other things. Screen brightness can be at its lowest.

+
+ +
+
2

Start stream learning

+
    +
  1. In AdHush, open Methods and scroll to the card Learn from a stream.
  2. +
  3. Check that Speech (card 5) is on if you want the words learned too; the speech model must be +installed. Remembered breaks are always on for stream learning.
  4. +
  5. Tap Start learning from a stream. Android asks whether AdHush may record or cast; choose the +whole screen and allow. (AdHush only takes the sound.)
  6. +
  7. Go back to the stream. The AdHush notification now reads STREAM LEARNING with the time listened and the +breaks learned so far.
  8. +
+

Leave it. Every commercial the phone recognises as a commercial, by the quiet gap, the loudness jump, a known script +or an AI judge, is fingerprinted when it ends. A commercial is learned once; the next airing only confirms it.

+
+ +
+
3

Help it along while you watch

+

If you are watching the stream anyway, the two buttons on the notification teach it faster than anything else. +When a break starts, tap ✓ Is an ad; when the show returns, tap ▶ Show’s +back. The whole break between the taps is remembered, spot by spot. Ten breaks taught this way cover most +of what a channel plays in a week.

+

If it called part of the show a commercial, tap ✗ Not an ad. Nothing was ducked, because +there is no TV in this mode, but the wrong fingerprint is dropped.

+
+ +
+
4

Stop, then use it at the TV

+
    +
  1. Tap ■ Stop on the notification or on Home. The notification’s last line tells you how +many breaks and scripts the session added.
  2. +
  3. Put the phone where it lives by the TV and tap ▶ Start as usual. Everything the stream +taught is in the same memory; card 3 shows the count.
  4. +
+
+ +
+
5

Move the memory to another phone

+

Learned on the OnePlus, wanted on the Moto? On card 3. Remembered breaks:

+
    +
  1. On the phone that learned, tap Share this phone’s memory and send the file +(adhush-memory.zip) to yourself: mail, Drive, messages, anything.
  2. +
  3. On the other phone, stop AdHush if it is running, open the same card, tap Import memory from a +file, and pick the file.
  4. +
  5. The two memories are merged: breaks, scripts and the break clock. Nothing already known is doubled.
  6. +
+

The Raspberry Pi listener cannot use this file yet: its fingerprint matcher works from the camera +picture first and the sound second, so a memory made of sound alone means nothing to it. Teaching the Pi still +happens at the TV with ✓ and ▶.

+
+ +
+
?

Which streams work

+
    +
  1. MS NOW membership ($7.99 a month): the live channel in Chrome. The one you bought works until the +month ends, cancelled or not.
  2. +
  3. Xfinity Stream (included with your cable): the live channels in Chrome with your Xfinity login. +The ad breaks are the closest match to what your TV plays, because it is the same company.
  4. +
  5. Network sites and apps that stream live with a cable login: CNN, Fox News, the broadcast +networks’ live feeds. Any of them teaches that channel’s spots.
  6. +
  7. Free streams with commercials (Pluto TV, Tubi, the networks’ free news streams) teach their +own ads, which overlap only partly with cable. Cheap practice, less payoff.
  8. +
+
+ +
+
+Fix-it table + + + + + + + + + + +
What you seeWhat to do
needs Android 10Hearing another app’s sound is an Android 10 feature. On an older phone, play the stream on a laptop next to the phone and use the ordinary Start instead: the mic learns the same spots, less cleanly.
hearing nothing from the playerThe app blocks capture. Use the channel’s web player in Chrome, or the laptop trick above.
The stream stops when the screen goes offKeep the screen on with the brightness down, or use picture-in-picture. Plug the phone in.
Hours listened, zero breaks learnedA stream with its own ad-free stretches, or breaks the quiet-gap and loudness methods cannot see on that channel. Teach ten breaks by hand with ✓ and ▶, or switch on an AI judge (card 7 or 8) for the session.
stop AdHush first when importingThe memory is read when AdHush starts, so it must be stopped to change it. Stop, import, Start.
It ducks the TV during the show for a spot it learned from the streamTap ✗ Not an ad: that fingerprint is forgotten.
+
+
AdHush is free software (MIT). This guide matches AdHush 0.19 on Android. Stream learning uses Android’s +playback-capture permission to hear the phone’s own audio; it stores fingerprints and words, never sound, and +uploads nothing. Streaming services’ terms allow personal viewing; AdHush neither records nor redistributes +anything, it only remembers what a commercial sounds like, as you would.
+
+ + + diff --git a/docs/release.md b/docs/release.md index 7e9b102..934bdb8 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,17 +1,25 @@ -# Release 0.10.0 — what runs where +# Release 0.28.5 — what runs where The honest platform matrix for this release. "Core" is the Python engine (`adhush run`); "UI" is what you look at and tap. -| Platform | Core runs here? | Always running | Always-on-top mini window | How to get it | +| Platform | Core runs here? | Always running | Always-on-top mini window | Download | |---|---|---|---|---| -| **Windows** | yes | `adhush service install` (Task Scheduler, at logon) | `adhush overlay` (Tk) — also the Mini window button in Chrome/Edge | `pip install adhush-0.10.0-py3-none-any.whl` | -| **macOS** | yes | `adhush service install` (launchd agent) | `adhush overlay` (Tk); Mini window in Chrome/Edge | wheel; tkinter ships with python.org Python | -| **Linux / Raspberry Pi** | yes (reference) | desktop: `adhush service install` (systemd --user); headless box: `scripts/install-pi.sh` | `adhush overlay` (Tk; `apt install python3-tk`) | wheel or `pip install -e .` | -| **ChromeOS** | yes, in the Linux container | `adhush service install` inside the container | Chrome's Mini window (Document Picture-in-Picture) floats above everything; the Tk overlay works inside the container's window | wheel in Crostini, then open the served page in Chrome | -| **Web** | no — thin client | n/a | Mini window (Document PiP in Chromium 116+); popup elsewhere | open `http://:8675/` — the core serves it | -| **Android** | **yes — on-device app** (since 0.7.0; Sharp LC-46LE830U via `VOLM` ducking) | microphone foreground service | notification + Quick Settings tile | `adhush--android-debug.apk` from the release, sideloaded; or the thin-client web app as before | -| **iOS** | **no — thin client** | same | none; Safari has no PiP for documents | open the address in Safari → Share → *Add to Home Screen* | +| **Windows 10/11 (x64)** | yes | `adhush service install` (Task Scheduler, at logon; uses the silent `adhushw.exe`) | `adhush overlay` (Tk) — also the Mini window button in Chrome/Edge | `adhush-0.28.5-windows-x64.zip`; or the wheel | +| **macOS (Apple silicon)** | yes | `adhush service install` (launchd agent) | `adhush overlay` (Tk); Mini window in Chrome/Edge | `adhush-0.28.5-macos-arm64.zip`; Intel Macs: the wheel with python.org Python | +| **Linux desktop (x64 / arm64)** | yes (reference) | `adhush service install` (systemd --user) | `adhush overlay` (Tk; `apt install python3-tk` if the overlay says so) | `adhush-0.28.5-linux-x64.tar.gz` / `-linux-arm64.tar.gz`; needs glibc 2.35+ (Ubuntu 22.04, Debian bookworm) | +| **Raspberry Pi 4 / 5** | yes (reference) | `adhush service install`; headless box: `scripts/install-pi.sh` | n/a | `adhush-0.28.5-raspberry-pi.zip` — the arm64 binary, the listener config and the PDF guides; 64-bit Pi OS bookworm | +| **ChromeOS** | yes, in the Linux container | `adhush service install` inside the container | Chrome's Mini window (Document Picture-in-Picture) floats above everything | the Linux binary for the Chromebook's chip (x64 or arm64) in Crostini, then open the served page in Chrome | +| **Web** | no — thin client | n/a | Mini window (Document PiP in Chromium 116+); popup elsewhere | open `http://:8675/` — the core serves it; the page itself is `adhush-web-0.28.5.zip` | +| **Android** | **yes — on-device app** (since 0.7.0; ten methods, stream learning, the remote, the set-up wizard, any brand of TV) | microphone foreground service | notification + Quick Settings tile | `adhush-0.28.5-android-debug.apk`, sideloaded; or the web app | +| **iOS** | **no — thin client**; there is no native app and none is planned without an Apple developer account | same | none; Safari has no PiP for documents | open the core's address in Safari → Share → *Add to Home Screen*: the page is a PWA and installs as an app | + +Every binary is smoke-tested on its own runner before it is published: +`--version`, `init`, `doctor`, and an offline replay of a synthesised +fixture. `ffmpeg` on PATH is the one thing every desktop needs for live +capture (`adhush doctor` says so). Unsigned binaries: Windows SmartScreen +and macOS Gatekeeper will ask once (More info → Run anyway; right-click → +Open, or `xattr -d com.apple.quarantine adhush`). ## What "always running" means @@ -27,6 +35,109 @@ and keeps going until you stop it. Stopping is deliberate and explicit: Closing the mini window with the right-click menu's *Hide* does not stop the core. Only ■ does. +## New in 0.28.5 + +The remote is its own tab, drawn like one; no button wraps its text. + +## New in 0.28.4 + +Buttons show real outcomes only: busy while a job runs, then green or +red; model downloads verified, resumable, never installed short. + +## New in 0.28.3 + +Every Android button dips when pressed and turns green or red with the +outcome; the teach and duck buttons are bright only when they would act. + +## New in 0.28.2 + +Stream learning names a player that blocks capture within eight seconds +and says what to do instead. + +## New in 0.28.1 + +The learning loop of the first evening closed (ADR 0027, amended): short +ducks teach nothing, one session counts once, a slip of the finger is not +material, a false match forgets itself. On the phone: Forget what it +learned, Save the log to a file, the wizard as a Home-page button, and a +remote laid out like one. + +## New in 0.28.0 + +Jingle families (a sting re-cut a few semitones up or a little slower is +the same sting), the segment stinger detector, and the hour of day on +every learned jingle (ADR 0027). Both cores. + +## New in 0.27.0 + +Android TV / Google TV, Hisense VIDAA and Philips JointSpace from the +phone (ADR 0026), each paired once through the wizard. + +## New in 0.26.0 + +Any brand of TV from the phone (ADR 0025): the wizard finds Sharp, +Samsung, LG, Sony, Roku TV, Vizio and DLNA sets and proves each way in; +infrared for seven brands. + +## New in 0.25.0 + +The wizard's *Find my TV*: Wi-Fi scan, serial cable and infrared, each +tried and reported, the first that works chosen (ADR 0022, amended). + +## New in 0.24.0 + +Nine more ways to know (ADR 0023, 0024): the rating box, ad-unit +lengths, uniform separators, captured cutscenes, the stereo switch, the +ATSC/DVB watermark probe, an XMLTV schedule, SCTE-35 cues from a +transport stream (`ts_stream`: HDHomeRun, DVB) and an opt-in crowd feed +(`adhush crowd serve`). On the phone, ad units ride on Quiet gaps and the +rating box on the camera. + +## New in 0.23.0 + +The Android set-up wizard: three minutes of questions, listening and +looking, then a suggested configuration with reasons, applied and opened +for review (ADR 0022). Method counts corrected everywhere. +`docs/ideas-backlog.md` lists what the next releases could take. + +## New in 0.22.0 + +`aspect_change` on the HDMI and screen paths — a spot that arrives in +another picture shape votes for as long as it holds it — and a crest-factor +cue inside loudness, so a spot compressed flat shows even when it is no +louder (ADR 0021). + +## New in 0.21.0 + +Method 10, the channel's break jingle, learned from the breaks the other +methods end; the ad badge read off a stream's player; learned break lengths +that cap every mute and say how long is left; manual ducks up to 300 s with +a `+30 s` countdown everywhere (ADR 0020). + +## New in 0.20.0 + +One-file binaries for Windows, macOS, Linux (x64 and arm64) and a Raspberry +Pi bundle, next to the APK, the web app and the wheel (ADR 0019); +`adhush init` for a first config. + +## New in 0.19.0 + +Android learns the commercials from a channel's live stream by hearing its +own playback (ADR 0018), and its memory (breaks, scripts, clock) can be +shared and imported between phones. `docs/print/AdHush-guide-stream-learning.pdf`. + +## New in 0.18.0 + +The break clock (method 9, a learned minute-of-hour prior), timed manual +ducks (30/60/90/120 s) and a remote control with every TV key and every +AdHush control on one screen — phone, web page and Python core (ADR 0017). + +## New in 0.17.0 + +Duck compensation for room microphones (ADR 0016, after admuffs) in both +the Python listener and the phone, and three sizes of local AI on Android +(`docs/print/AdHush-guide-local-AI-OnePlus.pdf`). + ## What this release does not include - **No native Android or iOS binaries.** The Android on-device app is @@ -44,8 +155,8 @@ core. Only ■ does. ## Reproducing the artifacts ``` -python -m build # dist/adhush-0.10.0-py3-none-any.whl, .tar.gz -(cd platforms/web && zip -r ../../dist/adhush-web-0.10.0.zip .) +python -m build # dist/adhush--py3-none-any.whl, .tar.gz +(cd platforms/web && zip -r ../../dist/adhush-web-0.28.5.zip .) ``` Pushing a `v*` tag runs `.github/workflows/release.yml`, which does the same diff --git a/docs/roadmap.md b/docs/roadmap.md index 07120ab..ecd2393 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -52,7 +52,70 @@ core serves the web front end, now an installable web app with a Document Picture-in-Picture mini window in Chromium browsers. `docs/release.md` has the per-platform matrix, including what mobile does and does not get yet. +## Phase 8 — the Pi learns from the phone (done, 0.15.0) +The living-room lessons from the Android app ported as rules (ADR 0014): +inert votes, the user's word reaching the detectors, a logo that must be +sighted before it can be missed, whole screen or nothing, teach mode with +"Show's back", a quiet period after "Not an ad", and a persistent, ducking +`network_ip`. `docs/build-guide-tv-listener.md` is the shelf build. + +## Phase 9 — the mute paradox (done, 0.17.0) +What admuffs does that we did not (ADR 0016): the engine tells room-mic +detectors about every duck; loudness measures the drop and compensates, or +goes inert when the ducked set is buried under the room; silence is inert +while ducked. Three sizes of local AI on the phone. + +## Phase 10 — the clock, the hand, the remote (done, 0.18.0) +ADR 0017: a learned minute-of-hour prior as method 9, timed manual ducks, +and a remote with every TV key and every AdHush control on one screen. + +## Phase 11 — teach it from a stream (done, 0.19.0) +ADR 0018: the phone hears its own playback and learns a channel's national +spots before the first evening at the TV; the memory moves between phones. + +## Phase 12 — a release for every platform (done, 0.20.0) +ADR 0019: PyInstaller one-file binaries for Windows, macOS and Linux (x64, +arm64), a Raspberry Pi bundle, the iOS web app as a PWA, `adhush init`. + +## Phase 13 — what the peer projects knew (done, 0.21.0) +ADR 0020: the break jingle as method 10 (AdVent), the ad badge during +stream learning, learned break lengths as the mute ceiling (DTC), manual +ducks to 300 s with an extendable countdown (Mx5-MuteTimer). + +## Phase 14 — two cheap cues (done, 0.22.0) +ADR 0021: `aspect_change` on the HDMI and screen paths (comskip's oldest +trick) and the crest factor inside loudness (beepscore's observation). + +## Phase 15 — the set-up wizard (done, 0.23.0) +ADR 0022: a wizard that measures the room and the camera and suggests the +settings. `docs/ideas-backlog.md` ranks what was found in the wider world. + +## Phase 16 — the backlog, built (done, 0.24.0) +ADR 0023: the rating box, ad units, uniform separators, cutscenes, the +stereo switch, the ATSC/DVB watermark probe. ADR 0024: an XMLTV schedule, +SCTE-35 from a transport stream, and the crowd feed. + +## Phase 17 — any brand of TV, from the phone (done, 0.26.0) +ADR 0025: drivers for Sony, LG, UPnP, Samsung, Roku and Vizio, infrared +for seven brands, and a finder the wizard uses to prove each path. + +## Phase 18 — the other families (done, 0.27.0) +ADR 0026: Android TV / Google TV, Hisense VIDAA and Philips JointSpace. + +## Phase 19 — families, stingers and hours (done, 0.28.0; amended 0.28.1) +ADR 0027: jingle family matching (pitch and tempo), the segment stinger +detector, and the hour of day on every learned jingle. 0.28.1 closed the +learning loop the first evening showed and gave the phone a Forget button. + ## Beyond the roadmap +Start with `docs/ideas-backlog.md`: the parental-rating bug as an unmute +cue, ad-unit length quantisation and uniform-colour separators are the +cheap ones. + +An audio-only fingerprint matcher for the Python listener (the phone's +`AudioMatcher` ported), so a memory learned from a stream or on a phone is +useful on the Pi too. + Candidates, in no particular order: in-browser WASM detectors (the rest of the Phase 4 remainder), an iOS on-device core following ADR 0007, an audio delay line for retroactive mutes on the passthrough box, `metrics.py` (Prometheus text endpoint), RC-6 / diff --git a/packaging/adhush.spec b/packaging/adhush.spec new file mode 100644 index 0000000..45ed6e4 --- /dev/null +++ b/packaging/adhush.spec @@ -0,0 +1,57 @@ +# PyInstaller spec for the one-file AdHush core binary (ADR 0019). +# Built by .github/workflows/release.yml on every platform runner: +# pyinstaller packaging/adhush.spec +# The binary carries the web front end and the device profiles as data, so it +# runs from any directory; ffmpeg is the one runtime dependency it expects on +# PATH for live capture (doctor says so when it is missing). +import sys + +from PyInstaller.utils.hooks import collect_submodules + +block_cipher = None + +a = Analysis( + ["../src/adhush/__main__.py"], + pathex=["../src"], + binaries=[], + datas=[ + ("../platforms/web", "platforms/web"), + ("../config/profiles", "config/profiles"), + ("../config/adhush.example.toml", "config"), + ("../config/adhush-listener.example.toml", "config"), + ], + hiddenimports=collect_submodules("adhush") + ["serial", "serial.tools.list_ports"], + hookspath=[], + runtime_hooks=[], + excludes=["pytest", "mypy", "ruff", "matplotlib", "scipy", "PIL"], + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="adhush", + debug=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, +) +if sys.platform == "win32": + # A second, windowless build for the logon task: no console flashes up at login. + exew = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="adhushw", + debug=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + ) diff --git a/platforms/android/README.md b/platforms/android/README.md index 78d484a..5d67370 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -80,8 +80,90 @@ Battery → Unrestricted) or Android may pause the service overnight. `quietMarginDb` (4 dB) and the fingerprint verify agreement (0.7) are starting guesses; see the design's testing section. +## The screens + +Five tabs along the bottom. **Home** is the status card (green while the +show is on, red while ducked, orange while teaching, grey when stopped), a +chip per method that lights in its colour while that method runs, the +Start/Stop pair (Start reads "Running" in green, Stop turns red), Test TV +and the set-up wizard, the teaching buttons and the timed ducks. **Remote** +(its own tab since 0.28.5) is AdHush's own controls on top and then the +TV's remote drawn as one: power, input and mute, the number pad, the +volume and channel rockers either side of a round D-pad, then menu, +transport, the colour keys and the rest, every key a dark pill or circle +with one line of text. **TV** is how this phone reaches its set, the +address and login, and the volumes. **Methods** is the ten ways of +spotting a commercial, each a switch with a ⓘ explanation and a coloured +dot while it runs; at least one must be on or the app refuses to start. +**Log** shows the app's own log with **Save**, **Share** and **Clear**: +every status line, every caught error and every crash with its stack +trace goes into a rolling file in the app's private storage. **Help**, +every explanation in full, is the ? in the toolbar. No button anywhere +wraps its text: labels shrink to fit on one line. + +## The set-up wizard (0.23.0) + +**Set-up wizard** on Home (offered once on first run; *Not now* keeps the +defaults) takes three minutes: **Find my TV** tries every way in — it +searches the Wi-Fi for a Sharp on the control port (or asks the address +you typed), tries a serial cable if an adapter is plugged in, and fires the +infrared blaster twice and asks whether the sound dipped; whatever answers +is chosen, network first — then where the phone sits, the channel, whether +it is a news channel and whether captions are shown; fifteen seconds of the +show and ten of the room with the TV muted; eight seconds through the camera +to see whether the whole TV is in view. It then lists the settings it +suggests with a reason for each — which methods, the duck level (a loud +room means a higher duck level so the phone can still hear the ducked set), +the camera target and zoom, the local AI size this phone can carry — and +**Apply and review** writes them and opens the Methods page, where every +switch can be changed. It keeps numbers only, never audio or pictures. + +## Words used in the app + +- **Bug** — the small channel logo that sits in a corner of the picture + during a show (broadcasters' word for it). It is taken off the screen + during commercials; that is what the camera watches for. +- **Script** — the words of one commercial, written down. Commercials say + the same words every airing; the app finds runs of words that repeat and + saves them as scripts, from speech or from the on-screen captions. +- **Ducking** — turning the TV down to a whisper instead of muting, so the + phone can still hear when the show is back. +- **Material / remembered break** — the audio fingerprint (numbers, not a + recording) of a break you bracketed with *Is an ad* and *Show's back*. + +## The ten methods + +| # | Method | What it uses | Can duck alone? | +|---|---|---|---| +| 1 | Quiet gaps | microphone: the short silence between show and ad; gaps on the 15-second ad-unit grid hold a duck through a pod (0.24.0) | no — needs a second opinion | +| 2 | Loudness jumps | microphone: the sound jumping up and staying up; a spot compressed flat (crest factor) counts for half | no — needs a second opinion | +| 3 | Remembered breaks | microphone: fingerprints of taught breaks | yes | +| 4 | Channel bug | camera: the logo corner, whole TV in view; the rating box ("TV-14") flashed after a break brings the show back at once (0.24.0) | yes | +| 5 | Spoken words | microphone + offline speech recogniser: scripts | yes | +| 6 | On-screen captions | camera: the caption band read as text: scripts | yes | +| 7 | Ask Claude | the words of 5 or 6 judged by Claude over the API (opt-in, paid) | yes | +| 8 | Local AI | the words of 5 or 6 judged by a small model on the phone (free) | yes | +| 9 | Break clock | the minute of the hour and how long breaks run, learned from every confirmed break | no — tips the balance; caps how long a duck may hold | +| 10 | Break jingle | microphone: the channel's sting into (and out of) every break, learned from three breaks; its re-cuts count as the same family; remembers the hours it plays | yes | + +**Not an ad** now does three things: restores the volume, tells every +method it was wrong (the camera must see the bug again before it may call +it gone; a matched script is dropped), and opens a one-minute quiet period +in which nothing may duck. The status line shows the countdown. + ## Which TV, which cable +Since 0.26.0 the phone is not only for a Sharp. The wizard's **Find my +TV** asks every set on the Wi-Fi who it is and proves each way in: a +Sharp's control port, a Sony Bravia (type its pre-shared key), an LG +webOS (allow AdHush on the set once), any DLNA set over UPnP, a Samsung's +remote channel (allow once), a Roku TV, a Vizio SmartCast (a PIN once), an +Android TV or Google TV (a six-character code once), a Hisense VIDAA (four +digits once on newer sets), a Philips (a PIN once on the newer sets). +Sets that read their volume back are ducked to an exact level; the others +are stepped with key presses. The TV page's fourth radio shows what was +found; Sharp's serial cable and infrared for seven brands remain (ADR 0025). + At the top of the settings, pick how *this* phone reaches *its* TV: - **Network** — the LC-46LE830U over IP Control (address, port, login). @@ -97,17 +179,107 @@ At the top of the settings, pick how *this* phone reaches *its* TV: moves, the codes are wrong for this set: tell me the remote's model number (printed on its back) and I will look up its code table. -## Watching for the logo with the camera +## Watching for the bug with the camera + +Press **Camera setup**. AdHush pauses and the screen shows what the back +camera sees, in colour, with the TV outlined in green. **Pinch the picture +(or move the slider) to zoom the camera in** until the bug is big but the +whole TV still fits — the outline turns red with a warning when the TV runs +off the edge. Hold the phone upright and, with a show on, press **Watch +45 s**. When it finishes, a yellow box marks what it thinks is the bug and +the strip underneath shows that box magnified, in colour: it should look +like the channel's logo. If it grabbed the wrong thing — on a news channel +the ticker frame or a banner is a common mistake — drag a box around the +real logo with your finger. The status line shows a live match score and +SEEN / GONE / not seen yet. When it reads SEEN during a show, press **Save & +start**; the zoom is saved with the template and the service uses the same. +Set-up is per channel. + +Hand-held is expected: the TV is re-found in every frame and the yellow box +follows the bug (it is slid over a small window and the best match counts); +a smeared frame counts as "can't see"; a TV that is only partly in the +picture is "whole TV not in view" — inert, never a duck; the bug must have +been seen once since Start before its absence counts; and it has to be +missing for 2.5 s before the set is ducked. The status line says what the +camera can see at any moment. + +## Reading the captions with the camera + +Turn closed captions on in the TV's own menu, switch on **On-screen +captions** under Methods, and Start (the camera permission is needed). The +lower third of the found screen is cut out of each frame, enlarged, and +read by the phone's on-device text recogniser once a second; new words go +into the same three-hour transcript, repetition learning and script +matching as speech does. Because the words are read rather than heard, the +room's fans do not garble them. It needs the whole TV in view and captions +tall enough to read — zoom in on the setup screen and look at what the +camera sees; at eight feet, 1.5–2× zoom is about right on a 46-inch set. + +Nothing can send the captions down a cable: the Sharp's RS-232C and IP +control ports carry commands and their one-word replies (volume, power, +input), never picture, sound or caption data, and infrared is one-way into +the set. The captions exist as data only inside the broadcast the TV or +cable box decodes; from there they are drawn into the picture and gone. +Android's own Live Caption and Live Transcribe transcribe *audio* on the +phone (Live Caption only for media playing on the phone itself), which is +what method 5 does offline. Reading them off the screen is the only way in +from outside, and is what this method does. Details: ADR 0013. + +## Hearing the commercials (speech) + +Press **Download speech model** once (40 MB, stays on the phone), tick +**Use speech**, and Start. The app now turns what it hears into words and +keeps the last three hours of them. Every ten minutes, and whenever you +press **Learn scripts now**, it looks for runs of ten or more words that +came back identically minutes apart — a commercial airing again — and saves +them as scripts. The words of anything you bracket with *Is an ad* and +*Show's back* are saved as a script at once. From then on, hearing a saved +script ducks the set on its own and the volume returns a few seconds after +the last matching words. Legal and sales boilerplate ("ask your doctor", +"call now") ducks the set by itself. Details: ADR 0012. + +## Asking an AI (methods 7 and 8) + +Both AI methods read the words that **Spoken words** or **On-screen +captions** produce, so one of those must be on. Every so often they hand +the last 40 seconds to a language model with one question — is a commercial +playing right now? — and take the one-line answer (`COMMERCIAL 0.9: ask your +doctor`) as a vote strong enough to duck on its own. By default they ask +only as a **tie-breaker**: when the other methods are unsure, or while the +set is ducked, and otherwise once a minute. "Always" asks every ten +seconds. A confident COMMERCIAL answer is saved as a script, so the same +spot is recognised offline the next time. -Tick **Use the back camera** and, with a show on and the whole TV in the -camera's view, press **Set up camera (45 s)**. The app finds the screen, -watches which edges never move — the network bug — and saves that corner as -the thing to look for. When it is done the notification says where it found -the logo; if it says none was found, check that a show (not an ad) was on and -the screen was fully in view, then press again. From then on the bug -disappearing ducks the set within about two seconds on its own, and the bug -reappearing restores it at once. Camera set-up is per channel: press it again -after switching channels. Details: ADR 0011. +- **Ask Claude** sends the text to Anthropic's API. You need an API key + (console.anthropic.com), pasted under Methods; it lives in the app's + encrypted settings. Haiku 4.5 is the cheap default, about 13 cents per + hour of TV in "always" mode and a tenth of that as a tie-breaker; Sonnet 5 + and Opus 5 cost about two and five times as much. Only text leaves the + phone, never audio — and the microphone hears the room, so your own words + can be in it. **Test Claude with a sample** shows what it answers. +- **Local AI** runs a Qwen model on the phone through MediaPipe's LLM + Inference, in one of three sizes chosen under Methods: Qwen 2.5 0.5B + (8-bit, 550 MB, any phone), Qwen 2.5 1.5B (8-bit, 1.6 GB, 6 GB+ of + memory) or Qwen 3 4B (mixed int4, 2.7 GB, the newer `.litertlm` bundle, + 12 GB+). Each size is its own file, so several can be installed and + switched. Free, private, works without Wi-Fi; a few seconds per answer + and a warmer phone. `docs/print/AdHush-guide-local-AI-OnePlus.pdf` is + the walkthrough. + +Both are late by nature — the speech engine finishes a sentence a few +seconds after it is said, the window is 40 seconds, and the answer takes a +moment — so they are best at *starting* a duck; the bug, the ticker or a +remembered break brings the sound back. Details: ADR 0015. + +## The news ticker instead of the bug + +On a news channel the lower third carries a band (the ticker or the chyron +with the headline) whose top and bottom edges never move while the words +inside scroll; during a commercial the band is gone. Under **Channel bug** +choose **the news ticker**, then Camera setup, Watch 45 s: the yellow box +should sit on the band. Same rules as the bug (whole TV in view, seen once +before it can be missed, gone for 2.5 s). Shows that drop the ticker for +interviews would read as a commercial — use the bug on those. ## Teaching it the commercials @@ -151,3 +323,124 @@ normal show with the phone where it will live; the digest tells you whether The installable web app served by a Pi or PC core (`docs/release.md`) remains the right choice when a passthrough box is in play or the TV has no network control. + +## The mute paradox (0.17.0) + +The phone's microphone hears the set get quieter the moment the app ducks +it. Before 0.17.0 the loudness method read that as "programme resumed", the +set came back up into the commercial and was ducked again. Now the engine +tells the audio methods about every duck (ADR 0016): loudness waits one +window, measures how far the room dropped and judges the ducked ad on the +original scale; silence is inert while ducked. When the ducked set is +buried under the room — fans louder than a TV at volume 4 — loudness says +`ducked_buried` and goes inert, and the camera, the fingerprints and the AI +judges carry the unmute. A duck level of 8–10 keeps the set audible to the +mic and lets loudness time the unmute itself. + +## Manual duck and the remote (0.18.0) + +Ten buttons on Home turn the TV down for 30 s to 5 min in 30-s steps and +bring it back on their own, whatever the methods think; **+30 s** (also on +the notification and the remote) adds to a running duck, and on a duck the +methods started it holds that one as a timed duck instead. The status line +counts down; Show's back ends a timed duck early; nothing is learned. +During an automatic duck the status line says "about m:ss left" once the +break clock has seen five breaks on this channel (0.21.0). +**Open the remote control** (also the remote icon in the toolbar of every +screen) shows every key of the Sharp handset (through +the network or the serial cable: `RCKY` codes; infrared knows only volume +and mute) together with Start, Stop, Is an ad, Show's back, Not an ad and +the timed ducks. While AdHush runs the keys go through its connection, since +the Sharp allows one; otherwise the screen opens its own and closes it when +it leaves the foreground (ADR 0017). + +## Break jingles (0.21.0) + +Method 10 listens for the channel's own sting. Every break the other +methods end cuts two six-second candidates from the audio history — around +the moment the break started and the moment it ended — and compares them +with the candidates already kept. The same three seconds heard at the start +of three different breaks becomes a known **opener**, and from then on +hearing it ducks the set at once, before loudness or the bug can react. A +known **closer** heard while ducked counts as the show being back. **Not an +ad** counts against the opener that fired and demotes it when it has been +wrong as often as right. A channel with no sting never promotes anything. +The learned stings live in `jingles.tsv` and travel with the shared memory. + +Since 0.28.0 (ADR 0027) a sting is also recognised transposed by up to two +semitones or stretched by up to a tenth in tempo — the same house sting +re-cut per show — at a slightly higher bar, and a re-cut heard live counts +for the sting it resembles. Every jingle remembers the local hours it has +opened breaks in; at one of those hours it is trusted after two breaks +instead of three and matched a little more loosely. The **segment +stinger** — the whoosh or hit on the cut, with no tune to learn — is a +separate default-weight detector that rides on the Loudness method: a +short noisy burst over the bed, then the level moves. It tips the balance +for a couple of seconds and never ducks alone. + +## What the buttons tell you (0.28.5) + +A button changes only when something real happened, never as a flex for +a tap that went nowhere. Three looks: **blue and greyed**, its label the +job's progress ("Downloading Qwen 3: 43 %", "Testing the TV…"), for as +long as the job it started is running, so a second press cannot start it +twice; **green with a tick** for a second and a half once what it asked +for went through; **red with a cross** when it did not (nothing running, +the set did not answer, an override in the way; the Log page says which). +Is an ad, Show's back, Not an ad, the timed ducks and every remote key +turn green or red on the service's word, on the Home page and the remote +alike. Start turns green and reads Running while AdHush runs; Stop turns +red then. The teach buttons and the ducks are bright only when they would +do something right now. + +Model downloads cannot leave a short file installed as if it were whole: +the bytes go to a `.part` file with the expected size kept beside it, a +download that stops short says how far it got and the button reads +*Resume* with the percentage, and only a part of exactly the expected size +is moved into place. **Delete partial model downloads** in the ⋮ menu +throws a stopped download away. + +## When it learned nonsense (0.28.1) + +A bad evening — a teach session that ran into the show, a couple of +mis-taps — can leave the memory matching the show itself. Four guards now +stop that feeding on itself (ADR 0027, amended): a break shorter than +twenty seconds teaches the jingle learner nothing; two hearings of a sting +within half an hour count once; a taught bracket under half a minute is a +slip of the finger and one over six minutes is cut there; and a remembered +break whose duck ends within ten seconds on the show's own evidence is a +false match — two of them forget it. When that is not enough, **Forget +what it learned…** (Methods page, or the ⋮ menu) wipes the remembered +breaks, the jingles, the break clock or the scripts, each on its own tick. + +The Log page's **Save to a file** writes the log through the system file +picker (Downloads by default) beside Share. The set-up wizard is a button +on the Home page next to Start, Stop, Test TV and Remote, and never opens +by itself; the remote is laid out like one, with the volume and channel +rockers either side of a D-pad and a number pad below. + +## Learn from a stream (0.19.0) + +On Android 10+, **Methods → Learn from a stream → Start** asks for the +"record or cast" permission and then the phone hears its own playback: open +the channel's live stream in Chrome and leave it playing. The engine runs +without a TV, fingerprints on, speech and the AI judges as configured, +**Read the ad badge** on by default (the player's corners are read once a +second with ML Kit; "Ad", "Ad 1 of 3", "AD 0:15" or "Your video will +resume" is the strongest evidence there is, and its absence while the +reader still scans counts as the show), and +every break it ends is learned into the same `ads.tsv` and `scripts.tsv` +the TV mode uses; ✓ / ▶ on the notification teach by hand. Players that +block capture give silence and the status line says so. **Share this +phone's memory** / **Import memory from a file** on card 3 move breaks, +scripts and the clock between phones (ADR 0018). + +**"App content hidden from screenshare for security purposes"** is +Android's own message: the player marks its window as protected (a DRM +player does), so the mirrored picture is black, and since a player must +opt in to being heard through playback capture, the sound is usually +silence as well. Since 0.28.2 the Home card and the notification say +within eight seconds which of the two is happening and what to do: play +the channel's website in Chrome instead, or play it out loud next to the +phone and use the normal Start, which hears any player through the +microphone. diff --git a/platforms/android/app/build.gradle.kts b/platforms/android/app/build.gradle.kts index fd8c0a9..1197369 100644 --- a/platforms/android/app/build.gradle.kts +++ b/platforms/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "io.adhush.android" minSdk = 26 // AudioSource.UNPROCESSED (24), notification channels (26) targetSdk = 35 - versionCode = 8 - versionName = "0.10.0" + versionCode = 33 + versionName = "0.28.5" } buildTypes { release { isMinifyEnabled = false } @@ -22,16 +22,28 @@ android { targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } - packaging { resources.excludes += "META-INF/*.kotlin_module" } + packaging { + resources.excludes += "META-INF/*.kotlin_module" + // The three BouncyCastle jars (core, for Android TV's client certificate) each carry the same OSGi manifest. + resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + resources.excludes += "META-INF/versions/*/OSGI-INF/MANIFEST.MF" + } } dependencies { implementation(project(":core")) implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.core:core-splashscreen:1.0.1") + implementation("com.google.mlkit:text-recognition:16.0.1") // reads the captions off the screen; model bundled, offline + implementation("com.anthropic:anthropic-java:2.62.0") // method 7: ask Claude (opt-in, text only) + implementation("com.google.mediapipe:tasks-genai:0.10.27") // method 8: a small language model on the phone implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("com.github.mik3y:usb-serial-for-android:3.8.1") // RS-232C through a USB-OTG cable implementation("androidx.camera:camera-core:1.3.4") implementation("androidx.camera:camera-camera2:1.3.4") implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("com.alphacephei:vosk-android:0.3.47@aar") // offline speech recognition + implementation("net.java.dev.jna:jna:5.13.0@aar") } diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index fc631e8..7de2ad1 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -2,9 +2,11 @@ + + @@ -14,20 +16,24 @@ - + android:theme="@style/Theme.AdHush"> + + + + android:foregroundServiceType="microphone|camera|mediaProjection" /> + AppLog.e("crash", "uncaught on ${thread.name}: ${t.javaClass.simpleName}: ${t.message}", t) + runCatching { AdHushService.emergencyRestore?.invoke() } // never leave the set ducked + previous?.uncaughtException(thread, t) // Android shows the dialog and restarts what it should + } + val version = runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?" + AppLog.i("app", "AdHush $version starting") + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/AdHushService.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/AdHushService.kt index 2fd462a..00ae501 100644 --- a/platforms/android/app/src/main/kotlin/io/adhush/android/AdHushService.kt +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/AdHushService.kt @@ -5,8 +5,11 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.app.Activity import android.app.Service import android.content.Context +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo @@ -16,12 +19,36 @@ import android.os.IBinder import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import io.adhush.core.AdState import io.adhush.core.Assembly +import io.adhush.core.BadgeDetector +import io.adhush.core.ClockDetector +import io.adhush.core.FileClockStore +import io.adhush.core.FileJingleStore +import io.adhush.core.JingleDetector +import io.adhush.core.RemoteKey +import io.adhush.core.press +import io.adhush.core.AndroidTvRemote +import io.adhush.core.ClientIdentity +import io.adhush.core.HisenseVidaa +import io.adhush.core.LevelController +import io.adhush.core.PhilipsJointSpace +import io.adhush.core.LgWebOs +import io.adhush.core.RokuEcp +import io.adhush.core.SamsungTizen +import io.adhush.core.SonyBravia +import io.adhush.core.UpnpRenderer +import io.adhush.core.VizioSmartCast import io.adhush.core.ControlError import io.adhush.core.DuckController +import io.adhush.core.HANDHELD_LOGO_CONFIG import io.adhush.core.LogoAbsenceDetector import io.adhush.core.LogoFinder import io.adhush.core.LogoTemplate +import io.adhush.core.FileScriptStore +import io.adhush.core.JudgeConfig +import io.adhush.core.JudgeDetector +import io.adhush.core.TranscriptDetector import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -50,12 +77,30 @@ class AdHushService : Service(), LifecycleOwner { override val lifecycle: Lifecycle get() = registry private lateinit var settings: Settings private var camera: CameraSource? = null + private var speech: SpeechSource? = null + private var captionReader: CaptionSource? = null + private var cloudJudge: ClaudeJudge? = null + private var localJudge: LocalJudge? = null + /** The judges' questions run here: slow (a network call, or seconds of CPU) and never on the audio thread. */ + private val think = Executors.newSingleThreadExecutor { r -> Thread(r, "adhush-judge").apply { priority = Thread.MIN_PRIORITY } } + private var scripts: FileScriptStore? = null + private var lastRepeatLearnAt = 0L @Volatile private var finder: LogoFinder? = null @Volatile private var finderStartedAt = 0L private var engine: Engine? = null private var controller: DuckController? = null private var transport: AutoCloseable? = null private var mic: MicSource? = null + /** Stream learning (ADR 0018): the phone's own playback instead of the mic, and no TV. */ + private var stream: StreamSource? = null + private var projection: MediaProjection? = null + private var streamStore: FileFingerprintStore? = null + private var badgeReader: BadgeReader? = null + private var blockedWarned = false + /** The brand driver behind a stepped controller, for the remote's keys (Roku, Samsung, Vizio). */ + private var keyDriver: Any? = null + private var streamAdsAtStart = 0 + private var streamScriptsAtStart = 0 private val io = Executors.newSingleThreadExecutor() private val main = Handler(Looper.getMainLooper()) @Volatile private var lastStatus: Status? = null @@ -71,23 +116,43 @@ class AdHushService : Service(), LifecycleOwner { registry.currentState = Lifecycle.State.RESUMED // CameraX binds to this service's lifetime settings = Settings(this) createChannel() - Thread.setDefaultUncaughtExceptionHandler { _, _ -> runCatching { controller?.restore() }; android.os.Process.killProcess(android.os.Process.myPid()) } + emergencyRestore = { controller?.restore() } // the app-wide crash handler calls this before reporting + AppLog.i("service", "created") } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Every start — including control actions delivered by startForegroundService — // must be answered with startForeground, or Android 8+ kills the app. - startForegroundWithType(buildNotification(lastText)) + if (lastText == "stopped") lastText = "starting…" val action = intent?.action + // A control action (Is an ad from the tile or a button) before the app has ever been + // granted the microphone: Android refuses a microphone-type foreground service and + // the refusal is a crash on the main thread. Say so in the log and stop instead. + try { startForegroundWithType(buildNotification(lastText), action == ACTION_STREAM_START || stream != null) } catch (e: SecurityException) { + AppLog.w("service", "cannot run in the foreground yet (${e.message?.substringBefore(':')}) — open the app, allow the microphone, press Start") + stopSelf(); return START_NOT_STICKY + } + if (action == ACTION_STREAM_START) { startStream(intent); return START_STICKY } if (engine == null && action != null && action != ACTION_SURVEY) { // a control action, but nothing is running - if (action != ACTION_STOP) update("not running — press Start in the app") + if (action != ACTION_STOP) { update("not running — press Start in the app"); report(action, false, intent) } stopSelf() return START_NOT_STICKY } when (action) { - ACTION_NOT_AD -> { io.execute { engine?.rejectAd(now()) }; return START_STICKY } - ACTION_IS_AD -> { io.execute { engine?.confirmAd(now()) }; return START_STICKY } - ACTION_SHOW_BACK -> { io.execute { engine?.showIsBack(now()); refresh() }; return START_STICKY } + ACTION_NOT_AD -> { io.execute { report(action, engine?.rejectAd(now()) == true, intent) }; return START_STICKY } + ACTION_IS_AD -> { io.execute { report(action, engine?.confirmAd(now()) == true, intent) }; return START_STICKY } + ACTION_SHOW_BACK -> { io.execute { val ok = engine?.showIsBack(now()) == true; refresh(); report(action, ok, intent) }; return START_STICKY } + ACTION_DUCK_FOR -> { + val seconds = intent.getIntExtra(EXTRA_SECONDS, 60).coerceIn(5, 600) + io.execute { + val ok = engine?.duckFor(now(), seconds.toDouble()) == true + if (ok) refresh() else main.post { update("could not duck (an override is on?)") } + report(action, ok, intent) + } + return START_STICKY + } + ACTION_KEY -> { intent.getStringExtra(EXTRA_KEY)?.let { k -> io.execute { report(action, pressKey(k), intent) } }; return START_STICKY } + ACTION_DUCK_MORE -> { io.execute { val ok = engine?.extendDuck(now(), 30.0) == true; if (ok) refresh(); report(action, ok, intent) }; return START_STICKY } ACTION_RESTORE -> { io.execute { runCatching { controller?.restore() }; refresh() }; return START_STICKY } ACTION_STOP -> { stopSelf(); return START_NOT_STICKY } ACTION_SURVEY -> { @@ -95,6 +160,15 @@ class AdHushService : Service(), LifecycleOwner { if (engine != null && survey == null) { survey = RoomSurvey(SURVEY_S); update("surveying the room: 0:00 / ${clock(SURVEY_S)}") } return START_STICKY } + ACTION_TEST -> { io.execute { runTest() }; return START_STICKY } + ACTION_LEARN_SCRIPTS -> { + io.execute { + val n = engine?.learnScriptsFromTranscript() ?: 0 + main.post { update(if (engine == null) "not running" else "repetition learning: $n new script(s), ${scripts?.count() ?: 0} total") } + report(action, engine != null, intent) + } + return START_STICKY + } ACTION_CAMERA_SETUP -> { if (engine == null) start() if (camera == null) { update("camera is off — tick 'Use the camera' and Start again"); return START_STICKY } @@ -106,51 +180,104 @@ class AdHushService : Service(), LifecycleOwner { return START_STICKY } - @Volatile private var lastText = "starting…" - private fun start() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { update("microphone permission missing"); stopSelf(); return } - val ctl: DuckController = when (settings.control) { + // The 0.13 log: a blank address was tried as ":10002" every five seconds. Refuse to start instead. + if (settings.control in setOf("ip", "sony", "lg", "samsung", "roku", "vizio", "androidtv", "hisense", "philips") && settings.host.isBlank()) { update("no TV address — run the wizard's Find my TV, or type it on the TV page and Save"); stopSelf(); return } + if (settings.control == "upnp" && settings.upnpControlUrl.isBlank()) { update("no renderer address — run the wizard's Find my TV"); stopSelf(); return } + if (settings.control == "androidtv" && settings.androidTvIdentity.isBlank()) { update("Android TV is not paired — run the wizard's Find my TV"); stopSelf(); return } + if (settings.methodsOn == 0) { update("no method is switched on — turn one on under Methods"); stopSelf(); return } + val persist = PrefsDuckPersistence(this) + keyDriver = null + val ctl: DuckController = try { when (settings.control) { "serial" -> { val t = SerialTransport(this); transport = t - SharpController(SharpIpClient(t), PrefsDuckPersistence(this), duckLevel = settings.duckLevel, normalVolume = settings.normalVolume, useMuteInstead = settings.useMute) + SharpController(SharpIpClient(t), persist, duckLevel = settings.duckLevel, normalVolume = settings.normalVolume, useMuteInstead = settings.useMute) } "ir" -> { transport = null - StepVolumeController(IrKeySender(this, settings.irAddress, settings.irVolumeUp, settings.irVolumeDown), PrefsDuckPersistence(this), duckLevel = settings.duckLevel, normalVolume = settings.normalVolume) + StepVolumeController(IrKeySender.fromSettings(this, settings), persist, duckLevel = settings.duckLevel, normalVolume = settings.normalVolume) } + // The other brands (ADR 0025): exact volume where the set reads it back, stepped keys where it does not. + "sony" -> { transport = null; LevelController(SonyBravia(settings.host, settings.sonyPsk), persist, settings.duckLevel, settings.normalVolume, settings.useMute) } + "lg" -> { transport = null; LevelController(LgWebOs(settings.host, settings.lgClientKey.ifBlank { null }), persist, settings.duckLevel, settings.normalVolume, settings.useMute) } + "upnp" -> { transport = null; LevelController(UpnpRenderer(settings.upnpControlUrl), persist, settings.duckLevel, settings.normalVolume, settings.useMute) } + "samsung" -> { transport = null; val d = SamsungTizen(settings.host, settings.samsungToken.ifBlank { null }); keyDriver = d; StepVolumeController(d, persist, settings.duckLevel, settings.normalVolume) } + "roku" -> { transport = null; val d = RokuEcp(settings.host); keyDriver = d; StepVolumeController(d, persist, settings.duckLevel, settings.normalVolume) } + "vizio" -> { transport = null; val d = VizioSmartCast(settings.host, settings.vizioToken.ifBlank { null }); keyDriver = d; StepVolumeController(d, persist, settings.duckLevel, settings.normalVolume) } + // The other families (ADR 0026): all three read the volume back. + "androidtv" -> { transport = null; LevelController(AndroidTvRemote(settings.host, ClientIdentity.deserialize(settings.androidTvIdentity)), persist, settings.duckLevel, settings.normalVolume, settings.useMute) } + "hisense" -> { transport = null; LevelController(HisenseVidaa(settings.host, settings.hisenseClientId.ifBlank { HisenseVidaa.newClientId() }), persist, settings.duckLevel, settings.normalVolume, settings.useMute) } + "philips" -> { transport = null; LevelController(PhilipsJointSpace(settings.host, settings.philipsVersion, settings.philipsDeviceId, settings.philipsKey), persist, settings.duckLevel, settings.normalVolume, settings.useMute) } else -> { val t = SocketTransport(settings.host, settings.port, 2500, settings.login); transport = t - SharpController(SharpIpClient(t), PrefsDuckPersistence(this), duckLevel = settings.duckLevel, normalVolume = settings.normalVolume, useMuteInstead = settings.useMute) + SharpController(SharpIpClient(t), persist, duckLevel = settings.duckLevel, normalVolume = settings.normalVolume, useMuteInstead = settings.useMute) } - } + } } catch (e: Exception) { update("TV path ${settings.control}: ${e.message}"); stopSelf(); return } controller = ctl - val store = FileFingerprintStore(File(filesDir, "ads.tsv")) - val cameraWanted = settings.camera && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - val logo = if (cameraWanted) LogoTemplate.load(File(filesDir, LOGO_FILE))?.let { LogoAbsenceDetector(template = it) } else null - val eng = Assembly.engine(NetworkedController(ctl), store, logo = logo) + val store = FileFingerprintStore(File(filesDir, ADS_FILE)) + val cameraOk = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + val logoWanted = settings.camera && cameraOk + val captionsWanted = settings.captions && cameraOk + val cameraWanted = logoWanted || captionsWanted + val watchTicker = settings.cameraTarget == "ticker" // not `ticker`, the poll Runnable + val logo = if (logoWanted) LogoTemplate.load(File(filesDir, if (watchTicker) TICKER_FILE else LOGO_FILE))?.let { + if (watchTicker) LogoAbsenceDetector(HANDHELD_LOGO_CONFIG.copy(searchPx = 4), it, name = "ticker_absence", noun = "ticker") else LogoAbsenceDetector(HANDHELD_LOGO_CONFIG, it) + } else null + val speechWanted = settings.speech && SpeechSource.isInstalled(this) + val cloudWanted = settings.judgeCloud && settings.claudeKey.isNotBlank() + val localWanted = settings.judgeLocal && LocalJudge.isInstalled(this) + val breakClock = if (settings.clock) ClockDetector(FileClockStore(File(filesDir, CLOCK_FILE))) else null // not `clock`, the m:ss helper + val jingle = if (settings.jingles) JingleDetector(FileJingleStore(File(filesDir, JINGLES_FILE))) else null + val scriptStore = if (speechWanted || captionsWanted || cloudWanted || localWanted) FileScriptStore(File(filesDir, SCRIPTS_FILE)).also { scripts = it } else null + val transcript = if (speechWanted) scriptStore?.let { TranscriptDetector(it) } else null + val captions = if (captionsWanted) scriptStore?.let { TranscriptDetector(it, name = "captions") } else null + // The judges need words from somewhere: speech or captions. + val judges = makeJudges(scriptStore, cloudWanted, localWanted) + val eng = try { + Assembly.engine(NetworkedController(ctl), store, logo = logo, transcript = transcript, captions = captions, judges = judges, + silence = settings.silence, loudness = settings.loudness, fingerprints = settings.fingerprints, clock = breakClock, jingle = jingle) + } catch (e: IllegalArgumentException) { + update("no method is switched on — turn one on under Methods"); runCatching { ctl.close() }; controller = null; stopSelf(); return + } eng.addListener { s -> lastStatus = s; main.post { update(describe(s)) } } engine = eng + running = RunningInfo( + silence = settings.silence, loudness = settings.loudness, fingerprints = settings.fingerprints, + logo = logo != null, logoNotSetUp = logoWanted && logo == null, speech = speechWanted, speechNoModel = settings.speech && !speechWanted, + captions = captionsWanted, control = settings.control, + cloud = cloudWanted, cloudNoKey = settings.judgeCloud && !cloudWanted, + local = localJudge != null, localNoModel = settings.judgeLocal && !LocalJudge.isInstalled(this), + judgesDeaf = judges.isNotEmpty() && !speechWanted && !captionsWanted, + clock = breakClock != null, jingle = jingle != null, + ) io.execute { try { if (ctl.recoverOnStart()) main.post { update("restored volume after an unclean exit") } ctl.trackNormal() } catch (e: ControlError) { + AppLog.w("tv", "unreachable at start: ${e.message}") main.post { update("TV unreachable: ${e.message}") } } } + // The speech model takes seconds to load: never on the main thread, where it would stall the service start. + if (speechWanted) startSpeech(eng, scriptStore) val m = MicSource(this) { block -> eng.onAudio(block) + speech?.feed(block) survey?.let { if (it.feed(block, controller?.ducked == true)) finishSurvey(it) } } - try { m.start() } catch (e: Exception) { update("mic failed: ${e.message}"); stopSelf(); return } + try { m.start() } catch (e: Exception) { AppLog.e("mic", "failed to start", e); update("mic failed: ${e.message}"); stopSelf(); return } mic = m if (cameraWanted) { - val cam = CameraSource(this, this) { gray -> + val reader = if (captionsWanted) runCatching { CaptionSource { words -> eng.onCaptions(words) } }.onFailure { AppLog.e("captions", "text recogniser failed to start", it) }.getOrNull() else null + captionReader = reader + val cam = CameraSource(this, this) { gray, _ -> // 2 fps, upright, luma only val ts = mic?.mediaTime ?: 0.0 eng.onFrame(gray, ts) + reader?.feed(gray, ts) finder?.let { f -> f.feed(gray) val elapsed = (System.currentTimeMillis() - finderStartedAt) / 1000 @@ -158,13 +285,59 @@ class AdHushService : Service(), LifecycleOwner { } } camera = cam + cam.setZoom(settings.cameraZoom) cam.start { msg -> main.post { update(msg) } } } - val eye = if (cameraWanted) (if (logo != null) " + camera (logo)" else " + camera (not set up)") else "" - update("listening (${m.sourceName}) via ${settings.control}$eye") + val noun = if (watchTicker) "ticker" else "bug" + val eye = if (logoWanted) (if (logo != null) " + camera ($noun)" else " + camera ($noun not set up)") else "" + val cc = if (captionsWanted) " + captions" else "" + val ear = if (speechWanted) " + speech (${scriptStore?.count() ?: 0} scripts)" else if (settings.speech) " + speech (model not downloaded)" else "" + val ai = (if (cloudWanted) " + Claude" else "") + (if (localJudge != null) " + local AI" else "") + (if (judges.isNotEmpty() && !speechWanted && !captionsWanted) " (AI has no words: turn on speech or captions)" else "") + update("listening (${m.sourceName}) via ${settings.control}$eye$cc$ear$ai") + lastRepeatLearnAt = System.currentTimeMillis() main.postDelayed(ticker, POLL_MS) } + /** + * Test mode while running: the same exchange the app's Test TV does, but + * over the connection the service already holds. The Sharp allows one + * control connection at a time, so a second one from the app would be + * hung up before the login prompt. Runs on `io`, serialised with the + * engine's own commands; every line goes to the app's Test card. + */ + private fun runTest() { + fun say(line: String) { AppLog.i("test", line); sendBroadcast(Intent(BROADCAST_TEST).setPackage(packageName).putExtra("line", line)) } + val ctl = controller ?: run { say("not running"); return } + val t = transport + try { + when { + ctl is StepVolumeController -> { + say("testing infrared through the running service: volume down ×3, then up ×3") + ctl.mute(); Thread.sleep(1500); ctl.unmute() + say("sent. Did the volume bar move down and back up?") + } + t is io.adhush.core.AquosTransport -> { + say("testing through the running connection (${settings.control}) …") + fun setTrace(f: ((String) -> Unit)?) { when (t) { is SocketTransport -> t.trace = f; is SerialTransport -> t.trace = f } } + setTrace { line -> say(" $line") } + try { + val client = SharpIpClient(t) + fun confirmed(ok: Boolean) = if (ok) "OK" else "sent, the set said nothing — did it happen?" + val vol = client.queryVolume(); say("VOLM? → " + (vol?.toString() ?: "no answer: the app will use your Normal volume")) + val mute = client.queryMute(); say("MUTE? → " + (mute?.let { if (it) "muted" else "not muted" } ?: "no answer")) + val m1 = client.muteOn(); Thread.sleep(1500); val m2 = client.muteOff(); say("MUTE1 → ${confirmed(m1)}; MUTE2 → ${confirmed(m2)}") + val back = vol ?: settings.normalVolume + val d1 = client.setVolume(settings.duckLevel); Thread.sleep(1500); val d2 = client.setVolume(back) + say("VOLM ${settings.duckLevel} → ${confirmed(d1)}; VOLM $back → ${confirmed(d2)} (ducking is what the app does)") + say("✓ done — if the sound dipped twice, the TV path works") + } finally { setTrace(null) } + } + else -> say("nothing to test on this connection") + } + } catch (e: ControlError) { say("✗ FAILED: ${e.message}") } + catch (e: Exception) { AppLog.e("test", "test failed", e); say("✗ FAILED: ${e.message}") } + } + /** Numbers only — the survey never stores audio. Written off the mic thread, then handed to the app. */ private fun finishSurvey(done: RoomSurvey) { survey = null @@ -181,14 +354,14 @@ class AdHushService : Service(), LifecycleOwner { /** The one button's result: save the template and restart with the logo detector in the loop. */ private fun finishSetup(f: LogoFinder) { finder = null - val t = f.result() + val t = if (settings.cameraTarget == "ticker") f.bandResult() else f.result() main.post { if (t == null) { update("no logo found — was a show on, and is the whole screen in view? (screen seen ${f.frames}/${f.frames + f.screenMisses})") sendBroadcast(Intent(BROADCAST_STATUS).setPackage(packageName).putExtra("text", lastText)) return@post } - runCatching { t.save(File(filesDir, LOGO_FILE)) } + runCatching { t.save(File(filesDir, if (settings.cameraTarget == "ticker") TICKER_FILE else LOGO_FILE)) } update("logo found ${t.roi.corner} (stability ${"%.2f".format(java.util.Locale.US, t.stability)}) — restarting with the camera watching") restart() } @@ -196,9 +369,14 @@ class AdHushService : Service(), LifecycleOwner { /** Tear the engine, mic and camera down and start again with the current settings. */ private fun restart() { + mic?.stop(); mic = null // the mic feeds the speech engine: stop the feeder first camera?.stop(); camera = null - mic?.stop(); mic = null + speech?.close(); speech = null + captionReader?.close(); captionReader = null + cloudJudge?.close(); cloudJudge = null + localJudge?.close(); localJudge = null main.removeCallbacks(ticker) + running = null engine?.close(); engine = null controller?.let { runCatching { it.close() } }; controller = null (transport as? AutoCloseable)?.close(); transport = null @@ -209,7 +387,32 @@ class AdHushService : Service(), LifecycleOwner { /** Every few seconds: follow the remote, and treat a dead mic while ducked as a reason to restore. */ private fun tick() { + // Every ten minutes, look for commercials that repeated in what was heard. + if (speech != null && System.currentTimeMillis() - lastRepeatLearnAt > REPEAT_LEARN_MS) { + lastRepeatLearnAt = System.currentTimeMillis() + io.execute { val n = engine?.learnScriptsFromTranscript() ?: 0; if (n > 0) main.post { update("learned $n new script(s) from repetition · ${scripts?.count()} total") } } + } survey?.let { update("surveying the room: ${clock(it.elapsedS)} / ${clock(SURVEY_S)} · ${lastStatus?.let(::describe) ?: "listening"}") } + stream?.let { st -> + val pictureBlack = (badgeReader?.blackS ?: 0.0) >= BLOCKED_S + when { + System.currentTimeMillis() - st.lastBlockAt > MIC_DEAD_MS -> update("playback capture stopped delivering audio — Stop and start stream learning again") + st.mediaTime >= BLOCKED_S && st.silentS >= BLOCKED_S -> { + // Digital silence from the first second: the player opted out of capture (Android 10+ players do unless they say otherwise). + // A black picture as well is the protected-window flag — the "content hidden from screenshare" message. + val what = if (pictureBlack) "this player blocks capture: nothing is heard and the picture is blacked out (protected content)" else "this player blocks capture: nothing is heard" + val fix = "play the channel's website in Chrome instead, or play it out loud next to the phone and use Start (the microphone)" + if (!blockedWarned) { blockedWarned = true; AppLog.w("stream", "$what — $fix") } + update("⚠ $what — $fix") + } + pictureBlack -> { + if (!blockedWarned) { blockedWarned = true; AppLog.w("stream", "the picture is blacked out (protected content): the audio is heard, the AD badge cannot be read") } + lastStatus?.let { update(describeStream(it) + " · ⚠ picture blacked out: the AD badge cannot be read (protected content)") } + } + else -> lastStatus?.let { update(describeStream(it)) } + } + return + } val ctl = controller ?: return val m = mic ?: return io.execute { @@ -225,18 +428,139 @@ class AdHushService : Service(), LifecycleOwner { } /** The engine runs on media time (seconds of audio delivered), not the wall clock. */ - private fun now(): Double = mic?.mediaTime ?: 0.0 + private fun now(): Double = mic?.mediaTime ?: stream?.mediaTime ?: 0.0 + + private fun makeJudges(scriptStore: FileScriptStore?, cloudWanted: Boolean, localWanted: Boolean): List { + val judgeCfg = JudgeConfig(tieBreaker = settings.judgeMode != "always") + val judges = ArrayList() + if (cloudWanted) { + val cj = ClaudeJudge(settings.claudeKey, settings.claudeModel); cloudJudge = cj + judges.add(JudgeDetector("judge_claude", judgeCfg, cj, { r -> think.execute(r) }, scriptStore, settings.channel)) + } + if (localWanted) { + LocalJudge.APP_CONTEXT = applicationContext + try { + val lj = LocalJudge(LocalJudge.modelFile(this)); localJudge = lj + judges.add(JudgeDetector("judge_local", judgeCfg, lj, { r -> think.execute(r) }, scriptStore, settings.channel)) + } catch (t: Throwable) { AppLog.e("judge", "local model failed to load", t); update("local AI failed to load: ${t.message} (see the error log)") } + } + return judges + } + + /** The speech model takes seconds to load: never on the main thread, where it would stall the service start. */ + private fun startSpeech(eng: Engine, scriptStore: FileScriptStore?) { + io.execute { + try { + val sp = SpeechSource(SpeechSource.modelDir(this)) { words -> eng.onWords(words) } + speech = sp + AppLog.i("speech", "recogniser ready") + main.post { update("speech recogniser ready · ${scriptStore?.count() ?: 0} scripts") } + } catch (t: Throwable) { + AppLog.e("speech", "recogniser failed to start", t) + main.post { update("speech engine failed: ${t.message} (see the error log)") } + } + } + } + + /** No TV in stream mode: the engine's ducks are bookkeeping, so a break is learned exactly as at the set. */ + private class VirtualController : MuteController { + private var muted = false + override fun mute() { muted = true } + override fun unmute() { muted = false } + override fun state() = muted + override fun close() {} + } + + /** + * Stream learning (ADR 0018): hear the phone's own playback through + * Android's playback capture and learn every break into the same memory + * the TV mode uses. The camera and the break clock stay off (the stream + * is delayed); fingerprints are always on, speech and the judges as set. + */ + private fun startStream(intent: Intent) { + if (Build.VERSION.SDK_INT < 29) { update("stream learning needs Android 10 or newer"); stopSelf(); return } + if (engine != null) { update("stop AdHush first, then start stream learning"); return } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { update("microphone permission missing"); stopSelf(); return } + val code = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED) + @Suppress("DEPRECATION") + val data: Intent? = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) else intent.getParcelableExtra(EXTRA_RESULT_DATA) + if (code != Activity.RESULT_OK || data == null) { update("screen capture was not allowed — stream learning needs it to hear the player"); stopSelf(); return } + val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val mp = try { mpm.getMediaProjection(code, data) } catch (e: Exception) { AppLog.e("stream", "projection", e); null } + if (mp == null) { update("could not start playback capture"); stopSelf(); return } + mp.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { main.post { if (stream != null) { update("stream capture ended by Android — stream learning stopped"); stopSelf() } } } + }, main) + projection = mp + val store = FileFingerprintStore(File(filesDir, ADS_FILE)); streamStore = store + val speechWanted = settings.speech && SpeechSource.isInstalled(this) + val cloudWanted = settings.judgeCloud && settings.claudeKey.isNotBlank() + val localWanted = settings.judgeLocal && LocalJudge.isInstalled(this) + val scriptStore = FileScriptStore(File(filesDir, SCRIPTS_FILE)).also { scripts = it } + val transcript = if (speechWanted) TranscriptDetector(scriptStore) else null + val judges = makeJudges(scriptStore, cloudWanted, localWanted) + val jingle = if (settings.jingles) JingleDetector(FileJingleStore(File(filesDir, JINGLES_FILE))) else null + val badge = if (settings.badge) BadgeDetector() else null + val eng = Assembly.engine(VirtualController(), store, transcript = transcript, judges = judges, + silence = settings.silence, loudness = settings.loudness, fingerprints = true, jingle = jingle, badge = badge) + eng.addListener { s -> lastStatus = s; main.post { update(describeStream(s)) } } + engine = eng + streamAdsAtStart = store.count(); streamScriptsAtStart = scriptStore.count() + running = RunningInfo( + silence = settings.silence, loudness = settings.loudness, fingerprints = true, + logo = false, logoNotSetUp = false, speech = speechWanted, speechNoModel = settings.speech && !speechWanted, + captions = false, control = "none", cloud = cloudWanted, local = localWanted, + judgesDeaf = judges.isNotEmpty() && !speechWanted, stream = true, jingle = jingle != null, + ) + if (speechWanted) startSpeech(eng, scriptStore) + val s = StreamSource(mp) { block -> eng.onAudio(block); speech?.feed(block) } + try { s.start() } catch (e: Exception) { AppLog.e("stream", "capture failed to start", e); update("playback capture failed: ${e.message}"); stopSelf(); return } + stream = s + blockedWarned = false + if (badge != null) { + try { badgeReader = BadgeReader(mp, this, { ts, text -> eng.onBadgeText(ts, text) }, { stream?.mediaTime ?: 0.0 }).also { it.start() } } + catch (e: Exception) { AppLog.w("badge", "screen reader failed to start: ${e.message}") } + } + main.postDelayed(ticker, POLL_MS) + AppLog.i("stream", "learning from the phone's playback") + update("STREAM LEARNING · play the channel's live stream in Chrome and leave it playing") + } + + private fun describeStream(s: Status): String { + val t = stream?.mediaTime ?: 0.0 + val breaks = (streamStore?.count() ?: 0) - streamAdsAtStart + val newScripts = (scripts?.count() ?: 0) - streamScriptsAtStart + val state = if (s.teaching) "teaching — press Show's back when the show returns" else if (s.muted) "commercial (learning)" else "show" + val ai = s.judges.entries.joinToString("") { (k, v) -> " · ${if (k == "judge_claude") "Claude" else "local AI"}: $v" } + val bdg = s.badge?.let { " · badge: $it" } ?: "" + val jng = s.jingles?.let { " · jingles: $it" } ?: "" + return "STREAM LEARNING · ${clock(t)} listened · $state$bdg$ai$jng · +$breaks breaks · +$newScripts scripts" + } override fun onDestroy() { + AppLog.i("service", "destroyed") + emergencyRestore = null + mic?.stop(); mic = null // before the speech engine, which it feeds + stream?.stop(); stream = null + badgeReader?.close(); badgeReader = null + projection?.stop(); projection = null + streamStore = null camera?.stop(); camera = null + speech?.close(); speech = null + captionReader?.close(); captionReader = null + cloudJudge?.close(); cloudJudge = null + localJudge?.close(); localJudge = null + think.shutdown() main.removeCallbacks(ticker) - mic?.stop(); mic = null + running = null val ctl = controller engine?.close(); engine = null if (ctl != null) runCatching { ctl.close() } // restore if ducked transport?.close(); transport = null io.shutdown() registry.currentState = Lifecycle.State.DESTROYED + lastText = "stopped" + sendBroadcast(Intent(BROADCAST_STATUS).setPackage(packageName).putExtra("text", "stopped").putExtra("running", false)) super.onDestroy() } @@ -244,19 +568,59 @@ class AdHushService : Service(), LifecycleOwner { // -- notification --------------------------------------------------------- + /** A remote-control key through the live connection (ADR 0017); the Sharp allows one, so it must be this one while running. */ + /** The buttons want to know whether what they asked for went through (0.28.3). */ + private fun report(action: String, ok: Boolean, intent: Intent) { + sendBroadcast(Intent(BROADCAST_ACTION).setPackage(packageName).putExtra("action", action).putExtra("ok", ok) + .putExtra(EXTRA_SECONDS, intent.getIntExtra(EXTRA_SECONDS, 0)).putExtra(EXTRA_KEY, intent.getStringExtra(EXTRA_KEY))) + } + + private fun pressKey(name: String): Boolean { + val key = RemoteKey.of(name) ?: return false + val c = controller + try { + val d = keyDriver + val sent = when { + c is SharpController -> c.client.press(key) + d is RokuEcp -> d.press(key) + d is SamsungTizen -> d.press(key) + d is VizioSmartCast -> d.press(key) + c is LevelController && c.device is AndroidTvRemote -> (c.device as AndroidTvRemote).press(key) + c is LevelController && c.device is HisenseVidaa -> (c.device as HisenseVidaa).press(key) + c is LevelController && c.device is PhilipsJointSpace -> (c.device as PhilipsJointSpace).press(key) + c is LevelController && key == RemoteKey.MUTE -> c.device.setMute(!c.ducked) + else -> { main.post { update("this connection (${settings.control}) has no remote keys beyond volume and mute") }; return false } + } + if (!sent) main.post { update("the set did not accept ${key.label}") } + return sent + } catch (e: ControlError) { main.post { update("remote ${key.label} failed: ${e.message}") }; return false } + } + private fun describe(s: Status): String { + if (s.timedS > 0.0) return "DUCKED — ${clock(s.timedS)} left (manual; +30 s on the notification) · ${s.adsLearned} learned" if (s.teaching) return "TEACHING — ducked; press Show's back when the show returns · ${s.adsLearned} learned" - val state = if (s.override != CoreOverride.AUTO) "override: ${s.override.wire}" else if (s.muted) "DUCKED — ad" else s.state.wire.uppercase() - return "$state · ${"%.2f".format(s.confidence)} · ${s.adsLearned} learned" + val left = s.breakLeftS // local: no smart cast on a property from another module + if (s.muted && s.override == CoreOverride.AUTO && left != null) { + val cam = s.camera?.let { " · camera: $it" } ?: "" + return "DUCKED — ad · about ${clock(left)} left$cam · ${s.adsLearned} learned" + } + val state = if (s.override != CoreOverride.AUTO) "override: ${s.override.wire}" else if (s.muted) "DUCKED — ad" else if (s.quietS > 0.0) "SHOW (you said not an ad; ${s.quietS.toInt()} s of quiet)" else if (s.state == AdState.PROGRAM) "SHOW" else s.state.wire.uppercase() + val cam = s.camera?.let { " · camera: $it" } ?: "" + val ai = s.judges.entries.joinToString("") { (k, v) -> " · ${if (k == "judge_claude") "Claude" else "local AI"}: $v" } + val clk = s.clock?.let { " · clock $it" } ?: "" + val jng = s.jingles?.let { if (it.startsWith("learning")) "" else " · jingles: $it" } ?: "" + return "$state · ${"%.2f".format(s.confidence)}$cam$ai$clk$jng · ${s.adsLearned} learned" } private fun refresh() { lastStatus?.let { update(describe(it)) } } private fun update(text: String) { lastText = text + if (text != lastLogged) { AppLog.i("status", text); lastLogged = text } // the same line every five seconds is noise in the log (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIF_ID, buildNotification(text)) - sendBroadcast(Intent(BROADCAST_STATUS).setPackage(packageName).putExtra("text", text)) + sendBroadcast(Intent(BROADCAST_STATUS).setPackage(packageName).putExtra("text", text).putExtra("running", engine != null).putExtra("ducked", lastStatus?.muted == true).putExtra("teaching", lastStatus?.teaching == true)) } + private var lastLogged = "" private fun action(code: Int, act: String, label: String): NotificationCompat.Action { val pi = PendingIntent.getService(this, code, Intent(this, AdHushService::class.java).setAction(act), @@ -277,12 +641,15 @@ class AdHushService : Service(), LifecycleOwner { .addAction(action(2, ACTION_IS_AD, getString(R.string.action_is_ad))) .addAction(action(4, ACTION_SHOW_BACK, getString(R.string.action_show_back))) .addAction(action(1, ACTION_NOT_AD, getString(R.string.action_not_ad))) + .addAction(action(5, ACTION_DUCK_MORE, "+30 s")) .build() } - private fun startForegroundWithType(n: Notification) { - val cameraOk = settings.camera && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or (if (cameraOk && Build.VERSION.SDK_INT >= 30) ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA else 0) + private fun startForegroundWithType(n: Notification, mediaProjection: Boolean = false) { + val cameraOk = (settings.camera || settings.captions) && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + // Stream learning declares the media-projection type: Android 14 refuses playback capture without it. + val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or (if (cameraOk && Build.VERSION.SDK_INT >= 30) ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA else 0) or + (if (mediaProjection && Build.VERSION.SDK_INT >= 29) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0) if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIF_ID, n, type) else startForeground(NOTIF_ID, n) } @@ -300,7 +667,28 @@ class AdHushService : Service(), LifecycleOwner { override fun close() { /* the service closes the real controller on its own thread */ } } + /** Which methods are actually running, for the app's indicators. */ + data class RunningInfo( + val silence: Boolean, val loudness: Boolean, val fingerprints: Boolean, + val logo: Boolean, val logoNotSetUp: Boolean, val speech: Boolean, val speechNoModel: Boolean, + val captions: Boolean, val control: String, + val cloud: Boolean = false, val cloudNoKey: Boolean = false, + val local: Boolean = false, val localNoModel: Boolean = false, + /** An AI judge is on but neither speech nor captions feed it words. */ + val judgesDeaf: Boolean = false, + val clock: Boolean = false, + /** Stream learning: hearing the phone's own playback, no TV (ADR 0018). */ + val stream: Boolean = false, + val jingle: Boolean = false, + ) + companion object { + /** Set while a controller exists: the crash handler restores the volume through it before Android reports. */ + @Volatile var emergencyRestore: (() -> Unit)? = null + /** Non-null while the engine runs (same process as the app): what is on, for the indicators. */ + @Volatile var running: RunningInfo? = null + /** The last status line, for an app screen that opens while the service runs. */ + @Volatile var lastText = "stopped" const val CHANNEL = "adhush" const val NOTIF_ID = 1 const val ACTION_NOT_AD = "io.adhush.android.NOT_AD" @@ -309,7 +697,28 @@ class AdHushService : Service(), LifecycleOwner { const val ACTION_STOP = "io.adhush.android.STOP" const val ACTION_SHOW_BACK = "io.adhush.android.SHOW_BACK" const val ACTION_CAMERA_SETUP = "io.adhush.android.CAMERA_SETUP" + const val ACTION_LEARN_SCRIPTS = "io.adhush.android.LEARN_SCRIPTS" + const val ACTION_TEST = "io.adhush.android.TEST" + const val ACTION_DUCK_FOR = "io.adhush.android.DUCK_FOR" + const val EXTRA_SECONDS = "seconds" + const val ACTION_KEY = "io.adhush.android.KEY" + const val EXTRA_KEY = "key" + const val ADS_FILE = "ads.tsv" + /** Seconds of digital silence, or of a black picture, before a player is called blocked (0.28.2). */ + const val BLOCKED_S = 8.0 + const val CLOCK_FILE = "clock.tsv" + const val ACTION_STREAM_START = "io.adhush.android.STREAM_START" + const val EXTRA_RESULT_CODE = "result_code" + const val EXTRA_RESULT_DATA = "result_data" + const val ACTION_DUCK_MORE = "io.adhush.android.DUCK_MORE" + const val JINGLES_FILE = "jingles.tsv" + const val BROADCAST_TEST = "io.adhush.android.TEST_LINE" + /** One per control action: "action", "ok", and the seconds or key it carried (0.28.3). */ + const val BROADCAST_ACTION = "io.adhush.android.ACTION_DONE" + const val SCRIPTS_FILE = "scripts.tsv" + const val REPEAT_LEARN_MS = 10 * 60 * 1000L const val LOGO_FILE = "logo.tsv" + const val TICKER_FILE = "ticker.tsv" const val SETUP_S = 45L const val ACTION_SURVEY = "io.adhush.android.SURVEY" const val BROADCAST_STATUS = "io.adhush.android.STATUS" diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/AppLog.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/AppLog.kt new file mode 100644 index 0000000..a3ad4a2 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/AppLog.kt @@ -0,0 +1,54 @@ +package io.adhush.android + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The error log: a rolling text file in app-private storage that every part + * of the app writes to, and that the user can share. Uncaught exceptions + * land here with their stack trace before Android is told about them, so a + * crash is a line in a file rather than an app that "disappeared". + */ +object AppLog { + private const val MAX_BYTES = 512 * 1024L + private var file: File? = null + private val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + + @Synchronized fun init(context: Context) { + val dir = File(context.filesDir, "logs"); dir.mkdirs() + file = File(dir, "adhush.log") + } + + fun file(context: Context): File { if (file == null) init(context); return file!! } + + fun i(tag: String, msg: String) = write("I", tag, msg, null) + fun w(tag: String, msg: String, t: Throwable? = null) = write("W", tag, msg, t) + fun e(tag: String, msg: String, t: Throwable? = null) = write("E", tag, msg, t) + + @Synchronized private fun write(level: String, tag: String, msg: String, t: Throwable?) { + when (level) { "E" -> Log.e(tag, msg, t); "W" -> Log.w(tag, msg, t); else -> Log.i(tag, msg) } + val f = file ?: return + try { + if (f.length() > MAX_BYTES) { val old = File(f.path + ".1"); old.delete(); f.renameTo(old) } + f.appendText(buildString { + append(stamp.format(Date())).append(' ').append(level).append('/').append(tag).append(": ").append(msg).append('\n') + if (t != null) { val sw = StringWriter(); t.printStackTrace(PrintWriter(sw)); append(sw.toString()) } + }) + } catch (_: Exception) {} + } + + /** The last [lines] lines, newest last. */ + @Synchronized fun tail(lines: Int = 200): String { + val f = file ?: return "" + if (!f.isFile) return "" + return try { f.readLines().takeLast(lines).joinToString("\n") } catch (_: Exception) { "" } + } + + @Synchronized fun clear() { file?.let { it.delete(); File(it.path + ".1").delete() } } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/BadgeReader.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/BadgeReader.kt new file mode 100644 index 0000000..f4ace60 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/BadgeReader.kt @@ -0,0 +1,111 @@ +package io.adhush.android + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.PixelFormat +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.ImageReader +import android.media.projection.MediaProjection +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import androidx.annotation.RequiresApi +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Reads the corners of the phone's own screen during stream learning (ADR + * 0020): a small virtual display mirrors the screen into an ImageReader, and + * once a second the four corner strips are stacked into one small bitmap and + * handed to the caption text recogniser. Whatever it reads goes to the + * engine's badge detector, which decides whether it says "AD". + */ +@RequiresApi(Build.VERSION_CODES.Q) +class BadgeReader( + private val projection: MediaProjection, + context: Context, + private val onText: (Double, String) -> Unit, + private val clock: () -> Double, +) : AutoCloseable { + private val dpi = context.resources.displayMetrics.densityDpi + private val thread = HandlerThread("adhush-badge").apply { start() } + private val handler = Handler(thread.looper) + private val reader: ImageReader = ImageReader.newInstance(W, H, PixelFormat.RGBA_8888, 2) + private var display: VirtualDisplay? = null + private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + private val busy = AtomicBoolean(false) + @Volatile var frames = 0L + private set + @Volatile var lastText = "" + private set + /** Consecutive frames that were black all over: a player with a protected window mirrors as nothing (0.28.2). */ + @Volatile var blackFrames = 0 + private set + val blackS: Double get() = blackFrames * INTERVAL_MS / 1000.0 + + private val tick = object : Runnable { + override fun run() { grab(); handler.postDelayed(this, INTERVAL_MS) } + } + + fun start() { + display = projection.createVirtualDisplay("adhush-badge", W, H, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader.surface, null, handler) + handler.postDelayed(tick, INTERVAL_MS) + } + + private fun grab() { + val image = try { reader.acquireLatestImage() } catch (e: Exception) { null } ?: return + try { + if (!busy.compareAndSet(false, true)) return + val plane = image.planes[0] + val rowStride = plane.rowStride; val pixelStride = plane.pixelStride + val padded = Bitmap.createBitmap(rowStride / pixelStride, H, Bitmap.Config.ARGB_8888) + padded.copyPixelsFromBuffer(plane.buffer) + val full = if (padded.width != W) Bitmap.createBitmap(padded, 0, 0, W, H) else padded + frames++ + blackFrames = if (isBlack(full)) blackFrames + 1 else 0 + // The four corners, stacked: badges live there; the middle of the picture never has one. + val cw = W * 3 / 10; val ch = H * 15 / 100 + val stack = Bitmap.createBitmap(cw, ch * 4, Bitmap.Config.ARGB_8888) + val canvas = Canvas(stack) + val corners = listOf(Pair(0, 0), Pair(W - cw, 0), Pair(0, H - ch), Pair(W - cw, H - ch)) + for ((i, c) in corners.withIndex()) canvas.drawBitmap(Bitmap.createBitmap(full, c.first, c.second, cw, ch), 0f, (i * ch).toFloat(), null) + val big = Bitmap.createScaledBitmap(stack, cw * 2, ch * 8, true) + recognizer.process(InputImage.fromBitmap(big, 0)) + .addOnSuccessListener { text -> + val line = text.textBlocks.flatMap { b -> b.lines.map { it.text } }.joinToString(" ").trim() + lastText = line + onText(clock(), line) + } + .addOnCompleteListener { busy.set(false) } + } catch (e: Exception) { + AppLog.w("badge", "frame skipped: ${e.message}"); busy.set(false) + } finally { image.close() } + } + + /** Sixty-four pixels spread over the frame, none brighter than a dark grey: the picture is blacked out, not just dim. */ + private fun isBlack(bitmap: Bitmap): Boolean { + for (y in 0 until 8) for (x in 0 until 8) { + val c = bitmap.getPixel((x * 2 + 1) * bitmap.width / 16, (y * 2 + 1) * bitmap.height / 16) + if ((c shr 16 and 0xFF) > 16 || (c shr 8 and 0xFF) > 16 || (c and 0xFF) > 16) return false + } + return true + } + + override fun close() { + handler.removeCallbacks(tick) + runCatching { display?.release() }; display = null + runCatching { reader.close() } + runCatching { recognizer.close() } + thread.quitSafely() + } + + companion object { + const val W = 640 + const val H = 360 + const val INTERVAL_MS = 1000L + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSetupActivity.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSetupActivity.kt new file mode 100644 index 0000000..919b140 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSetupActivity.kt @@ -0,0 +1,250 @@ +package io.adhush.android + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Bundle +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.slider.Slider +import io.adhush.core.Box +import io.adhush.core.Gray +import io.adhush.core.HANDHELD_LOGO_CONFIG +import io.adhush.core.LogoAbsenceDetector +import io.adhush.core.LogoFinder +import io.adhush.core.LogoTemplate +import io.adhush.core.Roi +import io.adhush.core.Vision +import java.io.File +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +/** + * See what the camera sees, in colour, and choose the bug by eye. The live + * picture shows the found screen (green, red when it runs off the edge) and + * the logo box (yellow); a magnified colour crop of the box sits below it + * with a live match score. Pinch the picture, or move the slider, to zoom the + * camera in until the bug is big but the whole TV still fits. "Watch 45 s" + * runs the automatic finder; dragging a box on the picture overrides its + * choice; "Save & start" writes the template and the zoom and starts the + * service. AdHush is stopped while this screen is open, because two things + * cannot hold the camera at once. + */ +class CameraSetupActivity : AppCompatActivity() { + private lateinit var picture: LogoOverlayView + private lateinit var status: TextView + private lateinit var hint: TextView + private lateinit var crop: ImageView + private lateinit var saveBtn: MaterialButton + private lateinit var zoom: Slider + private var camera: CameraSource? = null + private var finder = LogoFinder() + @Volatile private var watching = false + private var watchStartedAt = 0L + @Volatile private var roi: Roi? = null + @Volatile private var template: LogoTemplate? = null + @Volatile private var detector: LogoAbsenceDetector? = null + private var ts = 0.0 + private var lastPartial = false + /** Watch the lower-third band (a news ticker) instead of the corner bug. */ + private var ticker = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ContextCompat.startForegroundService(this, Intent(this, AdHushService::class.java).setAction(AdHushService.ACTION_STOP)) + setContentView(R.layout.activity_camera_setup) + status = findViewById(R.id.setupStatus) + hint = findViewById(R.id.setupHint) + picture = findViewById(R.id.setupPicture) + crop = findViewById(R.id.setupCrop) + zoom = findViewById(R.id.setupZoom) + saveBtn = findViewById(R.id.setupSave) + val watch = findViewById(R.id.setupWatch) + val cancel = findViewById(R.id.setupCancel) + findViewById(R.id.setupHelp).setOnClickListener { Help.show(this, if (ticker) Help.TICKER else Help.BUG) } + val settings = Settings(this) + ticker = settings.cameraTarget == "ticker" + if (ticker) findViewById(R.id.setupTitle).text = "Camera setup — find the news ticker" + zoom.value = settings.cameraZoom.coerceIn(1f, 4f) + zoom.addOnChangeListener { _, v, _ -> camera?.setZoom(v); zoomLabel(v) } + zoomLabel(zoom.value) + status.text = "starting the camera …" + + watch.setOnClickListener { + finder = LogoFinder(); watching = true; watchStartedAt = System.currentTimeMillis() + status.text = "watching — keep a show on, whole TV in view, hold still" + } + saveBtn.setOnClickListener { + val t = template ?: return@setOnClickListener + runCatching { t.save(File(filesDir, if (ticker) AdHushService.TICKER_FILE else AdHushService.LOGO_FILE)) } + settings.camera = true + settings.cameraZoom = zoom.value + AppLog.i("camera", "logo template saved: ${t.roi.corner} stability ${"%.2f".format(Locale.US, t.stability)} zoom ${"%.1f".format(Locale.US, zoom.value)}") + ContextCompat.startForegroundService(this, Intent(this, AdHushService::class.java)) + finish() + } + cancel.setOnClickListener { finish() } + picture.onBox = { boxInFrame -> chooseBox(boxInFrame) } + picture.onPinch = { factor -> + val v = (zoom.value * factor).coerceIn(zoom.valueFrom, zoom.valueTo) + zoom.value = v + } + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 7) + else startCamera() + } + + private fun zoomLabel(v: Float) { findViewById(R.id.setupZoomLabel).text = "Zoom ${"%.1f".format(Locale.US, v)}× — make the bug big, keep the whole TV in the picture" } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 7 && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) startCamera() else status.text = "camera permission is needed" + } + + private fun startCamera() { + val cam = CameraSource(this, this, intervalMs = 250, wantColor = true) { gray, color -> onFrame(gray, color) } + camera = cam + cam.setZoom(zoom.value) + cam.start { msg -> runOnUiThread { status.text = msg } } + // The lens's real range once it is open. + picture.postDelayed({ camera?.let { c -> val m = c.maxZoom; if (m > 1f) { zoom.valueTo = min(8f, max(2f, m)); if (zoom.value > zoom.valueTo) zoom.value = zoom.valueTo } } }, 1500) + } + + /** The user dragged a box on the picture: turn it into a normalised screen region and re-verify live. */ + private fun chooseBox(boxInFrame: Box) { + val screen = detector?.lastScreen ?: finder.lastScreen ?: return + val x = ((boxInFrame.x0 - screen.x0).toDouble() / screen.w).coerceIn(0.0, 0.99) + val y = ((boxInFrame.y0 - screen.y0).toDouble() / screen.h).coerceIn(0.0, 0.99) + val w = (boxInFrame.w.toDouble() / screen.w).coerceIn(0.02, 1.0 - x) + val h = (boxInFrame.h.toDouble() / screen.h).coerceIn(0.02, 1.0 - y) + roi = Roi(x, y, w, h) + adoptTemplate(finder.templateFor(Roi(x, y, w, h)), "your box") + } + + private fun adoptTemplate(t: LogoTemplate?, how: String) { + template = t + if (t == null) { runOnUiThread { status.text = "press Watch first, then draw or accept a box"; saveBtn.isEnabled = false }; return } + val d = if (ticker) LogoAbsenceDetector(HANDHELD_LOGO_CONFIG.copy(searchPx = 4), t, name = "ticker_absence", noun = "ticker") else LogoAbsenceDetector(HANDHELD_LOGO_CONFIG, t); d.warmup(); detector = d + runOnUiThread { status.text = "$how: ${t.roi.corner}, stability ${"%.2f".format(Locale.US, t.stability)} — watch the score, then Save"; saveBtn.isEnabled = true } + } + + private fun onFrame(gray: Gray, color: ColorFrame?) { + if (watching) { + finder.feed(gray) + val elapsed = (System.currentTimeMillis() - watchStartedAt) / 1000 + if (elapsed >= 45) { + watching = false + val chosen = roi + val auto = if (ticker) finder.bandResult() else finder.result() + adoptTemplate(if (chosen != null) finder.templateFor(chosen) else auto?.also { roi = it.roi }, if (chosen != null) "your box" else "found") + } else runOnUiThread { status.text = "watching $elapsed / 45 s · whole TV seen ${finder.frames} / ${finder.frames + finder.screenMisses + finder.partialFrames}" } + } + val det = detector + var line = "" + if (det != null) { + det.observeFrame(gray, ts); ts += 0.25 + val noun = if (ticker) "ticker" else "bug" + line = if (det.active) "$noun match ${"%.2f".format(Locale.US, det.lastScore)} — ${if (det.programPresent) "SEEN (a show is on)" else if (det.sighted) "GONE (a commercial?)" else "not seen yet"}" else "camera: ${det.describe()}" + } + val screen = det?.lastScreen ?: finder.lastScreen ?: Vision.findScreen(gray) + val partial = screen != null && !Vision.screenComplete(screen, gray.w, gray.h) + val roiBox = det?.lastRoiInFrame ?: roi?.let { r -> screen?.let { b -> Box(b.x0 + (r.x * b.w).toInt(), b.y0 + (r.y * b.h).toInt(), b.x0 + ((r.x + r.w) * b.w).toInt(), b.y0 + ((r.y + r.h) * b.h).toInt()) } } + val cropBmp = roiBox?.let { rb -> + val safe = Box(max(0, rb.x0), max(0, rb.y0), min(gray.w, rb.x1), min(gray.h, rb.y1)) + if (safe.w < 2 || safe.h < 2) null else if (color != null) colorCrop(color, safe) else toBitmap(gray.crop(safe)) + } + val shown = if (color != null) Bitmap.createBitmap(color.argb, color.w, color.h, Bitmap.Config.ARGB_8888) else toBitmap(gray) + runOnUiThread { + picture.update(shown, gray.w, gray.h, screen, roiBox, partial) + if (line.isNotEmpty() && !watching) status.text = line + (template?.let { " · box ${it.roi.corner}" } ?: "") + if (partial != lastPartial) { lastPartial = partial; hint.text = if (partial) "⚠ The TV runs off the edge of the picture — step back or zoom out. Nothing is muted while it looks like this." else getString(R.string.setup_hint) } + if (screen == null && !watching) hint.text = "No lit screen found — point the camera at the TV with a show on." + if (cropBmp != null) crop.setImageBitmap(cropBmp) + } + } + + override fun onDestroy() { camera?.stop(); camera = null; super.onDestroy() } + + private fun colorCrop(c: ColorFrame, b: Box): Bitmap { + val px = IntArray(b.w * b.h) + for (y in 0 until b.h) System.arraycopy(c.argb, (b.y0 + y) * c.w + b.x0, px, y * b.w, b.w) + return Bitmap.createBitmap(px, b.w, b.h, Bitmap.Config.ARGB_8888) + } + + private fun toBitmap(g: Gray): Bitmap { + val px = IntArray(g.w * g.h) { val v = g.px[it].toInt().coerceIn(0, 255); (0xFF shl 24) or (v shl 16) or (v shl 8) or v } + return Bitmap.createBitmap(px, g.w, g.h, Bitmap.Config.ARGB_8888) + } +} + +/** + * The camera picture with the screen (green; red when it runs off the edge) + * and logo (yellow) boxes; drag to draw your own box, pinch to zoom the camera. + */ +class LogoOverlayView(context: android.content.Context, attrs: android.util.AttributeSet? = null) : View(context, attrs) { + private var bitmap: Bitmap? = null + private var frameW = 1; private var frameH = 1 + private var screen: Box? = null + private var roi: Box? = null + private var partial = false + private var drag: RectF? = null + private var scale = 1f; private var offX = 0f; private var offY = 0f + var onBox: ((Box) -> Unit)? = null + var onPinch: ((Float) -> Unit)? = null + private val green = Paint().apply { color = Color.GREEN; style = Paint.Style.STROKE; strokeWidth = 4f } + private val red = Paint().apply { color = Color.RED; style = Paint.Style.STROKE; strokeWidth = 6f } + private val yellow = Paint().apply { color = Color.YELLOW; style = Paint.Style.STROKE; strokeWidth = 4f } + private val white = Paint().apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 3f } + private var pinching = false + private val pinch = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(d: ScaleGestureDetector): Boolean { pinching = true; drag = null; return true } + override fun onScale(d: ScaleGestureDetector): Boolean { onPinch?.invoke(d.scaleFactor); return true } + override fun onScaleEnd(d: ScaleGestureDetector) { pinching = false } + }) + + fun update(b: Bitmap, fw: Int, fh: Int, s: Box?, r: Box?, partialScreen: Boolean) { bitmap = b; frameW = fw; frameH = fh; screen = s; roi = r; partial = partialScreen; invalidate() } + + override fun onDraw(canvas: Canvas) { + val b = bitmap ?: return + scale = min(width.toFloat() / frameW, height.toFloat() / frameH) + offX = (width - frameW * scale) / 2; offY = (height - frameH * scale) / 2 + canvas.drawBitmap(b, null, RectF(offX, offY, offX + frameW * scale, offY + frameH * scale), null) + screen?.let { canvas.drawRect(rect(it), if (partial) red else green) } + roi?.let { canvas.drawRect(rect(it), yellow) } + drag?.let { canvas.drawRect(it, white) } + } + + private fun rect(bx: Box) = RectF(offX + bx.x0 * scale, offY + bx.y0 * scale, offX + bx.x1 * scale, offY + bx.y1 * scale) + + override fun onTouchEvent(e: MotionEvent): Boolean { + pinch.onTouchEvent(e) + if (pinching || e.pointerCount > 1) return true + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { drag = RectF(e.x, e.y, e.x, e.y); return true } + MotionEvent.ACTION_MOVE -> { drag?.let { it.right = e.x; it.bottom = e.y; invalidate() }; return true } + MotionEvent.ACTION_UP -> { + val d = drag ?: return true + drag = null + val x0 = ((min(d.left, d.right) - offX) / scale).toInt(); val x1 = ((max(d.left, d.right) - offX) / scale).toInt() + val y0 = ((min(d.top, d.bottom) - offY) / scale).toInt(); val y1 = ((max(d.top, d.bottom) - offY) / scale).toInt() + if (x1 - x0 >= 6 && y1 - y0 >= 6) onBox?.invoke(Box(x0, y0, x1, y1)) + invalidate(); return true + } + } + return super.onTouchEvent(e) + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSource.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSource.kt index f456ed1..b6cc8e3 100644 --- a/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSource.kt +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/CameraSource.kt @@ -2,6 +2,7 @@ package io.adhush.android import android.content.Context import android.util.Size +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy @@ -11,15 +12,39 @@ import androidx.lifecycle.LifecycleOwner import io.adhush.core.Gray import java.util.concurrent.Executors +/** A colour frame for the setup preview: row-major ARGB, upright like the [Gray] it came with. */ +class ColorFrame(val w: Int, val h: Int, val argb: IntArray) { + fun rotate(degrees: Int): ColorFrame = when (((degrees % 360) + 360) % 360) { + 0 -> this + 90 -> ColorFrame(h, w, IntArray(w * h).also { o -> for (y in 0 until h) for (x in 0 until w) o[x * h + (h - 1 - y)] = argb[y * w + x] }) + 180 -> ColorFrame(w, h, IntArray(w * h).also { o -> for (i in argb.indices) o[argb.size - 1 - i] = argb[i] }) + 270 -> ColorFrame(h, w, IntArray(w * h).also { o -> for (y in 0 until h) for (x in 0 until w) o[(w - 1 - x) * h + y] = argb[y * w + x] }) + else -> throw IllegalArgumentException("rotation must be a multiple of 90: $degrees") + } +} + /** - * The back camera as a trickle of luma frames — two a second at 640 × 480 is - * plenty to see whether a bug is on the screen, and a small fraction of what - * recording video costs. Frames are stamped by the caller with media time so - * the logo detector and the audio detectors share one clock. + * The back camera as a trickle of luma frames — two a second at 1280 × 720 is + * plenty to see whether a bug is on a screen eight feet away, and a small + * fraction of what recording video costs. Frames come out upright (rotated + * by the sensor's rotationDegrees) so the setup screen and the service see + * the same picture. Frames are stamped by the caller with media time so the + * logo detector and the audio detectors share one clock. The setup screen + * also asks for colour, which is only ever shown to a person: every detector + * works on the luma. */ -class CameraSource(private val context: Context, private val owner: LifecycleOwner, private val onFrame: (Gray) -> Unit) { +class CameraSource( + private val context: Context, + private val owner: LifecycleOwner, + private val intervalMs: Long = INTERVAL_MS, + /** Deliver a colour copy alongside the luma (the setup preview); costs a few tens of milliseconds a frame. */ + private val wantColor: Boolean = false, + private val onFrame: (Gray, ColorFrame?) -> Unit, +) { private val executor = Executors.newSingleThreadExecutor() private var provider: ProcessCameraProvider? = null + @Volatile private var camera: Camera? = null + @Volatile private var wantedZoom = 1f @Volatile private var lastAt = 0L @Volatile var frames = 0L private set @@ -31,22 +56,41 @@ class CameraSource(private val context: Context, private val owner: LifecycleOwn val p = future.get(); provider = p @Suppress("DEPRECATION") val analysis = ImageAnalysis.Builder() - .setTargetResolution(Size(640, 480)) + .setTargetResolution(Size(1280, 720)) // an 8 ft living room needs the pixels .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() analysis.setAnalyzer(executor) { image -> try { val now = System.currentTimeMillis() - if (now - lastAt >= INTERVAL_MS) { lastAt = now; frames++; onFrame(toGray(image)) } + if (now - lastAt >= intervalMs) { + lastAt = now; frames++ + val deg = image.imageInfo.rotationDegrees + val color = if (wantColor) toColor(image).rotate(deg) else null + onFrame(toGray(image).rotate(deg), color) + } } finally { image.close() } } p.unbindAll() - p.bindToLifecycle(owner, CameraSelector.DEFAULT_BACK_CAMERA, analysis) + val cam = p.bindToLifecycle(owner, CameraSelector.DEFAULT_BACK_CAMERA, analysis) + camera = cam + applyZoom(wantedZoom) } catch (e: Exception) { onError("camera: ${e.message}") } }, ContextCompat.getMainExecutor(context)) } - fun stop() { runCatching { provider?.unbindAll() }; provider = null; executor.shutdown() } + /** The lens's largest zoom, 1.0 until the camera is open. */ + val maxZoom: Float get() = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f + + /** Zoom in on the set (optical where the phone has it, digital otherwise); the whole TV must still fit. */ + fun setZoom(ratio: Float) { wantedZoom = ratio; applyZoom(ratio) } + + private fun applyZoom(ratio: Float) { + val cam = camera ?: return + val max = cam.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f + runCatching { cam.cameraControl.setZoomRatio(ratio.coerceIn(1f, max)) } + } + + fun stop() { runCatching { provider?.unbindAll() }; provider = null; camera = null; executor.shutdown() } /** The Y plane, honouring the row stride; chroma is irrelevant to edges. */ private fun toGray(image: ImageProxy): Gray { @@ -61,5 +105,27 @@ class CameraSource(private val context: Context, private val owner: LifecycleOwn return Gray(w, h, px) } + /** YUV_420_888 → ARGB, honouring each plane's strides; only for the preview a person looks at. */ + private fun toColor(image: ImageProxy): ColorFrame { + val yP = image.planes[0]; val uP = image.planes[1]; val vP = image.planes[2] + val yb = yP.buffer; val ub = uP.buffer; val vb = vP.buffer + val w = image.width; val h = image.height + val out = IntArray(w * h) + for (y in 0 until h) { + val yRow = y * yP.rowStride; val cRow = (y / 2) + for (x in 0 until w) { + val yy = yb.get(yRow + x * yP.pixelStride).toInt() and 0xFF + val ci = (x / 2) + val u = (ub.get(cRow * uP.rowStride + ci * uP.pixelStride).toInt() and 0xFF) - 128 + val v = (vb.get(cRow * vP.rowStride + ci * vP.pixelStride).toInt() and 0xFF) - 128 + val r = (yy + 1.370705f * v).toInt().coerceIn(0, 255) + val g = (yy - 0.337633f * u - 0.698001f * v).toInt().coerceIn(0, 255) + val b = (yy + 1.732446f * u).toInt().coerceIn(0, 255) + out[y * w + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + } + return ColorFrame(w, h, out) + } + companion object { const val INTERVAL_MS = 500L } } diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/CaptionSource.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/CaptionSource.kt new file mode 100644 index 0000000..1808c6b --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/CaptionSource.kt @@ -0,0 +1,92 @@ +package io.adhush.android + +import android.graphics.Bitmap +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import io.adhush.core.Box +import io.adhush.core.Gray +import io.adhush.core.Vision +import io.adhush.core.Word +import io.adhush.core.normalizeWord +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The sixth method's eyes: reads the closed captions off the TV picture. The + * caption band (the lower part of the found screen) is cut out of each camera + * frame, enlarged, and handed to the on-device text recogniser (ML Kit's + * Latin model, bundled in the APK; nothing leaves the phone). New words — + * captions sit still for a second or two, so the same line comes back many + * times — are stamped with media time and fed to a transcript detector, + * exactly as the microphone's words are. A commercial's captions repeat + * verbatim every airing, so repetition learning and script matching apply + * unchanged; the difference is that captions are never misheard over the + * room's fans. + */ +class CaptionSource(private val onWords: (List) -> Unit) : AutoCloseable { + private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + private val busy = AtomicBoolean(false) + private var lastLine = "" + private var lastAt = 0L + @Volatile var lastText = "" + private set + @Volatile var linesRead = 0L + private set + @Volatile var screenSeen = false + private set + + /** Feed an upright camera frame with its media time; at most one recognition runs at a time. */ + fun feed(frame: Gray, ts: Double) { + val now = System.currentTimeMillis() + if (now - lastAt < INTERVAL_MS || !busy.compareAndSet(false, true)) return + lastAt = now + val screen = Vision.findScreen(frame)?.takeIf { Vision.screenComplete(it, frame.w, frame.h) } + screenSeen = screen != null + if (screen == null) { busy.set(false); return } + // The caption band: the bottom 35 % of the screen, enlarged so eight-foot text is tall enough to read. + val band = Box(screen.x0, screen.y0 + screen.h * 65 / 100, screen.x1, screen.y1) + val crop = frame.crop(band) + val scale = (MIN_BAND_HEIGHT / crop.h.toFloat()).coerceAtLeast(1f) + val bmp = toBitmap(crop) + val big = if (scale > 1f) Bitmap.createScaledBitmap(bmp, (crop.w * scale).toInt(), (crop.h * scale).toInt(), true) else bmp + recognizer.process(InputImage.fromBitmap(big, 0)) + .addOnSuccessListener { text -> + val line = text.textBlocks.flatMap { b -> b.lines.map { it.text } }.joinToString(" ").trim() + lastText = line + if (line.isNotEmpty() && line != lastLine) { + val fresh = newWords(lastLine, line) + lastLine = line + if (fresh.isNotEmpty()) { linesRead++; onWords(fresh.map { Word(ts, it) }) } + } + } + .addOnCompleteListener { busy.set(false) } + } + + /** + * Captions scroll: the new line usually shares its head with the old one + * (the old top row is gone, the new bottom row arrived). Only words that + * were not in the previous reading are new; the rest were already fed. + */ + private fun newWords(previous: String, current: String): List { + val old = previous.split(Regex("\\s+")).map(::normalizeWord).filter { it.isNotEmpty() } + val cur = current.split(Regex("\\s+")).map(::normalizeWord).filter { it.isNotEmpty() } + if (old.isEmpty()) return cur + // Longest suffix of old that is a prefix of cur, or any overlap of at least three words anywhere. + for (k in minOf(old.size, cur.size) downTo 3) if (old.takeLast(k) == cur.take(k)) return cur.drop(k) + val seen = old.toHashSet() + return cur.filter { it !in seen } + } + + private fun toBitmap(g: Gray): Bitmap { + val px = IntArray(g.w * g.h) { val v = g.px[it].toInt().coerceIn(0, 255); (0xFF shl 24) or (v shl 16) or (v shl 8) or v } + return Bitmap.createBitmap(px, g.w, g.h, Bitmap.Config.ARGB_8888) + } + + override fun close() { runCatching { recognizer.close() } } + + companion object { + const val INTERVAL_MS = 1000L + /** The recogniser wants text about 20 px tall; a caption row is a tenth of the band. */ + const val MIN_BAND_HEIGHT = 240f + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/ClaudeJudge.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/ClaudeJudge.kt new file mode 100644 index 0000000..622d493 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/ClaudeJudge.kt @@ -0,0 +1,65 @@ +package io.adhush.android + +import com.anthropic.client.AnthropicClient +import com.anthropic.client.okhttp.AnthropicOkHttpClient +import com.anthropic.errors.AnthropicServiceException +import com.anthropic.errors.RateLimitException +import com.anthropic.models.messages.CacheControlEphemeral +import com.anthropic.models.messages.MessageCreateParams +import com.anthropic.models.messages.TextBlockParam +import io.adhush.core.JudgeDetector +import io.adhush.core.TranscriptJudge +import io.adhush.core.Verdict + +/** + * The seventh method's brain: Claude, over the API. One short question per + * call — the fixed instruction (cached on Anthropic's side, so it is billed + * once) plus the last 40 seconds of words — and a one-line answer. Only + * text ever leaves the phone, never audio, and only while the switch is on. + * Haiku 4.5 is the cheap default; Sonnet 5 and Opus 5 are choices. + */ +class ClaudeJudge(apiKey: String, private val model: String = DEFAULT_MODEL) : TranscriptJudge, AutoCloseable { + private val client: AnthropicClient = AnthropicOkHttpClient.builder().apiKey(apiKey).build() + @Volatile var calls = 0L + private set + @Volatile var inputTokens = 0L + private set + @Volatile var outputTokens = 0L + private set + + override fun judge(transcript: String, channel: String): Verdict? { + val params = MessageCreateParams.builder() + .model(model) + .maxTokens(64L) + .systemOfTextBlockParams(listOf( + TextBlockParam.builder() + .text(JudgeDetector.prompt(channel)) + .cacheControl(CacheControlEphemeral.builder().build()) + .build())) + .addUserMessage("Transcript of the last 40 seconds:\n$transcript") + .build() + val response = try { + client.messages().create(params) + } catch (e: RateLimitException) { + throw IllegalStateException("Claude rate limit — try again in a minute", e) + } catch (e: AnthropicServiceException) { + throw IllegalStateException("Claude API ${e.statusCode()}: ${e.message?.take(80)}", e) + } + calls++ + inputTokens += response.usage().inputTokens() + outputTokens += response.usage().outputTokens() + val text = response.content().mapNotNull { block -> block.text().map { it.text() }.orElse(null) }.joinToString(" ") + return Verdict.parse(text) + } + + override fun close() { runCatching { client.close() } } + + companion object { + const val DEFAULT_MODEL = "claude-haiku-4-5" + val MODELS = linkedMapOf( + "claude-haiku-4-5" to "Haiku 4.5 — cheapest (about 13 ¢ an hour of TV)", + "claude-sonnet-5" to "Sonnet 5 — sharper (about 26 ¢ an hour)", + "claude-opus-5" to "Opus 5 — best judgement (about 65 ¢ an hour)", + ) + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/Downloads.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/Downloads.kt new file mode 100644 index 0000000..9363c96 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/Downloads.kt @@ -0,0 +1,67 @@ +package io.adhush.android + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +/** + * Model downloads that cannot leave a half file behind as if it were whole + * (0.28.4). The bytes go to a `.part` file next to the target; the expected + * size from Content-Length is kept in a `.total` file beside it; a download + * that stops short is an error that says how far it got, and the next press + * resumes from there. Only a `.part` of exactly the expected size is ever + * moved into place. + */ +object Downloads { + /** How far a stopped download got: (bytes so far, bytes expected or null), or null when nothing is pending. */ + fun partial(part: File): Pair? { + if (!part.isFile) return null + val total = File(part.path + ".total").takeIf { it.isFile }?.readText()?.trim()?.toLongOrNull() + return Pair(part.length(), total) + } + + fun describePartial(p: Pair): String { + val (have, total) = p + return if (total != null && total > 0) "${have * 100 / total} % of ${total / 1_000_000} MB" else "${have / 1_000_000} MB" + } + + /** Remove every stopped download under [dir]; returns how many files went. */ + fun deletePartials(dir: File): Int = dir.listFiles { f -> f.name.endsWith(".part") || f.name.endsWith(".total") }?.count { it.delete() } ?: 0 + + /** + * Fetch [url] into [part], resuming what is there; progress is 0..99. + * Throws with a plain message when the server refuses or the bytes stop + * short, leaving the `.part` for the next attempt. + */ + fun fetch(url: String, part: File, onProgress: (Int) -> Unit, connectTimeoutMs: Int = 20_000, readTimeoutMs: Int = 60_000) { + part.parentFile?.mkdirs() + val totalFile = File(part.path + ".total") + val have = if (part.isFile) part.length() else 0L + val conn = URL(url).openConnection() as HttpURLConnection + conn.instanceFollowRedirects = true + conn.connectTimeout = connectTimeoutMs; conn.readTimeout = readTimeoutMs + if (have > 0) conn.setRequestProperty("Range", "bytes=$have-") + val code = conn.responseCode + if (code == 416) { part.delete(); totalFile.delete(); throw IOException("the saved part did not match the file — press again to start over") } + if (code !in 200..299) throw IOException("download failed: HTTP $code") + val resuming = code == 206 + val total = (if (resuming) have else 0L) + conn.contentLengthLong.coerceAtLeast(0L) + if (total > 0) totalFile.writeText(total.toString()) else totalFile.delete() + conn.inputStream.use { inp -> FileOutputStream(part, resuming).use { out -> + val buf = ByteArray(256 * 1024); var got = if (resuming) have else 0L; var n: Int + while (inp.read(buf).also { n = it } > 0) { out.write(buf, 0, n); got += n; if (total > 0) onProgress((got * 100 / total).toInt().coerceIn(0, 99)) } + out.fd.sync() + } } + val size = part.length() + if (total > 0 && size != total) throw IOException("the download stopped at ${size * 100 / total} % (${size / 1_000_000} of ${total / 1_000_000} MB) — press again to resume") + totalFile.delete() + } + + /** Move a complete `.part` into place; never touches a finished [target] unless the part is there to replace it. */ + fun install(part: File, target: File) { + check(part.isFile) { "nothing downloaded" } + if (!part.renameTo(target)) { target.delete(); if (!part.renameTo(target)) throw IOException("could not move the file into place") } + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/Feedback.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/Feedback.kt new file mode 100644 index 0000000..371b0c7 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/Feedback.kt @@ -0,0 +1,82 @@ +package io.adhush.android + +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.widget.Button +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import java.util.WeakHashMap + +/** + * A button changes only when something real happened (0.28.4) — never a + * flex for a tap that went nowhere. Three states: **busy** (blue, disabled, + * its label the job's progress) for as long as a job the button started is + * running, so a second press cannot start it twice; **ok** (green, a tick) + * for a second and a half once what it asked for went through; **fail** + * (red, a cross) when it did not. Then it comes back as it was, or with the + * label the outcome calls for. + */ +object Feedback { + private class Saved(var label: CharSequence, val bg: ColorStateList?, val text: ColorStateList, val stroke: ColorStateList?, val chipBg: ColorStateList?, val enabled: Boolean) + private val saved = WeakHashMap() + private val main = Handler(Looper.getMainLooper()) + private const val HOLD_MS = 1500L + private val GLYPH = Regex("^[✓✗▶■●] ") + + /** The job has started and is still running: the button is taken until [ok] or [fail]. */ + fun busy(b: Button?, label: CharSequence) { + if (b == null) return + remember(b) + main.removeCallbacksAndMessages(b) + paint(b, R.color.adhush_primary) + b.isEnabled = false + b.text = label + } + + /** A busy button's label follows the job (a download's percentage). */ + fun progress(b: Button?, label: CharSequence) { if (b != null && !b.isEnabled) b.text = label } + + fun ok(b: Button?, thenLabel: CharSequence? = null) { if (b != null) flash(b, R.color.adhush_program, "✓", thenLabel) } + fun fail(b: Button?, thenLabel: CharSequence? = null) { if (b != null) flash(b, R.color.adhush_ducked, "✗", thenLabel) } + + private fun remember(b: Button): Saved = saved.getOrPut(b) { Saved(b.text, b.backgroundTintList, b.textColors, (b as? MaterialButton)?.strokeColor, (b as? Chip)?.chipBackgroundColor, b.isEnabled) } + + private fun paint(b: Button, colour: Int) { + val c = ColorStateList.valueOf(ContextCompat.getColor(b.context, colour)) + when (b) { + is Chip -> b.chipBackgroundColor = c + is MaterialButton -> { b.backgroundTintList = c; b.strokeColor = c } + else -> b.backgroundTintList = c + } + b.setTextColor(Color.WHITE) + } + + private fun flash(b: Button, colour: Int, mark: String, thenLabel: CharSequence?) { + val orig = remember(b) + if (thenLabel != null) orig.label = thenLabel + main.removeCallbacksAndMessages(b) + paint(b, colour) + b.isEnabled = false + b.text = "$mark " + orig.label.toString().replace(GLYPH, "") + main.postAtTime({ restore(b) }, b, SystemClock.uptimeMillis() + HOLD_MS) + } + + private fun restore(b: Button) { + val orig = saved.remove(b) ?: return + b.text = orig.label + b.setTextColor(orig.text) + b.isEnabled = orig.enabled + when (b) { + is Chip -> b.chipBackgroundColor = orig.chipBg + is MaterialButton -> { b.backgroundTintList = orig.bg; b.strokeColor = orig.stroke } + else -> b.backgroundTintList = orig.bg + } + } +} + +/** A click; the block gets the button so it can report the outcome. */ +fun Button.onTap(block: (Button) -> Unit) = setOnClickListener { block(this) } diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/Help.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/Help.kt new file mode 100644 index 0000000..e5bb8e8 --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/Help.kt @@ -0,0 +1,152 @@ +package io.adhush.android + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** + * Plain-language explanations, one per idea, shown from the ⓘ buttons and + * the Help page. Written for someone who has never heard the jargon: what a + * "bug" is, what a "script" is, what ducking is, and what each of the ten + * methods actually looks or listens for. + */ +object Help { + class Topic(val title: String, val body: String) + + val BUG = Topic("What is the \"bug\"?", + "The bug is the small channel logo that sits in a corner of the picture during a show — the MSNBC peacock, the CNN letters, the network's name. " + + "Broadcasters call it a \"bug\" because it never goes away, like an insect on the glass.\n\n" + + "During a commercial the bug is taken off the screen. That is the whole trick: the camera looks at the corner where the bug lives; " + + "bug there means your show is on, bug gone for a couple of seconds means a commercial has started.\n\n" + + "The camera has to see the WHOLE TV to know where the corner is. If only part of the TV is in the picture, or the picture is blurred, " + + "the app says \"whole TV not in view\" and does nothing — it never mutes just because it cannot see. " + + "It also has to have SEEN the bug at least once since it started before it is allowed to say the bug is gone.") + + val SCRIPT = Topic("What is a \"script\"?", + "A script is the words of one commercial, written down. \"Ask your doctor if Zyprexa is right for you\" is part of a script.\n\n" + + "The app can hear words two ways: the microphone plus an offline speech recogniser (Speech), or the camera reading the closed captions " + + "off the bottom of the screen (Captions). Either way it keeps the last three hours of words.\n\n" + + "Commercials say exactly the same words every time they air; a show never repeats ten words in a row. So every ten minutes the app looks " + + "for a run of words that came back identically minutes apart and saves it as a script. When you use Is an ad and Show's back, the words " + + "in between are saved as a script straight away. From then on, hearing or reading a saved script ducks the TV by itself.") + + val DUCK = Topic("Ducking, muting and \"Not an ad\"", + "Ducking means turning the TV down to a whisper (the \"Duck to\" volume on the TV page, 4 by default) instead of muting it. " + + "The phone can still hear the TV a little, which is how it notices when the show is back. Muting makes the phone deaf, so ducking is the default.\n\n" + + "Not an ad: press it whenever the TV went quiet during your show. The volume comes back at once, whatever caused the duck is dropped, " + + "and for one minute nothing is allowed to duck again. The camera then has to see the bug again before it may say the bug is gone.\n\n" + + "The app never fights your remote: if you turn the volume up yourself while it is ducked, it stands down.") + + val TEACH = Topic("Teaching it (Is an ad / Show's back)", + "Press Is an ad the moment a commercial break starts. The TV ducks and stays ducked. Press Show's back the second your show returns. " + + "Everything in between — the sound (as numbers, never a recording) and the words — is filed as commercial material.\n\n" + + "After five or six breaks the app recognises those commercials on its own, in any order, and ducks within a few seconds. " + + "Pressed Is an ad by mistake? Not an ad cancels it without learning anything.\n\n" + + "Two guards (0.28.1): a bracket shorter than half a minute is taken as a slip of the finger and not kept, and one longer than six minutes is cut there — you forgot Show's back and the show ran into it. " + + "A remembered break that keeps ducking for only a few seconds (the show's own evidence ends it at once) was the show, not a break: two such false matches forget it. " + + "Forget what it learned… under Methods (or the ⋮ menu) wipes the remembered breaks, jingles, clock or scripts when a bad evening has taught it nonsense.") + + val SILENCE = Topic("Method 1 — Quiet gaps", + "Broadcasters leave a short silence (a fraction of a second) between the show and each commercial, and between commercials. " + + "The microphone listens for those gaps at the level of the room's background. On its own this method is a hint, not a verdict: it needs another method to agree.") + + val LOUDNESS = Topic("Method 2 — Loudness jumps", + "Commercials are mixed to sound louder than the show they interrupt (the CALM Act limits the average, not how it feels). " + + "The microphone tracks how loud the show normally is and notices when the sound jumps up and stays up. Like quiet gaps, this is a hint that needs a second opinion. " + + "It works best after the ten-minute Room survey has measured this room.") + + val FINGERPRINTS = Topic("Method 3 — Remembered breaks", + "Every commercial you bracket with Is an ad and Show's back is remembered as an audio fingerprint (a stream of numbers, not a recording). " + + "When the same commercial airs again the fingerprint matches within a few seconds and the TV ducks on its own. This is the most reliable method once it has been taught, " + + "and the only one that can act alone with no help from the others.") + + val CAMERA = Topic("Method 4 — Channel bug (camera)", + "The back camera watches the corner of the TV where the channel's bug lives. Bug gone for 2.5 seconds means a commercial; bug back means the show. " + + "Set it up once per channel on the Camera setup screen: zoom in until the bug is big but the whole TV still fits, press Watch 45 s, check the magnified box shows the bug, Save.\n\n" + + "It only works while the whole TV is in the picture and sharp; otherwise it says so and stays out of the decision. Hand-held is fine — the TV is found again in every frame and the box follows the bug.") + + val SPEECH = Topic("Method 5 — Spoken words (speech)", + "An offline speech recogniser (a 40 MB model downloaded once; nothing leaves the phone) turns what the microphone hears into words. " + + "Commercials repeat their words verbatim, so runs of words that come back minutes apart are saved as scripts and recognised from then on. " + + "Legal and sales phrases (\"ask your doctor\", \"call now\") duck by themselves. Loud room fans make this method mishear; captions are the deaf-proof version.") + + val CAPTIONS = Topic("Method 6 — On-screen captions", + "With closed captions switched on in the TV's own menu, the camera reads the caption text off the bottom of the screen. " + + "The words go into the same script matching as speech, but nothing is misheard over the room's fans: what the TV prints is what the app reads. " + + "It needs the whole TV in view, reasonably close or zoomed, and captions big enough to read — the Camera setup screen shows what the camera sees.\n\n" + + "No cable can deliver captions to the phone: the TV's serial and network control ports only carry commands (volume, power, input), and infrared is one-way into the set. The camera is the only way in.") + + val CLAUDE = Topic("Method 7 — Ask Claude (cloud AI)", + "Every ten seconds or so, the last 40 seconds of words (from Speech or Captions) are sent to Claude, Anthropic's AI, with one question: is a commercial playing right now? " + + "It answers COMMERCIAL or SHOW with a confidence and a few words why. Unlike the script methods it needs no memory of the commercial: it recognises someone selling something from the words alone.\n\n" + + "It costs money: about 13 cents per hour of TV with Haiku, the cheap model, in \"always\" mode, and a tenth of that in tie-breaker mode (ask only when the other methods are unsure, or while ducked). " + + "A confident COMMERCIAL answer is saved as a script, so the next airing is recognised offline for free.\n\n" + + "Privacy: only text leaves the phone, never audio, and only while this switch is on. The microphone also hears the room, so words spoken near the phone can be in that text. Anthropic keeps API data for 30 days. " + + "You need an API key from console.anthropic.com; it is stored in the app's encrypted settings.") + + val LOCAL = Topic("Method 8 — Local AI (on the phone)", + "The same question as Method 7, answered by a language model running on the phone itself (Qwen, downloaded once). " + + "Nothing leaves the phone and nothing costs money. Each answer takes a few seconds of CPU, so it asks on a cadence, not on every word, and the phone runs warmer while it is on.\n\n" + + "Three sizes: 0.5 billion parameters (550 MB, runs on any phone, hedges the most), 1.5 billion (1.6 GB, needs about 6 GB of memory, noticeably sharper) and Qwen 3 at 4 billion (2.7 GB, needs 12 GB or more, the best judgement, about ten seconds an answer). " + + "Each size is its own file, so you can download more than one and switch. Pick the size, press Download, wait, switch on Local AI, Start.\n\n" + + "Even the largest is less sharp than Claude — a small model hedges more and is fooled by garbled speech more easily — but it is free, private and works without Wi-Fi. " + + "Like Method 7 it needs words from Speech or Captions, and a confident COMMERCIAL answer is saved as a script.") + + val TICKER = Topic("The news ticker instead of the bug", + "On a news channel the lower part of the picture carries a band — the ticker, or the chyron with the headline — whose top and bottom edges are straight lines that never move, while the words inside scroll. " + + "During a commercial the band is gone. Camera setup can watch that band instead of the corner bug: choose \"news ticker\" under Channel bug, press Camera setup, Watch 45 s, and the yellow box should sit on the band.\n\n" + + "Same rules as the bug: the whole TV must be in view, the band must have been seen once before its absence counts, and it must be gone for 2.5 seconds. " + + "It works on channels that keep a ticker up through the whole show; some shows drop it for interviews, which would read as a commercial — then use the bug instead.") + + val CLOCK = Topic("Method 9 — the break clock", + "Cable news runs to a format clock: the breaks land at roughly the same minutes every hour, but the clock is not published and it is not exact, so nothing is hard-coded. " + + "Instead the app keeps a score for each minute of the hour. Every break the other methods or you confirm marks the minutes it covered; every minute the app watches counts as watched.\n\n" + + "Once a minute has been watched in three different hours it starts to vote: a break in most of those hours is a strong vote, never a break is a vote for the show. " + + "It is a light vote — on its own it can never duck — but when Loudness or Quiet gaps are halfway sure, the clock tips the balance. " + + "The status line shows what it thinks of the current minute. It is on by default and stays quiet until it has learned.") + + val TIMED = Topic("Manual duck and the remote", + "The buttons turn the TV down for exactly that long — 30 seconds to five minutes in 30-second steps — whatever the methods think, then bring it back up on their own. " + + "Pressed while the TV is already ducked, they keep it down for that long instead; +30 s adds half a minute to whatever is running, on the Home page, the remote and the notification, which counts down. Show's back ends one early. Nothing is learned from a manual duck.\n\n" + + "The Remote tab has every button of the TV's own remote, drawn as one (through the network or the serial cable — infrared knows only volume and mute) plus Start, Stop, Is an ad, Show's back, Not an ad and the timed ducks with +30 s, " + + "so one screen does it all. When AdHush is running, the keys go through its connection; the Sharp allows only one.") + + val STREAM = Topic("Learn from a stream", + "A channel's live stream plays the same national commercials as cable. Play it on this phone — in Chrome, or in an app that allows capture — press Start learning from a stream, and the phone hears its own playback instead of the microphone: clean sound, no room, no fans. " + + "Every break it recognises (quiet gap, loudness jump, a known script, an AI judge) is remembered when it ends, and ✓ / ▶ on the notification teach by hand. No TV is touched.\n\n" + + "What it learns lands in the same memory the TV mode uses, so when you Stop and press the normal Start by the TV, those spots are already known. The stream's own inserted ads never air on cable; they simply never match. " + + "The break clock does not learn from a stream: streams run about half a minute behind cable.\n\n" + + "If Android says \"App content hidden from screenshare for security purposes\", the player marks its window as protected (a DRM player does) and nothing useful reaches the phone: the picture is black and, since a player must opt in to being heard, the sound is usually silence too. Within eight seconds the Home card says so. Play the channel's website in Chrome instead, or play it out loud next to the phone and use the normal Start: the microphone hears any player.\n\n" + + "With Read the player's AD badge on, the phone also reads the four corners of its own screen once a second: streaming players draw \"AD\", \"Ad 1 of 3\" or a countdown there during a break, which is as good as being told. A badge in view is a near-certain break; the badge gone is the show. Whole words only, so a caption saying \"already\" never counts.\n\n" + + "Share this phone's memory (card 3) zips the breaks, scripts, clock and jingles; Import memory from a file merges another phone's, skipping what is already known. Stop AdHush before importing.") + + val JINGLE = Topic("Method 10 — break jingles", + "A channel plays the same short sting going into and out of every break — a few notes, a whoosh, the show's own bumper. Instead of remembering each commercial, this method remembers the sting: one fingerprint covers every break on that channel for good.\n\n" + + "Nothing is typed in. Every break the app ends (by any method, or by your ✓ and ▶) teaches the seconds around its start and its end as candidates. A candidate that turns out to open three different breaks is promoted; from then on, hearing it ducks the set the moment the break starts, and hearing the closing sting brings the show back at once.\n\n" + + "Not an ad after a jingle duck counts against that jingle; enough wrong calls demote it. Stream learning teaches jingles too, from clean audio. The idea comes from the AdVent project, which measured that three seconds of a sting is enough to recognise it.\n\n" + + "A channel re-cuts its sting per show, so a version a couple of semitones up or down, or a little faster or slower, counts as the same sting (a family). Each sting also remembers the hours of the day it has opened breaks in: at one of those hours it is trusted after two breaks instead of three. The short whoosh or hit on the cut into a segment — a burst of noise, then the level moves — is heard separately while Loudness is on; it tips the balance for a couple of seconds and never ducks alone.") + + val BUTTONS = Topic("What the buttons tell you", + "A button changes only when something real happened — never a flex for a tap that went nowhere. Three looks: blue and greyed with its progress as the label (\"Downloading Qwen 3: 43 %\", \"Testing the TV…\") for as long as the job it started is running, so a second press cannot start it twice; green with a tick for a second and a half once what it asked for went through; red with a cross when it did not — nothing running, the set did not answer, an override in the way — and the Log page says why.\n\n" + + "Is an ad, Show's back, Not an ad, the timed ducks and every remote key turn green or red on the service's word, not on the tap. Start turns green and reads Running while AdHush runs; Stop turns red then. Is an ad, Show's back, Not an ad and the ducks are bright only when they would do something right now.\n\n" + + "A model download that stops (Wi-Fi dropped, the app closed) is kept and the button reads Resume with how far it got; a short file is never installed as if it were whole. Delete partial model downloads in the ⋮ menu throws a stopped download away.") + + val METHODS = Topic("Choosing methods", + "Any mix of the ten methods can be on, but AT LEAST ONE must be on or the app has nothing to go on and will refuse to start.\n\n" + + "Quiet gaps, loudness jumps and the break clock are hints: two of them have to agree before the TV is ducked. Remembered breaks, the channel bug or ticker, spoken words, captions, the two AI judges and a learned break jingle are each strong enough to duck on their own.\n\n" + + "The AI judges (7 and 8) need words to read, so they only work together with Speech or Captions.\n\n" + + "A good starting set: the three sound methods on, plus the camera once you have set the bug or ticker up, plus captions if the TV shows them, plus one AI judge as a tie-breaker. The set-up wizard on the Home page measures the room and the camera and suggests exactly that.") + + val WIZARD = Topic("The set-up wizard", + "Three minutes, once, from the Set-up wizard button on the Home page (it never opens by itself). Find my TV asks every set on the Wi-Fi who it is — Sharp, Samsung, LG, Sony, Roku TV, Vizio, Android TV and Google TV, Hisense, Philips, any DLNA set — and proves each way in: a set that reads its volume back is ducked exactly; one that only takes keys (Samsung, Roku, Vizio) is stepped, and the wizard fires MUTE twice and asks whether it muted. A Sony needs the pre-shared key from its own menu; an LG or Samsung asks you to allow AdHush on the screen once; a Vizio shows a PIN once. Then the serial cable if an adapter is plugged in, then the infrared blaster, one brand's codes at a time. Whatever answers is chosen, network first. Then it asks where the phone sits, listens to fifteen seconds of the show and ten seconds of the room with the TV muted, and looks through the camera for eight seconds to see whether the whole TV is in view.\n\n" + + "From those numbers it suggests which methods to switch on, the duck level (a loud room means a higher duck level, so the phone can still hear the ducked set and bring the show back on time), the camera zoom, the local AI size this phone can carry, and what to do next — then writes the settings and opens the Methods page, where every one of them can be changed. Nothing is recorded: the wizard keeps numbers, never audio or pictures.") + + val TEST = Topic("Test mode", + "Test TV talks to the set the way the app does when a commercial comes on: it asks the volume, mutes and unmutes, ducks and restores, and prints every byte it sent and got back. " + + "If the sound dips twice, the connection works. It uses whatever connection is chosen on the TV page — network, serial cable or infrared.") + + val ALL = listOf(WIZARD, BUTTONS, METHODS, BUG, TICKER, SCRIPT, DUCK, TEACH, TIMED, TEST, SILENCE, LOUDNESS, FINGERPRINTS, CAMERA, SPEECH, CAPTIONS, CLAUDE, LOCAL, CLOCK, JINGLE, STREAM) + + fun show(context: Context, topic: Topic) { + MaterialAlertDialogBuilder(context).setTitle(topic.title).setMessage(topic.body).setPositiveButton("Got it", null).show() + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/IrKeySender.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/IrKeySender.kt index 20a8d0a..13b5ed5 100644 --- a/platforms/android/app/src/main/kotlin/io/adhush/android/IrKeySender.kt +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/IrKeySender.kt @@ -3,25 +3,34 @@ package io.adhush.android import android.content.Context import android.hardware.ConsumerIrManager import io.adhush.core.ControlError +import io.adhush.core.IrCodeSet +import io.adhush.core.IrCodeSets +import io.adhush.core.IrProtocol import io.adhush.core.KeySender -import io.adhush.core.SharpIr import io.adhush.core.TvKey -/** The phone's own infrared blaster, speaking Sharp's protocol. One-way: nothing comes back. */ -class IrKeySender(context: Context, private val address: Int, private val volumeUp: Int, private val volumeDown: Int, private val mute: Int = 0x17) : KeySender { +/** The phone's own infrared blaster, speaking whichever brand's protocol the code set names (ADR 0025). One-way: nothing comes back. */ +class IrKeySender(context: Context, val codes: IrCodeSet) : KeySender { private val ir = context.getSystemService(Context.CONSUMER_IR_SERVICE) as? ConsumerIrManager val available: Boolean get() = ir?.hasIrEmitter() == true - private fun code(key: TvKey) = when (key) { TvKey.VOLUME_UP -> volumeUp; TvKey.VOLUME_DOWN -> volumeDown; TvKey.MUTE -> mute } - override fun press(key: TvKey, times: Int) { val emitter = ir?.takeIf { it.hasIrEmitter() } ?: throw ControlError("this phone has no infrared blaster") - val pattern = SharpIr.press(address, code(key)) + val pattern = codes.pattern(key) repeat(times) { - emitter.transmit(SharpIr.CARRIER_HZ, pattern) + emitter.transmit(codes.protocol.carrierHz, pattern) Thread.sleep(PRESS_GAP_MS) // the set counts distinct presses, not a held key } } - companion object { const val PRESS_GAP_MS = 60L } + companion object { + const val PRESS_GAP_MS = 60L + + /** The set the owner chose: a named brand, or Sharp with the codes typed on the TV page. */ + fun codesFor(settings: Settings): IrCodeSet = + IrCodeSets.byName(settings.irCodeSet)?.takeIf { settings.irCodeSet != "sharp" } + ?: IrCodeSet("sharp", "Sharp", IrProtocol.SHARP, settings.irAddress, settings.irVolumeUp, settings.irVolumeDown, 0x17, 0x16) + + fun fromSettings(context: Context, settings: Settings) = IrKeySender(context, codesFor(settings)) + } } diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/LocalJudge.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/LocalJudge.kt new file mode 100644 index 0000000..2b204ba --- /dev/null +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/LocalJudge.kt @@ -0,0 +1,113 @@ +package io.adhush.android + +import android.content.Context +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import io.adhush.core.JudgeDetector +import io.adhush.core.TranscriptJudge +import io.adhush.core.Verdict +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * The eighth method's brain: a small language model running on the phone + * itself through MediaPipe's LLM Inference task. Nothing leaves the phone + * and nothing costs money; each question takes a few seconds of CPU, which + * is why the judge asks on a cadence rather than on every word. The model + * file is downloaded once into app-private storage; three sizes are offered + * ([TIERS]) so a phone with the room can run a sharper model. + */ +class LocalJudge(modelFile: File) : TranscriptJudge, AutoCloseable { + private val llm: LlmInference + @Volatile var calls = 0L + private set + @Volatile var lastMillis = 0L + private set + + init { + val options = LlmInference.LlmInferenceOptions.builder() + .setModelPath(modelFile.absolutePath) + .setMaxTokens(MAX_TOKENS) + .build() + llm = LlmInference.createFromOptions(APP_CONTEXT!!, options) + } + + override fun judge(transcript: String, channel: String): Verdict? { + val t0 = System.currentTimeMillis() + val prompt = chatPrompt(JudgeDetector.prompt(channel), "Transcript of the last 40 seconds:\n$transcript") + val out = synchronized(llm) { llm.generateResponse(prompt) } + lastMillis = System.currentTimeMillis() - t0 + calls++ + return Verdict.parse(out.lineSequence().firstOrNull { it.isNotBlank() } ?: out) + } + + override fun close() { runCatching { llm.close() } } + + /** One downloadable model. [file] keeps each size in its own file, so switching never clobbers a finished download. */ + data class Tier(val key: String, val label: String, val url: String, val sizeMb: Int, val file: String, val ramGb: Int) + + companion object { + /** The instruction + transcript must fit with the answer: 1280 tokens of context in the smallest model. */ + const val MAX_TOKENS = 1024 + /** + * All three are Qwen instruct models packaged by the LiteRT community on Hugging Face; Apache-2.0, no login + * needed. The small and medium files are MediaPipe `.task` bundles; the large one is the newer `.litertlm` + * bundle (there is no plain 3B Qwen chat bundle; Qwen3 4B is the next size up). Phones with 8 GB should stay + * on small; 12 GB or more can run any of them. + */ + val TIERS = listOf( + Tier("small", "Qwen 2.5 — 0.5 billion", "https://huggingface.co/litert-community/Qwen2.5-0.5B-Instruct/resolve/main/Qwen2.5-0.5B-Instruct_multi-prefill-seq_q8_ekv1280.task", 547, "judge.task", 4), + Tier("medium", "Qwen 2.5 — 1.5 billion", "https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/main/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.task", 1599, "judge-1.5b.task", 6), + Tier("large", "Qwen 3 — 4 billion", "https://huggingface.co/litert-community/Qwen3-4B-Instruct-2507/resolve/main/qwen3_4b_instruct_2507_mixed_int4.litertlm", 2660, "judge-4b.litertlm", 8), + ) + val DEFAULT_URL = TIERS[0].url + val DEFAULT_SIZE_MB = TIERS[0].sizeMb + @Volatile var APP_CONTEXT: Context? = null + + fun tier(context: Context): Tier = tier(Settings(context).localModelSize) + fun tier(key: String): Tier = TIERS.firstOrNull { it.key == key } ?: TIERS[0] + fun modelFile(context: Context, tier: Tier = tier(context)) = File(File(context.filesDir, "llm"), tier.file) + /** The file is there and, when its expected size is known, every byte of it. */ + fun isInstalled(context: Context, tier: Tier = tier(context)): Boolean { + val f = modelFile(context, tier) + if (!f.isFile) return false + val expected = File(f.path + ".size").takeIf { it.isFile }?.readText()?.trim()?.toLongOrNull() + return if (expected != null) f.length() == expected else f.length() > 10_000_000 + } + /** Which sizes are already on the phone. */ + fun installed(context: Context): List = TIERS.filter { isInstalled(context, it) } + + /** The Qwen chat layout; the model answers after the final assistant tag. */ + fun chatPrompt(system: String, user: String): String = + "<|im_start|>system\n$system<|im_end|>\n<|im_start|>user\n$user<|im_end|>\n<|im_start|>assistant\n" + + /** True while a download runs: a second press must not start a second writer on the same file. */ + @Volatile var installing = false + private set + + fun partFile(context: Context, tier: Tier = tier(context)) = File(modelFile(context, tier).path + ".part") + /** A stopped download waiting to be resumed, or null. */ + fun partial(context: Context, tier: Tier = tier(context)) = Downloads.partial(partFile(context, tier)) + fun deletePartials(context: Context) = Downloads.deletePartials(File(context.filesDir, "llm")) + + /** + * Download the chosen size once; progress is 0..100. Blocking — call off + * the main thread. Resumes a stopped download; a short file is never + * installed (0.28.4). Its expected size is kept beside it, so a model + * counts as installed only when every byte is there. + */ + fun install(context: Context, url: String, onProgress: (Int) -> Unit, tier: Tier = tier(context)) { + synchronized(this) { check(!installing) { "a download is already running — wait for it to finish" }; installing = true } + try { + val target = modelFile(context, tier) + if (isInstalled(context, tier)) { onProgress(100); return } + val part = partFile(context, tier) + Downloads.fetch(url, part, onProgress) + File(target.path + ".size").writeText(part.length().toString()) + Downloads.install(part, target) + onProgress(100) + } finally { installing = false } + } + } +} diff --git a/platforms/android/app/src/main/kotlin/io/adhush/android/MainActivity.kt b/platforms/android/app/src/main/kotlin/io/adhush/android/MainActivity.kt index 9872f6d..48eaa71 100644 --- a/platforms/android/app/src/main/kotlin/io/adhush/android/MainActivity.kt +++ b/platforms/android/app/src/main/kotlin/io/adhush/android/MainActivity.kt @@ -1,19 +1,29 @@ package io.adhush.android import android.Manifest +import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.ColorStateList import android.os.Build import android.os.Bundle +import android.view.View import android.widget.Button -import android.widget.CheckBox +import android.widget.CompoundButton import android.widget.EditText +import android.widget.LinearLayout import android.widget.TextView +import android.media.projection.MediaProjectionManager +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip import io.adhush.core.Aquos import io.adhush.core.ControlError import io.adhush.core.SharpIpClient @@ -22,11 +32,17 @@ import kotlin.concurrent.thread class MainActivity : AppCompatActivity() { private lateinit var settings: Settings + private lateinit var remote: RemotePanel private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val i = intent ?: return when (i.action) { - AdHushService.BROADCAST_STATUS -> findViewById(R.id.status).text = i.getStringExtra("text") ?: "" + AdHushService.BROADCAST_STATUS -> { + showStatus(i.getStringExtra("text") ?: "", i.getBooleanExtra("running", AdHushService.running != null), i.getBooleanExtra("ducked", false), i.getBooleanExtra("teaching", false)) + remote.status(i.getStringExtra("text") ?: "", i.getBooleanExtra("running", AdHushService.running != null), i.getBooleanExtra("ducked", false), i.getBooleanExtra("teaching", false)) + } + AdHushService.BROADCAST_TEST -> testLine(i.getStringExtra("line") ?: "") + AdHushService.BROADCAST_ACTION -> { actionDone(i); remote.actionDone(i) } AdHushService.BROADCAST_SURVEY -> { log("— survey saved; press Share survey to send the numbers (no audio is stored) —") (i.getStringExtra("summary") ?: "").lines().reversed().forEach { log(it) } @@ -36,21 +52,399 @@ class MainActivity : AppCompatActivity() { } private var pendingAction: String? = null + /** Android's "record or cast" consent, which is how one app may hear another's sound (ADR 0018). */ + private val projectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val data = r.data + if (r.resultCode != Activity.RESULT_OK || data == null) { log("screen capture was not allowed — stream learning needs it to hear the player"); return@registerForActivityResult } + save() + val i = Intent(this, AdHushService::class.java).setAction(AdHushService.ACTION_STREAM_START) + .putExtra(AdHushService.EXTRA_RESULT_CODE, r.resultCode).putExtra(AdHushService.EXTRA_RESULT_DATA, data) + ContextCompat.startForegroundService(this, i) + log("stream learning started — play the channel's live stream in Chrome and leave it playing") + Feedback.ok(findViewById