beqforge 0.2.8__tar.gz → 0.2.9__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 (218) hide show
  1. {beqforge-0.2.8 → beqforge-0.2.9}/PKG-INFO +1 -1
  2. {beqforge-0.2.8 → beqforge-0.2.9}/TODO.md +3 -1
  3. {beqforge-0.2.8 → beqforge-0.2.9}/beq_common/__init__.py +1 -1
  4. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/AGENTS.md +2 -0
  5. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/accept.py +32 -15
  6. beqforge-0.2.9/beqforge/adjustments.py +365 -0
  7. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/cache.py +2 -0
  8. beqforge-0.2.9/beqforge/dc_offset.py +130 -0
  9. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/designer.py +64 -3
  10. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/diagnose.py +13 -0
  11. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/explain.py +6 -1
  12. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/filters.py +61 -4
  13. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/pipeline.py +184 -16
  14. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/record.py +1 -0
  15. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/verify.py +5 -3
  16. beqforge-0.2.9/plans/contract-1.5-adjustments.md +130 -0
  17. beqforge-0.2.9/plans/library-review-improvements.md +436 -0
  18. {beqforge-0.2.8 → beqforge-0.2.9}/tests/fixtures/design_golden.json +13 -13
  19. beqforge-0.2.9/tests/test_design_adjustments.py +288 -0
  20. beqforge-0.2.9/tests/test_design_dc_offset.py +69 -0
  21. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_designer_server.py +5 -2
  22. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_diagnose.py +12 -0
  23. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_explain.py +23 -1
  24. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_filters.py +72 -0
  25. beqforge-0.2.9/tests/test_design_hum.py +131 -0
  26. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_pipeline.py +29 -0
  27. {beqforge-0.2.8 → beqforge-0.2.9}/tools/designer_server.py +3 -0
  28. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/refit_probe.py +8 -1
  29. {beqforge-0.2.8 → beqforge-0.2.9}/uv.lock +19 -19
  30. beqforge-0.2.8/tests/test_design_hum.py +0 -63
  31. {beqforge-0.2.8 → beqforge-0.2.9}/.dockerignore +0 -0
  32. {beqforge-0.2.8 → beqforge-0.2.9}/.gitattributes +0 -0
  33. {beqforge-0.2.8 → beqforge-0.2.9}/.github/dependabot.yml +0 -0
  34. {beqforge-0.2.8 → beqforge-0.2.9}/.github/workflows/build-designer-image.yml +0 -0
  35. {beqforge-0.2.8 → beqforge-0.2.9}/.github/workflows/build-device-check.yml +0 -0
  36. {beqforge-0.2.8 → beqforge-0.2.9}/.github/workflows/build-executable.yml +0 -0
  37. {beqforge-0.2.8 → beqforge-0.2.9}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  38. {beqforge-0.2.8 → beqforge-0.2.9}/.github/workflows/tests.yml +0 -0
  39. {beqforge-0.2.8 → beqforge-0.2.9}/.gitignore +0 -0
  40. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/.gitignore +0 -0
  41. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/beqforge.iml +0 -0
  42. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/editor.xml +0 -0
  43. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/jsonSchemas.xml +0 -0
  44. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/misc.xml +0 -0
  45. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/modules.xml +0 -0
  46. {beqforge-0.2.8 → beqforge-0.2.9}/.idea/vcs.xml +0 -0
  47. {beqforge-0.2.8 → beqforge-0.2.9}/AGENTS.md +0 -0
  48. {beqforge-0.2.8 → beqforge-0.2.9}/CLAUDE.md +0 -0
  49. {beqforge-0.2.8 → beqforge-0.2.9}/LICENCE.md +0 -0
  50. {beqforge-0.2.8 → beqforge-0.2.9}/README.md +0 -0
  51. {beqforge-0.2.8 → beqforge-0.2.9}/beq_common/AGENTS.md +0 -0
  52. {beqforge-0.2.8 → beqforge-0.2.9}/beq_common/biquad.py +0 -0
  53. {beqforge-0.2.8 → beqforge-0.2.9}/beq_common/provenance.py +0 -0
  54. {beqforge-0.2.8 → beqforge-0.2.9}/beq_common/publication.py +0 -0
  55. {beqforge-0.2.8 → beqforge-0.2.9}/beq_common/types.py +0 -0
  56. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/__init__.py +0 -0
  57. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/beqd.py +0 -0
  58. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/biquad.py +0 -0
  59. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/charts.py +0 -0
  60. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/cli.py +0 -0
  61. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/design.py +0 -0
  62. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/extraction.py +0 -0
  63. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/harness.py +0 -0
  64. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/identify.py +0 -0
  65. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/material.py +0 -0
  66. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/reference.py +0 -0
  67. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge/rolloff.py +0 -0
  68. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge-device-check.spec +0 -0
  69. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge.spec +0 -0
  70. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/AGENTS.md +0 -0
  71. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/__init__.py +0 -0
  72. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/analyse.py +0 -0
  73. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/audio.py +0 -0
  74. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/catalogue.py +0 -0
  75. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/cli.py +0 -0
  76. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/coefficients.py +0 -0
  77. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/engines.py +0 -0
  78. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/evidence.py +0 -0
  79. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/manifest.py +0 -0
  80. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/measurement.py +0 -0
  81. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/profiles.py +0 -0
  82. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/qualification.py +0 -0
  83. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/report.py +0 -0
  84. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/transactions.py +0 -0
  85. {beqforge-0.2.8 → beqforge-0.2.9}/beqforge_device_check/verify.py +0 -0
  86. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/AGENTS.md +0 -0
  87. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/__init__.py +0 -0
  88. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/cache.py +0 -0
  89. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/cli.py +0 -0
  90. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/core.py +0 -0
  91. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/data/seed-manifest.json +0 -0
  92. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/data/seed.json.gz +0 -0
  93. {beqforge-0.2.8 → beqforge-0.2.9}/beqoptimiser/parameters.py +0 -0
  94. {beqforge-0.2.8 → beqforge-0.2.9}/docs/device-check-examples/minidsp-2x4hd-usb-loopback.json +0 -0
  95. {beqforge-0.2.8 → beqforge-0.2.9}/docs/device-check-examples/simulation.json +0 -0
  96. {beqforge-0.2.8 → beqforge-0.2.9}/docs/device-check.md +0 -0
  97. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/README.md +0 -0
  98. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/assets/aggregate-48000.png +0 -0
  99. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/assets/aggregate-96000.png +0 -0
  100. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/assets/example-1.png +0 -0
  101. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/assets/example-2.png +0 -0
  102. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/entry-results.json.gz +0 -0
  103. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser-report/statistics.json +0 -0
  104. {beqforge-0.2.8 → beqforge-0.2.9}/docs/optimiser.md +0 -0
  105. {beqforge-0.2.8 → beqforge-0.2.9}/evidence_validation.json +0 -0
  106. {beqforge-0.2.8 → beqforge-0.2.9}/negative_corpus.json +0 -0
  107. {beqforge-0.2.8 → beqforge-0.2.9}/packaging/designer/Dockerfile +0 -0
  108. {beqforge-0.2.8 → beqforge-0.2.9}/packaging/designer/beqforge-designer.cmd +0 -0
  109. {beqforge-0.2.8 → beqforge-0.2.9}/packaging/designer/smoke.py +0 -0
  110. {beqforge-0.2.8 → beqforge-0.2.9}/packaging/device-check/NOTICE.txt +0 -0
  111. {beqforge-0.2.8 → beqforge-0.2.9}/plans/F2-catalogue-filter-optimisation.md +0 -0
  112. {beqforge-0.2.8 → beqforge-0.2.9}/plans/F2-device-precision-validation.md +0 -0
  113. {beqforge-0.2.8 → beqforge-0.2.9}/plans/F2-package-consolidation.md +0 -0
  114. {beqforge-0.2.8 → beqforge-0.2.9}/plans/R2a-server-stage-cache.md +0 -0
  115. {beqforge-0.2.8 → beqforge-0.2.9}/plans/default-record-and-log-locations.md +0 -0
  116. {beqforge-0.2.8 → beqforge-0.2.9}/plans/design-version.md +0 -0
  117. {beqforge-0.2.8 → beqforge-0.2.9}/plans/done-baseline-evidence.md +0 -0
  118. {beqforge-0.2.8 → beqforge-0.2.9}/plans/done-design-and-pipeline.md +0 -0
  119. {beqforge-0.2.8 → beqforge-0.2.9}/plans/parameter-space-optimisation.md +0 -0
  120. {beqforge-0.2.8 → beqforge-0.2.9}/plans/research-design-decisions.md +0 -0
  121. {beqforge-0.2.8 → beqforge-0.2.9}/plans/test-portability-and-runtime.md +0 -0
  122. {beqforge-0.2.8 → beqforge-0.2.9}/plans/texture-yardstick-sub-band.md +0 -0
  123. {beqforge-0.2.8 → beqforge-0.2.9}/plans/texture-yardstick-sub-band.patch +0 -0
  124. {beqforge-0.2.8 → beqforge-0.2.9}/pyproject.toml +0 -0
  125. {beqforge-0.2.8 → beqforge-0.2.9}/tests/__init__.py +0 -0
  126. {beqforge-0.2.8 → beqforge-0.2.9}/tests/conftest.py +0 -0
  127. {beqforge-0.2.8 → beqforge-0.2.9}/tests/fixtures/optimiser_seed_entry.json +0 -0
  128. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_cli.py +0 -0
  129. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_accept.py +0 -0
  130. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_cache.py +0 -0
  131. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_cache_stores.py +0 -0
  132. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_channel_evidence.py +0 -0
  133. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_charts.py +0 -0
  134. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_corpus.py +0 -0
  135. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_design.py +0 -0
  136. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_designer.py +0 -0
  137. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_device_rate.py +0 -0
  138. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_evidence.py +0 -0
  139. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_evidence_excess.py +0 -0
  140. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_exclusions.py +0 -0
  141. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_extraction.py +0 -0
  142. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_frozen_cache.py +0 -0
  143. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_golden.py +0 -0
  144. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_guard.py +0 -0
  145. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_harness.py +0 -0
  146. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_headroom.py +0 -0
  147. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_identify.py +0 -0
  148. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_jacobian.py +0 -0
  149. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_parallel.py +0 -0
  150. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_playback.py +0 -0
  151. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_playback_headroom.py +0 -0
  152. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_publication.py +0 -0
  153. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_record.py +0 -0
  154. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_record_fingerprint.py +0 -0
  155. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_reference.py +0 -0
  156. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_references.py +0 -0
  157. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_ripple_band.py +0 -0
  158. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_rolloff.py +0 -0
  159. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_strategy_config.py +0 -0
  160. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_target_fit.py +0 -0
  161. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_timings.py +0 -0
  162. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_design_verify.py +0 -0
  163. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_designer_image.py +0 -0
  164. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_designer_server_startup.py +0 -0
  165. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_analyse.py +0 -0
  166. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_catalogue.py +0 -0
  167. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_catalogue_predict.py +0 -0
  168. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_cli.py +0 -0
  169. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_engines.py +0 -0
  170. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_evidence.py +0 -0
  171. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_examples.py +0 -0
  172. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_manifest.py +0 -0
  173. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_measurement.py +0 -0
  174. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_qualification.py +0 -0
  175. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_transactions.py +0 -0
  176. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_device_check_verify.py +0 -0
  177. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_extract_layout.py +0 -0
  178. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_optimiser.py +0 -0
  179. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_optimiser_cache.py +0 -0
  180. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_optimiser_parameters.py +0 -0
  181. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_optimiser_report.py +0 -0
  182. {beqforge-0.2.8 → beqforge-0.2.9}/tests/test_package_boundaries.py +0 -0
  183. {beqforge-0.2.8 → beqforge-0.2.9}/tools/__init__.py +0 -0
  184. {beqforge-0.2.8 → beqforge-0.2.9}/tools/build_optimiser_seed.py +0 -0
  185. {beqforge-0.2.8 → beqforge-0.2.9}/tools/design_beq.py +0 -0
  186. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/a1_sweep.py +0 -0
  187. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/compare_records.py +0 -0
  188. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/compare_verdicts.py +0 -0
  189. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/inject_variants.py +0 -0
  190. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p14_compare.py +0 -0
  191. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p14_jacobian.py +0 -0
  192. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p14_run_pipeline.py +0 -0
  193. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p14_surrogate.py +0 -0
  194. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p18_compare_greedy.py +0 -0
  195. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p18_compare_seeded.py +0 -0
  196. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p18_compare_structure.py +0 -0
  197. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p18_greedy.py +0 -0
  198. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p18_seeded.py +0 -0
  199. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p18_structure.py +0 -0
  200. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/p20_smooth_drift.py +0 -0
  201. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/parameter_benchmark.py +0 -0
  202. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/probe.py +0 -0
  203. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/request_timing.py +0 -0
  204. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/score_injected.py +0 -0
  205. {beqforge-0.2.8 → beqforge-0.2.9}/tools/experiments/t8_floor_rules.py +0 -0
  206. {beqforge-0.2.8 → beqforge-0.2.9}/tools/extract.py +0 -0
  207. {beqforge-0.2.8 → beqforge-0.2.9}/tools/ledger_template.html +0 -0
  208. {beqforge-0.2.8 → beqforge-0.2.9}/tools/negative_corpus.py +0 -0
  209. {beqforge-0.2.8 → beqforge-0.2.9}/tools/optimiser_catalogue_report.py +0 -0
  210. {beqforge-0.2.8 → beqforge-0.2.9}/tools/prepare_device_check_helper.py +0 -0
  211. {beqforge-0.2.8 → beqforge-0.2.9}/tools/render_ledger.py +0 -0
  212. {beqforge-0.2.8 → beqforge-0.2.9}/tools/replay.py +0 -0
  213. {beqforge-0.2.8 → beqforge-0.2.9}/tools/seed_optimiser_cache.py +0 -0
  214. {beqforge-0.2.8 → beqforge-0.2.9}/tools/smoke_test_device_check.py +0 -0
  215. {beqforge-0.2.8 → beqforge-0.2.9}/tools/smoke_test_exe.py +0 -0
  216. {beqforge-0.2.8 → beqforge-0.2.9}/tools/smoke_test_install.py +0 -0
  217. {beqforge-0.2.8 → beqforge-0.2.9}/tools/summarise.py +0 -0
  218. {beqforge-0.2.8 → beqforge-0.2.9}/tools/validate_evidence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beqforge
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Audio-derived BEQ design, published filter optimisation and DSP validation
5
5
  License-File: LICENCE.md
6
6
  Requires-Python: <3.15,>=3.13
@@ -43,7 +43,9 @@ are retained so records and code comments remain traceable. Completed items are
43
43
  | 25 | Documentation citations | open | Remove or repoint orphan references and stale descriptions of shipped behaviour. Low filter impact, but prevents future changes being based on obsolete assumptions. |
44
44
  | 26 | Texture yardstick retune (library review) | parked | The cubic (row 2) keeps the 200 Hz band only because the sub-band cap admitted one synthetic negative, `broadband/5`: chance unevenness in noise bursts, at a 2.6 dB boost. With 45 gated cases a single case moves the gate's upper bound from 0.079 to 0.118. Carrie is still declined (0.85x about a cubic, 1.33x with the cap). Reopen when the beqdesigner library review (~1,290 titles) is complete. Then retune against its declines: the band edge, the trend degree, and whether the gate weights a false acceptance by its boost. The library holds no known negative, so it can expose wrong declines but not measure false acceptances. Any retune still needs the corpus or a fresh E10 set for that, ideally with negatives built from real programme texture (row 5). See [the outcome](plans/texture-yardstick-sub-band.md#outcome-17-october-2026). |
45
45
  | 27 | Keep run records and logs by default | open | `serve-designer` keeps no run record and no log file unless told to, so the Windows designer's 432 library titles (111 declines) have no records and can be reviewed only from their responses. Default records and a rotating log to the platform's per-user data directory (as the `.cmd` already does for the cache), set and declare `/records` in the image, report `record_dir` on `/health`. See [the handoff](plans/default-record-and-log-locations.md). |
46
- | 28 | Fit sensitivity to arithmetic | open | The same request can get a different filter on a different processor. On the synthetic BW4 24 Hz title, `counterfactual` gave 2 sections here and 3 on Linux and macOS CI; `parametric`'s boost was 15.21 dB on this AVX-512 machine, 15.78 dB with Haswell BLAS kernels and 16.49 dB on CI. `flatten` matched everywhere. It's repeatable on one machine, so last-digit differences grow into a different local optimum. This already matters in practice: beqdesigner's library run uses two designers on different hardware (the unraid container and the Windows exe), so the same title may come back different depending on which one designed it. beqdesigner's out-of-date filter compares only design version and settings, so it can't catch that. Measure it on real titles first: run the baseline set under `OPENBLAS_CORETYPE=Haswell` (or on another CPU) and compare verdicts and corrected curves. Then decide whether the fit needs a deterministic tie-break or a conditioning fix, or whether only the golden's coverage does. See [the correction](plans/design-version.md#correction-after-the-v026-release-tests). |
46
+ | 28 | Fit sensitivity to arithmetic | open | The same request can get a different filter on a different processor. On the synthetic BW4 24 Hz title, `counterfactual` gave 2 sections here and 3 on Linux and macOS CI; `parametric`'s boost was 15.21 dB on this AVX-512 machine, 15.78 dB with Haswell BLAS kernels and 16.49 dB on CI. `flatten` matched everywhere. It's repeatable on one machine, so last-digit differences grow into a different local optimum. This already matters in practice: beqdesigner's library run uses two designers on different hardware (the unraid container and the Windows exe), so the same title may come back different depending on which one designed it. beqdesigner's out-of-date filter compares only design version and settings, so it can't catch that. Measure it on real titles first: run the baseline set under `OPENBLAS_CORETYPE=Haswell` (or on another CPU) and compare verdicts and corrected curves. Then decide whether the fit needs a deterministic tie-break or a conditioning fix, or whether only the golden's coverage does. See [the correction](plans/design-version.md#correction-after-the-v026-release-tests). Measured after TODO 29's fix 2: the fitter is BLAS-independent, and on the 11 baseline titles an emulated older CPU changed only 28 Years Later's parametric (a 0.001 dB target difference amplified by the fit); rounding targets to 0.001 dB before fitting is the cheap thing to try ([measurements](plans/library-review-improvements.md#row-28-different-answers-by-machine-after-fix-2)). |
47
+ | 29 | Library review improvements | in progress | From beqdesigner's review of all 279 declines in its full-library run: F2's unmeasured floor still reported as a measurement (4 titles, live on 0.2.5), a near-miss over-lift rejecting the whole design (7), the tilt check (5), the texture retune cases for row 26 (9), the absolute scene margin (4), the dialogue-band reference (2), a plateau at the filter floor (1), DC offset, infrasonic plateaus, and different answers by machine. Evidence, ids and run records per item in [the proposal](plans/library-review-improvements.md). Fix 1 (the unmeasured floor wording) and fix 2 (the fit scored on the published parameters; The Quiet Girl now designs, Bugonia now declines on coverage below 8.1 Hz) fix 3 (mains-hum notches filled before the deficit is measured) and fix 4 (the fit bounded by the judge's over-lift bar; Crazy Heart's flatten now passes) O2 (an infrasonic plateau refused as the reference) and O1 (a stepping DC offset reported) are done; see [the outcomes](plans/library-review-improvements.md#outcomes). The research items are measured and none is built: see [the research](plans/library-review-improvements.md#research-after-the-fixes) (1b has no contained fix; 6 rescues one plausible title; 7 is item 5; 5 and 4 are experiments for the protocol, corpus and E10). |
48
+ | 30 | Contract 1.5: design adjustments | built, unreleased | beqdesigner can ask for a different design per title: ignore a band, a floor, a boost cap, a tilted goal, a note. `beqforge/adjustments.py` answers each, on branch `contract-1.5-adjustments`: default answers unchanged (golden; probe `--tol 0`, all 11 titles), 936 tests pass. Left: merge, and a release, which turns the browser's adjustments on. See [the outcome](plans/contract-1.5-adjustments.md#outcome). |
47
49
 
48
50
  ## Review evidence
49
51
 
@@ -1,6 +1,6 @@
1
1
  """Shared filter primitives; no workflow package dependencies."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.9"
4
4
 
5
5
  from .types import BiquadSpec as BiquadSpec
6
6
  from .types import BiquadType as BiquadType
@@ -33,6 +33,7 @@ still holds: no other API, no other service, and the server is a thin transport
33
33
  | `beqforge/harness.py` | Synthetic ground truth — known-filter injection and constructed negatives. |
34
34
  | `beqforge/verify.py` | Applies a design and measures the corrected low end, **including the error the device's own coefficient rounding adds** — so what is judged is what will play. **The only check that can say a filter is wrong rather than merely inaccurate.** |
35
35
  | `beqforge/diagnose.py` | Per-channel decomposition: mix shares, the level-independence test (R2), band tracking, and each channel's own plateau reference. Where the evidence for a rolloff actually is. |
36
+ | `beqforge/dc_offset.py` | A channel's DC offset and the steps in it a correction would lift into thumps (TODO 29, O1). Report-only: `analyse` adds one evidence note per channel found, no decision reads it. A static offset needs nothing (headroom filters the sub feed with the full transfer, 0 Hz included), and a DC-blocking high-pass does not touch a step's 4-20 Hz transient. Three tests, no dB constant: a persistent offset whose step would reach the channel's 90th-percentile programme level in 4-20 Hz, a step whose transient does, and (with other channels) one that stands out of every other channel near it. |
36
37
  | `beqforge/accept.py` | The acceptance model. The cliff, flatness, turnover and overshoot tests are comparative and so need no calibrated threshold; the shape request (`target_tilt_db_per_octave` and its tolerances) is a stated preference; realisability is judged on the curve the device will play rather than against a drift constant. Tilt, level and extent judge against *intent* (`before_db` + the evidence-priced target), not flat, so a partial correction is judged on whether it achieved what it was licensed to. `recovered_fraction` and `shaping_fraction` report how much of the deficit was licensed and how much of the correction's peak lies below the level-independence floor — record-only diagnostics, not in the designer response; `Candidate.confidence` is a separate, uncalibrated ordinal (`pipeline.correction_evidence_score`), not derived from either. Headroom (`required_offset_db`) is reported, never gated. |
37
38
  | `beqforge/pipeline.py` | The repeatable process — diagnose, propose, fit, judge. Holds the `STRATEGIES` registry. |
38
39
  | `beqforge/cache.py` | Stage cache — the analysis, and any strategy declaring `cache_modules`. On by default; keyed per stage so work on the fitter does not drop the analysis. |
@@ -41,6 +42,7 @@ still holds: no other API, no other service, and the server is a thin transport
41
42
  | `beqforge/beqd.py` | Export to a `.beq` beqdesigner project. A separate job from the record: idiomatic in their UI, allowed to be lossy. |
42
43
  | `beqforge/cli.py` | `beqforge <subcommand> ...` — the installed entry point. Strips the subcommand off argv and hands the rest to the matching script in `tools/` unchanged. |
43
44
  | `beqforge/explain.py` | The plain-language account a designer response leads with — `found` (reference plateau, which channels carry it and which are silent, down to where there is real bass content, judged band, goal), `correction` (the cascade, then frequency by frequency what was wanted, what the content supports and before → after), `clipping` (the sub-feed peak and how far to turn the sub down, on the request's or the assumed bass management), `fit` (how closely the filter as played delivers the boost the content supports — a match to the target, not a verdict on it) and `alternatives` (why each other candidate lost). Reads the `Report`; measures and decides nothing. |
45
+ | `beqforge/adjustments.py` | Contract 1.5's adjustments: what a request asks a design to change (`exclude_band`, `floor`, `max_boost`, `goal_tilt`, `note`), set on that request's `PipelineParams` only, and the answer to each (`honoured`, `adapted`, `declined`, `not_understood`); `/health`'s `adjustment_kinds`. Pure. See `plans/contract-1.5-adjustments.md`. |
44
46
  | `beqforge/designer.py` | The `design(request) -> response` adapter between beqdesigner's `designer-interface.md` v1.1 and `beqforge.pipeline.run` — request/response dataclasses (1.1: every candidate the judge failed goes back in `rejected` with its failures, review only, beside the answer or the decline; 1.3: `residual_db` and its companions come from `Candidate.target_fit`, never `fit_error_db`; 1.4: `design_version` (`DESIGN_VERSION`, bumped only by a change that can alter an answer, guarded by `tests/test_design_golden.py`), `design_settings` (`design_settings_digest`, the non-default server settings that can change an answer) and `designer_build` on every response), wire-format (de)serialisation and the field-by-field mapping onto `Report`/`Candidate`/`Verdict`. Also `validate_response`, an independent re-implementation of beqdesigner's own §3-§5 rules (that repo isn't importable — PyQt6), called by the server before a response ever goes on the wire. Pure and independently testable; no socket. |
45
47
  | `tools/design_beq.py` | **The entry point.** One command, a filter and its reasoning. `--charts DIR` for the pictures; writes a `.run.json.gz` record beside the material unless `--no-record`. |
46
48
  | `tools/designer_server.py` | `beqforge serve-designer` — the HTTP transport (`designer-interface.md` §7.1) around `beqforge/designer.py`. Server-wide flags for device realisation/strategies/exclusions; everything per-title comes from the request. A `ThreadingHTTPServer`, so `/health` answers during a design, with every design run in a long-lived worker process started with "spawn" (`_DesignWorker`): the fitter forks worker processes (`beqforge.filters.PARALLEL_FITS`) when a fit escalates past one section count, and forking a multi-threaded process risks a deadlock, so the process that forks must stay single-threaded — the worker's log records come back over a `SimpleQueue`, not a `Queue`, for that reason. A worker designs one title at a time; `--workers N` (1-8) runs N of them, each request taking the next idle one, and `/health` reports N as `parallel` so a caller can size its sends from the designer. With N > 1 each worker's fit pool is the logical processors less one, divided by N and capped at `filters.FIT_WORKERS` (`fit_share`), unless `BEQ_FIT_WORKERS` is set; `--cores N` instead caps the designs' total at N, split evenly, still at most `filters.FIT_WORKERS` each: concurrent designs are what can use SMT threads, and pool size changes timing, never an answer. A worker that dies answers 500 and is replaced; the others carry on. SIGTERM shuts down like Ctrl-C, stopping the worker's process group, fit pool included. Every response names its build (`beqforge_revision`); `--record-dir DIR` also writes each request's run record, named by a digest of the request audio, so a review-queue entry can be replayed. `--cache-dir DIR` keeps the stage cache there (`cache.DirStore`, one file per entry, atomic): a repeat request for a title skips the analysis — 110 s cold, 50 s warm on a 2-hour 8-channel title. Each request logs a timing line (read, parse, decode, design, respond, queued); `tools/experiments/request_timing.py` reports them. `--shared-root DIR` accepts audio by reference (contract 1.2, beqdesigner's `design/designer-by-reference.md` §3): an array names a WAV relative to DIR plus the SHA-256 of its decoded column, and `beqforge/reference.py` resolves, decodes and checks it; one that cannot be honoured is a 422 naming the array, never a decline. `GET /health` reports `contract_version`, `shared_root`, `parallel`, and 1.4's `design_version`, `design_settings`, `design_settings_detail` and `designer_build`. |
@@ -202,8 +202,10 @@ class AcceptParams:
202
202
  Kept because the fitter still screens on it to *prefer* a robust cascade among equals, which
203
203
  is a legitimate tiebreak and the reason cancelling cascades rarely reach acceptance at all.
204
204
  The p90 is over `drift_samples` jitters of the published parameters — note that this explores
205
- coefficient sets near the published one rather than the rounding the published filter itself
206
- undergoes, which `verify` measures directly and which comes to 0.006-0.014 dB."""
205
+ coefficient sets near the published one rather than the device's rounding of the published
206
+ filter itself, which `verify` measures directly and which the fit now scores on the published
207
+ parameters too (TODO 29). That rounding is not negligible: on a steep cascade it can run to
208
+ several dB, which is why the fit has to see it."""
207
209
 
208
210
 
209
211
  @dataclass(frozen=True, slots=True)
@@ -387,6 +389,33 @@ def turnover_db_per_octave(
387
389
  return slope, material, float(freqs[peak])
388
390
 
389
391
 
392
+ def overlift_bar_db(
393
+ correction: Correction, params: AcceptParams
394
+ ) -> tuple[np.ndarray, np.ndarray]:
395
+ """The guarded bins and the most the corrected curve may reach in each: `assess`'s over-lift bar.
396
+
397
+ The guard runs from the bottom of the design grid to the top of the judged band, below the
398
+ judged floor too, because a shelf can still boost that region. In each bin the corrected
399
+ curve may reach the higher of the requested shape and the material, plus the level
400
+ tolerance and half the material's own roughness. Depends on the material and the judged
401
+ band only, never on a filter, so the fitter scores against the same bar before anything is
402
+ fitted (`overlift_allowance` in `pipeline`).
403
+ """
404
+ guard = (correction.freqs >= DESIGN_GRID[0]) & (
405
+ correction.freqs <= correction.band_hz[1]
406
+ )
407
+ slack = spectral_roughness(correction, params.roughness_degree) / 2.0
408
+ bar = (
409
+ np.maximum(
410
+ correction.requested_db(params.target_tilt_db_per_octave)[guard],
411
+ correction.before_db[guard],
412
+ )
413
+ + params.level_tolerance_db
414
+ + slack
415
+ )
416
+ return guard, bar
417
+
418
+
390
419
  def spectral_roughness(correction: Correction, degree: int = 3) -> float:
391
420
  """How far the *input* departs from a smooth trend over the judged band.
392
421
 
@@ -580,19 +609,7 @@ def assess(
580
609
  "rolloff"
581
610
  )
582
611
 
583
- # Guard below the judged floor too: a shelf can still boost that region.
584
- guard = (correction.freqs >= DESIGN_GRID[0]) & (
585
- correction.freqs <= correction.band_hz[1]
586
- )
587
- slack = spectral_roughness(correction, params.roughness_degree) / 2.0
588
- bar = (
589
- np.maximum(
590
- correction.requested_db(params.target_tilt_db_per_octave)[guard],
591
- correction.before_db[guard],
592
- )
593
- + params.level_tolerance_db
594
- + slack
595
- )
612
+ guard, bar = overlift_bar_db(correction, params)
596
613
  overshoot = correction.after_db[guard] - bar
597
614
  worst = int(np.argmax(overshoot))
598
615
  if _at(float(overshoot[worst]), quantum) > 0.0:
@@ -0,0 +1,365 @@
1
+ """Contract 1.5's adjustments: what a caller asks a design to change, and beqforge's answer to each.
2
+
3
+ beqdesigner's `designer-interface.md` §2 "Adjustments" and §3 "Adjustment outcomes"; the handoff that set out this
4
+ mapping is [plans/contract-1.5-adjustments.md](../plans/contract-1.5-adjustments.md). Each kind lands on a dial
5
+ `serve-designer` already has, set for this one request:
6
+
7
+ * `exclude_band` (`low_hz`, `high_hz`): appended to `PipelineParams.exclude_bands_hz`, the list `--exclude` fills.
8
+ * `floor` (`hz`): `PipelineParams.lowest_frequency_hz`, raised (never lowered below the server's).
9
+ * `max_boost` (`db`): `PipelineParams.max_target_boost_db`, a ceiling on every strategy's target beside the evidence's
10
+ (`pipeline.priced_by_evidence`), and `parametric_max_boost_db` and every `restore_caps_db` capped at it.
11
+ * `goal_tilt` (`db_per_octave`): `AcceptParams.target_tilt_db_per_octave`, which `--goal-tilt` sets, for this request.
12
+ * `note`: free text, kept in the answer's reason and not acted on.
13
+
14
+ Nothing here can make beqforge publish what its evidence does not support (§4): every kind narrows or weakens a
15
+ design, or changes the goal the judge then holds the design to. A request with no adjustments is not touched, so its
16
+ answer is the 1.4 answer. Pure: no I/O, no fitting.
17
+ """
18
+
19
+ import dataclasses
20
+ import math
21
+ from dataclasses import dataclass
22
+
23
+ import numpy as np
24
+
25
+ from beqforge.filters import biquad_sos, magnitude_db
26
+ from beqforge.pipeline import DESIGN_GRID, PipelineParams, Report
27
+
28
+ KINDS = ("exclude_band", "floor", "max_boost", "goal_tilt", "note")
29
+ OUTCOMES = ("honoured", "adapted", "declined", "not_understood")
30
+
31
+ MAX_TILT_DB_PER_OCTAVE = 3.0
32
+ """The steepest goal tilt taken. `AcceptParams.target_tilt_db_per_octave` documents roughly 6 dB of headroom for
33
+ +2 dB/octave; past 3 the goal asks more of the bottom than any title in the baseline set had to give."""
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class DesignAdjustment:
38
+ """§2: one change asked for."""
39
+
40
+ id: str
41
+ kind: str
42
+ params: dict
43
+ text: str | None = None
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class AdjustmentOutcome:
48
+ """§3: what was done with one adjustment."""
49
+
50
+ id: str
51
+ outcome: str
52
+ applied: dict | None = None
53
+ reason: str | None = None
54
+
55
+
56
+ def adjustments_from_json(body: object) -> tuple[DesignAdjustment, ...]:
57
+ """The request's `adjustments`, checked for shape only (§2: an unknown kind or a bad parameter is answered, not
58
+ refused). Absent or null is none.
59
+
60
+ Raises:
61
+ TypeError: when it is not a list of objects, or one's `params` is not an object.
62
+ ValueError: when one has no `id` or `kind`, or two share an id.
63
+ """
64
+ if body is None:
65
+ return ()
66
+ if not isinstance(body, list):
67
+ raise TypeError("adjustments must be a list")
68
+ seen: set[str] = set()
69
+ adjustments = []
70
+ for i, item in enumerate(body):
71
+ if not isinstance(item, dict):
72
+ raise TypeError(f"adjustments[{i}] must be an object")
73
+ ident, kind, params = item.get("id"), item.get("kind"), item.get("params", {})
74
+ if (
75
+ not isinstance(ident, str)
76
+ or not ident
77
+ or not isinstance(kind, str)
78
+ or not kind
79
+ ):
80
+ raise ValueError(f"adjustments[{i}] needs a non-empty id and kind")
81
+ if not isinstance(params, dict):
82
+ raise TypeError(f"adjustments[{i}].params must be an object")
83
+ if ident in seen:
84
+ raise ValueError(f"adjustment id {ident!r} is used twice")
85
+ seen.add(ident)
86
+ text = item.get("text")
87
+ adjustments.append(
88
+ DesignAdjustment(
89
+ ident, kind, dict(params), text if isinstance(text, str) else None
90
+ )
91
+ )
92
+ return tuple(adjustments)
93
+
94
+
95
+ def outcome_to_json(outcome: AdjustmentOutcome) -> dict:
96
+ return {
97
+ "id": outcome.id,
98
+ "outcome": outcome.outcome,
99
+ "applied": outcome.applied,
100
+ "reason": outcome.reason,
101
+ }
102
+
103
+
104
+ def kinds_for(params: PipelineParams) -> dict[str, dict]:
105
+ """`/health`'s `adjustment_kinds` (§7.1): each kind this server takes, its parameters' defaults this server's own
106
+ settings, so a caller's form opens on what the server would do."""
107
+ low, high = params.diagnose.band_hz
108
+ return {
109
+ "exclude_band": {
110
+ "description": "Leave a frequency range alone: neither evidence of a rolloff nor something to correct.",
111
+ "params": {
112
+ "low_hz": {
113
+ "type": "number",
114
+ "unit": "Hz",
115
+ "minimum": low,
116
+ "maximum": high,
117
+ },
118
+ "high_hz": {
119
+ "type": "number",
120
+ "unit": "Hz",
121
+ "minimum": low,
122
+ "maximum": high,
123
+ },
124
+ },
125
+ },
126
+ "floor": {
127
+ "description": "Restore nothing below this frequency: no section is placed lower.",
128
+ "params": {
129
+ "hz": {
130
+ "type": "number",
131
+ "unit": "Hz",
132
+ "minimum": params.lowest_frequency_hz,
133
+ "maximum": high / 2,
134
+ "default": params.lowest_frequency_hz,
135
+ },
136
+ },
137
+ },
138
+ "max_boost": {
139
+ "description": "Boost no more than this anywhere.",
140
+ "params": {
141
+ "db": {
142
+ "type": "number",
143
+ "unit": "dB",
144
+ "minimum": 1.0,
145
+ "maximum": max(params.restore_caps_db),
146
+ "default": params.parametric_max_boost_db,
147
+ },
148
+ },
149
+ },
150
+ "goal_tilt": {
151
+ "description": "Tilt the goal towards the bottom: positive for more bass than flat, negative for less.",
152
+ "params": {
153
+ "db_per_octave": {
154
+ "type": "number",
155
+ "unit": "dB/oct",
156
+ "minimum": -MAX_TILT_DB_PER_OCTAVE,
157
+ "maximum": MAX_TILT_DB_PER_OCTAVE,
158
+ "default": params.accept.target_tilt_db_per_octave,
159
+ },
160
+ },
161
+ },
162
+ "note": {
163
+ "description": "Anything else, in words. Kept with the design; not acted on."
164
+ },
165
+ }
166
+
167
+
168
+ def _number(params: dict, name: str) -> float | None:
169
+ value = params.get(name)
170
+ if (
171
+ isinstance(value, bool)
172
+ or not isinstance(value, (int, float))
173
+ or not math.isfinite(value)
174
+ ):
175
+ return None
176
+ return float(value)
177
+
178
+
179
+ def _declined(adjustment: DesignAdjustment, reason: str) -> AdjustmentOutcome:
180
+ return AdjustmentOutcome(adjustment.id, "declined", None, reason)
181
+
182
+
183
+ def apply_adjustments(
184
+ params: PipelineParams, adjustments: tuple[DesignAdjustment, ...]
185
+ ) -> tuple[PipelineParams, dict[str, AdjustmentOutcome]]:
186
+ """The parameters this request designs with, and a provisional outcome for every adjustment (`finish_outcomes`
187
+ revisits them once the design is made). Order does not matter: the result is the same whichever comes first,
188
+ except that of two asks of a kind that takes one value, the first by id is used and the other declined."""
189
+ outcomes: dict[str, AdjustmentOutcome] = {}
190
+ band_low, band_high = params.diagnose.band_hz
191
+ excluded = list(params.exclude_bands_hz)
192
+ floor, cap, tilt = params.lowest_frequency_hz, None, None
193
+ taken: dict[str, str] = {}
194
+ for adjustment in sorted(adjustments, key=lambda a: a.id):
195
+ kind, p = adjustment.kind, adjustment.params
196
+ if kind in ("floor", "max_boost", "goal_tilt") and kind in taken:
197
+ outcomes[adjustment.id] = _declined(
198
+ adjustment, f"only one {kind} is taken: {taken[kind]} was used"
199
+ )
200
+ continue
201
+ if kind == "exclude_band":
202
+ low, high = _number(p, "low_hz"), _number(p, "high_hz")
203
+ if low is None or high is None or low >= high:
204
+ outcomes[adjustment.id] = _declined(
205
+ adjustment, "low_hz and high_hz must be numbers, low below high"
206
+ )
207
+ continue
208
+ used = (max(low, band_low), min(high, band_high))
209
+ if used[0] >= used[1]:
210
+ outcomes[adjustment.id] = _declined(
211
+ adjustment,
212
+ f"{low:g}-{high:g} Hz is outside the band analysed ({band_low:g}-{band_high:g} Hz)",
213
+ )
214
+ continue
215
+ excluded.append(used)
216
+ applied = {"low_hz": used[0], "high_hz": used[1]}
217
+ outcomes[adjustment.id] = (
218
+ AdjustmentOutcome(adjustment.id, "honoured", applied)
219
+ if used == (low, high)
220
+ else AdjustmentOutcome(
221
+ adjustment.id,
222
+ "adapted",
223
+ applied,
224
+ f"narrowed to the band analysed ({band_low:g}-{band_high:g} Hz)",
225
+ )
226
+ )
227
+ elif kind == "floor":
228
+ hz = _number(p, "hz")
229
+ if hz is None or hz <= 0:
230
+ outcomes[adjustment.id] = _declined(
231
+ adjustment, "hz must be a positive number"
232
+ )
233
+ continue
234
+ if hz >= band_high / 2:
235
+ outcomes[adjustment.id] = _declined(
236
+ adjustment,
237
+ f"{hz:g} Hz leaves too little of the band analysed (to {band_high:g} Hz) to correct",
238
+ )
239
+ continue
240
+ taken[kind] = adjustment.id
241
+ if hz < params.lowest_frequency_hz:
242
+ outcomes[adjustment.id] = AdjustmentOutcome(
243
+ adjustment.id,
244
+ "adapted",
245
+ {"hz": params.lowest_frequency_hz},
246
+ f"nothing is placed below {params.lowest_frequency_hz:g} Hz on any title: nothing was measured there",
247
+ )
248
+ continue
249
+ floor = hz
250
+ outcomes[adjustment.id] = AdjustmentOutcome(
251
+ adjustment.id, "honoured", {"hz": hz}
252
+ )
253
+ elif kind == "max_boost":
254
+ db = _number(p, "db")
255
+ if db is None or db <= 0:
256
+ outcomes[adjustment.id] = _declined(
257
+ adjustment, "db must be a positive number"
258
+ )
259
+ continue
260
+ taken[kind] = adjustment.id
261
+ cap = db
262
+ outcomes[adjustment.id] = AdjustmentOutcome(
263
+ adjustment.id, "honoured", {"db": db}
264
+ )
265
+ elif kind == "goal_tilt":
266
+ rate = _number(p, "db_per_octave")
267
+ if rate is None:
268
+ outcomes[adjustment.id] = _declined(
269
+ adjustment, "db_per_octave must be a number"
270
+ )
271
+ continue
272
+ if abs(rate) > MAX_TILT_DB_PER_OCTAVE:
273
+ outcomes[adjustment.id] = _declined(
274
+ adjustment,
275
+ f"a tilt beyond ±{MAX_TILT_DB_PER_OCTAVE:g} dB/oct is not taken",
276
+ )
277
+ continue
278
+ taken[kind] = adjustment.id
279
+ tilt = rate
280
+ outcomes[adjustment.id] = AdjustmentOutcome(
281
+ adjustment.id, "honoured", {"db_per_octave": rate}
282
+ )
283
+ elif kind == "note":
284
+ outcomes[adjustment.id] = AdjustmentOutcome(
285
+ adjustment.id,
286
+ "not_understood",
287
+ None,
288
+ "a note is kept with the design, not acted on",
289
+ )
290
+ else:
291
+ outcomes[adjustment.id] = AdjustmentOutcome(
292
+ adjustment.id,
293
+ "not_understood",
294
+ None,
295
+ f"beqforge takes {', '.join(KINDS)}; not {kind!r}",
296
+ )
297
+ changes: dict = {}
298
+ if excluded != list(params.exclude_bands_hz):
299
+ changes["exclude_bands_hz"] = tuple(excluded)
300
+ if floor != params.lowest_frequency_hz:
301
+ changes["lowest_frequency_hz"] = floor
302
+ if cap is not None:
303
+ changes["max_target_boost_db"] = cap
304
+ changes["parametric_max_boost_db"] = min(params.parametric_max_boost_db, cap)
305
+ changes["restore_caps_db"] = tuple(
306
+ sorted({min(c, cap) for c in params.restore_caps_db})
307
+ )
308
+ if tilt is not None:
309
+ changes["accept"] = dataclasses.replace(
310
+ params.accept, target_tilt_db_per_octave=tilt
311
+ )
312
+ return (dataclasses.replace(params, **changes) if changes else params), outcomes
313
+
314
+
315
+ def peak_boost_db(filters: list, fs: float) -> float:
316
+ """The most a cascade boosts anywhere on the design grid, as realised at `fs`."""
317
+ if not filters:
318
+ return 0.0
319
+ return float(np.max(magnitude_db(biquad_sos(list(filters), fs), DESIGN_GRID, fs)))
320
+
321
+
322
+ def finish_outcomes(
323
+ outcomes: dict[str, AdjustmentOutcome],
324
+ adjustments: tuple[DesignAdjustment, ...],
325
+ report: Report,
326
+ params: PipelineParams,
327
+ ) -> list[AdjustmentOutcome]:
328
+ """The answers in the request's order, once the design is made: a cap the realised filters still go past is
329
+ `adapted` (the fit meets a target within a tolerance, so it can overshoot a little: say by how much), and an
330
+ adjustment that was applied when the design then declined says so (it may be why the evidence left was too thin)."""
331
+ accepted = report.accepted
332
+ peak = (
333
+ peak_boost_db(accepted.filters, params.realisation.fs)
334
+ if accepted is not None
335
+ else None
336
+ )
337
+ finished = []
338
+ for adjustment in adjustments:
339
+ outcome = outcomes[adjustment.id]
340
+ if (
341
+ adjustment.kind == "max_boost"
342
+ and outcome.outcome == "honoured"
343
+ and peak is not None
344
+ and outcome.applied is not None
345
+ and peak
346
+ > outcome.applied["db"]
347
+ + params.residual_target_db # within the fit's own tolerance: met
348
+ ):
349
+ outcome = AdjustmentOutcome(
350
+ adjustment.id,
351
+ "adapted",
352
+ {"db": round(peak, 2)},
353
+ f"capped at {outcome.applied['db']:g} dB; the realised design peaks at {peak:.1f} dB",
354
+ )
355
+ elif (
356
+ accepted is None
357
+ and outcome.outcome in ("honoured", "adapted")
358
+ and not outcome.reason
359
+ ):
360
+ outcome = dataclasses.replace(
361
+ outcome,
362
+ reason="applied; with it the evidence left did not support a design (see the decline)",
363
+ )
364
+ finished.append(outcome)
365
+ return finished
@@ -291,6 +291,7 @@ def analysis_to_json(analysis: Analysis) -> dict[str, Any]:
291
291
  "level_spread_db": _pack(diagnosis.level_spread_db),
292
292
  "filter_floor_hz": _num(diagnosis.filter_floor_hz),
293
293
  "noise_floor_hz": _num(diagnosis.noise_floor_hz),
294
+ "tracking_measured": bool(diagnosis.tracking_measured),
294
295
  },
295
296
  "envelopes": {
296
297
  "freqs": _pack(envelopes.freqs),
@@ -342,6 +343,7 @@ def analysis_from_json(raw: dict[str, Any]) -> Analysis:
342
343
  level_spread_db=_unpack(d["level_spread_db"]),
343
344
  filter_floor_hz=_back(d["filter_floor_hz"]),
344
345
  noise_floor_hz=_back(d["noise_floor_hz"]),
346
+ tracking_measured=bool(d.get("tracking_measured", True)),
345
347
  )
346
348
  envelopes = Envelopes(
347
349
  freqs=_unpack(e["freqs"]),
@@ -0,0 +1,130 @@
1
+ """A channel's DC offset, and the steps in it a correction would turn into thumps (TODO 29, O1).
2
+
3
+ Reported, never decided on. A static offset needs nothing here: `measure_headroom` filters the
4
+ sub feed with the device's full transfer, 0 Hz included, so the lifted offset is already in the
5
+ measured peak. What it does not show is an offset that *steps*. Each step is an infrasonic
6
+ transient, and a correction boosting the bottom octaves lifts it by its gain there: Desperately
7
+ Seeking Susan's FR carries -48 dBFS of drifting offset whose steps stand 15-22 dB above FL in
8
+ 4-20 Hz, heard as thumps once corrected. A DC-blocking high-pass would not help — at 1, 3 or
9
+ 5 Hz it left those transients where they were, since a step's energy reaches far above any
10
+ blocker's corner — so the honest response is to say so.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+ from scipy import ndimage, signal
17
+
18
+ from beqforge.material import Material
19
+
20
+ STEP_BAND_HZ = (4.0, 20.0)
21
+ """Where a step's transient is measured: the band a correction boosts most."""
22
+
23
+ LEVEL_HALF_WIDTH_S = 15
24
+ """Half the window, in seconds, the slow DC level is the median over. Long enough that a scene's
25
+ bass does not move it, short enough to follow an offset that changes at an edit."""
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class DcSteps:
30
+ """One channel's offset and the steps in it that stand out of the programme."""
31
+
32
+ channel: str
33
+ offset_dbfs: float
34
+ """The channel's typical slow DC level, re full scale."""
35
+ steps_s: tuple[int, ...]
36
+ """Where each step is, to the second."""
37
+ worst_excess_db: float
38
+ """How far the largest step's transient rises above the programme's own level in the band."""
39
+
40
+ def note(self) -> str:
41
+ times = ", ".join(f"{s // 60}:{s % 60:02d}" for s in self.steps_s[:5])
42
+ more = f" and {len(self.steps_s) - 5} more" if len(self.steps_s) > 5 else ""
43
+ return (
44
+ f"DC offset: {self.channel} carries a {self.offset_dbfs:.0f} dBFS offset that steps "
45
+ f"{len(self.steps_s)} time(s) (at {times}{more}); each step is an infrasonic "
46
+ f"transient up to {self.worst_excess_db:.1f} dB above the programme's own "
47
+ f"{STEP_BAND_HZ[0]:g}-{STEP_BAND_HZ[1]:g} Hz level, and a correction boosting that "
48
+ "band lifts it by its gain there, as a thump; headroom already counts the offset "
49
+ "itself"
50
+ )
51
+
52
+
53
+ def dc_steps(material: Material) -> list[DcSteps]:
54
+ """The channels whose DC offset steps audibly, by three tests and no dB constant.
55
+
56
+ A channel qualifies when its slow DC level (the median of per-second means over
57
+ `LEVEL_HALF_WIDTH_S` either side) is persistently large enough that a step in it would reach
58
+ the programme: the band's response to a step of that size exceeds the 90th percentile of the
59
+ channel's per-second peak in `STEP_BAND_HZ`, the level its own programme regularly reaches
60
+ there. Each step reported is a change in that level whose transient exceeds it too, and, with
61
+ other channels to compare, near which this channel's band level stands out of every other's:
62
+ programme bass arrives in several channels at once, a step in one channel's offset does not.
63
+
64
+ The 90th percentile, not the median, because a sparse channel — an LFE silent most of the
65
+ time — has a median near nothing, and every small drift cleared it. The persistent offset,
66
+ because an LFE carries infrasonic content no other channel does, so it always stands out.
67
+ On 34 titles at hand only Desperately Seeking Susan's FR qualifies.
68
+ """
69
+ fs = int(material.fs)
70
+ if not material.channels or fs <= 2 * STEP_BAND_HZ[1]:
71
+ return []
72
+ seconds = min(len(x) for x in material.channels.values()) // fs
73
+ if seconds <= 2 * LEVEL_HALF_WIDTH_S:
74
+ return []
75
+ band = signal.butter(4, STEP_BAND_HZ, btype="band", fs=fs, output="sos")
76
+ unit_step = np.r_[np.zeros(4 * fs), np.ones(4 * fs)]
77
+ step_gain = float(np.max(np.abs(signal.sosfilt(band, unit_step))))
78
+ peaks = {
79
+ name: np.abs(signal.sosfilt(band, np.asarray(x, dtype=np.float64)))[
80
+ : seconds * fs
81
+ ]
82
+ .reshape(seconds, fs)
83
+ .max(axis=1)
84
+ for name, x in material.channels.items()
85
+ }
86
+ found: list[DcSteps] = []
87
+ for name, x in material.channels.items():
88
+ means = (
89
+ np.asarray(x[: seconds * fs], dtype=np.float64)
90
+ .reshape(seconds, fs)
91
+ .mean(axis=1)
92
+ )
93
+ level = ndimage.median_filter(
94
+ means, size=2 * LEVEL_HALF_WIDTH_S + 1, mode="nearest"
95
+ )
96
+ programme = float(np.percentile(peaks[name], 90))
97
+ offset = float(np.median(np.abs(level)))
98
+ if programme <= 0.0 or offset * step_gain <= programme:
99
+ continue
100
+ shift = np.zeros(seconds)
101
+ shift[LEVEL_HALF_WIDTH_S : seconds - LEVEL_HALF_WIDTH_S] = (
102
+ level[2 * LEVEL_HALF_WIDTH_S :] - level[: seconds - 2 * LEVEL_HALF_WIDTH_S]
103
+ )
104
+ candidates, _ = signal.find_peaks(
105
+ np.abs(shift), distance=2 * LEVEL_HALF_WIDTH_S
106
+ )
107
+ others = [peaks[k] for k in material.channels if k != name]
108
+ steps: list[int] = []
109
+ worst = 0.0
110
+ for centre in candidates:
111
+ transient = abs(float(shift[centre])) * step_gain
112
+ if transient <= programme:
113
+ continue
114
+ window = slice(
115
+ max(centre - LEVEL_HALF_WIDTH_S, 1),
116
+ min(centre + LEVEL_HALF_WIDTH_S + 1, seconds),
117
+ )
118
+ if others and peaks[name][window].max() <= max(
119
+ o[window].max() for o in others
120
+ ):
121
+ continue
122
+ # to the second: the largest jump between consecutive means in the window
123
+ jumps = np.abs(np.diff(means[window.start - 1 : window.stop]))
124
+ steps.append(int(window.start + np.argmax(jumps)))
125
+ worst = max(worst, 20.0 * np.log10(transient / programme))
126
+ if steps:
127
+ found.append(
128
+ DcSteps(name, 20.0 * np.log10(offset), tuple(steps), float(worst))
129
+ )
130
+ return found