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.
- stenograf-0.1.0/.github/workflows/ci.yml +30 -0
- stenograf-0.1.0/.github/workflows/release.yml +128 -0
- stenograf-0.1.0/.gitignore +40 -0
- stenograf-0.1.0/LICENSE +21 -0
- stenograf-0.1.0/PKG-INFO +192 -0
- stenograf-0.1.0/PLAN-AEC.md +211 -0
- stenograf-0.1.0/PLAN.md +1570 -0
- stenograf-0.1.0/README.md +165 -0
- stenograf-0.1.0/eval/README.md +142 -0
- stenograf-0.1.0/eval/adjudicate.py +310 -0
- stenograf-0.1.0/eval/aec_rig.py +216 -0
- stenograf-0.1.0/eval/aec_score.py +161 -0
- stenograf-0.1.0/eval/backends.py +226 -0
- stenograf-0.1.0/eval/common.py +90 -0
- stenograf-0.1.0/eval/der.py +271 -0
- stenograf-0.1.0/eval/diarize.py +129 -0
- stenograf-0.1.0/eval/extract.py +75 -0
- stenograf-0.1.0/eval/live.py +150 -0
- stenograf-0.1.0/eval/manifest.example.json +18 -0
- stenograf-0.1.0/eval/rttm.py +72 -0
- stenograf-0.1.0/eval/scan_languages.py +80 -0
- stenograf-0.1.0/eval/score.py +91 -0
- stenograf-0.1.0/eval/transcribe.py +106 -0
- stenograf-0.1.0/hatch_build.py +62 -0
- stenograf-0.1.0/native/README.md +72 -0
- stenograf-0.1.0/native/helper/Info.plist +16 -0
- stenograf-0.1.0/native/helper/build.sh +20 -0
- stenograf-0.1.0/native/helper/main.swift +464 -0
- stenograf-0.1.0/native/spike/Info.plist +16 -0
- stenograf-0.1.0/native/spike/build.sh +15 -0
- stenograf-0.1.0/native/spike/main.swift +238 -0
- stenograf-0.1.0/pyproject.toml +121 -0
- stenograf-0.1.0/src/stenograf/__init__.py +8 -0
- stenograf-0.1.0/src/stenograf/aec.py +355 -0
- stenograf-0.1.0/src/stenograf/archive.py +330 -0
- stenograf-0.1.0/src/stenograf/asr/__init__.py +23 -0
- stenograf-0.1.0/src/stenograf/asr/base.py +62 -0
- stenograf-0.1.0/src/stenograf/asr/parakeet.py +94 -0
- stenograf-0.1.0/src/stenograf/asr/registry.py +100 -0
- stenograf-0.1.0/src/stenograf/audio.py +69 -0
- stenograf-0.1.0/src/stenograf/capture/__init__.py +3 -0
- stenograf-0.1.0/src/stenograf/capture/base.py +70 -0
- stenograf-0.1.0/src/stenograf/capture/file.py +78 -0
- stenograf-0.1.0/src/stenograf/capture/macos.py +164 -0
- stenograf-0.1.0/src/stenograf/cli.py +1386 -0
- stenograf-0.1.0/src/stenograf/config.py +130 -0
- stenograf-0.1.0/src/stenograf/control.py +237 -0
- stenograf-0.1.0/src/stenograf/diarization/__init__.py +3 -0
- stenograf-0.1.0/src/stenograf/diarization/base.py +57 -0
- stenograf-0.1.0/src/stenograf/diarization/sherpa.py +142 -0
- stenograf-0.1.0/src/stenograf/doctor.py +151 -0
- stenograf-0.1.0/src/stenograf/glossary.py +207 -0
- stenograf-0.1.0/src/stenograf/lid.py +69 -0
- stenograf-0.1.0/src/stenograf/live.py +324 -0
- stenograf-0.1.0/src/stenograf/models.py +119 -0
- stenograf-0.1.0/src/stenograf/pipeline.py +259 -0
- stenograf-0.1.0/src/stenograf/profiles.py +287 -0
- stenograf-0.1.0/src/stenograf/recording.py +210 -0
- stenograf-0.1.0/src/stenograf/session.py +1152 -0
- stenograf-0.1.0/src/stenograf/transcript.py +288 -0
- stenograf-0.1.0/src/stenograf/tui.py +449 -0
- stenograf-0.1.0/src/stenograf/vad.py +109 -0
- stenograf-0.1.0/src/stenograf/view.py +185 -0
- stenograf-0.1.0/tests/fake_stenocap.py +44 -0
- stenograf-0.1.0/tests/test_aec.py +240 -0
- stenograf-0.1.0/tests/test_archive.py +218 -0
- stenograf-0.1.0/tests/test_asr_parakeet.py +41 -0
- stenograf-0.1.0/tests/test_asr_registry.py +50 -0
- stenograf-0.1.0/tests/test_audio.py +40 -0
- stenograf-0.1.0/tests/test_build_hook.py +86 -0
- stenograf-0.1.0/tests/test_capture_file.py +72 -0
- stenograf-0.1.0/tests/test_capture_macos.py +112 -0
- stenograf-0.1.0/tests/test_cli.py +744 -0
- stenograf-0.1.0/tests/test_config.py +98 -0
- stenograf-0.1.0/tests/test_control.py +337 -0
- stenograf-0.1.0/tests/test_diarization_sherpa.py +169 -0
- stenograf-0.1.0/tests/test_diarization_sherpa_unit.py +126 -0
- stenograf-0.1.0/tests/test_doctor.py +109 -0
- stenograf-0.1.0/tests/test_echo_dedup.py +126 -0
- stenograf-0.1.0/tests/test_eval_der.py +117 -0
- stenograf-0.1.0/tests/test_glossary.py +91 -0
- stenograf-0.1.0/tests/test_lid.py +30 -0
- stenograf-0.1.0/tests/test_live.py +274 -0
- stenograf-0.1.0/tests/test_live_orchestration.py +421 -0
- stenograf-0.1.0/tests/test_models.py +131 -0
- stenograf-0.1.0/tests/test_pipeline.py +374 -0
- stenograf-0.1.0/tests/test_profiles.py +194 -0
- stenograf-0.1.0/tests/test_recording.py +129 -0
- stenograf-0.1.0/tests/test_session.py +774 -0
- stenograf-0.1.0/tests/test_transcript.py +335 -0
- stenograf-0.1.0/tests/test_tui.py +300 -0
- stenograf-0.1.0/tests/test_vad.py +39 -0
- stenograf-0.1.0/tests/test_view.py +192 -0
- 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
|
stenograf-0.1.0/LICENSE
ADDED
|
@@ -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.
|
stenograf-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|