ripdoctor 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 (171) hide show
  1. ripdoctor-0.1.0/.agent-standard +3 -0
  2. ripdoctor-0.1.0/.github/dependabot.yml +30 -0
  3. ripdoctor-0.1.0/.github/rulesets/main.json +55 -0
  4. ripdoctor-0.1.0/.github/workflows/ci.yml +76 -0
  5. ripdoctor-0.1.0/.github/workflows/release.yml +85 -0
  6. ripdoctor-0.1.0/.gitignore +30 -0
  7. ripdoctor-0.1.0/LICENSE +21 -0
  8. ripdoctor-0.1.0/Makefile +76 -0
  9. ripdoctor-0.1.0/PKG-INFO +269 -0
  10. ripdoctor-0.1.0/README.md +243 -0
  11. ripdoctor-0.1.0/docs/decisions.md +1418 -0
  12. ripdoctor-0.1.0/docs/images/cut_panel.png +0 -0
  13. ripdoctor-0.1.0/docs/images/rip_panel.png +0 -0
  14. ripdoctor-0.1.0/docs/method.md +265 -0
  15. ripdoctor-0.1.0/packaging/ripdoctor.service +65 -0
  16. ripdoctor-0.1.0/pyproject.toml +86 -0
  17. ripdoctor-0.1.0/ripdoctor/__init__.py +23 -0
  18. ripdoctor-0.1.0/ripdoctor/audio/__init__.py +1 -0
  19. ripdoctor-0.1.0/ripdoctor/audio/align.py +249 -0
  20. ripdoctor-0.1.0/ripdoctor/audio/astats.py +148 -0
  21. ripdoctor-0.1.0/ripdoctor/audio/capture.py +309 -0
  22. ripdoctor-0.1.0/ripdoctor/audio/decode.py +92 -0
  23. ripdoctor-0.1.0/ripdoctor/audio/devices.py +136 -0
  24. ripdoctor-0.1.0/ripdoctor/audio/ffprobe.py +81 -0
  25. ripdoctor-0.1.0/ripdoctor/audio/passthru.py +241 -0
  26. ripdoctor-0.1.0/ripdoctor/audio/runner.py +360 -0
  27. ripdoctor-0.1.0/ripdoctor/audio/session.py +197 -0
  28. ripdoctor-0.1.0/ripdoctor/audio/split.py +154 -0
  29. ripdoctor-0.1.0/ripdoctor/cli.py +661 -0
  30. ripdoctor-0.1.0/ripdoctor/config/__init__.py +1 -0
  31. ripdoctor-0.1.0/ripdoctor/config/machine.py +75 -0
  32. ripdoctor-0.1.0/ripdoctor/config/settings.py +209 -0
  33. ripdoctor-0.1.0/ripdoctor/config/thresholds.py +56 -0
  34. ripdoctor-0.1.0/ripdoctor/core/__init__.py +1 -0
  35. ripdoctor-0.1.0/ripdoctor/core/autostop.py +106 -0
  36. ripdoctor-0.1.0/ripdoctor/core/envelope.py +194 -0
  37. ripdoctor-0.1.0/ripdoctor/core/fit.py +247 -0
  38. ripdoctor-0.1.0/ripdoctor/core/gaps.py +181 -0
  39. ripdoctor-0.1.0/ripdoctor/core/measure.py +179 -0
  40. ripdoctor-0.1.0/ripdoctor/core/meter.py +175 -0
  41. ripdoctor-0.1.0/ripdoctor/core/naming.py +116 -0
  42. ripdoctor-0.1.0/ripdoctor/core/plan.py +222 -0
  43. ripdoctor-0.1.0/ripdoctor/core/sides.py +104 -0
  44. ripdoctor-0.1.0/ripdoctor/core/xcorr.py +133 -0
  45. ripdoctor-0.1.0/ripdoctor/doctor/__init__.py +1 -0
  46. ripdoctor-0.1.0/ripdoctor/doctor/checks.py +332 -0
  47. ripdoctor-0.1.0/ripdoctor/integrations/__init__.py +1 -0
  48. ripdoctor-0.1.0/ripdoctor/integrations/artwork.py +293 -0
  49. ripdoctor-0.1.0/ripdoctor/integrations/importer.py +310 -0
  50. ripdoctor-0.1.0/ripdoctor/integrations/musicbrainz.py +292 -0
  51. ripdoctor-0.1.0/ripdoctor/integrations/tagger.py +197 -0
  52. ripdoctor-0.1.0/ripdoctor/store/__init__.py +1 -0
  53. ripdoctor-0.1.0/ripdoctor/store/archive.py +179 -0
  54. ripdoctor-0.1.0/ripdoctor/store/cache.py +281 -0
  55. ripdoctor-0.1.0/ripdoctor/store/files.py +333 -0
  56. ripdoctor-0.1.0/ripdoctor/store/safety.py +34 -0
  57. ripdoctor-0.1.0/ripdoctor/web/__init__.py +1 -0
  58. ripdoctor-0.1.0/ripdoctor/web/app.py +96 -0
  59. ripdoctor-0.1.0/ripdoctor/web/auth.py +237 -0
  60. ripdoctor-0.1.0/ripdoctor/web/http.py +196 -0
  61. ripdoctor-0.1.0/ripdoctor/web/httpd.py +213 -0
  62. ripdoctor-0.1.0/ripdoctor/web/routes/__init__.py +27 -0
  63. ripdoctor-0.1.0/ripdoctor/web/routes/art.py +145 -0
  64. ripdoctor-0.1.0/ripdoctor/web/routes/capture.py +468 -0
  65. ripdoctor-0.1.0/ripdoctor/web/routes/cutting.py +140 -0
  66. ripdoctor-0.1.0/ripdoctor/web/routes/library.py +390 -0
  67. ripdoctor-0.1.0/ripdoctor/web/routes/punching.py +130 -0
  68. ripdoctor-0.1.0/ripdoctor/web/routes/records.py +394 -0
  69. ripdoctor-0.1.0/ripdoctor/web/routes/session.py +45 -0
  70. ripdoctor-0.1.0/ripdoctor/web/service.py +40 -0
  71. ripdoctor-0.1.0/ripdoctor/web/static/app.js +2098 -0
  72. ripdoctor-0.1.0/ripdoctor/web/static/audio.js +97 -0
  73. ripdoctor-0.1.0/ripdoctor/web/static/editor.js +355 -0
  74. ripdoctor-0.1.0/ripdoctor/web/static/icon.svg +39 -0
  75. ripdoctor-0.1.0/ripdoctor/web/static/index.html +329 -0
  76. ripdoctor-0.1.0/ripdoctor/web/static/ripui.js +80 -0
  77. ripdoctor-0.1.0/ripdoctor/web/static/style.css +274 -0
  78. ripdoctor-0.1.0/ripdoctor/web/static.py +55 -0
  79. ripdoctor-0.1.0/ripdoctor/work/__init__.py +1 -0
  80. ripdoctor-0.1.0/ripdoctor/work/capture.py +203 -0
  81. ripdoctor-0.1.0/ripdoctor/work/jobs.py +138 -0
  82. ripdoctor-0.1.0/ripdoctor/work/punch.py +336 -0
  83. ripdoctor-0.1.0/scripts/budgets.py +165 -0
  84. ripdoctor-0.1.0/tests/__init__.py +0 -0
  85. ripdoctor-0.1.0/tests/conftest.py +39 -0
  86. ripdoctor-0.1.0/tests/fixtures/golden/manifest.json +38 -0
  87. ripdoctor-0.1.0/tests/fixtures/golden/record-30deab76.json +383 -0
  88. ripdoctor-0.1.0/tests/fixtures/golden/record-389fec66.json +571 -0
  89. ripdoctor-0.1.0/tests/fixtures/golden/record-910f935f.json +1 -0
  90. ripdoctor-0.1.0/tests/fixtures/golden/record-a5b4a005.json +1 -0
  91. ripdoctor-0.1.0/tests/fixtures/golden/record-c5652c48.json +337 -0
  92. ripdoctor-0.1.0/tests/fixtures/golden/record-f838e3f8.json +493 -0
  93. ripdoctor-0.1.0/tests/fixtures/plans/ear-set.plan.json +99 -0
  94. ripdoctor-0.1.0/tests/fixtures/plans/ear-set.spec.json +117 -0
  95. ripdoctor-0.1.0/tests/fixtures/sides/album-1bc1c97e-side-a.env +0 -0
  96. ripdoctor-0.1.0/tests/fixtures/sides/album-1bc1c97e-side-b.env +0 -0
  97. ripdoctor-0.1.0/tests/fixtures/sides/album-1bc1c97e-side-c.env +0 -0
  98. ripdoctor-0.1.0/tests/fixtures/sides/album-1bc1c97e-side-d.env +0 -0
  99. ripdoctor-0.1.0/tests/fixtures/sides/album-30deab76-side-a.env +0 -0
  100. ripdoctor-0.1.0/tests/fixtures/sides/album-30deab76-side-b.env +0 -0
  101. ripdoctor-0.1.0/tests/fixtures/sides/album-52578964-side-a.env +0 -0
  102. ripdoctor-0.1.0/tests/fixtures/sides/album-52578964-side-b.env +0 -0
  103. ripdoctor-0.1.0/tests/fixtures/sides/album-52578964-side-c.env +0 -0
  104. ripdoctor-0.1.0/tests/fixtures/sides/album-52578964-side-d.env +0 -0
  105. ripdoctor-0.1.0/tests/fixtures/sides/album-910f935f-side-Orphan.env +0 -0
  106. ripdoctor-0.1.0/tests/fixtures/sides/album-910f935f-side-a.env +0 -0
  107. ripdoctor-0.1.0/tests/fixtures/sides/album-910f935f-side-b.env +0 -0
  108. ripdoctor-0.1.0/tests/fixtures/sides/album-949564fd-side-a.env +0 -0
  109. ripdoctor-0.1.0/tests/fixtures/sides/album-949564fd-side-b.env +0 -0
  110. ripdoctor-0.1.0/tests/fixtures/sides/album-a5b4a005-side-a.env +0 -0
  111. ripdoctor-0.1.0/tests/fixtures/sides/album-a5b4a005-side-b.env +0 -0
  112. ripdoctor-0.1.0/tests/fixtures/sides/album-a5b4a005-side-c.env +0 -0
  113. ripdoctor-0.1.0/tests/fixtures/sides/album-a5b4a005-side-d.env +0 -0
  114. ripdoctor-0.1.0/tests/fixtures/sides/album-b098cc91-side-Sample.env +0 -0
  115. ripdoctor-0.1.0/tests/fixtures/sides/album-b098cc91-side-a.env +0 -0
  116. ripdoctor-0.1.0/tests/fixtures/sides/album-c5652c48-side-a.env +0 -0
  117. ripdoctor-0.1.0/tests/fixtures/sides/album-c5652c48-side-b.env +0 -0
  118. ripdoctor-0.1.0/tests/fixtures/sides/album-c5652c48-side-c.env +0 -0
  119. ripdoctor-0.1.0/tests/fixtures/sides/album-c5652c48-side-d.env +0 -0
  120. ripdoctor-0.1.0/tests/fixtures/sides/album-d68a2806-side-a.env +0 -0
  121. ripdoctor-0.1.0/tests/fixtures/sides/album-d68a2806-side-b.env +0 -0
  122. ripdoctor-0.1.0/tests/fixtures/sides/album-d68a2806-side-c.env +0 -0
  123. ripdoctor-0.1.0/tests/fixtures/sides/album-d68a2806-side-d.env +0 -0
  124. ripdoctor-0.1.0/tests/fixtures/sides/manifest.json +147 -0
  125. ripdoctor-0.1.0/tests/pool.py +77 -0
  126. ripdoctor-0.1.0/tests/tape.py +102 -0
  127. ripdoctor-0.1.0/tests/test_align.py +190 -0
  128. ripdoctor-0.1.0/tests/test_architecture.py +165 -0
  129. ripdoctor-0.1.0/tests/test_artwork.py +214 -0
  130. ripdoctor-0.1.0/tests/test_audio_decode.py +181 -0
  131. ripdoctor-0.1.0/tests/test_audio_probe.py +281 -0
  132. ripdoctor-0.1.0/tests/test_audio_split.py +188 -0
  133. ripdoctor-0.1.0/tests/test_autostop.py +226 -0
  134. ripdoctor-0.1.0/tests/test_beets_live.py +156 -0
  135. ripdoctor-0.1.0/tests/test_cache.py +213 -0
  136. ripdoctor-0.1.0/tests/test_capture.py +369 -0
  137. ripdoctor-0.1.0/tests/test_cli.py +694 -0
  138. ripdoctor-0.1.0/tests/test_config.py +212 -0
  139. ripdoctor-0.1.0/tests/test_devices.py +113 -0
  140. ripdoctor-0.1.0/tests/test_doctor.py +357 -0
  141. ripdoctor-0.1.0/tests/test_end_to_end.py +168 -0
  142. ripdoctor-0.1.0/tests/test_envelope.py +295 -0
  143. ripdoctor-0.1.0/tests/test_fit.py +367 -0
  144. ripdoctor-0.1.0/tests/test_frontend.py +147 -0
  145. ripdoctor-0.1.0/tests/test_gaps.py +268 -0
  146. ripdoctor-0.1.0/tests/test_golden.py +270 -0
  147. ripdoctor-0.1.0/tests/test_importer.py +288 -0
  148. ripdoctor-0.1.0/tests/test_jobs.py +161 -0
  149. ripdoctor-0.1.0/tests/test_measure.py +124 -0
  150. ripdoctor-0.1.0/tests/test_meter.py +212 -0
  151. ripdoctor-0.1.0/tests/test_musicbrainz.py +312 -0
  152. ripdoctor-0.1.0/tests/test_naming.py +180 -0
  153. ripdoctor-0.1.0/tests/test_passthru.py +196 -0
  154. ripdoctor-0.1.0/tests/test_plan.py +202 -0
  155. ripdoctor-0.1.0/tests/test_punch.py +304 -0
  156. ripdoctor-0.1.0/tests/test_runner.py +146 -0
  157. ripdoctor-0.1.0/tests/test_session.py +252 -0
  158. ripdoctor-0.1.0/tests/test_sides.py +172 -0
  159. ripdoctor-0.1.0/tests/test_store.py +436 -0
  160. ripdoctor-0.1.0/tests/test_tagger.py +213 -0
  161. ripdoctor-0.1.0/tests/test_web_app.py +183 -0
  162. ripdoctor-0.1.0/tests/test_web_auth.py +219 -0
  163. ripdoctor-0.1.0/tests/test_web_capture.py +701 -0
  164. ripdoctor-0.1.0/tests/test_web_contract.py +172 -0
  165. ripdoctor-0.1.0/tests/test_web_http.py +140 -0
  166. ripdoctor-0.1.0/tests/test_web_httpd.py +248 -0
  167. ripdoctor-0.1.0/tests/test_web_library.py +651 -0
  168. ripdoctor-0.1.0/tests/test_web_punch.py +213 -0
  169. ripdoctor-0.1.0/tests/test_web_routes.py +799 -0
  170. ripdoctor-0.1.0/tests/test_work_capture.py +141 -0
  171. ripdoctor-0.1.0/tests/test_xcorr.py +181 -0
@@ -0,0 +1,3 @@
1
+ standard = 1
2
+ stakes = zero
3
+ todo = 0
@@ -0,0 +1,30 @@
1
+ # One dependency and a handful of actions, all of which move without telling
2
+ # anyone. A pull request that has to pass the same gates as anything else is a
3
+ # reasonable way to hear about it; an automatic merge would not be.
4
+ version: 2
5
+ updates:
6
+ - package-ecosystem: pip
7
+ directory: /
8
+ schedule:
9
+ # Mondays, early. Nothing here is urgent, and weekly is often enough to
10
+ # catch a release without the tracker becoming wallpaper.
11
+ interval: weekly
12
+ day: monday
13
+ time: "06:00"
14
+ open-pull-requests-limit: 3
15
+ groups:
16
+ # The dev tools move together and are reviewed together; one pull request
17
+ # for a ruff and a mypy bump is one review, not two.
18
+ tooling:
19
+ patterns: ["ruff", "mypy", "pytest*", "hypothesis"]
20
+
21
+ - package-ecosystem: github-actions
22
+ directory: /
23
+ schedule:
24
+ interval: weekly
25
+ day: monday
26
+ time: "06:00"
27
+ open-pull-requests-limit: 3
28
+ groups:
29
+ actions:
30
+ patterns: ["*"]
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "main",
3
+ "target": "branch",
4
+ "enforcement": "active",
5
+ "conditions": {
6
+ "ref_name": {
7
+ "exclude": [],
8
+ "include": ["~DEFAULT_BRANCH"]
9
+ }
10
+ },
11
+ "rules": [
12
+ { "type": "deletion" },
13
+ { "type": "non_fast_forward" },
14
+ { "type": "required_linear_history" },
15
+ {
16
+ "type": "pull_request",
17
+ "parameters": {
18
+ "required_approving_review_count": 0,
19
+ "dismiss_stale_reviews_on_push": true,
20
+ "required_reviewers": [],
21
+ "require_code_owner_review": false,
22
+ "require_last_push_approval": false,
23
+ "required_review_thread_resolution": false,
24
+ "allowed_merge_methods": ["squash", "rebase"]
25
+ }
26
+ },
27
+ {
28
+ "type": "required_status_checks",
29
+ "parameters": {
30
+ "strict_required_status_checks_policy": true,
31
+ "do_not_enforce_on_create": false,
32
+ "required_status_checks": [
33
+ { "context": "check (3.11)", "integration_id": 15368 },
34
+ { "context": "check (3.12)", "integration_id": 15368 },
35
+ { "context": "check (3.13)", "integration_id": 15368 },
36
+ { "context": "with-tools", "integration_id": 15368 },
37
+ { "context": "packaging", "integration_id": 15368 }
38
+ ]
39
+ }
40
+ },
41
+ {
42
+ "type": "code_scanning",
43
+ "parameters": {
44
+ "code_scanning_tools": [
45
+ {
46
+ "tool": "CodeQL",
47
+ "security_alerts_threshold": "high_or_higher",
48
+ "alerts_threshold": "errors"
49
+ }
50
+ ]
51
+ }
52
+ }
53
+ ],
54
+ "bypass_actors": []
55
+ }
@@ -0,0 +1,76 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ check:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python: ["3.11", "3.12", "3.13"]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0 # hatch-vcs derives the version from tags
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python }}
24
+ - run: pip install -e ".[dev]"
25
+ - run: python -m ruff format --check .
26
+ - run: python -m ruff check .
27
+ - run: python -m mypy
28
+ - run: python -m pytest
29
+ - run: python scripts/budgets.py
30
+
31
+ # The tier that needs real external programs. It asserts the marked tests
32
+ # actually ran: a filter that matches nothing exits zero, so a job meant to
33
+ # run this tier and running none of it would otherwise report green.
34
+ with-tools:
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ with:
39
+ fetch-depth: 0
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: "3.12"
43
+ # flac carries metaflac; beets arrives as a dependency of the package.
44
+ - run: sudo apt-get update && sudo apt-get install -y ffmpeg flac
45
+ - run: pip install -e ".[dev]"
46
+ - run: python -m pytest -q --require=needs_ffmpeg --require=needs_beets
47
+
48
+ # Python's failure mode is not a compile error, it is a wheel that will not
49
+ # install. Build it, check it, install it clean, and run the binary.
50
+ packaging:
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ with:
55
+ fetch-depth: 0
56
+ - uses: actions/setup-python@v5
57
+ with:
58
+ python-version: "3.11"
59
+ - run: pip install build twine
60
+ - run: python -m build
61
+ - run: python -m twine check dist/*
62
+ - name: install the wheel into a clean environment
63
+ run: |
64
+ python -m venv /tmp/clean
65
+ /tmp/clean/bin/pip install dist/*.whl
66
+ /tmp/clean/bin/ripdoctor --version
67
+ /tmp/clean/bin/ripdoctor doctor --json > /dev/null || true
68
+ # The install is allowed beets and whatever beets itself needs. What it
69
+ # is not allowed is a dependency of this project's own that nobody
70
+ # decided on - scripts/budgets.py holds that line against pyproject.toml,
71
+ # and this checks the thing pip actually resolved.
72
+ - name: the install pulls in what it says it does
73
+ run: |
74
+ /tmp/clean/bin/pip show ripdoctor | grep -q '^Requires: beets$' || {
75
+ /tmp/clean/bin/pip show ripdoctor; echo "unexpected requirements"; exit 1; }
76
+ /tmp/clean/bin/beet version
@@ -0,0 +1,85 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ permissions:
8
+ contents: write
9
+ # Trusted publishing and keyless signing both work the same way: id-token
10
+ # mints an OIDC token that binds the upload to this workflow, and there is no
11
+ # API token to store, rotate or leak. attestations writes the provenance.
12
+ id-token: write
13
+ attestations: write
14
+
15
+ jobs:
16
+ release:
17
+ runs-on: ubuntu-latest
18
+ environment: pypi
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0 # hatch-vcs derives the version from tags
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.11"
26
+
27
+ # The gates run again here rather than trusting the branch: a tag can be
28
+ # pushed at any commit, including one CI never saw.
29
+ - run: sudo apt-get update && sudo apt-get install -y ffmpeg flac
30
+ - run: pip install -e ".[dev]"
31
+ - run: python -m ruff format --check .
32
+ - run: python -m ruff check .
33
+ - run: python -m mypy
34
+ - run: python -m pytest -q --require=needs_ffmpeg --require=needs_beets
35
+ - run: python scripts/budgets.py
36
+
37
+ - run: pip install build twine
38
+ - run: python -m build
39
+
40
+ # hatch-vcs writes the version from the tag, so a mismatch means the tree
41
+ # was dirty or the tags did not arrive - either way the artifact is not
42
+ # the thing the tag names, and it is cheaper to find that out here than
43
+ # from a release nobody can reproduce.
44
+ - name: the artifact matches the tag
45
+ run: |
46
+ built=$(ls dist/*.whl | sed -E 's/.*ripdoctor-([^-]+)-py3.*/\1/')
47
+ want="${GITHUB_REF_NAME#v}"
48
+ test "$built" = "$want" || {
49
+ echo "::error::tag ${GITHUB_REF_NAME} but the wheel says $built"
50
+ exit 1; }
51
+
52
+ - run: python -m twine check dist/*
53
+
54
+ # Python's failure mode is not a compile error, it is a wheel that will
55
+ # not install. Prove it does, from the artifact about to be published.
56
+ - name: install the wheel into a clean environment
57
+ run: |
58
+ python -m venv /tmp/clean
59
+ /tmp/clean/bin/pip install dist/*.whl
60
+ /tmp/clean/bin/ripdoctor --version
61
+ /tmp/clean/bin/beet version
62
+
63
+ - name: sign the artifacts
64
+ id: attest
65
+ uses: actions/attest-build-provenance@v2
66
+ with:
67
+ subject-path: 'dist/*'
68
+
69
+ # Published beside the artifacts so verifying needs no GitHub account:
70
+ # `gh attestation verify` without --bundle asks the API, which requires a
71
+ # login; with one it reads from disk.
72
+ - name: publish the release
73
+ run: |
74
+ cp "${{ steps.attest.outputs.bundle-path }}" attestation.jsonl
75
+ gh release create "${GITHUB_REF_NAME}" \
76
+ --title "${GITHUB_REF_NAME}" \
77
+ --generate-notes \
78
+ dist/* attestation.jsonl
79
+ env:
80
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81
+
82
+ # Last, because it is the only step that cannot be undone. Everything
83
+ # above can be deleted and done again; a version on PyPI cannot be
84
+ # replaced, only yanked.
85
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,30 @@
1
+ # Secrets never enter this repo.
2
+ .env
3
+ .env.*
4
+
5
+ # Build and test artifacts
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ __pycache__/
15
+ *.pyc
16
+
17
+ # Virtualenvs
18
+ .venv/
19
+ venv/
20
+
21
+ # Editor / OS
22
+ *.bak
23
+ *.orig
24
+ *.swp
25
+ *~
26
+ .DS_Store
27
+
28
+ # Maps anonymised fixtures back to album names. Never committed.
29
+ tests/fixtures/PRIVATE-mapping.json
30
+ tests/fixtures/PRIVATE-golden-mapping.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bryan Speelman
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,76 @@
1
+ # RipDoctor.
2
+ #
3
+ # `make check` is the gate. It is what CI runs and what must pass before a
4
+ # commit. Everything else here is a component of it or a convenience.
5
+
6
+ PY ?= .venv/bin/python
7
+
8
+ .PHONY: help venv lint types test cov budgets check wheel release clean
9
+
10
+ help:
11
+ @echo "make check lint, types, tests, budgets - the gate"
12
+ @echo "make venv create .venv and install the package editable"
13
+ @echo "make lint ruff format --check and ruff check"
14
+ @echo "make types mypy (strict on core/)"
15
+ @echo "make test pytest"
16
+ @echo "make cov pytest with coverage, gated on core/ only"
17
+ @echo "make budgets size budgets; failing one means retire, not raise"
18
+ @echo "make wheel build the wheel and check its size"
19
+ @echo "make release VERSION=x.y.z tag it; Actions builds and publishes"
20
+
21
+ venv:
22
+ python3 -m venv .venv
23
+ $(PY) -m pip install --upgrade pip
24
+ $(PY) -m pip install -e ".[dev]"
25
+
26
+ lint:
27
+ $(PY) -m ruff format --check .
28
+ $(PY) -m ruff check .
29
+
30
+ types:
31
+ $(PY) -m mypy
32
+
33
+ test:
34
+ $(PY) -m pytest
35
+
36
+ # Scoped to core/ deliberately. A whole-project coverage number lets untested
37
+ # application code hide behind a well-tested algorithm, which is the opposite of
38
+ # what the number is for.
39
+ cov:
40
+ $(PY) -m pytest --cov=ripdoctor/core --cov-report=term-missing --cov-fail-under=95
41
+
42
+ budgets:
43
+ $(PY) scripts/budgets.py
44
+
45
+ check: lint types test budgets
46
+
47
+ wheel:
48
+ $(PY) -m pip install --quiet build
49
+ $(PY) scripts/budgets.py --wheel
50
+
51
+ # There is no version to bump: hatch-vcs writes it from the tag, so the tag is
52
+ # the release. What is left is the checking that the tag is being put somewhere
53
+ # it can be reproduced from - a green tree, on main, level with the remote.
54
+ release:
55
+ @test -n "$(VERSION)" || { echo "usage: make release VERSION=x.y.z"; exit 1; }
56
+ @echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || \
57
+ { echo "VERSION must be x.y.z"; exit 1; }
58
+ @test "$$(git rev-parse --abbrev-ref HEAD)" = main || \
59
+ { echo "release from main"; exit 1; }
60
+ @git diff --quiet && git diff --cached --quiet || \
61
+ { echo "working tree is dirty; commit first"; exit 1; }
62
+ @! git rev-parse -q --verify "refs/tags/v$(VERSION)" >/dev/null || \
63
+ { echo "v$(VERSION) already exists"; exit 1; }
64
+ git fetch --quiet origin main
65
+ @test "$$(git rev-parse HEAD)" = "$$(git rev-parse origin/main)" || \
66
+ { echo "main is not level with origin/main; run 'git pull'"; exit 1; }
67
+ $(MAKE) check
68
+ git tag -a "v$(VERSION)" -m "v$(VERSION)"
69
+ git push origin "v$(VERSION)"
70
+ @echo
71
+ @echo "pushed v$(VERSION). Actions runs the gates again at the tag, builds,"
72
+ @echo "attests, publishes the release and uploads to PyPI."
73
+
74
+ clean:
75
+ rm -rf dist build .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage
76
+ find . -name __pycache__ -type d -prune -exec rm -rf {} +
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.5
2
+ Name: ripdoctor
3
+ Version: 0.1.0
4
+ Summary: Capture a vinyl side, find the track boundaries, cut them where you want them.
5
+ Project-URL: Homepage, https://github.com/bspeelm/ripdoctor
6
+ Project-URL: Source, https://github.com/bspeelm/ripdoctor
7
+ Author: Bryan Speelman
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: audio,digitization,flac,track-splitting,vinyl
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Editors
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: beets>=2.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: hypothesis>=6; extra == 'dev'
21
+ Requires-Dist: mypy>=1.11; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # RipDoctor
28
+
29
+ Capture a vinyl side, find the track boundaries, cut them where you want them.
30
+
31
+ **Status: it has taken a record.** One LP, start to finish on real hardware -
32
+ both sides captured from the turntable, boundaries fitted and checked, tracks
33
+ cut, tagged and filed with cover art and album-mode ReplayGain, and the raw
34
+ sides archived only once the record was provably in the library. Driven from
35
+ the page throughout, with nothing typed at a shell to get it through.
36
+
37
+ That is one record, on one machine, through one signal chain, by the person who
38
+ wrote it. It is the difference between "the tests pass" and "it works", and it
39
+ is not the same as "it works for you". See
40
+ [docs/decisions.md](docs/decisions.md) for what has been decided and why, and
41
+ `ripdoctor doctor` for what this machine is missing.
42
+
43
+ ## The problem it solves
44
+
45
+ Splitting a recorded vinyl side into tracks is usually done by detecting
46
+ silence: pick a threshold in decibels, call anything quieter a gap, cut there.
47
+ On most records this works.
48
+
49
+ On a sparse pressing it cannot work, because there is no such threshold. A real
50
+ inter-track gap and a quiet passage inside a song both sit near −43 dB
51
+ full-band, and no number separates them. Tuning the threshold trades one failure
52
+ for the other. On one album this produced two tracks that were 29 seconds too
53
+ long and 22 seconds too short.
54
+
55
+ Restrict the measurement to 1–3 kHz and the same two things separate by about
56
+ 20 dB. Vinyl's noise — plinth rumble, arm handling, warp — is bass-heavy, and on
57
+ a quiet pressing there is nothing at all above 8 kHz. The 1–3 kHz band is where
58
+ music is and noise is not.
59
+
60
+ | | full band | 1–3 kHz |
61
+ |---|---|---|
62
+ | music | −26 | −40 |
63
+ | arm up, being handled | −36 | −67 |
64
+ | silent groove | −64 | −85 |
65
+ | dead air, needle up | −89 | −93 |
66
+
67
+ Full band puts music and arm-rumble 10 dB apart. The band lane puts them 27 dB
68
+ apart. Every boundary decision in this tool keys off that.
69
+
70
+ These are measurements from one signal chain, not universal constants. See
71
+ [docs/method.md](docs/method.md) for what each number means and where it came
72
+ from.
73
+
74
+ ![The cut panel: a side's waveform above the 1-3 kHz level curve, with the gap
75
+ threshold drawn across it and the detected boundaries marked.](docs/images/cut_panel.png)
76
+
77
+ That is the argument in one picture. The blue is the full-band waveform, where
78
+ the gaps between tracks are barely dents. The orange line under it is the same
79
+ side measured in 1-3 kHz, and every inter-track gap drops clean through the
80
+ dashed threshold. Nine of them, on a side where full-band silence detection
81
+ finds an ambiguous handful.
82
+
83
+ ## What it does
84
+
85
+ **Records**, if the turntable is attached to the machine running it, with a live
86
+ meter showing the band level so you can see what the stylus is doing. It also
87
+ accepts a WAV or FLAC you recorded somewhere else; neither path is
88
+ second-class.
89
+
90
+ **Fits** boundaries by requiring two independent constraints to agree: the cut
91
+ must fall inside a measured gap, and it must fall at the catalogue duration from
92
+ the previous cut. Where they disagree, the cut is clamped and the disagreement
93
+ is reported rather than absorbed.
94
+
95
+ **Cuts** each track with its own start and end, so the groove between tracks is
96
+ discarded instead of being attached to whichever neighbour is nearer.
97
+
98
+ **Verifies by ear**, which is the part that matters. Automatic detection finds
99
+ where music *dominates*; only a person finds where music *is*. So the tool does
100
+ not ask you to scrub through a track and judge. It mixes a 1400 Hz tick at the
101
+ exact cut instant and asks one question: does the tick land in the gap, or on
102
+ the music?
103
+
104
+ ## What it is not
105
+
106
+ - Not a library manager. It writes tagged files into a directory and stops.
107
+ Whatever you use to serve or sync your music watches that directory; RipDoctor
108
+ never calls it.
109
+ - Not a restoration tool. No click removal, no declicking, no noise reduction.
110
+ - Not a cataloguing app.
111
+ - Not fully automatic, and not trying to be. The last judgement is yours.
112
+
113
+ ## Install
114
+
115
+ ```
116
+ pip install ripdoctor
117
+ ```
118
+
119
+ It pulls in [beets](https://beets.io), which does the tagging, the path
120
+ formatting and the album-mode ReplayGain a finished record is filed by. Nothing
121
+ else comes with it.
122
+
123
+ beets keeps its own configuration, and RipDoctor uses whatever it finds -
124
+ `BEETSDIR` and the default location are beets' own, and a service that sets one
125
+ passes it down like any other environment variable. Point it at the
126
+ configuration the rest of the machine uses: beets with no configuration is
127
+ beets with no plugins, which means no cover art and no ReplayGain, filing into
128
+ a library database nothing else reads. `ripdoctor doctor` reports when beets
129
+ would file a record somewhere other than the configured library. The single thing RipDoctor
130
+ adds is a layer that turns the import prompt back on, because under `quiet: yes`
131
+ beets never asks: it applies anything above the match threshold and skips
132
+ anything below it without a word, and there is then no such thing as a preview.
133
+
134
+ The system tools it needs are `ffmpeg`, `ffprobe`, `flac` and `metaflac`, plus
135
+ `arecord` if you want to record. `ripdoctor doctor` reports which of those are
136
+ missing and what to install for each.
137
+
138
+ ## Commands
139
+
140
+ ```
141
+ ripdoctor doctor what this machine has, what it lacks, and what to do
142
+ ripdoctor config every resolved setting, including where the audio lives
143
+ ripdoctor devices capture devices this machine has, and what each accepts
144
+ ripdoctor probe record twenty seconds and say what arrived
145
+ ripdoctor measure compare this signal chain against the shipped thresholds
146
+ ripdoctor record record one side, metered, stopping itself at the run-out
147
+ ripdoctor serve run the web interface
148
+ ripdoctor salvage finish a capture an interrupted session left behind
149
+ ripdoctor name say what a record is, for one ripped before it was
150
+ ripdoctor lookup find a release in the catalogue, usable entries first
151
+ ripdoctor fit place track boundaries from a spec and measured levels
152
+ ripdoctor split cut a plan into tracks
153
+ ripdoctor check build tick clips, one per boundary, for listening to
154
+ ripdoctor import tag the cut tracks and place them in the library
155
+ ripdoctor archive confirm a record arrived before the raw sides are cleared
156
+ ```
157
+
158
+ `ripdoctor probe` before a record, not after. It turns "drop the needle, wait
159
+ twenty minutes, find out it was the wrong input" into a twenty-second question,
160
+ and tells the four states apart: music, signal with nothing musical in it, an
161
+ empty room, or something merely quiet.
162
+
163
+ `ripdoctor doctor` is the one to run first. It never needs a working machine -
164
+ that is the point of it - and every problem it reports comes with what to do
165
+ about it.
166
+
167
+ The raw sides are never cleared until the record is provably somewhere else -
168
+ `ripdoctor archive` counts what actually arrived and says so, asking beets where
169
+ it filed things rather than guessing at a path.
170
+
171
+ Nothing is cut until a plan validates. A track that would end before it starts,
172
+ overlap its neighbour, collide on a track number or run past the end of the side
173
+ is refused with the reason, rather than written as an empty or duplicated file.
174
+
175
+ ## The web interface
176
+
177
+ `ripdoctor serve` is the same pipeline with a waveform in front of it, on the
178
+ machine that holds the library. [`packaging/ripdoctor.service`](packaging/ripdoctor.service)
179
+ runs it as a service; the paths in it are examples and the comments say what
180
+ each one is for. It binds to loopback unless told otherwise, and
181
+ asks for a login either way: it can write to the pool and run ffmpeg, so a
182
+ password is the compensating control for being reachable at all.
183
+
184
+ What the page adds over the commands is the part that needs eyes and ears. The
185
+ 1-3 kHz gap curve is drawn under the waveform with the detection threshold on
186
+ it, so a boundary is placed against the measurement rather than against a
187
+ number in a table. Ghost markers show where the catalogue says each cut should
188
+ fall, and the distance between a ghost and a real one is the disagreement the
189
+ fitter reported. Every boundary can be auditioned as a tick clip without
190
+ leaving the page.
191
+
192
+ Recording runs on the server rather than in the browser, so the meter and the
193
+ auto-stop survive a closed laptop. You can listen to the input while cueing the
194
+ needle, which is the only way to tell that the arm is tracking before twenty
195
+ minutes have gone by.
196
+
197
+ ![The rip panel while a side is recording: the elapsed time, the music level,
198
+ and a bar showing which zone the 1-3 kHz level is sitting in.](docs/images/rip_panel.png)
199
+
200
+ The bar under the controls is what you watch while cueing. Arm-up handling
201
+ rumble is loud full-band and dead in 1-3 kHz, a silent groove is quiet in both,
202
+ and music is loud in both - so where the band bar sits says which of those is
203
+ happening, before twenty minutes have gone by. Drop the needle when it reads
204
+ `groove`, not below it.
205
+
206
+ ## Phases
207
+
208
+ | Phase | State |
209
+ |---|---|
210
+ | 0 — scaffold, decisions, budgets, architecture tests | done |
211
+ | 1 — the detection and fitting core, and its tests | done |
212
+ | 2 — audio seam, config, doctor; offline CLI | done |
213
+ | 3 — capture, the live meter, auto-stop | done, and run against a turntable |
214
+ | 4 — web interface | done |
215
+ | 5 — tagging and import | done |
216
+ | 6 — documentation revision | done |
217
+ | 7 — validate on the server, then cut over | done; cut over 2026-09-07 |
218
+
219
+ ## Development
220
+
221
+ ```
222
+ make check # lint, types, tests, budgets
223
+ make test
224
+ ```
225
+
226
+ The core layer computes over decibel envelopes and touches nothing — no
227
+ subprocess, no filesystem, no clock — so the whole algorithm is testable with
228
+ no ffmpeg, no sound card and no audio files. That boundary is enforced by
229
+ `tests/test_architecture.py`, not by convention.
230
+
231
+ On Python 3.14 specifically, this suite is occasionally unreliable through no
232
+ fault of its own - see ADR-019. A failure whose error is *impossible* (an
233
+ unknown opcode, a NameError for something imported at the top of the file) is
234
+ the interpreter, not the code. Run it again. CI uses 3.11 to 3.13 and is not
235
+ affected.
236
+
237
+ `main` is protected by a ruleset kept in `.github/rulesets/main.json`: linear
238
+ history, no force-push, no deletion, and a pull request whose checks have all
239
+ passed. Nobody bypasses it, the author included - a gate with an exception for
240
+ the person most likely to be in a hurry is not a gate.
241
+
242
+ Size budgets are failing checks. The comment ratio is hard and is never raised;
243
+ the others are the author's call. Never write less code to fit a number.
244
+
245
+ The documentation was written ahead of the code and revised once the code was
246
+ complete. What holds it true from here is a set of tests rather than a ratio:
247
+ every command the README names is a real subparser and every subparser is
248
+ named; every threshold appears in `docs/method.md` with the number the code
249
+ actually uses; every element the front end reaches for exists in the markup;
250
+ and every endpoint it calls is a route. See ADR-023 and ADR-037.
251
+
252
+ ## Prior work
253
+
254
+ [VinylFlow](https://github.com/olimic1000/vinylflow) (MIT) covers adjacent
255
+ ground and is worth your attention if RipDoctor is not what you want. It takes
256
+ a recording you already made and splits, tags and exports it, with an
257
+ interactive waveform editor for adjusting boundaries.
258
+
259
+ The differences are in scope rather than quality. VinylFlow does not record, so
260
+ capture happens elsewhere and the file is uploaded to it; RipDoctor records on
261
+ the machine that holds the library, so the file is written once and never moves.
262
+ VinylFlow previews the resulting track; RipDoctor is built to judge the boundary
263
+ itself, which is a different question. VinylFlow splits on full-band silence
264
+ detection with a tunable threshold, which is the approach this project exists
265
+ because of.
266
+
267
+ ## Licence
268
+
269
+ MIT. See [LICENSE](LICENSE).