stenograf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. stenograf-0.1.0/.github/workflows/ci.yml +30 -0
  2. stenograf-0.1.0/.github/workflows/release.yml +128 -0
  3. stenograf-0.1.0/.gitignore +40 -0
  4. stenograf-0.1.0/LICENSE +21 -0
  5. stenograf-0.1.0/PKG-INFO +192 -0
  6. stenograf-0.1.0/PLAN-AEC.md +211 -0
  7. stenograf-0.1.0/PLAN.md +1570 -0
  8. stenograf-0.1.0/README.md +165 -0
  9. stenograf-0.1.0/eval/README.md +142 -0
  10. stenograf-0.1.0/eval/adjudicate.py +310 -0
  11. stenograf-0.1.0/eval/aec_rig.py +216 -0
  12. stenograf-0.1.0/eval/aec_score.py +161 -0
  13. stenograf-0.1.0/eval/backends.py +226 -0
  14. stenograf-0.1.0/eval/common.py +90 -0
  15. stenograf-0.1.0/eval/der.py +271 -0
  16. stenograf-0.1.0/eval/diarize.py +129 -0
  17. stenograf-0.1.0/eval/extract.py +75 -0
  18. stenograf-0.1.0/eval/live.py +150 -0
  19. stenograf-0.1.0/eval/manifest.example.json +18 -0
  20. stenograf-0.1.0/eval/rttm.py +72 -0
  21. stenograf-0.1.0/eval/scan_languages.py +80 -0
  22. stenograf-0.1.0/eval/score.py +91 -0
  23. stenograf-0.1.0/eval/transcribe.py +106 -0
  24. stenograf-0.1.0/hatch_build.py +62 -0
  25. stenograf-0.1.0/native/README.md +72 -0
  26. stenograf-0.1.0/native/helper/Info.plist +16 -0
  27. stenograf-0.1.0/native/helper/build.sh +20 -0
  28. stenograf-0.1.0/native/helper/main.swift +464 -0
  29. stenograf-0.1.0/native/spike/Info.plist +16 -0
  30. stenograf-0.1.0/native/spike/build.sh +15 -0
  31. stenograf-0.1.0/native/spike/main.swift +238 -0
  32. stenograf-0.1.0/pyproject.toml +121 -0
  33. stenograf-0.1.0/src/stenograf/__init__.py +8 -0
  34. stenograf-0.1.0/src/stenograf/aec.py +355 -0
  35. stenograf-0.1.0/src/stenograf/archive.py +330 -0
  36. stenograf-0.1.0/src/stenograf/asr/__init__.py +23 -0
  37. stenograf-0.1.0/src/stenograf/asr/base.py +62 -0
  38. stenograf-0.1.0/src/stenograf/asr/parakeet.py +94 -0
  39. stenograf-0.1.0/src/stenograf/asr/registry.py +100 -0
  40. stenograf-0.1.0/src/stenograf/audio.py +69 -0
  41. stenograf-0.1.0/src/stenograf/capture/__init__.py +3 -0
  42. stenograf-0.1.0/src/stenograf/capture/base.py +70 -0
  43. stenograf-0.1.0/src/stenograf/capture/file.py +78 -0
  44. stenograf-0.1.0/src/stenograf/capture/macos.py +164 -0
  45. stenograf-0.1.0/src/stenograf/cli.py +1386 -0
  46. stenograf-0.1.0/src/stenograf/config.py +130 -0
  47. stenograf-0.1.0/src/stenograf/control.py +237 -0
  48. stenograf-0.1.0/src/stenograf/diarization/__init__.py +3 -0
  49. stenograf-0.1.0/src/stenograf/diarization/base.py +57 -0
  50. stenograf-0.1.0/src/stenograf/diarization/sherpa.py +142 -0
  51. stenograf-0.1.0/src/stenograf/doctor.py +151 -0
  52. stenograf-0.1.0/src/stenograf/glossary.py +207 -0
  53. stenograf-0.1.0/src/stenograf/lid.py +69 -0
  54. stenograf-0.1.0/src/stenograf/live.py +324 -0
  55. stenograf-0.1.0/src/stenograf/models.py +119 -0
  56. stenograf-0.1.0/src/stenograf/pipeline.py +259 -0
  57. stenograf-0.1.0/src/stenograf/profiles.py +287 -0
  58. stenograf-0.1.0/src/stenograf/recording.py +210 -0
  59. stenograf-0.1.0/src/stenograf/session.py +1152 -0
  60. stenograf-0.1.0/src/stenograf/transcript.py +288 -0
  61. stenograf-0.1.0/src/stenograf/tui.py +449 -0
  62. stenograf-0.1.0/src/stenograf/vad.py +109 -0
  63. stenograf-0.1.0/src/stenograf/view.py +185 -0
  64. stenograf-0.1.0/tests/fake_stenocap.py +44 -0
  65. stenograf-0.1.0/tests/test_aec.py +240 -0
  66. stenograf-0.1.0/tests/test_archive.py +218 -0
  67. stenograf-0.1.0/tests/test_asr_parakeet.py +41 -0
  68. stenograf-0.1.0/tests/test_asr_registry.py +50 -0
  69. stenograf-0.1.0/tests/test_audio.py +40 -0
  70. stenograf-0.1.0/tests/test_build_hook.py +86 -0
  71. stenograf-0.1.0/tests/test_capture_file.py +72 -0
  72. stenograf-0.1.0/tests/test_capture_macos.py +112 -0
  73. stenograf-0.1.0/tests/test_cli.py +744 -0
  74. stenograf-0.1.0/tests/test_config.py +98 -0
  75. stenograf-0.1.0/tests/test_control.py +337 -0
  76. stenograf-0.1.0/tests/test_diarization_sherpa.py +169 -0
  77. stenograf-0.1.0/tests/test_diarization_sherpa_unit.py +126 -0
  78. stenograf-0.1.0/tests/test_doctor.py +109 -0
  79. stenograf-0.1.0/tests/test_echo_dedup.py +126 -0
  80. stenograf-0.1.0/tests/test_eval_der.py +117 -0
  81. stenograf-0.1.0/tests/test_glossary.py +91 -0
  82. stenograf-0.1.0/tests/test_lid.py +30 -0
  83. stenograf-0.1.0/tests/test_live.py +274 -0
  84. stenograf-0.1.0/tests/test_live_orchestration.py +421 -0
  85. stenograf-0.1.0/tests/test_models.py +131 -0
  86. stenograf-0.1.0/tests/test_pipeline.py +374 -0
  87. stenograf-0.1.0/tests/test_profiles.py +194 -0
  88. stenograf-0.1.0/tests/test_recording.py +129 -0
  89. stenograf-0.1.0/tests/test_session.py +774 -0
  90. stenograf-0.1.0/tests/test_transcript.py +335 -0
  91. stenograf-0.1.0/tests/test_tui.py +300 -0
  92. stenograf-0.1.0/tests/test_vad.py +39 -0
  93. stenograf-0.1.0/tests/test_view.py +192 -0
  94. stenograf-0.1.0/uv.lock +4370 -0
@@ -0,0 +1,30 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ # macos-15 = arm64 (the shipping target); ubuntu keeps the suite
18
+ # collecting and passing on Linux ahead of the Phase 5 backends
19
+ # (model-gated + real-audio tests self-skip on both).
20
+ os: [macos-15, ubuntu-latest]
21
+ runs-on: ${{ matrix.os }}
22
+ timeout-minutes: 30
23
+ steps:
24
+ - uses: actions/checkout@v7
25
+ - uses: astral-sh/setup-uv@v8.3.2
26
+ with:
27
+ enable-cache: true
28
+ - run: uv sync
29
+ - run: uv run ruff check .
30
+ - run: uv run pytest -q
@@ -0,0 +1,128 @@
1
+ name: release
2
+
3
+ # Tag v<version> → build the wheel matrix, smoke-test clean installs on both
4
+ # OSes, publish to PyPI (Trusted Publishing/OIDC — no token secrets) and attach
5
+ # the artifacts to a GitHub release. workflow_dispatch runs everything except
6
+ # the publish step, as a dry run.
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ build-macos:
15
+ # arm64 wheel with the compiled+signed stenocap (hatch_build.py)
16
+ runs-on: macos-15
17
+ timeout-minutes: 20
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: astral-sh/setup-uv@v8.3.2
21
+ - run: uv build --wheel
22
+ - name: Verify the wheel bundles a signed helper for macOS 14.4+
23
+ run: |
24
+ ls dist/*macosx_14_0_arm64.whl
25
+ unzip -o dist/*.whl 'stenograf/bin/stenocap' -d /tmp/whl
26
+ otool -l /tmp/whl/stenograf/bin/stenocap | grep -q 'minos 14.4'
27
+ codesign -v /tmp/whl/stenograf/bin/stenocap
28
+ - uses: actions/upload-artifact@v7
29
+ with:
30
+ name: dist-macos
31
+ path: dist/
32
+
33
+ build-linux:
34
+ # pure py3-none-any wheel + the sdist
35
+ runs-on: ubuntu-latest
36
+ timeout-minutes: 20
37
+ steps:
38
+ - uses: actions/checkout@v7
39
+ - uses: astral-sh/setup-uv@v8.3.2
40
+ - run: uv build
41
+ - name: Verify the pure wheel carries no helper
42
+ run: |
43
+ ls dist/*py3-none-any.whl dist/*.tar.gz
44
+ if unzip -l dist/*.whl | grep -q stenocap; then
45
+ echo "pure wheel must not contain the capture helper" >&2
46
+ exit 1
47
+ fi
48
+ - uses: actions/upload-artifact@v7
49
+ with:
50
+ name: dist-linux
51
+ path: dist/
52
+
53
+ smoke-macos:
54
+ # The acceptance the wheel exists for: a clean machine's `uv tool install`
55
+ # yields a green doctor and a working pipeline (capture itself needs TCC
56
+ # prompts no headless runner can answer; --replay drives the same pipeline
57
+ # from a file).
58
+ needs: build-macos
59
+ runs-on: macos-15
60
+ timeout-minutes: 45
61
+ steps:
62
+ - uses: astral-sh/setup-uv@v8.3.2
63
+ - uses: actions/download-artifact@v8
64
+ with:
65
+ name: dist-macos
66
+ path: dist
67
+ - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
68
+ - run: brew install ffmpeg
69
+ - run: uv tool install -p 3.13 ./dist/stenograf-*.whl
70
+ - run: steno setup --models-only
71
+ - run: steno doctor
72
+ - name: Pipeline smoke on a synthetic WAV
73
+ run: |
74
+ python3 - <<'EOF'
75
+ import math, struct, wave
76
+ with wave.open("smoke.wav", "wb") as w:
77
+ w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
78
+ w.writeframes(b"".join(
79
+ struct.pack("<h", int(8000 * math.sin(2 * math.pi * 440 * i / 16000)))
80
+ for i in range(16000 * 3)))
81
+ EOF
82
+ steno start --replay smoke.wav --local 1 --remote 0 --lang en --out smoke-out
83
+ test -f smoke-out/transcript.json
84
+
85
+ smoke-linux:
86
+ needs: build-linux
87
+ runs-on: ubuntu-latest
88
+ timeout-minutes: 20
89
+ steps:
90
+ - uses: astral-sh/setup-uv@v8.3.2
91
+ - uses: actions/download-artifact@v8
92
+ with:
93
+ name: dist-linux
94
+ path: dist
95
+ - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
96
+ - run: uv tool install -p 3.13 ./dist/stenograf-*py3-none-any.whl
97
+ - run: steno --version
98
+ # Linux capture/ASR land in Phase 5; today the smoke is: the pure wheel
99
+ # installs, the CLI runs, and the headless model download works.
100
+ - run: steno setup --models-only
101
+
102
+ publish:
103
+ needs: [smoke-macos, smoke-linux]
104
+ if: github.ref_type == 'tag'
105
+ runs-on: ubuntu-latest
106
+ timeout-minutes: 15
107
+ environment: pypi
108
+ permissions:
109
+ id-token: write # PyPI Trusted Publishing (OIDC)
110
+ contents: write # GitHub release
111
+ steps:
112
+ - uses: actions/checkout@v7
113
+ - uses: actions/download-artifact@v8
114
+ with:
115
+ path: dist
116
+ merge-multiple: true
117
+ - name: Verify the tag matches the project version
118
+ run: |
119
+ version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')
120
+ test "${GITHUB_REF_NAME}" = "v${version}" || {
121
+ echo "tag ${GITHUB_REF_NAME} != project version v${version}" >&2
122
+ exit 1
123
+ }
124
+ - uses: pypa/gh-action-pypi-publish@release/v1
125
+ - name: Attach artifacts to the GitHub release
126
+ env:
127
+ GH_TOKEN: ${{ github.token }}
128
+ run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes
@@ -0,0 +1,40 @@
1
+ # Meeting recordings and other local-only material — NEVER commit (public repo, private audio)
2
+ examples/
3
+ legacy/
4
+ # Eval harness working data: extracted audio, hypotheses, and reference
5
+ # transcripts all contain private meeting content; the manifest leaks
6
+ # recording filenames (dates, locations). manifest.example.json is the
7
+ # committed template.
8
+ eval/audio/
9
+ eval/out/
10
+ eval/refs/
11
+ eval/manifest.json
12
+ *.mov
13
+ *.m4a
14
+ *.wav
15
+ *.mp3
16
+ *.mp4
17
+
18
+ # Python
19
+ __pycache__/
20
+ *.py[cod]
21
+ *.egg-info/
22
+ dist/
23
+ build/
24
+ .venv/
25
+
26
+ # Tooling caches
27
+ .pytest_cache/
28
+ .ruff_cache/
29
+ .mypy_cache/
30
+
31
+ # Local settings
32
+ .claude/settings.local.json
33
+
34
+ # OS
35
+ .DS_Store
36
+
37
+ # Native helper build artifacts
38
+ native/.build/
39
+ native/spike/tap-spike
40
+ native/helper/stenocap
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Weber
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: stenograf
3
+ Version: 0.1.0
4
+ Summary: Accuracy-first local meeting transcription for German and English. Audio never touches disk.
5
+ Project-URL: Repository, https://github.com/daniel-om-weber/stenograf
6
+ Project-URL: Issues, https://github.com/daniel-om-weber/stenograf/issues
7
+ Author-email: Daniel Weber <danielusweber@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: diarization,local,meetings,privacy,speech-to-text,transcription
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
16
+ Requires-Python: <3.14,>=3.12
17
+ Requires-Dist: click>=8.1
18
+ Requires-Dist: livekit>=1.1
19
+ Requires-Dist: numba>=0.60; sys_platform == 'darwin' and platform_machine == 'arm64'
20
+ Requires-Dist: numpy>=1.26
21
+ Requires-Dist: parakeet-mlx>=0.5.2; sys_platform == 'darwin' and platform_machine == 'arm64'
22
+ Requires-Dist: sherpa-onnx-core<1.13,>=1.12.40
23
+ Requires-Dist: sherpa-onnx<1.13,>=1.12.40
24
+ Requires-Dist: textual>=8.2.8
25
+ Provides-Extra: ollama
26
+ Description-Content-Type: text/markdown
27
+
28
+ # stenograf
29
+
30
+ Accuracy-first, fully local meeting transcription for **German** and **English**
31
+ (one language per meeting), with speaker labels. Audio is processed entirely
32
+ **in memory** — it never touches disk; only the transcript is persisted.
33
+
34
+ Built for Apple Silicon (M-series) first; Linux and Windows support is designed
35
+ in from the start.
36
+
37
+ > **Status: pre-alpha, macOS only.** The pipeline is complete end to end: live
38
+ > system-audio + microphone capture, live captions, and the high-accuracy
39
+ > speaker-labelled finalize pass. The local web UI, meeting notes, and Linux
40
+ > capture are not built yet. See [PLAN.md](PLAN.md).
41
+
42
+ ## Why another transcription tool?
43
+
44
+ - **No audio on disk, ever.** Live transcription of a meeting has far lighter
45
+ legal requirements than recording it. stenograf keeps the session's audio in
46
+ RAM only and writes nothing but text.
47
+ - **Accuracy first.** A two-pass design: fast live captions while the meeting
48
+ runs, then a high-accuracy re-transcription of the full in-memory buffer the
49
+ moment it ends. German is a first-class citizen, not an afterthought.
50
+ - **Channel-aware speakers.** Microphone and system audio are captured as
51
+ separate streams, so local and remote voices never get confused; diarization
52
+ handles the rest (2–8 speakers).
53
+ - **Speakers, not headphones.** Remote voices leaving your laptop speakers and
54
+ re-entering the mic are cancelled in the audio domain (WebRTC AEC3, with the
55
+ system channel as the far-end reference), so they are never transcribed as a
56
+ local speaker.
57
+
58
+ ## Install
59
+
60
+ Requires macOS 14.4+ on Apple Silicon and [uv](https://docs.astral.sh/uv/).
61
+ The wheel ships the signed capture helper — no toolchain needed.
62
+
63
+ ```sh
64
+ uv tool install stenograf
65
+ steno doctor # environment checks
66
+ steno setup # one-time: mic + system-audio permission prompts, model downloads
67
+ ```
68
+
69
+ macOS scopes the permission grant to the app the prompt came from, so run
70
+ `steno setup` once from each terminal app (or IDE) you'll start meetings from.
71
+
72
+ Pre-release channel: `uv tool install git+https://github.com/daniel-om-weber/stenograf`
73
+ installs the current main branch; building from the repository compiles the
74
+ capture helper on your machine, which needs the Xcode command-line tools
75
+ (`xcode-select --install`).
76
+
77
+ ### From a checkout
78
+
79
+ ```sh
80
+ git clone https://github.com/daniel-om-weber/stenograf
81
+ cd stenograf
82
+ uv sync
83
+ sh native/helper/build.sh # builds + ad-hoc signs native/helper/stenocap
84
+ uv run steno doctor
85
+ uv run steno setup
86
+ ```
87
+
88
+ Every command below is then `uv run steno …` from the repo.
89
+
90
+ ## Usage
91
+
92
+ ```sh
93
+ uv run steno start # live captions, everything auto-detected
94
+ uv run steno start --lang de --local 3 --remote 2 # hybrid meeting, German
95
+ uv run steno transcribe recording.mov # batch-transcribe an existing file
96
+ ```
97
+
98
+ `steno start` streams **live captions** while the meeting runs — a full-screen
99
+ TUI on a terminal, a plain line-by-line stream when piped — and replaces them
100
+ with the high-accuracy, speaker-labelled transcript the moment you stop
101
+ (Ctrl-C). The audio stays in RAM throughout; only the transcript is written.
102
+
103
+ Useful flags:
104
+
105
+ ```sh
106
+ steno start --plain # plain caption stream instead of the TUI
107
+ steno start --no-live # skip live captions; just finalize on stop
108
+ steno start --title "Weekly sync" # name the meeting in the archive
109
+ steno start --flush-interval 60 # crash-checkpoint the captions every 60s
110
+ steno start --no-aec # disable echo cancellation (headphones)
111
+ steno start --record-audio # opt in to keeping a WAV (off by default)
112
+ steno start --replay mic.wav # dev: drive the live pass from a file
113
+ ```
114
+
115
+ Both `start` and `transcribe` accept `--format md,json,srt,vtt` (default
116
+ `md,json`), `--lang de|en`, and `--print` to echo the transcript to stdout.
117
+
118
+ ## Your meeting archive
119
+
120
+ Transcripts are filed automatically into a managed archive at
121
+ `~/Library/Application Support/stenograf/meetings/<id>/`. Use `--out DIR` to
122
+ write somewhere else (still archived), or `--no-archive` to write loose files
123
+ next to the source and register nothing.
124
+
125
+ ```sh
126
+ steno meetings list # every transcript, newest first
127
+ steno meetings show meeting-20260710-091500
128
+ steno meetings rm meeting-20260710-091500
129
+ ```
130
+
131
+ Audio is stored only when you passed `--record-audio`; without it the archive
132
+ holds text alone.
133
+
134
+ ## Naming speakers across meetings
135
+
136
+ Enroll a voice once and every later meeting relabels that speaker automatically
137
+ (cross-meeting re-identification):
138
+
139
+ ```sh
140
+ steno profiles enroll Daniel daniel-sample.wav # a short clip of one speaker
141
+ steno profiles list # show enrolled voiceprints
142
+ steno profiles rename Daniel "Daniel W."
143
+ steno profiles remove Daniel
144
+ ```
145
+
146
+ To name one person from a multi-speaker recording (e.g. a meeting saved with
147
+ `--record-audio`), diarize it and pick their cluster:
148
+
149
+ ```sh
150
+ steno profiles enroll Anna meeting.wav --speakers 4 # lists the clusters
151
+ steno profiles enroll Anna meeting.wav --speakers 4 --speaker S2
152
+ ```
153
+
154
+ Matching is on by default in `steno start`/`transcribe` and does nothing until
155
+ you enroll someone; disable it with `--no-reid`, or adjust the match strictness
156
+ with `--reid-threshold` (0–1, default 0.5). Voiceprints live in the platform data
157
+ dir (not the model cache) and are never uploaded.
158
+
159
+ ## Vocabulary
160
+
161
+ Domain terms and attendee names are corrected in the finalized transcript
162
+ (the ASR has no decode-time biasing, so this is a post-correction pass):
163
+
164
+ ```sh
165
+ steno transcribe rec.mov --attendee "Anja Müller" --glossary Kubernetes,gRPC
166
+ steno transcribe rec.mov --glossary-file terms.txt
167
+ ```
168
+
169
+ A term and its transcription must share a word count — `gRPC` can fix `G R P C`
170
+ spoken as one word, but not a term split across word boundaries.
171
+
172
+ ## Development
173
+
174
+ Requires [uv](https://docs.astral.sh/uv/) and Python ≥ 3.12.
175
+
176
+ ```sh
177
+ uv sync
178
+ uv run pytest
179
+ uv run steno doctor
180
+ ```
181
+
182
+ The test suite is label-free and runs without a meeting: model-gated and
183
+ real-audio tests self-skip when their assets are absent.
184
+
185
+ See [PLAN.md](PLAN.md) for the full architecture, model choices, and roadmap;
186
+ [PLAN-AEC.md](PLAN-AEC.md) for the echo-cancellation design and its measurements;
187
+ `native/README.md` for the capture helper and its wire protocol; `eval/README.md`
188
+ for the model-evaluation and AEC-scoring harnesses.
189
+
190
+ ## License
191
+
192
+ [MIT](LICENSE)
@@ -0,0 +1,211 @@
1
+ # Echo cancellation: evaluation & improvement plan
2
+
3
+ > **Status: complete (2026-07-10). All four tasks closed.** Tasks 1–2 shipped
4
+ > (measurement rig; dedup data-loss fix); Tasks 3–4 (energy gate, neural residual
5
+ > suppressor) were **closed as unnecessary** — a canceller with a live reference
6
+ > leaks nothing the ASR decodes. What remains open is capture-tap robustness, in
7
+ > §5 below. Read §1 for the measurements, §4 for how each task resolved.
8
+
9
+ Speakers + built-in mic is the default way to sit in a meeting, so remote voices
10
+ re-enter the mic and, untreated, get transcribed as `Local-N`. This document
11
+ planned the next iteration of the echo path: replace the text-dedup backstop with
12
+ an audio-domain gate, and build the measurement rig that every future change is
13
+ judged against. It extends PLAN.md §2 ("Hybrid-mode caveats"); the shipped state
14
+ it revised is `stenograf/aec.py` + `session.drop_echo_duplicates`.
15
+
16
+ ## 1. Where we stand, and what the numbers mean
17
+
18
+ Two layers shipped 2026-07-09:
19
+
20
+ 1. **WebRTC AEC3** (`stenograf.aec`, via livekit's `AudioProcessingModule`):
21
+ near end = mic, far end = the process tap. Measured 36 dB ERLE synthetic,
22
+ **30.5 dB live** on real acoustics.
23
+ 2. **Character-coverage text dedup** (`session.drop_echo_duplicates`): drops a
24
+ mic line whose text is ≥0.8-covered by an overlapping remote line.
25
+
26
+ Findings that reshape the plan:
27
+
28
+ - **~30 dB is the physics ceiling of the linear stage, not a tuning failure.**
29
+ The process tap captures the digital mix *upstream* of the smart-amp
30
+ speaker-protection DSP (nonlinear, time-varying excursion/thermal limiting on
31
+ Apple Silicon), so the reference AEC3 adapts against is not what the speaker
32
+ physically emitted. A linear filter cannot cancel distortion absent from its
33
+ input; the ~−70 dBFS residual is that nonlinear remainder. Don't chase linear
34
+ ERLE past 30 dB.
35
+ - **"Conference software cancels completely" is a misconception.** Zoom/Meet
36
+ ship the same ~20–40 dB linear canceller (this architecture *is* Chrome's:
37
+ AEC3 + system-loopback reference) followed by an aggressive residual echo
38
+ suppressor that ducks the mic into comfort noise. That suffices for a human
39
+ ear; it fails our requirements twice over: the ASR happily decodes −70 dBFS
40
+ speech-shaped residue a human never hears, and the suppressor damages
41
+ near-end speech during double-talk — exactly the overlapping speech a
42
+ transcriber must keep. Their quoted 55–65 dB ERLE is far-end-single-talk only.
43
+ - **The text-dedup layer is a measured data-loss bug, not a safe backstop.**
44
+ `_covered_by` normalizes by the mic line's length only, so a short local line
45
+ that is a chance subsequence of a long remote monologue scores ~1.0: against
46
+ a 56-word remote line, "no I don't think so" → 1.00, "yeah I think so" → 0.93;
47
+ 6 of 10 generic local utterances were destroyed, unrecoverably (the `.partial`
48
+ checkpoint is deleted on clean finalize). It also false-positives on
49
+ headphones (no acoustic path exists, dedup runs anyway; `--no-aec` disables
50
+ only the canceller, not dedup), and it never protected the live view — echo
51
+ lines display live and vanish only at finalize.
52
+ - **An audio-domain gate is viable, contra the aec.py docstring.** Genuine
53
+ near-end speech is an independent source and *raises* post-AEC output energy.
54
+ Measured through the real AEC3: echo-only output −58 dBFS vs double-talk
55
+ −15.6 dBFS — a **42 dB gap**. (Coherence on AEC3's *output* is dead — its
56
+ suppressor already stripped the coherent part — so gate on post-AEC energy,
57
+ or coherence on the raw mic.)
58
+ - **The current stack has no knobs and no eyes.** livekit's
59
+ `AudioProcessingModule` is four booleans; no `EchoCanceller3Config`, no ERLE
60
+ stats. No PyPI package ships tunable AEC3 for macOS arm64, and tuning the
61
+ suppressor trades double-talk transparency for suppression — the wrong trade
62
+ here. So: keep AEC3 as-is, add stages after it, and build our own metering.
63
+
64
+ ## 2. Target architecture
65
+
66
+ ```
67
+ mic ──► AEC3 (linear, unchanged) ──► residual gate (energy) ──► [neural RES]* ──► ASR
68
+ tap ──► reference ────────────────────┘ │ │
69
+ (*only if the gate measurably isn't enough)
70
+ ```
71
+
72
+ - **Residual energy gate** (Task 3): during far-end activity, post-AEC mic
73
+ audio at the residual floor is replaced by silence before it reaches the ASR.
74
+ Sits in the audio path, so it protects the live pass and the finalize pass
75
+ identically. Headphone-safe by construction: with no echo, low-energy mic
76
+ audio during playback is room noise that gates to silence harmlessly.
77
+ - **Neural residual suppressor** (Task 4, conditional): the principled tool for
78
+ nonlinear residue. Candidate: **LocalVQE** (Apache-2.0 code, CC-BY-4.0
79
+ weights, streaming DeepVQE derivative; 16 kHz mono, mic+reference input,
80
+ 16 ms latency, 49K–203K params, GGML CPU backend). Fallback: **DTLN-aec**
81
+ (MIT, TF-Lite). Adopt only if it beats the gate on the rig *and* its
82
+ double-talk near-end degradation is nil — an aggressive RES eating local
83
+ speech is the failure mode that disqualifies.
84
+ - **Text dedup**: demoted to a diagnostic once the gate ships; removed when the
85
+ rig shows zero leaked lines without it. Until then its data-loss bug is fixed
86
+ (Task 2) because it destroys real transcript lines today.
87
+
88
+ ## 3. Measurement (before any behavior change)
89
+
90
+ Three layers, all automated — no hand-labeling.
91
+
92
+ **Layer 0 — signal.** `--aec-dump DIR` writes three clock-aligned mono 16 kHz
93
+ WAVs per session: `mic.wav` (raw near end), `lpb.wav` (loopback/tap reference),
94
+ `enh.wav` (post-AEC, post-gate mic — what the ASR hears). Opt-in like
95
+ `--record-audio`, since it writes audio to disk. On these triples,
96
+ `eval/aec_score.py` computes:
97
+
98
+ - **ERLE** over far-active/near-silent spans (energy-based; waveform
99
+ correlation is useless on AEC3 output — it is fractionally delayed).
100
+ - **AECMOS** via the `speechmos` package: the AEC-Challenge metric, scoring
101
+ *echo annoyance* and *near-end degradation* separately, with a double-talk
102
+ mode. The degradation score is the guard on every suppression stage.
103
+
104
+ **Layer 1 — does residue become text (the metric that matters).**
105
+ `eval/aec_rig.py` orchestrates repeatable runs on real hardware: play a fixed
106
+ far-end WAV out the speakers while the real pipeline captures with
107
+ `--aec-dump`, then score. Scenarios:
108
+
109
+ | scenario | far end | near end | pass criteria |
110
+ |---|---|---|---|
111
+ | far-only | fixed WAV | silence | 0 `Local-N` lines ≥3 words |
112
+ | near-only | silence | scripted speech | local WER ≈ speakers-muted baseline |
113
+ | double-talk | fixed WAV | scripted speech | local lines survive; AECMOS-DT degradation ≈ nil |
114
+
115
+ Scripted near end is played from a second device at fixed position, so runs are
116
+ reproducible without a human performing each one. Report far-only leakage both
117
+ pre- and post-backstop so the canceller and the gate are measured separately.
118
+
119
+ **Layer 2 — backstop false positives.** Adversarial fixture: the local speaker
120
+ repeating what the remote just said within the dedup window ("so you're saying
121
+ we should ship Friday…") — any surviving whole-line text matcher must not
122
+ delete it.
123
+
124
+ **Scenario matrix** (the echo path is not one thing): speaker volume 50/75/100 %
125
+ (smart-amp nonlinearity grows with level), Bluetooth speaker (large variable
126
+ delay), headphones (zero drops, zero gating), German + English, music as far
127
+ end, device switch mid-session. For regression breadth beyond this one MacBook:
128
+ the Microsoft AEC-Challenge dataset (real recordings, 10k+ devices, genuine
129
+ nonlinear echo, double-talk) replayed through `--replay`.
130
+
131
+ Also surfaced by the dump: `far_end_missing_ticks` (exists, currently
132
+ unobservable) and the known long-session tap failure where PCM goes all-zeros —
133
+ which would silently blind the canceller.
134
+
135
+ ## 4. Tasks, in order
136
+
137
+ 1. **Measurement rig** — `--aec-dump`, `eval/aec_score.py` (+ `speechmos` in
138
+ the eval group), `eval/aec_rig.py`. Cheapest item; de-risks everything else.
139
+ Acceptance: one command produces scored far-only / near-only / double-talk
140
+ results on this machine. **DONE 2026-07-10.** Its first far-only run caught
141
+ a bug this plan didn't predict: the batch tail-checkpointer busy-spun on
142
+ `AudioBus.wait`, starved the capture thread, and Core Audio killed the
143
+ system tap ~3 s into every real-hardware `--no-live` meeting — the
144
+ canceller lost its reference and leaked everything (fixed in `ebf660a`;
145
+ regression-tested). After the fix, far-only measures **37.6 dB ERLE live,
146
+ −65 dBFS residual, AECMOS echo 4.73/deg 5.00, and 0 leaked lines before any
147
+ text backstop** (vs −27 dBFS raw / echo 1.49 uncancelled). The historical
148
+ "AEC leaks lines" evidence predates this fix and needs re-measuring.
149
+ 2. **Fix the dedup data loss** — normalize coverage against the aligned remote
150
+ span, not the whole remote line; `--no-aec` disables dedup; dedup skipped
151
+ when no echo path exists. Acceptance: the measured false-positive utterances
152
+ survive; the original leaked-echo fixtures still drop. **DONE 2026-07-10**
153
+ (`c2795f4`): span-density normalization drops the chance-subsequence scores
154
+ from 0.80–0.95 to 0.08–0.16 while real echoes stay at 0.89–1.00, and
155
+ `--no-aec` now disables dedup entirely.
156
+ 3. **Post-AEC energy gate** — in `stenograf.aec`, behind the same `--aec` flag.
157
+ Threshold placed with rig data (the 42 dB gap), not hand-tuned feel.
158
+ Acceptance: far-only leakage 0 lines pre-dedup; near-only WER unchanged;
159
+ double-talk AECMOS degradation unchanged. **CLOSED as unnecessary
160
+ 2026-07-10.** The full scenario matrix ran clean with no gate: far-only at
161
+ volume 63 and 100 % (37.6 / 33.0 dB ERLE, 0 leaks), far-only under live
162
+ inference load (0 leaks ≥3 words), double-talk (0 leaks, 0 false drops,
163
+ local speech transcribed throughout), and Bluetooth after the aggregate-rate
164
+ fix (`7dd1510`; 28.1 dB ERLE, 0 leaks). A healthy canceller's residual
165
+ simply does not decode. What *does* leak is a canceller that lost its
166
+ reference — two capture bugs proved it — so the shipped mitigation is the
167
+ **armed backstop** (`3d079cb`): `drop_echo_duplicates` runs only when
168
+ `far_end_missing_ticks > 0` (or the canceller was unobserved), and the CLI
169
+ warns with cause and drop count when it acts. Healthy meetings never run
170
+ it, so a verbatim local repeat can never be deleted.
171
+ 4. **Neural RES spike (conditional)** — only if (3) leaves leakage. **CLOSED
172
+ with (3)**: no decodeable residual to suppress. LocalVQE/DTLN-aec remain in
173
+ §5 as the escalation path if a future device class measures differently.
174
+
175
+ ## 5. Open items (the echo path is settled; the tap that feeds it is not)
176
+
177
+ Both tasks 3 and 4 closed on the same finding: **a canceller with a live reference
178
+ does not leak.** Every measured leak came from *losing* the reference. That makes
179
+ tap robustness — not suppression — the remaining work. Neither item below is
180
+ scheduled; both are cheap, and either would silently reintroduce echo lines.
181
+
182
+ 1. **The tap dies on any Python-side stall >~1 s, permanently, with no recovery.**
183
+ `stenocap`'s 64 KB stdout pipe fills, Core Audio kills the tap, and nothing
184
+ restarts it. Two separate bugs have already reached production through this
185
+ path (the tail-checkpointer busy-spin, `ebf660a`; the aggregate-rate mismatch,
186
+ `7dd1510`). A **drain thread in `MacOSCaptureProvider`** — reading the pipe
187
+ into a queue independently of the consumer — decouples capture from every
188
+ downstream stall. Highest-value hardening in the capture layer.
189
+
190
+ 2. **An all-zero tap is undetected.** `far_end_missing_ticks` (`aec.py:235`)
191
+ increments only when a far-end frame is **absent**. The known long-session
192
+ failure where the tap keeps delivering frames of silent PCM therefore leaves
193
+ the counter at 0: the armed text backstop never arms, the CLI never warns, and
194
+ AEC3 adapts against silence while echo passes straight through to the ASR —
195
+ the exact failure the backstop exists to catch, in its quietest form. Fix: an
196
+ energy check on the far-end tick (a reference that is bit-exact zero for many
197
+ consecutive seconds *while the near end is not* is a dead tap, not a quiet
198
+ meeting), feeding the same `reference_gap_s` signal.
199
+
200
+ ## 6. Sources
201
+
202
+ - livekit APM surface: `livekit-rtc` `apm.py` (four booleans; no config/stats).
203
+ - AEC3 suppressor internals & config: `api/audio/echo_canceller3_config.h`;
204
+ switchboard.audio "How WebRTC AEC3 works".
205
+ - Smart-amp DSP downstream of the tap: Apple loudspeaker-protection patents
206
+ (US10015593, US9525945, US10219074); tap-based EQ tools documenting
207
+ post-tap limiting.
208
+ - LocalVQE: github.com/localai-org/LocalVQE (weights: HF `LocalAI-io/LocalVQE`).
209
+ - DTLN-aec: github.com/breizhn/DTLN-aec (ICASSP 2021 AEC Challenge, 3rd).
210
+ - AECMOS / dataset: github.com/microsoft/AEC-Challenge; `speechmos` on PyPI.
211
+ - Double-talk detection basis: Benesty–Morgan–Cho (2000); Gänsler (1996).