From a22586a172a4f061205c514267473189c6ce759d Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Sun, 13 Sep 2026 14:03:16 +0800 Subject: [PATCH] test(asr): [PKG-ASR-01] freeze published interfaces and source contracts --- package.json | 5 +- packages/artplayer-plugin-asr/ARCHITECTURE.md | 33 +++++ packages/artplayer-plugin-asr/README.md | 8 +- refactor/ai-workflow.md | 5 + refactor/baselines/asr-contract.md | 60 ++++++++ refactor/baselines/asr-release.json | 138 ++++++++++++++++++ .../2026-09-13-PKG-ASR-01-contracts.md | 18 +++ refactor/plan.md | 5 +- refactor/progress.md | 12 ++ refactor/risk-table.md | 3 + refactor/risks.json | 48 ++++++ refactor/scripts/asr-contract.mjs | 31 ++++ refactor/scripts/asr-contract.test.mjs | 46 ++++++ refactor/tasks.json | 8 +- test/asr.test.js | 68 +++++++++ test/helpers/asr.js | 62 ++++++++ 16 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 packages/artplayer-plugin-asr/ARCHITECTURE.md create mode 100644 refactor/baselines/asr-contract.md create mode 100644 refactor/baselines/asr-release.json create mode 100644 refactor/changes/2026-09-13-PKG-ASR-01-contracts.md create mode 100644 refactor/scripts/asr-contract.mjs create mode 100644 refactor/scripts/asr-contract.test.mjs create mode 100644 test/asr.test.js create mode 100644 test/helpers/asr.js diff --git a/package.json b/package.json index 64fb8988d..4d5d93099 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js", "typecheck": "node scripts/typecheck.mjs", - "test:unit": "node --test test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js", + "test:unit": "node --test test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js", "test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", @@ -93,7 +93,8 @@ "test:jassub": "node --test test/jassub.test.js refactor/scripts/jassub-contract.test.mjs", "check:ci": "node refactor/scripts/ci-workflow.mjs", "test:ci": "node --test test/ci-summary.test.js refactor/scripts/ci-workflow.test.mjs refactor/scripts/impact.test.mjs test/package-runtime.test.js", - "test:package:runtime": "node scripts/package-runtime.mjs --canonical" + "test:package:runtime": "node scripts/package-runtime.mjs --canonical", + "test:asr": "node --test test/asr.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/packages/artplayer-plugin-asr/ARCHITECTURE.md b/packages/artplayer-plugin-asr/ARCHITECTURE.md new file mode 100644 index 000000000..86696a00f --- /dev/null +++ b/packages/artplayer-plugin-asr/ARCHITECTURE.md @@ -0,0 +1,33 @@ +# ASR maintenance + +The current implementation is still in `src/index.js`. TypeScript migration and +resource ownership changes are tracked in the repository refactor plan as +PKG-ASR-02 through PKG-ASR-06. Do not interpret the baseline tests as completion. + +- The default factory snapshots options. Registration creates the `asr` layer + and subscribes to player play, pause, volumechange and destroy events. +- Play creates an AudioContext and tries a media element source, then a captured + MediaStream source. An inline Blob contains the `recorder-processor` Worklet. +- Worklet messages supply first-channel Float32 samples. The interval callback + encodes little-endian mono PCM16 and a 44-byte-header WAV and invokes the + caller's `onAudioChunk({ pcm, wav })`. A returned string replaces subtitles. +- `append` splits punctuation and sets HTML in `div.art-asr-line`; `hide` changes + display only. `src/style.less` owns layout and existing player CSS variables. +- `stop()` is actually asynchronous and releases audio resources, but retains + playback subscriptions. It is not a permanent disable API. There is no public + `start()` method. Public declarations currently describe stop as returning void. + +This package does not implement recognition or open an ASR network connection. +The demo service and its WebSocket belong to the caller. Do not run that service +to validate audio capture; use local media and a local callback. + +The initial implementation has suspected chunk loss, partial initialization +cleanup and stale callback issues. Preserve the frozen historical tests while +adding candidate regression tests when fixing these. Keep callback/stop typing +compatible with historical assignments, and preserve public entrypoints, HTML +rendering behavior, layer names and stylesheet identity. + +From the repository root, `yarn test:asr` verifies historical public behavior; +`yarn test:baseline` includes archive provenance. `yarn dev artplayer-plugin-asr` +and `yarn build artplayer-plugin-asr` use repository build tools. Real browser +audio and lifecycle validation remains a separate required migration step. diff --git a/packages/artplayer-plugin-asr/README.md b/packages/artplayer-plugin-asr/README.md index eea8cbcd7..545056cf2 100644 --- a/packages/artplayer-plugin-asr/README.md +++ b/packages/artplayer-plugin-asr/README.md @@ -1,6 +1,12 @@ # artplayer-plugin-asr -Ads plugin for ArtPlayer +Audio capture and subtitle display plugin for ArtPlayer. Supplies PCM/WAV chunks +to a caller-provided recognition service. + +## Maintenance + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the current audio flow, public behavior, +known migration gaps and test commands. ## Demo diff --git a/refactor/ai-workflow.md b/refactor/ai-workflow.md index 45ee9cf9b..2ae4773a3 100644 --- a/refactor/ai-workflow.md +++ b/refactor/ai-workflow.md @@ -5,6 +5,11 @@ AI 用于源码分析、任务拆分、实现、测试和差异审查。现代化的验收是可验证的类型、行为和构建质量,不以生成代码量为指标。 +2026-09-13 用户授权使用子代理加快重构。按独立包或不重叠文件划分有界任务, +主代理负责集成、共享任务台账和每任务提交;子代理不得同时改同一生产模块或 +自行批量提交。测试可与契约核对并行,依赖未满足的实现不得提前宣布完成。 +按实际影响范围验证,完整回归保留在跨包变更和阶段验收节点;不承诺线性加速。 + ## 开始任务 1. 阅读根 AGENTS.md、本目录 README、progress、quality-contract.md,以及所选任务关联的契约/设计。 diff --git a/refactor/baselines/asr-contract.md b/refactor/baselines/asr-contract.md new file mode 100644 index 000000000..854ee1ecd --- /dev/null +++ b/refactor/baselines/asr-contract.md @@ -0,0 +1,60 @@ +# ASR 发布契约 + +PKG-ASR-01 于 2026-09-13 固定 npm 2.0.0、2.1.0 的实际归档,每包 6 个成员。 +[来源与逐文件指纹](asr-release.json)另保存工作区提交的 9 个 Git 文本输入。 +运行 `node --test test/asr.test.js refactor/scripts/asr-contract.test.mjs`:32 项通过。 +首次下载使用固定 npm URL;缓存后离线复核 SHA-512、SHA-256、成员与历史 Git 输入。 + +## 支持范围与入口 + +- registry gitHead 对应核心 manifest 为 5.3.1-beta.1 / 5.3.1;这是来源关联, + 不是已完成这些核心的播放器组合验证,更不是最低核心版本声明。 +- 两个发布包没有 engines/peerDependencies;工作区 2.1.0 不等于发布 tarball。 +- main/legacy 可在 CJS 读取并作为普通 script 暴露 artplayerPluginAsr;2.0.0 的 CJS + 是只有 default 可枚举成员的对象,2.1.0 是无可枚举成员的函数。测试同时断言这些 + 原始形状,再从函数或 default 成员取得工厂;迁移必须分别保留历史调用路径。 +- 两个实际 ESM 文件均在无 DOM 环境导入,仅导出 default,调用工厂仍不创建音频资源。 +- 旧 TS 工厂替换和安装消费者验收归 PKG-ASR-04;不能以本次 + VM 运行通过替代实际 tarball 安装验证。 + +## 公开行为 + +| 项目 | 既有行为 | +| --- | --- | +| 工厂 | option 可省略;在调用工厂时解构,之后更改 option 不影响注册 | +| 默认值 | length=3、interval=100ms、sampleRate=16000、autoHideTimeout=10000ms | +| 注册 | 同步创建 name=asr 的 layer,返回 name/stop/hide/append;无公开 start | +| 事件 | 注册 video:volumechange、play、pause、destroy;播放事件启动采集 | +| append | 同步返回 undefined;非字符串忽略,字符串替换全部内容 | +| 行拆分 | 按 、。!?!?. 断句,trim/filter,保留最后 length 段;length=0 保留全部 | +| 内容 | div.art-asr-line 包裹,通过 innerHTML 接受 HTML;不能静默改为纯文本 | +| hide | 只隐藏显示,不清内容;下一次字符串 append 恢复并重置自动隐藏定时器 | +| stop | 实际返回 Promise,调用完整音频销毁;保留播放订阅,可随后重启 | +| 样式 | 模块加载时按 artplayer-plugin-asr ID 去重;沿用 art-layer-asr/CSS 变量 | + +32 项测试包括两个发布版本的 main/legacy、冻结源码以及冻结工作区 main/legacy, +分别观察 CJS/global 工厂、返回值、选项快照、字幕行为、样式去重;另有归档与 ESM 检查。 +冻结源码用 esbuild 转换,仅把 LESS import 换为测试字符串;该项不验证真实 CSS 编译。 +真实发布和冻结工作区 bundle 使用其实际内嵌样式。 + +## 已发现的差异及后续处理 + +1. 两版及工作区声明把 stop 写成 void,onAudioChunk 写成 void | Promise; + 实际 stop 是异步,并等待回调结果交给 append。类型迁移需保留旧函数赋值能力, + 补充精确异步消费,不能只改返回类型就宣布兼容。由 PKG-ASR-04 处理。 +2. 源码每次 tick 取队列再截断,未满一块的样本和超过块长的尾部均没有放回; + 这是源码事实,PKG-ASR-02 用准确 PCM 样本序列复现,PKG-ASR-03 修复。 +3. 初始化最后才置 started;停止及异常清理依赖 started,addModule 失败没有 finally + 回收 URL;停止不能使在途回调失效。PKG-ASR-02 复现后再作独立生命周期修复。 +4. pause 后 sourceNode 仍存在,而 setupAudioSource 仅在不存在时直接返回节点; + 再次启动可能进入 captureStream 分支。需验证重复启动与恢复,不能只测一次播放。 +5. README 误写 Ads;本次改正包功能描述。demo 使用外部 ASR HTTP/WebSocket,服务 + 不是本包依赖或已验证能力。本包只产出 PCM/WAV,不请求麦克风,也不自带识别模型。 + +## 下一步与限制 + +PKG-ASR-02 建立 PCM/WAV、Worklet 消息、失败、乱序和资源的受控回归;PKG-ASR-03 +拆分字幕、编码、队列和音频资源职责;PKG-ASR-04 完成严格 TS 与旧消费者。 +PKG-ASR-05 使用本地媒体与本地 onAudioChunk 验证真实 WebAudio/Worklet、音频持续性、 +新旧核心与三个浏览器。WebAudio mock 不代替实际音频/采样率/静音/CORS/设备验证。 +本任务没有运行真实识别服务,不增加依赖,不改生产源码/分发版本。 diff --git a/refactor/baselines/asr-release.json b/refactor/baselines/asr-release.json new file mode 100644 index 000000000..6b42a8ba6 --- /dev/null +++ b/refactor/baselines/asr-release.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "registry": "https://registry.npmjs.org/artplayer-plugin-asr", + "capturedAt": "2026-09-13T05:57:08.117Z", + "sourceCommit": "3b016970f24fea603706a2f4bd50026846f5d939", + "sourceNormalization": "Git text normalized to LF; archive members hashed as bytes.", + "source": { + "packages/artplayer-plugin-asr/package.json": "f0c9fc6c294376c7ed8974556672b077e31fae480bd8e3ecc8b2a060811b29cd", + "packages/artplayer-plugin-asr/README.md": "8bc731990d7ab32837af8382a99759ee14b6872dd24065929054a69fdcea2e4e", + "packages/artplayer-plugin-asr/src/index.js": "af24a6334298d636235d3af417bdea83444d200563ba6df054b66c53cdaef47f", + "packages/artplayer-plugin-asr/src/style.less": "ed40939c1db527bddf98c3e08b8c2e0b1d5a3581804fe2084d1070c9d50af0d6", + "packages/artplayer-plugin-asr/types/artplayer-plugin-asr.d.ts": "2d1ecb04f285b5835744d80dbde3c4a2a124bf1f5ffc995d0d775f5725309d0f", + "packages/artplayer-plugin-asr/dist/artplayer-plugin-asr.js": "7f975b131a9ea207f88a157aafec778bc4906230500db9b30f5bbfbf327b251d", + "packages/artplayer-plugin-asr/dist/artplayer-plugin-asr.legacy.js": "8b9f07ce97a8ce2e4f29e41e79257be530fd7a76558369d34c3d7497ea606c33", + "packages/artplayer-plugin-asr/dist/artplayer-plugin-asr.mjs": "f1c4274541e86544b65936ff470413cfc7001999ebc193969f95c123be48cd61", + "docs/assets/example/asr.js": "bb7fd5eee3df887b7e0c72a6f42f74f0253350f9bc884f73cb9c2996f0e35724" + }, + "release": { + "name": "artplayer-plugin-asr", + "version": "2.1.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-asr/-/artplayer-plugin-asr-2.1.0.tgz", + "integrity": "sha512-15gXff458PAx9LeJxKzZEvmpFhfwKUfSLgb1Mfr7Xb/ZGsnE+6EA7ezTnO5S55W5mFExjv/e95iOIXP4ijuruQ==", + "sha256": "8bc3cd93f78ce99e5c09cc546c770a63e8aea5729d3d584134ce04d826b7708b", + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "files": { + "package/README.md": "8bc731990d7ab32837af8382a99759ee14b6872dd24065929054a69fdcea2e4e", + "package/dist/artplayer-plugin-asr.js": "7f975b131a9ea207f88a157aafec778bc4906230500db9b30f5bbfbf327b251d", + "package/dist/artplayer-plugin-asr.legacy.js": "8b9f07ce97a8ce2e4f29e41e79257be530fd7a76558369d34c3d7497ea606c33", + "package/dist/artplayer-plugin-asr.mjs": "f1c4274541e86544b65936ff470413cfc7001999ebc193969f95c123be48cd61", + "package/package.json": "f0c9fc6c294376c7ed8974556672b077e31fae480bd8e3ecc8b2a060811b29cd", + "package/types/artplayer-plugin-asr.d.ts": "2d1ecb04f285b5835744d80dbde3c4a2a124bf1f5ffc995d0d775f5725309d0f" + }, + "manifest": { + "name": "artplayer-plugin-asr", + "version": "2.1.0", + "description": "Asr plugin for ArtPlayer", + "author": "Harvey Zhao ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-asr.d.ts", + "import": "./dist/artplayer-plugin-asr.mjs", + "require": "./dist/artplayer-plugin-asr.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-asr.d.ts", + "import": "./dist/artplayer-plugin-asr.legacy.js", + "require": "./dist/artplayer-plugin-asr.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-asr.js", + "module": "./dist/artplayer-plugin-asr.mjs", + "types": "./types/artplayer-plugin-asr.d.ts", + "legacy": "./dist/artplayer-plugin-asr.legacy.js", + "browserslist": "last 1 Chrome version" + }, + "historicalCore": { + "commit": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "file": "packages/artplayer/package.json", + "version": "5.3.1", + "sha256LF": "862f3697876fe915dd0ada178da766ce823aae7563ed30b3e5b11b811202929c" + } + }, + "previous": [ + { + "name": "artplayer-plugin-asr", + "version": "2.0.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-asr/-/artplayer-plugin-asr-2.0.0.tgz", + "integrity": "sha512-nXTf2drMSKnHRbyU9CQ+3o7Jsf1Meam2O9T8LqYakIr18ag/+i71p2x9zdFrLz16Xa6jfQgKeddmU5r3sXp4LA==", + "sha256": "faa726b9536306664d935c95367230e0c622bd046faf636fc2dcef4bbce4cd47", + "registryGitHead": "ca34b47180c94048ee765ab7e146face737eac1c", + "files": { + "package/README.md": "24446684a782c4c179516917a42862809164aac1ae9746e21a09da738e19ca42", + "package/dist/artplayer-plugin-asr.js": "30c7b1fc92a262cda8c3aba63afea9c6c038f52c057039d1c6aa957baed155b6", + "package/dist/artplayer-plugin-asr.legacy.js": "2c2bf48751e4d3cfa88f7313f092d395b61bc0564f6cc44f2c76f0fbcfc66412", + "package/dist/artplayer-plugin-asr.mjs": "98ebdec2c66cecd9d19904a2e54e4b645f7c267b569e560d9bcbeee6028b2267", + "package/package.json": "7545d9bb95b48aedec45c0345f39dd8f0728a24ffe148a04e3740e1ef50239df", + "package/types/artplayer-plugin-asr.d.ts": "2d1ecb04f285b5835744d80dbde3c4a2a124bf1f5ffc995d0d775f5725309d0f" + }, + "manifest": { + "name": "artplayer-plugin-asr", + "version": "2.0.0", + "description": "Asr plugin for ArtPlayer", + "author": "Harvey Zack ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-asr.d.ts", + "import": "./dist/artplayer-plugin-asr.mjs", + "require": "./dist/artplayer-plugin-asr.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-asr.d.ts", + "import": "./dist/artplayer-plugin-asr.legacy.js", + "require": "./dist/artplayer-plugin-asr.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-asr.js", + "module": "./dist/artplayer-plugin-asr.mjs", + "types": "./types/artplayer-plugin-asr.d.ts", + "legacy": "./dist/artplayer-plugin-asr.legacy.js", + "browserslist": "last 1 Chrome version" + }, + "historicalCore": { + "commit": "ca34b47180c94048ee765ab7e146face737eac1c", + "file": "packages/artplayer/package.json", + "version": "5.3.1-beta.1", + "sha256LF": "e1a0a390ec10cd8c1c12006ac6515414ae98a964300dd3e9c288377def948497" + } + } + ] +} diff --git a/refactor/changes/2026-09-13-PKG-ASR-01-contracts.md b/refactor/changes/2026-09-13-PKG-ASR-01-contracts.md new file mode 100644 index 000000000..9f00f2afa --- /dev/null +++ b/refactor/changes/2026-09-13-PKG-ASR-01-contracts.md @@ -0,0 +1,18 @@ +# PKG-ASR-01:固定 ASR 公开契约 + +源起点为 3b016970f24fea603706a2f4bd50026846f5d939。冻结实际 npm 2.0.0/2.1.0、 +12 个归档成员、9 个工作区 Git 文本及各自历史核心 manifest;不覆盖初始 BASE 快照。 +交付物为 [契约](../baselines/asr-contract.md)、[来源](../baselines/asr-release.json)、 +可重跑的 asr-contract 校验器与 test/asr.test.js。生产行为未改动。 + +确认接口同步注册、异步 stop、工厂参数快照、字幕替换/HTML/隐藏行为及样式去重。 +公开声明与异步行为不一致、队列丢样本及初始化清理疑点分别登记到后续任务, +未把源码观察写成已修复问题。README 的 Ads 误写修正并链接包内维护说明。 + +验证:`node --test test/asr.test.js refactor/scripts/asr-contract.test.mjs` 32 项通过; +定向 ESLint、计划与风险台账检查通过。未运行全量 CI、真实浏览器、安装消费者或外部 ASR。 +本批只固定后续迁移依据,真实音频、旧类型消费与发行验收仍由 ASR-02 至 ASR-06 负责。 + +无新依赖。增加 test:asr 命令并把公开行为测试接入 test:unit;来源检查在既有 +test:baseline 通配入口执行。每次重跑都会校验归档,不静默重写基线。 +回退本任务提交可移除这些基线、测试、文档与任务状态,不影响当前播放器运行时。 diff --git a/refactor/plan.md b/refactor/plan.md index 60115f968..1b5d43e89 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 225 项,范围 22 个包及工作区/示例。 -状态:todo 86 / doing 16 / blocked 0 / done 123 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 85 / doing 16 / blocked 0 / done 124 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -251,7 +251,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | -| PKG-ASR-01 | artplayer-plugin-asr
核对包契约与历史用法 | BASE-05 | length/interval/sampleRate/onAudioChunk、append/hide/stop 的真实能力 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | todo | +| PKG-ASR-01 | artplayer-plugin-asr
核对包契约与历史用法 | BASE-05 | length/interval/sampleRate/onAudioChunk、append/hide/stop 的真实能力 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-ASR-02 | artplayer-plugin-asr
建立特有行为与错误测试 | PKG-ASR-01, ENG-03, ENG-05 | PCM/WAV、chunk 时序、回调慢/拒绝、重复初始化与停止 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | | PKG-ASR-03 | artplayer-plugin-asr
整理内部职责与资源 | PKG-ASR-02, CORE-10, CORE-18 | AudioContext/Worklet/Stream 生命周期和背压分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-ASR-04 | artplayer-plugin-asr
迁移自有源码和公开类型 | PKG-ASR-03, ENG-04, ENG-06, CORE-07 | 主线程/Worklet/音频 buffer 与异步回调类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | @@ -526,6 +526,7 @@ - PKG-MULTI-SUB-04: [记录](baselines/multiple-subtitles-runtime-types.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-04-runtime-types.md) [记录](baselines/multiple-subtitles-public-types.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-04-public-types.md) - PKG-MULTI-SUB-07: [记录](baselines/multiple-subtitles-timestamps.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-07-timestamps.md) - PKG-JASSUB-01: [记录](baselines/jassub-release.json) [记录](baselines/jassub-vendor.json) [记录](baselines/jassub-font-metadata.json) [记录](baselines/jassub-contract.md) [记录](baselines/jassub-contract-validation.json) [记录](changes/2026-09-13-PKG-JASSUB-01-baseline.md) +- PKG-ASR-01: [记录](changes/2026-09-13-PKG-ASR-01-contracts.md) [记录](baselines/asr-contract.md) [记录](baselines/asr-release.json) - PKG-ADS-01: [记录](baselines/ads-release.json) [记录](baselines/ads-contract.md) [记录](baselines/ads-contract-validation.json) [记录](changes/2026-09-12-PKG-ADS-01-contract.md) [记录](scripts/ads-contract.test.mjs) - PKG-ADS-02: [记录](changes/2026-09-12-PKG-ADS-02-tests.md) [记录](ads-validation.md) [记录](baselines/ads-validation.json) - PKG-ADS-03: [记录](changes/2026-09-12-PKG-ADS-03-lifecycle.md) [记录](ads-validation.md) [记录](baselines/ads-lifecycle-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index 7ff51e73a..6902a8e59 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,17 @@ # 进度与证据 +## PKG-ASR-01 发布契约完成 + +冻结实际 npm 2.0.0/2.1.0 共 12 成员及工作区 9 个 Git 输入;32 项契约测试通过, +包含 CJS/global、实际 ESM、字幕 HTML/行数/隐藏、异步 stop 和工厂选项快照。 +声明异步差异、音频丢样本及生命周期疑点已分别登记,未将源码疑点写成已修复。 +修正 README 的 Ads 误写并增加包内维护地图;无生产源码、依赖或版本变化。 +见[契约](baselines/asr-contract.md)和[变更](changes/2026-09-13-PKG-ASR-01-contracts.md)。 +225 项:124 done、16 doing、85 todo;205 风险。下一步 ASR 受控音频测试与源码拆分。 +用户已授权子代理;当前子代理只写独立音频测试文件,主代理负责契约、台账和提交。 +实际浏览器、旧类型消费者和全包发布仍待完成;没有运行外部识别服务。 + + ## CORE-25 无 navigator 默认选项修复完成 defaults.ts 使用 typeof 保护不存在的全局绑定,保留浏览器语言小写、lang 自有字段及每次 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 914092375..fa1ed810d 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -206,3 +206,6 @@ | JASSUB-EXPORT-01 | open / 已复现 | JASSUB historical CommonJS object.default changes to a direct factory in 1.1.0 | PKG-JASSUB-04, PKG-JASSUB-06 | | CORE-DEFAULTS-SSR-01 | resolved / 已复现 | Reading static defaults throws when navigator is absent | CORE-25 | | ENG-DIST-PUBLIC-01 | resolved / 已复现 | Vite copied authored declarations into library distribution | ENG-12 | +| ASR-TYPE-01 | open / 源码/产物事实 | ASR declarations omit asynchronous stop and callback text results | PKG-ASR-04 | +| ASR-CHUNK-01 | open / 源码/产物事实 | ASR tick discards underfilled chunks and overrun samples in source | PKG-ASR-02, PKG-ASR-03 | +| ASR-LIFE-01 | open / 源码/产物事实 | ASR partial initialization, pause resume and stale callback cleanup need controlled evidence | PKG-ASR-02, PKG-ASR-03, PKG-ASR-05 | diff --git a/refactor/risks.json b/refactor/risks.json index 29b1b3b03..2dcb0290d 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -4541,6 +4541,54 @@ "refactor/changes/2026-09-13-ENG-12-library-public.md" ], "resolutionRationale": "Three-format disk fixture and actual core archive prove removal; all other package members remain byte-identical. Full 21-library and docs build passed. CORE-25 runtime work was present but is not closed by this task." + }, + { + "id": "ASR-TYPE-01", + "title": "ASR declarations omit asynchronous stop and callback text results", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-ASR-04" + ], + "evidence": [ + "refactor/baselines/asr-contract.md", + "refactor/baselines/asr-release.json" + ], + "compatibleResolution": "Keep historical assignment compatibility while supplying accurate asynchronous consumption.", + "closureCriteria": "Actual old/new installed TS consumers cover void callback, Promise, returned text and factory substitution." + }, + { + "id": "ASR-CHUNK-01", + "title": "ASR tick discards underfilled chunks and overrun samples in source", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-ASR-02", + "PKG-ASR-03" + ], + "evidence": [ + "refactor/baselines/asr-contract.md", + "refactor/baselines/asr-release.json" + ], + "compatibleResolution": "Reproduce exact sample loss and retain queue remainders with bounded, ordered callback delivery.", + "closureCriteria": "Exact PCM/WAV byte sequence and slow/rejected callback regression tests pass without losing or reordering accepted input." + }, + { + "id": "ASR-LIFE-01", + "title": "ASR partial initialization, pause resume and stale callback cleanup need controlled evidence", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-ASR-02", + "PKG-ASR-03", + "PKG-ASR-05" + ], + "evidence": [ + "refactor/baselines/asr-contract.md", + "refactor/baselines/asr-release.json" + ], + "compatibleResolution": "Own pending setup, URLs, source nodes and callbacks across pause, stop, restart and terminal destruction.", + "closureCriteria": "Failure/race regression tests plus native WebAudio playback and cleanup evidence prove resource ownership." } ] } diff --git a/refactor/scripts/asr-contract.mjs b/refactor/scripts/asr-contract.mjs new file mode 100644 index 000000000..c2bf72182 --- /dev/null +++ b/refactor/scripts/asr-contract.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { archiveFiles, ensureArchive, hash, readMember, refactorDir } from './releases.mjs' + +export async function verifyAsrContract() { + const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/asr-release.json'), 'utf8')) + const archives = new Map() + for (const release of [baseline.release, ...baseline.previous]) { + const archive = await ensureArchive(release) + assert.deepEqual(archiveFiles(archive), Object.keys(release.files).sort()) + for (const [file, expected] of Object.entries(release.files)) + assert.equal(hash(readMember(archive, file)), expected, `${release.version}/${file}`) + assert.deepEqual(JSON.parse(readMember(archive, 'package/package.json')), release.manifest) + for (const field of ['main', 'module', 'types', 'legacy']) + assert(Object.hasOwn(release.files, `package/${release.manifest[field].replace(/^\.\//, '')}`)) + const core = release.historicalCore + const source = execFileSync('git', ['show', `${core.commit}:${core.file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n') + assert.equal(hash(source), core.sha256LF) + assert.equal(JSON.parse(source).version, core.version) + archives.set(release.version, archive) + } + const sources = new Map() + for (const [file, expected] of Object.entries(baseline.source)) { + const source = execFileSync('git', ['show', `${baseline.sourceCommit}:${file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n') + assert.equal(hash(source), expected, file) + sources.set(file, source) + } + return { baseline, archives, sources } +} diff --git a/refactor/scripts/asr-contract.test.mjs b/refactor/scripts/asr-contract.test.mjs new file mode 100644 index 000000000..6c55fe857 --- /dev/null +++ b/refactor/scripts/asr-contract.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +// eslint-disable-next-line test/no-import-node-test -- Frozen published package evidence uses the repository runner. +import test from 'node:test' +import { verifyAsrContract } from './asr-contract.mjs' +import { readMember } from './releases.mjs' + +const { baseline, archives, sources } = await verifyAsrContract() +const releases = [baseline.release, ...baseline.previous] +test('ASR verifies both releases, twelve archive members, nine Git inputs and historical core references', () => { + assert.deepEqual(releases.map(r => r.version), ['2.1.0', '2.0.0']) + assert.equal(releases.reduce((n, r) => n + Object.keys(r.files).length, 0), 12) + assert.equal(sources.size, 9) + assert.deepEqual(releases.map(r => r.historicalCore.version), ['5.3.1', '5.3.1-beta.1']) + for (const release of releases) { + assert.equal(release.manifest.peerDependencies, undefined) + assert.equal(release.manifest.engines, undefined) + } +}) +test('ASR old declarations describe void callback/stop despite runtime consuming returned text and returning a Promise', () => { + for (const release of releases) { + const types = readMember(archives.get(release.version), 'package/types/artplayer-plugin-asr.d.ts').toString() + assert.match(types, /onAudioChunk\?: \(chunk: AudioChunk\) => void \| Promise/) + assert.match(types, /stop: \(\) => void/) + assert.match(types, /export default artplayerPluginAsr/) + assert.match(types, /option\?: AsrPluginOption/) + } +}) +test('ASR actual published ESM exposes a lazy default factory without requiring a browser', async () => { + for (const release of releases) { + const source = readMember(archives.get(release.version), 'package/dist/artplayer-plugin-asr.mjs') + const module = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`) + assert.deepEqual(Object.keys(module), ['default']) + assert.equal(typeof module.default(), 'function') + } +}) +test('ASR source ships an inline Worklet; network transcription is caller-owned example behavior', () => { + const source = sources.get('packages/artplayer-plugin-asr/src/index.js') + assert.match(source, /registerProcessor\('recorder-processor'/) + assert.doesNotMatch(source, /\bfetch\(|new WebSocket\(/) + const demo = sources.get('docs/assets/example/asr.js') + assert.match(demo, /await fetch\(api\)/) + assert.match(demo, /new WebSocket\(url\)/) + for (const release of releases) + assert(!Object.keys(release.files).some(file => /worklet|worker|\.wasm/.test(file))) +}) diff --git a/refactor/tasks.json b/refactor/tasks.json index 1b93f499d..006f6d3bc 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -2617,11 +2617,15 @@ "dependsOn": [ "BASE-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "length/interval/sampleRate/onAudioChunk、append/hide/stop 的真实能力", "acceptance": "源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结", - "evidence": [] + "evidence": [ + "changes/2026-09-13-PKG-ASR-01-contracts.md", + "baselines/asr-contract.md", + "baselines/asr-release.json" + ] }, { "id": "PKG-ASR-02", diff --git a/test/asr.test.js b/test/asr.test.js new file mode 100644 index 000000000..0cecc29ad --- /dev/null +++ b/test/asr.test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Historical ASR contracts use the repository runner. +import test from 'node:test' +import { asrEnvironment, asrHistorical } from './helpers/asr.js' + +for (const implementation of await asrHistorical()) { + for (const script of [false, true]) { + test(`ASR ${implementation.name}: ${script ? 'global' : 'CJS'} lazy factory, synchronous result and async stop`, async () => { + const env = asrEnvironment(implementation, { script }) + if (!script) { + const namespace = implementation.name.startsWith('published-2.0.0') || implementation.name === 'frozen-source' + assert.equal(typeof env.exported, namespace ? 'object' : 'function') + assert.deepEqual(Object.keys(env.exported), namespace ? ['default'] : []) + } + const register = env.factory() + assert.equal(env.layers.length, 0) + const result = register(env.art) + assert.deepEqual(Object.keys(result), ['name', 'stop', 'hide', 'append']) + assert.equal(result.name, 'artplayerPluginAsr') + assert.equal(result.then, undefined) + assert.equal(result.start, undefined) + assert.equal(env.layers[0].name, 'asr') + assert.deepEqual([...env.listeners.keys()], ['video:volumechange', 'play', 'pause', 'destroy']) + const stopped = result.stop() + assert.equal(typeof stopped.then, 'function') + assert.equal(await stopped, undefined) + assert.equal(env.listeners.get('play').length, 1, 'stop retains historical restart subscription') + assert.equal(env.styles.size, 1) + env.reload() + assert.equal(env.styles.size, 1) + }) + } + test(`ASR ${implementation.name}: append replaces punctuation lines, permits markup, and hide retains content`, () => { + const env = asrEnvironment(implementation) + const result = env.factory()(env.art) + assert.equal(result.append('One. Two! 三。 Four?'), undefined) + assert.equal(env.layer.innerHTML, '
Two!
三。
Four?
') + assert.equal([...env.timeouts.values()][0].delay, 10000) + result.append('markup') + assert.equal(env.layer.innerHTML, '
markup
') + assert.equal(env.timeouts.size, 1) + result.hide() + assert.equal(env.layer.style.display, 'none') + assert.equal(result.append(null), undefined) + assert.equal(env.layer.style.display, 'none') + assert.equal(env.timeouts.size, 1) + result.append('') + assert.equal(env.layer.innerHTML, '') + assert.equal(env.layer.style.display, '') + ;[...env.timeouts.values()][0].callback() + assert.equal(env.layer.style.display, 'none') + }) + test(`ASR ${implementation.name}: options are captured at factory call and instances own subtitle timers`, () => { + const option = { length: 1, autoHideTimeout: 25 } + const env = asrEnvironment(implementation) + const register = env.factory(option) + option.length = 4 + option.autoHideTimeout = 99 + register(env.art).append('one. two.') + assert.equal(env.layer.innerHTML, '
two.
') + assert.equal([...env.timeouts.values()][0].delay, 25) + const other = asrEnvironment(implementation) + other.factory({ length: 0 })(other.art).append('one. two.') + assert.equal(other.layer.innerHTML, '
one.
two.
', 'zero retains historical slice(-0) semantics') + assert.equal(env.timeouts.size, 1) + assert.equal(other.timeouts.size, 1) + }) +} diff --git a/test/helpers/asr.js b/test/helpers/asr.js new file mode 100644 index 000000000..965dc69ae --- /dev/null +++ b/test/helpers/asr.js @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import vm from 'node:vm' +import { transform } from 'esbuild' +import { verifyAsrContract } from '../../refactor/scripts/asr-contract.mjs' +import { readMember } from '../../refactor/scripts/releases.mjs' + +export async function asrHistorical() { + const { baseline, archives, sources } = await verifyAsrContract() + const implementations = [] + for (const release of [baseline.release, ...baseline.previous]) { + for (const field of ['main', 'legacy']) + implementations.push({ name: `published-${release.version}-${field}`, code: readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//, '')}`).toString() }) + } + const prefix = 'packages/artplayer-plugin-asr' + const source = sources.get(`${prefix}/src/index.js`).replace('import style from \'./style.less?inline\'', 'const style = \'frozen-source-style\'') + implementations.push({ name: 'frozen-source', code: (await transform(source, { format: 'cjs', target: 'es2020' })).code, scriptCode: (await transform(source, { format: 'iife', globalName: 'artplayerPluginAsr', target: 'es2020' })).code }) + for (const suffix of ['js', 'legacy.js']) + implementations.push({ name: `frozen-workspace-${suffix}`, code: sources.get(`${prefix}/dist/artplayer-plugin-asr.${suffix}`) }) + return implementations +} + +export function asrEnvironment(implementation, { script = false } = {}) { + const listeners = new Map() + const timeouts = new Map() + const styles = new Map() + const layer = { style: {}, innerHTML: '' } + let id = 0 + const context = vm.createContext({ + console, + setTimeout(callback, delay) { + timeouts.set(++id, { callback, delay }) + return id + }, + clearTimeout(timer) { timeouts.delete(timer) }, + document: { + getElementById: id => styles.get(id), + createElement: () => ({}), + head: { appendChild(element) { styles.set(element.id, element) } }, + }, + }) + context.window = context + if (!script) { + context.module = { exports: {} } + context.exports = context.module.exports + } + const code = script ? implementation.scriptCode || implementation.code : `(function () {\n${implementation.code}\n}).call(globalThis)` + vm.runInContext(code, context, { timeout: 1000 }) + const exported = script ? context.artplayerPluginAsr : context.module.exports + const factory = typeof exported === 'function' ? exported : exported.default + assert.equal(typeof factory, 'function', implementation.name) + const layers = [] + const art = { + video: {}, + volume: 0.5, + layers: { add(option) { + layers.push(option) + return layer + } }, + on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) }, + } + return { context, factory, exported, styles, layer, layers, listeners, timeouts, art, reload: () => vm.runInContext(code, context), emit: name => Promise.all((listeners.get(name) || []).map(callback => callback())) } +}