diff --git a/bun.lock b/bun.lock index b99a4bc..668eebc 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@astrojs/rss": "^4.0.19", "@astrojs/solid-js": "7.1.0", "@biomejs/biome": "^2.5.15", + "@microsoft/api-extractor": "7.59.4", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", @@ -198,8 +199,9 @@ "@moq/json": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", + "@moq/ui": "workspace:^", "@moq/video": "workspace:^", - "mediabunny": "^1.61.4", + "mediabunny": "^1.61.6", }, "devDependencies": { "@types/audioworklet": "^0.0.100", @@ -250,6 +252,20 @@ "solid-js", ], }, + "moq/js/ui": { + "name": "@moq/ui", + "version": "0.1.0", + "dependencies": { + "@moq/signals": "workspace:^", + }, + "devDependencies": { + "@types/bun": "^1.4.3", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "rimraf": "^6.1.3", + "typescript": "7.0.2", + "vite": "^8.3.4", + }, + }, "moq/js/video": { "name": "@moq/video", "version": "0.1.0", @@ -273,6 +289,7 @@ "@moq/msf": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", + "@moq/ui": "workspace:^", "@moq/video": "workspace:^", "media-captions": "0.0.18", }, @@ -546,6 +563,14 @@ "@libav.js/types": ["@libav.js/types@6.10.9", "", {}, "sha512-AdngxD/VO8OI6/w7wlJC77kuFcsSvCUuo2RuC6gGeSW2DEvbzgBo2JtFLV6UWZp/tRNZhKm6jS0Tv8gwCKBHLg=="], + "@microsoft/api-extractor": ["@microsoft/api-extractor@7.59.4", "", { "dependencies": { "@microsoft/api-extractor-model": "7.33.15", "@microsoft/tsdoc": "~0.17.0", "@microsoft/tsdoc-config": "~0.18.2", "@rushstack/node-core-library": "5.25.1", "@rushstack/rig-package": "0.7.4", "@rushstack/terminal": "0.24.7", "@rushstack/ts-command-line": "5.3.17", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig=="], + + "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.33.15", "", { "dependencies": { "@microsoft/tsdoc": "~0.17.0", "@microsoft/tsdoc-config": "~0.18.2", "@rushstack/node-core-library": "5.25.1" } }, "sha512-UpCnItIJwu03iNTWtJVKv8Qbf0MXN7WLQZUail9cpAQmnZiIX36YPRRoRxz0pocwytzUpmJhstsLkyW5tsaHBA=="], + + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.17.1", "", {}, "sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA=="], + + "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.3", "", { "dependencies": { "@microsoft/tsdoc": "0.17.1", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-0I0rGXo1eJziw3EEErkDS9p/0RzBcyOiV+zbSDqOZTeURmhtut+WIvpih0LM5g+ZpZ9SyFxDfkhAcl/r8e2zGw=="], + "@moq/boy": ["@moq/boy@workspace:moq/js/moq-boy"], "@moq/clock": ["@moq/clock@workspace:moq/js/clock"], @@ -572,6 +597,8 @@ "@moq/signals": ["@moq/signals@workspace:moq/js/signals"], + "@moq/ui": ["@moq/ui@workspace:moq/js/ui"], + "@moq/video": ["@moq/video@workspace:moq/js/video"], "@moq/watch": ["@moq/watch@workspace:moq/js/watch"], @@ -620,6 +647,16 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rushstack/node-core-library": ["@rushstack/node-core-library@5.25.1", "", { "dependencies": { "ajv": "~8.20.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.7.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ozoRDwIx3jStOYpx86rFyXH4SaUR+DVT6HubQ59avLplJkWXSm5InztYH7RrOzzq9PJXTdq9/+lKX06SfkLVsA=="], + + "@rushstack/problem-matcher": ["@rushstack/problem-matcher@0.2.1", "", { "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog=="], + + "@rushstack/rig-package": ["@rushstack/rig-package@0.7.4", "", { "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" } }, "sha512-SHZdzc9b2ptUb5uvRWALtAceD/QKZ4UAnZjcaKviTMPmHBnW14gs7XDocn6sDv2EqgnxxFPFkpV7/I3cUijCbw=="], + + "@rushstack/terminal": ["@rushstack/terminal@0.24.7", "", { "dependencies": { "@rushstack/node-core-library": "5.25.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-o8J9nHOIwfYb0s0eG2WYhx7ed3YIvBXQHVFxYdXCmVdBOkTkdArJcgVzRS8ewUepmlic4nGgJPjKIVaNKSqEnA=="], + + "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.17", "", { "dependencies": { "@rushstack/terminal": "0.24.7", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-pIwhWawZXAT0nTXwvcE/InQ7uaVAgSeIzILMb2mBrI01Hz1yEvxrhV2+DE2Fzux8aZY9OJTdLsI+sk9kZDuAJw=="], + "@shikijs/core": ["@shikijs/core@4.5.0", "", { "dependencies": { "@shikijs/primitive": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA=="], @@ -676,6 +713,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], + "@types/audioworklet": ["@types/audioworklet@0.0.100", "", {}, "sha512-mWt4Z5CWi0CupDnSYWJgx6dw61XUk9JSRGrM9F/YNrpOoY/0XG+sxP+8rAjQq80H4febOe8PrzGmmN/X3w8LsQ=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -756,10 +795,18 @@ "@zod/mini": ["@zod/mini@4.6.7", "", { "peerDependencies": { "zod": "^4.6.0" } }, "sha512-oLORzy4OERpr+gaITCsrCK9uDWd9lOCLG00fRZEJidVITLS5SQtifmzP6fBSFH9YxkXFoT6/o4bRZDq2tTHrog=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "am-i-vibing": ["am-i-vibing@0.5.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "astro": ["astro@7.4.0", "", { "dependencies": { "@astrojs/compiler-rs": "^0.5.1", "@astrojs/internal-helpers": "0.13.0", "@astrojs/markdown-satteri": "0.4.4", "@astrojs/telemetry": "3.4.0", "@capsizecss/unpack": "^4.0.1", "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.5.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^6.0.2", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.6", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.3.0", "jsonc-parser": "^3.3.1", "magic-string": "^1.4.3", "magicast": "^0.5.5", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.3", "p-queue": "^9.3.5", "package-manager-detector": "^1.9.0", "piccolore": "^0.1.3", "picomatch": "^4.0.7", "rolldown": "^1.2.13", "shiki": "^4.5.0", "smol-toml": "^1.9.1", "svgo": "^4.1.0", "tinyclip": "^1.0.3", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tinyps": "^0.5.0", "ultrahtml": "^1.7.0", "unifont": "^1.0.3", "unstorage": "^1.17.5", "verkit": "^0.5.0", "vite": "^8.3.4", "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.6.6" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-6ThZU2bp7asGSceVcnh3r23dE88HpYN5vxtnCWYYrjr8lpQjx4eDW6/W8WgNlcc+T/b8zHmQ/EcS4DpQG4ZoOg=="], @@ -846,7 +893,7 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -866,6 +913,8 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@3.0.4", "", {}, "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA=="], "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], @@ -878,10 +927,14 @@ "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], @@ -902,8 +955,12 @@ "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], @@ -918,6 +975,10 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], @@ -932,10 +993,14 @@ "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="], + "is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -950,14 +1015,20 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -1014,7 +1085,7 @@ "miniflare": ["miniflare@5.20261012.1-beta", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", "undici": "7.29.1", "workerd": "1.20261012.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-8Canhy6g9+vAhqMc0o80YphqYIwpiA3aAp8aX+uDXU7J/qbUeoZzcII9c2V1P4uT/eHD4SB93qet1o1bRY3BuQ=="], - "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -1064,6 +1135,8 @@ "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -1100,6 +1173,10 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve": ["resolve@1.22.13", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], @@ -1112,7 +1189,7 @@ "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "seroval": ["seroval@1.6.11", "", {}, "sha512-hpb6XnMCGN/A+79Uf8V7/iTGkyyyazNn5X0CoCAp7Uy9vT3RT8jZ9GLcnxGoVjg3P8AArbe4ZVHq6C8oT2a0kA=="], @@ -1130,17 +1207,25 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], @@ -1194,6 +1279,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="], @@ -1284,6 +1371,12 @@ "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@microsoft/api-extractor/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "@rushstack/node-core-library/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1312,8 +1405,12 @@ "@types/babel__traverse/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "astro/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "babel-plugin-jsx-dom-expressions/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], @@ -1328,6 +1425,8 @@ "fontace/fontkitten": ["fontkitten@1.0.2", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q=="], + "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "hast-util-to-html/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "hast-util-whitespace/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -1340,6 +1439,8 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "solid-refresh/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "unifont/undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], diff --git a/moq b/moq index 89e1ff3..d140840 160000 --- a/moq +++ b/moq @@ -1 +1 @@ -Subproject commit 89e1ff39d35eaf41f3395c99b03721cd22dfe7e3 +Subproject commit d140840f868e5197ca98037d2c3866d07c0e4510 diff --git a/package.json b/package.json index 5f94d34..8d4de88 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "moq/js/publish", "moq/js/qmux", "moq/js/signals", + "moq/js/ui", "moq/js/video", "moq/js/watch" ], @@ -46,6 +47,7 @@ "@astrojs/rss": "^4.0.19", "@astrojs/solid-js": "7.1.0", "@biomejs/biome": "^2.5.15", + "@microsoft/api-extractor": "7.59.4", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", diff --git a/quest/m1/README.md b/quest/m1/README.md index 3e34c40..98a6588 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -9,4 +9,3 @@ moves and buffers the call and why it beats WebRTC for voice agents. - [Drain the voice goodbye instead of waiting](/quest/m1/voice-goodbye-drain.md) - hanging up closes through the draining `close()`, not a fixed wait - [The voice agent publishes the full voice.json](/quest/m1/voice-timeline-track.md) - condition: the agent's side of the call, timed, on wtf -- [A live timeline of the voice call](/quest/m1/voice-buffers.md) - every frame, buffer, VAD decision, and model step on one axis, marking frames late for a typical WebRTC jitter buffer diff --git a/quest/m1/voice-buffers.md b/quest/m1/voice-buffers.md deleted file mode 100644 index 5bbc604..0000000 --- a/quest/m1/voice-buffers.md +++ /dev/null @@ -1,71 +0,0 @@ -# [L] A live timeline of the voice call - -## Goal - -The `/voice` demo draws the latest exchange, your most recent statement and -the agent's most recent reply, on one live timeline from real measurements: your mic frames as captured, those frames reaching the -agent, its VAD and turn decisions, the words STT recognizes and when, the LLM -and TTS working, each reply frame published, in flight, and buffered in your -browser, and the playhead. A reader watches packets cross before the AI -recognizes them as words, sees how far ahead MoQ holds the reply in memory, and -can tell at a glance where a slow reply spent its time, which answers "is the -delay MoQ?". - -Frames that arrived later than a typical WebRTC jitter buffer waits turn red: -an estimate from the MoQ arrivals, not a measured WebRTC receiver. Nothing is -simulated or injected. - -## Plan - -Decided 2026-10-23: - -- **One shared wall-clock axis**, not per-direction panels or strips under - words: every lane on the same time, so a frame's capture, arrival, and - recognition line up vertically. Buffered reply audio sits to the right of - the playhead. -- **The latest exchange, not the whole call** (decided later on 2026-10-23, - replacing a sliding window around now): the axis spans your most recent - statement and the agent's most recent reply. `voice.json` holds exactly - that, so the page draws what it reads. -- **All at once.** Build the page after the agent-side and reply-side data - exist, rather than shipping lanes piecemeal. -- **Real data only**, and keep the WebRTC mark: a frame is late for WebRTC - when its arrival spread exceeds a typical WebRTC jitter target. Measure - lateness as arrival minus media timestamp, relative to the smallest seen, - so it needs no clock agreement. Choose and cite the target when - implementing. -- **Draw it like the post.** Reuse `src/components/voice/`'s colors and - vocabulary (buffered, consumed, waiting, lost; packets crossing a network - band; words over their audio) so the live view reads as the post's diagrams - come alive. - -Sources, as of planning: - -- Browser, available now: `@moq/publish`'s captured audio frames; - `@moq/watch`'s audio `buffered` ranges and playback `timestamp`; the - connection's PROBE RTT; RTVI turn events and per-service `metrics` on the - transcript track. -- Reply-side arrivals: `@moq/watch`'s per-frame `arrivals()`, which also - yields a gap item where media will never arrive. Draw a frame as lost only - from such a gap; until then a missing frame is in flight. -- Agent side: `voice.json` on the response broadcast, a moq-json Window - carrying everything that happens on the agent with its time. At each - utterance boundary the agent cuts a group and pops everything older than - the latest statement on each side, so a joiner or reconnect gets exactly the - current exchange. Read it with `@moq/json`'s Window consumer. - -The browser cannot see its own QUIC send buffer. Show "captured, not yet at -the agent" from the agent's arrivals instead. - -Placing agent events on the browser's axis needs a clock offset. Both -directions carry media timestamps with send and arrival times, so an -NTP-style estimate from the minimum transit each way is a good starting point. -The two minimum transits should sum to about the PROBE RTT, and their -asymmetry bounds the error; show agent events only once that bound is small. - -Build on [moq-dev/moq.dev#194](https://github.com/moq-dev/moq.dev/pull/194) -(transcript auto-scroll, steadier cat) if it is still open. - -## Required - -- [The voice agent publishes the full voice.json](/quest/m1/voice-timeline-track.md) - the agent's side of the timeline diff --git a/src/components/voice-demo.tsx b/src/components/voice-demo.tsx index 61aaf8f..bddfc5e 100644 --- a/src/components/voice-demo.tsx +++ b/src/components/voice-demo.tsx @@ -9,7 +9,9 @@ import type MoqPublish from "@moq/publish/element"; import "@moq/watch/element"; import type MoqWatch from "@moq/watch/element"; import { createEffect, createSignal, For, Index, onCleanup, Show } from "solid-js"; +import { type AgentRecord, CallLog } from "./voice/call"; import Cat from "./voice/cat"; +import Timeline from "./voice/timeline"; type Turnstile = { render: ( @@ -63,6 +65,8 @@ const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js? // The agent subscribes to this fixed-name track on our broadcast and publishes its own beside // its audio. Neither is in the catalog. const RTVI_TRACK = "transcript.json.z"; +// The agent's own measurements of the call (moq.pro agent/VOICE.md), a window of records. +const TIMELINE_TRACK = "voice.json"; const RTVI_VERSION = "2.1.0"; // How often a caller waiting for pickup asks whether an edge refused it. @@ -122,8 +126,9 @@ type Call = { ticker?: ReturnType; liveAt?: number; epoch: string; - log: Message[]; + messages: Message[]; producer?: Json.Stream.Producer; + log: CallLog; }; let turnstilePromise: Promise | undefined; @@ -199,6 +204,8 @@ export default function VoiceDemo() { const [transcript, setTranscript] = createSignal([]); // The agent's decoded audio, before volume, for the cat to lip-sync to. const [voice, setVoice] = createSignal(); + // The last call's measurements, kept after it ends so the reader can look them over. + const [timeline, setTimeline] = createSignal(); // Bumped by every start and teardown, so an await that resumes after Stop sees it is stale // and backs out instead of resurrecting the call. @@ -221,8 +228,8 @@ export default function VoiceDemo() { }; const send = (call: Call, message: Message) => { - const record = { ...message, seq: call.log.length, epoch: call.epoch }; - call.log.push(record); + const record = { ...message, seq: call.messages.length, epoch: call.epoch }; + call.messages.push(record); call.producer?.append({ value: record }); }; @@ -236,6 +243,7 @@ export default function VoiceDemo() { if (call) { for (const timer of call.timers) clearTimeout(timer); clearInterval(call.ticker); + call.log.ended = performance.now(); // The agent hangs up at once on this, instead of waiting for us to redial. send(call, { label: "moq-transport", type: "session-ending" }); const unmount = () => setMounted((m) => (m === call ? undefined : m)); @@ -308,7 +316,8 @@ export default function VoiceDemo() { const epoch = Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join( "", ); - const call: Call = { gen, session, timers: [], epoch, log: [] }; + const call: Call = { gen, session, timers: [], epoch, messages: [], log: new CallLog() }; + setTimeline(call.log); current = call; // Queued until the publisher's broadcast exists, so the agent reads it first. send(call, { @@ -420,7 +429,7 @@ export default function VoiceDemo() { track: broadcast.createTrack(RTVI_TRACK), compression: "deflate", }); - for (const record of call.log) producer.append({ value: record }); + for (const record of call.messages) producer.append({ value: record }); call.producer = producer; effect.cleanup(() => { if (call.producer === producer) call.producer = undefined; @@ -438,11 +447,35 @@ export default function VoiceDemo() { setVoice(root); effect.cleanup(() => setVoice((v) => (v === root ? undefined : v))); }); + effects.run((effect) => { + const media = effect.get(el.audio.out.timestamp); + const playing = !effect.get(el.audio.out.stalled); + call.log.playhead(performance.now(), media, playing); + }); + effects.spawn(async () => { + const arrivals = el.audio.arrivals(); + effects.cleanup(() => arrivals.close()); + for (;;) { + const next = await arrivals.next(); + if (!next) return; + if (next.kind === "frame") call.log.received(next.timestamp, next.arrival, next.late); + else call.log.gap(next.timestamp, next.duration); + } + }); effects.run((effect) => { const active = effect.get(el.broadcast.out.active); if (!active) return; onPickup(call); + const timeline = active.track(TIMELINE_TRACK).subscribe(); + effect.cleanup(() => timeline.close()); + const records = new Json.Window.Consumer({ track: timeline }); + effect.spawn(async () => { + for await (const { value } of records) { + if ("push" in value) call.log.agent(value.push.value); + } + }); + const track = active.track(RTVI_TRACK).subscribe(); effect.cleanup(() => track.close()); const consumer = new Json.Stream.Consumer({ track, compression: "deflate" }); @@ -556,6 +589,10 @@ export default function VoiceDemo() { + + {(log) => } + + {(call) => { const effects = new Signals.Effect(); diff --git a/src/components/voice/call.test.ts b/src/components/voice/call.test.ts new file mode 100644 index 0000000..5f5f865 --- /dev/null +++ b/src/components/voice/call.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { CallLog } from "./call"; + +// Millisecond ticks, so media times read as written. +const arrival = (capture: number, arrive: number) => ({ + version: 2, + type: "arrival", + timescale: 1000, + media_ticks: capture, + arrival_ns: arrive * 1e6, +}); + +const publish = (track: string, start: number, end: number, at: number) => ({ + version: 2, + type: "publish", + track, + timescale: 1000, + start_ticks: start, + end_ticks: end, + at_ns: at * 1e6, +}); + +// The agent's clock is 1000 ms ahead of ours, with 10 ms of transit each way. +function synced(): CallLog { + const log = new CallLog(); + log.agent(arrival(0, 1010)); + log.agent(publish("bot-audio", 0, 5000, 1100)); + log.received(0, 110, false); + return log; +} + +describe("clock", () => { + test("places the agent's clock from the fastest transit each way", () => { + const log = synced(); + expect(log.offset).toBe(1000); + expect(log.error).toBe(10); + }); +}); + +describe("clock", () => { + // The first downlink sample is 90 ms slow, so the estimate starts 45 ms off (error 55). + function slow(): CallLog { + const log = new CallLog(); + log.agent(arrival(0, 1010)); + log.agent(publish("bot-audio", 0, 1000, 1100)); + log.received(0, 200, false); + expect(log.offset).toBe(955); + return log; + } + + test("a faster frame refines the estimate when its record comes first", () => { + const log = slow(); + log.agent(publish("bot-audio", 1000, 2000, 1200)); + log.received(1000, 210, false); + expect(log.offset).toBe(1000); + expect(log.error).toBe(10); + }); + + test("a faster frame refines the estimate when it beats its record", () => { + const log = slow(); + log.received(1000, 210, false); + log.agent(publish("bot-audio", 1000, 2000, 1200)); + expect(log.offset).toBe(1000); + }); + + test("a frame either of two overlapping publishes could have sent leaves the estimate alone", () => { + const log = slow(); + // The barge-in's publish was sent at 215 local, after this frame arrived, but the loose estimate + // can't rule it out. Measuring transit from it would claim 1007.5 ± 2.5, excluding the truth. + log.agent(publish("bot-audio-1", 500, 1500, 1215)); + log.received(600, 210, false); + expect(log.offset).toBe(955); + expect(log.error).toBe(55); + }); +}); + +describe("publish", () => { + test("a barge-in's track claims the frames that beat its record", () => { + const log = synced(); + // The new track resumes at media 2000. One frame of the old track arrived before it was sent, + // and one of the new track arrived before its record did. + log.received(2020, 2500, false); + log.received(2000, 3050, false); + log.agent(publish("bot-audio-1", 2000, 3000, 4000)); + + const [old, claimed] = [log.reply.find((f) => f.media === 2020), log.reply.find((f) => f.media === 2000)]; + expect(old?.track).toBe("bot-audio"); + expect(claimed?.track).toBe("bot-audio-1"); + expect(claimed && log.downLateness(claimed)).toBe(40); + }); + + test("a claimed frame sheds the retired track's discard", () => { + const log = synced(); + log.received(2020, 2500, false); + log.received(2000, 3050, false); + log.agent({ + version: 2, + type: "retire", + track: "bot-audio", + timescale: 1000, + unplayed_ticks: 3000, + at_ns: 3900 * 1e6, + }); + log.agent(publish("bot-audio-1", 2000, 3000, 4000)); + + const [old, claimed] = [log.reply.find((f) => f.media === 2020), log.reply.find((f) => f.media === 2000)]; + expect(old?.discarded).toBe(true); + expect(claimed?.track).toBe("bot-audio-1"); + expect(claimed?.discarded).toBeFalsy(); + expect(claimed?.play).toBeUndefined(); + }); + + test("a frame of a track retired before it arrived never plays", () => { + const log = synced(); + log.agent({ + version: 2, + type: "retire", + track: "bot-audio", + timescale: 1000, + unplayed_ticks: 3000, + at_ns: 3900 * 1e6, + }); + log.received(3000, 3000, false); + + const late = log.reply.find((f) => f.media === 3000); + expect(late?.track).toBe("bot-audio"); + expect(late?.discarded).toBe(true); + expect(late?.play).toBeDefined(); + }); + + test("a frame of a track retired before any reply arrived is placed at the retirement", () => { + const log = new CallLog(); + log.agent(arrival(0, 1010)); + log.agent(publish("bot-audio", 0, 5000, 1100)); + log.agent({ + version: 2, + type: "retire", + track: "bot-audio", + timescale: 1000, + unplayed_ticks: 5000, + at_ns: 1200 * 1e6, + }); + log.received(0, 300, false); + + const frame = log.reply.find((f) => f.media === 0); + expect(frame?.discarded).toBe(true); + expect(frame?.play).toBeDefined(); + }); + + test("an older publish still holds media past a newer, shorter one", () => { + const log = synced(); + log.agent(publish("bot-audio-1", 2000, 3000, 4000)); + log.received(4500, 4100, false); + + expect(log.reply.find((f) => f.media === 4500)?.track).toBe("bot-audio"); + }); +}); + +describe("exchange", () => { + test("starts with the earlier of your latest statement and the agent's latest reply", () => { + const log = synced(); + const record = (type: string, at: number, fields: object) => ({ + version: 2, + type, + timescale: 1000, + at_ns: at * 1e6, + ...fields, + }); + // The agent starts replying at 3000 local, then you interrupt: speech from 5000, confirmed at 5200. + log.agent(record("llm", 4000, { event: "request" })); + log.agent(record("vad", 6210, { speaking: true, media_ticks: 5200, confirm_ticks: 200 })); + expect(log.exchange()).toBe(3000); + log.agent(record("turn", 6250, { speaking: true, media_ticks: 5240 })); + expect(log.exchange()).toBe(3000); + // Its next reply is to your interruption, so the exchange now starts where you did. + log.agent(record("llm", 7000, { event: "request" })); + expect(log.exchange()).toBe(5000); + }); +}); diff --git a/src/components/voice/call.ts b/src/components/voice/call.ts new file mode 100644 index 0000000..967d975 --- /dev/null +++ b/src/components/voice/call.ts @@ -0,0 +1,397 @@ +// Everything measured about one live call, on this page's clock (`performance.now()` milliseconds). +// +// Three sources feed it: +// - Your mic frames: `@moq/publish` stamps each with its capture time on this clock, so the media +// timestamp the agent reports is already a local time. +// - The agent's `voice.json` (moq.pro agent/VOICE.md, version 2): its view of the call on its own +// monotonic clock. +// - The reply: `@moq/watch` reports each frame's arrival on this clock, and the playhead. +// +// The agent's clock is placed on ours NTP-style, from the fastest transit each way: request frames +// bound the offset from one side and reply frames from the other, so it needs no clock agreement. + +// A frame is late for WebRTC once it arrives this much later than the fastest one did: the initial +// target of libwebrtc's audio jitter buffer (NetEQ's kStartDelayMs in +// modules/audio_coding/neteq/delay_manager.cc), which only grows after it has already dropped audio. +export const WEBRTC_MS = 80; + +// The agent's Opus frames, and the duration drawn for a reply frame. +export const FRAME_MS = 20; + +// How much history to keep, in milliseconds. +const KEEP_MS = 60_000; + +/** A `voice.json` record. Unknown types are skipped; see moq.pro agent/VOICE.md. */ +export type AgentRecord = { version?: number; type?: string; [key: string]: unknown }; + +export interface MicFrame { + capture: number; // local + arrive: number; // agent +} + +export interface ReplyFrame { + media: number; // reply media time + arrive: number; // local + late: boolean; // missed even MoQ's playout deadline + // Which publish carried it, once known. A barge-in starts a new track where the old one's + // playhead was, so two tracks can hold the same media time. + track?: string; + published?: number; // agent + play?: number; // local, frozen once it played or was interrupted + discarded?: boolean; +} + +export interface Word { + text: string; + start: number; + end: number; + at?: number; // agent, when STT settled on it + track?: string; +} + +export interface Mark { + at: number; // agent + kind: "vad" | "turn" | "llm" | "tts" | "retire"; + label: string; + media?: number; // local, for user-side marks: what the agent had heard +} + +export interface Span { + start: number; // agent + end?: number; // agent, open while running + kind: "llm" | "tts"; + first?: number; // agent: first text or audio +} + +interface Publish { + track: string; + start: number; // reply media + end: number; + at: number; // agent +} + +// A record's media time in milliseconds on its own track. +function ms(record: AgentRecord, field: string): number | undefined { + const ticks = record[field]; + const scale = record.timescale; + if (typeof ticks !== "number" || typeof scale !== "number" || scale <= 0) return undefined; + return (ticks * 1000) / scale; +} + +function agentMs(record: AgentRecord, field: string): number | undefined { + const ns = record[field]; + return typeof ns === "number" ? ns / 1e6 : undefined; +} + +// Insert keeping `list` sorted by `key`. Records arrive in about time order, so scan from the back. +function insert(list: T[], item: T, key: (t: T) => number) { + let i = list.length; + while (i > 0 && key(list[i - 1]) > key(item)) i--; + list.splice(i, 0, item); +} + +export class CallLog { + mic: MicFrame[] = []; + reply: ReplyFrame[] = []; + gaps: { media: number; duration: number }[] = []; + userWords: Word[] = []; + interims: Word[] = []; + agentWords: Word[] = []; + marks: Mark[] = []; + spans: Span[] = []; + // User speech as the VAD decided it, in local capture time. + speech: { start: number; end?: number }[] = []; + publishes: Publish[] = []; + // The longest publish, which bounds how far back one can still hold a media time. + #longest = 0; + // Retired tracks and when they retired, on the local clock: their later arrivals never play. + #retired = new Map(); + + // The fastest transit each way, each including the clock offset. + minUp?: number; // agent - local + minDown?: number; // local - agent + + // Where the reply's playhead was, and when. + #anchor?: { local: number; media: number }; + #playing = false; + #head?: { now: number; media: number | undefined }; + + ended?: number; // local + + /** Agent time minus local time, once both directions have been measured. */ + get offset(): number | undefined { + if (this.minUp === undefined || this.minDown === undefined) return undefined; + return (this.minUp - this.minDown) / 2; + } + + /** The most the offset can be wrong by: half the fastest round trip. */ + get error(): number | undefined { + if (this.minUp === undefined || this.minDown === undefined) return undefined; + return (this.minUp + this.minDown) / 2; + } + + /** An agent time on the local clock, once the offset is known. */ + local(agent: number): number | undefined { + const offset = this.offset; + return offset === undefined ? undefined : agent - offset; + } + + /** How much later than the fastest a request frame reached the agent. */ + upLateness(frame: MicFrame): number { + return frame.arrive - frame.capture - (this.minUp ?? 0); + } + + /** How much later than the fastest a reply frame reached us, once its publish is known. */ + downLateness(frame: ReplyFrame): number | undefined { + if (frame.published === undefined) return undefined; + return frame.arrive - frame.published - (this.minDown ?? 0); + } + + /** Ingest one `voice.json` record. */ + agent(record: AgentRecord) { + if (record.version !== 2) return; + switch (record.type) { + case "arrival": { + const capture = ms(record, "media_ticks"); + const arrive = agentMs(record, "arrival_ns"); + if (capture === undefined || arrive === undefined) return; + insert(this.mic, { capture, arrive }, (f) => f.capture); + const up = arrive - capture; + if (this.minUp === undefined || up < this.minUp) this.minUp = up; + return; + } + case "word": { + const word = this.#word(record); + if (!word) return; + if (record.speaker === "user") { + insert(this.userWords, word, (w) => w.start); + // The final word supersedes what the interims heard there. + this.interims = this.interims.filter((i) => i.end <= word.start || i.start >= word.end); + } else if (record.speaker === "agent") { + insert(this.agentWords, word, (w) => w.start); + } + return; + } + case "interim": { + const word = this.#word(record); + if (!word) return; + // A later interim revises the ones it overlaps. + this.interims = this.interims.filter((i) => i.end <= word.start || i.start >= word.end); + if (!this.userWords.some((w) => w.start < word.end && w.end > word.start)) { + insert(this.interims, word, (w) => w.start); + } + return; + } + case "vad": { + const at = agentMs(record, "at_ns"); + const media = ms(record, "media_ticks"); + const confirm = ms(record, "confirm_ticks") ?? 0; + if (at === undefined || media === undefined) return; + const speaking = record.speaking === true; + const change = media - confirm; + const open = this.speech.at(-1); + if (speaking && (!open || open.end !== undefined)) this.speech.push({ start: change }); + if (!speaking && open && open.end === undefined) open.end = change; + this.marks.push({ at, kind: "vad", label: speaking ? "speech" : "silence", media }); + return; + } + case "turn": { + const at = agentMs(record, "at_ns"); + if (at === undefined) return; + const media = ms(record, "media_ticks"); + this.marks.push({ at, kind: "turn", label: record.speaking === true ? "your turn" : "my turn", media }); + return; + } + case "llm": + case "tts": { + const at = agentMs(record, "at_ns"); + if (at === undefined) return; + this.#span(record.type, record.event, at); + return; + } + case "publish": { + const start = ms(record, "start_ticks"); + const end = ms(record, "end_ticks"); + const at = agentMs(record, "at_ns"); + if (typeof record.track !== "string" || start === undefined || end === undefined || at === undefined) return; + const publish = { track: record.track, start, end, at }; + insert(this.publishes, publish, (p) => p.start); + this.#longest = Math.max(this.#longest, end - start); + // The audio can beat its own record here, leaving a frame on the retired track's publish + // of the same media time; the newest publish sent before it arrived claims it. + for (const frame of this.reply) { + if (frame.media < start || frame.media >= end) continue; + if (frame.published !== undefined && frame.published >= at) continue; + if (!this.#sentBefore(at, frame.arrive)) continue; + this.#published(frame, publish); + } + return; + } + case "retire": { + const at = agentMs(record, "at_ns"); + const unplayed = ms(record, "unplayed_ticks"); + if (at === undefined) return; + const label = unplayed ? `barge-in, dropped ${(unplayed / 1000).toFixed(1)}s` : "barge-in"; + this.marks.push({ at, kind: "retire", label }); + // What the retired track still held never plays; draw it where it would have. + if (typeof record.track !== "string") return; + const now = this.local(at) ?? performance.now(); + this.#retired.set(record.track, now); + for (const frame of this.reply) { + if (frame.track === record.track) this.#discard(frame, now); + } + return; + } + } + } + + #word(record: AgentRecord): Word | undefined { + const start = ms(record, "start_ticks"); + const end = ms(record, "end_ticks"); + if (typeof record.text !== "string" || start === undefined || end === undefined) return undefined; + const track = typeof record.track === "string" ? record.track : undefined; + return { text: record.text, start, end, at: agentMs(record, "at_ns"), track }; + } + + #span(kind: "llm" | "tts", event: unknown, at: number) { + const open = this.spans.findLast((s) => s.kind === kind && s.end === undefined); + if (event === "request") { + // TTS requests each sentence; one span covers the whole reply until it ends. Each LLM request + // is a new statement, and supersedes one an interruption left without an end. + if (open && kind === "llm") open.end = at; + if (!open || kind === "llm") this.spans.push({ kind, start: at }); + } else if (event === "first_text" || event === "first_audio") { + if (open && open.first === undefined) open.first = at; + } else if (event === "end") { + if (open) open.end = at; + } + } + + /** Whether a publish at agent time `at` can have been sent before local time `arrive`. */ + #sentBefore(at: number, arrive: number): boolean { + // Trusting the offset's midpoint would turn away the faster frames that would refine it. + const offset = this.offset; + const error = this.error; + return offset === undefined || error === undefined || at - offset - error <= arrive; + } + + #published(frame: ReplyFrame, publish: Publish) { + // A frame claimed from a retired track is not what that retirement discarded. + if (frame.track !== undefined && frame.track !== publish.track && frame.discarded) { + frame.discarded = false; + frame.play = undefined; + } + frame.track = publish.track; + frame.published = publish.at; + // Between overlapping publishes the pick is only as good as the clock, and the wrong one could + // undercut the fastest transit, so only a frame one publish alone holds measures it. + const down = frame.arrive - publish.at; + const holders = this.publishes.filter((p) => frame.media >= p.start && frame.media < p.end).length; + if (holders === 1 && (this.minDown === undefined || down < this.minDown)) this.minDown = down; + const retired = this.#retired.get(publish.track); + if (retired !== undefined) this.#discard(frame, retired); + } + + /** What a retired track still held never plays; draw it where it would have. */ + #discard(frame: ReplyFrame, retired: number) { + if (frame.play !== undefined) return; + // Before any playhead there is nowhere it would have played, so it ends where it was retired. + frame.play = this.playAt(frame, retired) ?? retired; + frame.discarded = true; + } + + /** A reply frame as `@moq/watch` received it. */ + received(media: number, arrive: number, late: boolean) { + const frame: ReplyFrame = { media, arrive, late }; + // Of the publishes holding this media time, the newest one sent before it arrived. Without a + // clock offset yet there has been no barge-in, so there is only one. + let best: Publish | undefined; + for (let i = this.publishes.length - 1; i >= 0; i--) { + const p = this.publishes[i]; + // Sorted by start, so once even the longest publish ends before this media time, none earlier holds it. + if (p.start + this.#longest <= media) break; + if (media < p.start || media >= p.end) continue; + if (!this.#sentBefore(p.at, arrive)) continue; + if (!best || p.at > best.at) best = p; + } + if (best) this.#published(frame, best); + insert(this.reply, frame, (f) => f.media); + } + + /** Reply media that will never arrive. */ + gap(media: number, duration: number) { + this.gaps.push({ media, duration }); + } + + /** The reply's playhead: media `media` is playing at local time `now`, or nothing is. */ + playhead(now: number, media: number | undefined, playing: boolean) { + // The reply's media time only moves forward, across track changes too; an idle player + // reports zero. + if (media === undefined || (this.#anchor && media < this.#anchor.media)) { + this.#playing = false; + return; + } + this.#playing = playing; + this.#anchor = { local: now, media }; + for (const frame of this.reply) { + if (frame.play === undefined && frame.media <= media) frame.play = now - (media - frame.media); + } + } + + /** When a reply frame plays (or played) on the local clock, if it can be placed. */ + playAt(frame: ReplyFrame, now: number): number | undefined { + if (frame.play !== undefined) return frame.play; + const head = this.mediaNow(now); + return head === undefined ? undefined : now + Math.max(0, frame.media - head); + } + + /** Media time of the reply's playhead now, extrapolated between updates. */ + mediaNow(now: number): number | undefined { + if (this.#head?.now === now) return this.#head.media; + const anchor = this.#anchor; + let media: number | undefined; + if (anchor && this.#playing) { + media = anchor.media + (now - anchor.local); + } else { + // A stalled player starts the next utterance from its own first frame, about now. + const next = this.reply.find((f) => f.play === undefined && !f.discarded); + media = next ? Math.max(next.media, anchor?.media ?? 0) : anchor?.media; + } + this.#head = { now, media }; + return media; + } + + /** + * Where the latest exchange starts on the local clock: your latest statement or the agent's + * latest reply, whichever began first, as `voice.json` keeps the latest statement on each side. + */ + exchange(): number | undefined { + // The turn is decided a little into the statement, so it starts with the speech before it. + const heard = this.marks.findLast((m) => m.kind === "turn" && m.label === "your turn")?.media; + const said = heard === undefined ? undefined : (this.speech.findLast((s) => s.start <= heard)?.start ?? heard); + const thought = this.spans.findLast((s) => s.kind === "llm"); + const replied = thought && this.local(thought.start); + if (said === undefined) return replied; + return replied === undefined ? said : Math.min(said, replied); + } + + /** Drop what has scrolled far out of view. */ + prune(now: number) { + const cut = now - KEEP_MS; + const offset = this.offset ?? 0; + this.mic = this.mic.filter((f) => f.capture >= cut); + this.userWords = this.userWords.filter((w) => w.end >= cut); + this.interims = this.interims.filter((w) => w.end >= cut); + this.speech = this.speech.filter((s) => s.end === undefined || s.end >= cut); + this.marks = this.marks.filter((m) => m.at - offset >= cut); + this.spans = this.spans.filter((s) => s.end === undefined || s.end - offset >= cut); + this.reply = this.reply.filter((f) => f.play === undefined || f.play >= cut); + const media = this.mediaNow(now); + if (media !== undefined) { + const mediaCut = media - KEEP_MS; + this.agentWords = this.agentWords.filter((w) => w.end >= mediaCut); + this.publishes = this.publishes.filter((p) => p.end >= mediaCut); + this.gaps = this.gaps.filter((g) => g.media + g.duration >= mediaCut); + } + } +} diff --git a/src/components/voice/diagram.tsx b/src/components/voice/diagram.tsx index 44356b7..e442448 100644 --- a/src/components/voice/diagram.tsx +++ b/src/components/voice/diagram.tsx @@ -62,7 +62,7 @@ const BUBBLE_H = 30; const lanes = (y: number, count: number) => Array.from({ length: count }, (_, i) => y + i * (TRACK_H + LANE_GAP)); const span = (count: number) => count * TRACK_H + (count - 1) * LANE_GAP; -const COLOR = { +export const COLOR = { done: "#22c55e", have: "rgba(34, 197, 94, 0.25)", missing: "#f59e0b", diff --git a/src/components/voice/timeline.tsx b/src/components/voice/timeline.tsx new file mode 100644 index 0000000..326a0da --- /dev/null +++ b/src/components/voice/timeline.tsx @@ -0,0 +1,383 @@ +// The live call on one scrolling clock, drawn like the post's diagrams come alive: your mic frames +// crossing to the agent, what it heard and did, and its reply crossing back into your buffer. +// Canvas, not SVG: a call is thousands of 20 ms frames, redrawn every animation frame. +import { createSignal, onCleanup, onMount, Show } from "solid-js"; +import { type CallLog, FRAME_MS, WEBRTC_MS } from "./call"; +import { COLOR } from "./diagram"; + +// The axis spans the latest exchange, at least this many milliseconds, with room either side. +const MIN_SPAN = 8000; +const PAD = 500; +// How quickly the axis eases to a new exchange, in milliseconds. +const EASE = 250; + +const GUTTER = 64; +const LANE_H = 26; +const NET_H = 34; +const AGENT_H = 40; +const AXIS_H = 16; +const TOP = 4; + +// Lane tops, top to bottom. +const MIC_Y = TOP; +const UP_Y = MIC_Y + LANE_H; +const AGENT_Y = UP_Y + NET_H; +const DOWN_Y = AGENT_Y + AGENT_H; +const SPEAKER_Y = DOWN_Y + NET_H; +const AXIS_Y = SPEAKER_Y + LANE_H; +const H = AXIS_Y + AXIS_H; + +const PALETTE = { + ...COLOR, + flight: "rgba(245, 158, 11, 0.45)", + thinking: "rgba(245, 158, 11, 0.55)", + speech: "rgba(148, 163, 184, 0.18)", + discarded: "rgba(148, 163, 184, 0.35)", + wire: "rgba(34, 197, 94, 0.22)", + late: "#ef4444", +}; + +const FONT = "11px 'JetBrains Mono', ui-monospace, monospace"; + +export default function Timeline(props: { call: CallLog }) { + const [summary, setSummary] = createSignal(""); + const [stats, setStats] = createSignal<{ + lateUp: number; + up: number; + lateDown: number; + down: number; + ahead?: number; + error?: number; + }>(); + let canvas!: HTMLCanvasElement; + + onMount(() => { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let width = 0; + let frame = 0; + let view: { left: number; right: number; at: number } | undefined; + const resize = () => { + const dpr = window.devicePixelRatio || 1; + width = canvas.clientWidth; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(H * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + // Resizing clears the canvas, so a finished call whose loop has stopped draws again. + const call = props.call; + if (frame === 0 && view && call.ended !== undefined) draw(ctx, call, call.ended, width, view.left, view.right); + }; + const observer = new ResizeObserver(resize); + observer.observe(canvas); + resize(); + + let pruned = 0; + let summarized = 0; + const tick = () => { + const call = props.call; + const now = call.ended ?? performance.now(); + if (now - pruned > 1000) { + call.prune(now); + pruned = now; + } + const [left, right] = span(call, now); + const ease = view ? 1 - Math.exp(-(performance.now() - view.at) / EASE) : 1; + view = { + left: view ? view.left + (left - view.left) * ease : left, + right: view ? view.right + (right - view.right) * ease : right, + at: performance.now(), + }; + draw(ctx, call, now, width, view.left, view.right); + if (now - summarized > 500) { + summarized = now; + const s = measure(call, now); + setStats(s); + setSummary(describe(s)); + } + frame = call.ended === undefined || performance.now() - call.ended < 2000 ? requestAnimationFrame(tick) : 0; + }; + frame = requestAnimationFrame(tick); + onCleanup(() => { + cancelAnimationFrame(frame); + observer.disconnect(); + }); + }); + + return ( +
+ +
+ arrived + buffered + in flight + late for WebRTC (+{WEBRTC_MS} ms) + interrupted +
+ + {(s) => ( +

+ you → agent: {s().lateUp} of {s().up} frames late for WebRTC · agent → you: {s().lateDown} of {s().down} + · reply buffered {((s().ahead ?? 0) / 1000).toFixed(1)}s ahead + · clocks synced ±{Math.round(s().error ?? 0)} ms +

+ )} +
+
+ ); +} + +function Swatch(props: { color: string; children: unknown }) { + return ( + + + {props.children as string} + + ); +} + +function measure(call: CallLog, now: number) { + let lateUp = 0; + for (const f of call.mic) if (call.upLateness(f) > WEBRTC_MS) lateUp++; + let lateDown = 0; + let down = 0; + for (const f of call.reply) { + const late = call.downLateness(f); + if (late === undefined) continue; + down++; + if (late > WEBRTC_MS) lateDown++; + } + const head = call.mediaNow(now); + // A retired track's frames can sit past the replacement in media time, but never play. + const last = call.reply.findLast((f) => f.play === undefined && !f.discarded); + // A frame the extrapolated head already passed is due, not owed. + const ahead = head !== undefined && last ? Math.max(0, last.media + FRAME_MS - head) : undefined; + return { lateUp, up: call.mic.length, lateDown, down, ahead, error: call.error }; +} + +function describe(s: ReturnType): string { + const parts = [ + `Live call timeline. Your audio: ${s.lateUp} of ${s.up} frames reached the agent too late for a WebRTC jitter buffer.`, + `The reply: ${s.lateDown} of ${s.down} frames late.`, + ]; + if (s.ahead !== undefined) parts.push(`${(s.ahead / 1000).toFixed(1)} seconds of reply buffered ahead.`); + return parts.join(" "); +} + +// The latest exchange, through the end of the reply buffered so far, with now always in view. +function span(call: CallLog, now: number): [number, number] { + let end = now; + const last = call.reply.findLast((f) => !f.discarded); + const played = last && call.playAt(last, now); + if (played !== undefined) end = Math.max(end, played + FRAME_MS); + const right = end + PAD; + const start = call.exchange(); + const left = start === undefined ? right - MIN_SPAN : Math.min(start - PAD, right - MIN_SPAN); + return [left, right]; +} + +function draw(ctx: CanvasRenderingContext2D, call: CallLog, now: number, width: number, left: number, right: number) { + ctx.clearRect(0, 0, width, H); + if (width <= GUTTER) return; + + const trackW = width - GUTTER; + const pxPerMs = trackW / (right - left); + const x = (t: number) => GUTTER + (t - left) * pxPerMs; + const visible = (start: number, end: number) => end >= left && start <= right; + + ctx.font = FONT; + ctx.textBaseline = "middle"; + + // Lane backgrounds and labels. + const lane = (y: number, h: number, label: string) => { + ctx.fillStyle = PALETTE.track; + ctx.fillRect(GUTTER, y, trackW, h); + ctx.fillStyle = PALETTE.neutral; + ctx.textAlign = "right"; + ctx.fillText(label, GUTTER - 6, y + h / 2); + }; + lane(MIC_Y, LANE_H, "you"); + lane(AGENT_Y, AGENT_H, "agent"); + lane(SPEAKER_Y, LANE_H, "you hear"); + + ctx.save(); + ctx.beginPath(); + ctx.rect(GUTTER, 0, trackW, H); + ctx.clip(); + + // Your speech as the agent's VAD heard it. + ctx.fillStyle = PALETTE.speech; + for (const s of call.speech) { + const end = s.end ?? now; + if (visible(s.start, end)) ctx.fillRect(x(s.start), MIC_Y, (end - s.start) * pxPerMs, LANE_H); + } + + // Mic frames: green once the agent has them, red if too late for WebRTC. + const frameW = Math.max(1, FRAME_MS * pxPerMs); + let lastCapture = left; + for (const f of call.mic) { + if (f.capture + FRAME_MS < left || f.capture > right) continue; + const late = call.upLateness(f) > WEBRTC_MS; + ctx.fillStyle = late ? PALETTE.late : PALETTE.done; + ctx.fillRect(x(f.capture), MIC_Y + (late ? 2 : 6), frameW, LANE_H - (late ? 4 : 12)); + lastCapture = Math.max(lastCapture, f.capture + FRAME_MS); + } + // Captured but not yet at the agent: the agent's arrivals stop short of now. + if (call.ended === undefined && call.mic.length > 0 && lastCapture < now) { + ctx.fillStyle = PALETTE.flight; + ctx.fillRect(x(lastCapture), MIC_Y + 6, (now - lastCapture) * pxPerMs, LANE_H - 12); + } + + // Your words over their audio; interim guesses fainter. + words(ctx, call.interims, x, MIC_Y + LANE_H / 2, PALETTE.faint, left, right, (w) => w.start); + words(ctx, call.userWords, x, MIC_Y + LANE_H / 2, PALETTE.text, left, right, (w) => w.start); + + // Request frames crossing to the agent, once its clock is placed on ours. + const offset = call.offset; + if (offset !== undefined) { + ctx.lineWidth = 1; + for (const f of call.mic) { + const arrive = f.arrive - offset; + if (!visible(f.capture, arrive)) continue; + const late = call.upLateness(f) > WEBRTC_MS; + // Every fifth frame, or the stream is a solid wall. + if (!late && Math.round(f.capture / FRAME_MS) % 5 !== 0) continue; + ctx.strokeStyle = late ? PALETTE.late : PALETTE.wire; + ctx.beginPath(); + ctx.moveTo(x(f.capture + FRAME_MS / 2), UP_Y); + ctx.lineTo(x(arrive), AGENT_Y); + ctx.stroke(); + } + } + + // The agent: hearing on top, thinking and speaking below. + if (offset !== undefined) { + const hearY = AGENT_Y + AGENT_H / 4; + const doY = AGENT_Y + (AGENT_H * 3) / 4; + // Where each row's last label ends, so a quick retry's label does not overprint the last one. + const labeled = { llm: Number.NEGATIVE_INFINITY, tts: Number.NEGATIVE_INFINITY }; + for (const s of call.spans) { + const start = s.start - offset; + const end = (s.end ?? now + offset) - offset; + if (!visible(start, end)) continue; + const first = s.first !== undefined ? s.first - offset : end; + const y = s.kind === "llm" ? doY - 8 : doY + 1; + ctx.fillStyle = PALETTE.thinking; + ctx.fillRect(x(start), y, (first - start) * pxPerMs, 7); + ctx.fillStyle = PALETTE.done; + ctx.fillRect(x(first), y, Math.max(0, (end - first) * pxPerMs), 7); + const label = Math.max(GUTTER + 2, x(start)) + 2; + if (label < labeled[s.kind]) continue; + ctx.fillStyle = PALETTE.text; + ctx.textAlign = "left"; + ctx.font = "9px 'JetBrains Mono', ui-monospace, monospace"; + ctx.fillText(s.kind.toUpperCase(), label, y + 4); + labeled[s.kind] = label + ctx.measureText(s.kind.toUpperCase()).width + 4; + ctx.font = FONT; + } + // A barge-in lands right on the turn that caused it, so its label goes first and a turn label + // that would overprint a placed one is left off. + const placed: [number, number][] = []; + ctx.textAlign = "left"; + for (const kind of ["retire", "turn", "vad"] as const) { + for (const m of call.marks) { + const at = m.at - offset; + if (m.kind !== kind || at < left || at > right) continue; + const px = x(at); + if (kind === "vad") { + ctx.fillStyle = PALETTE.neutral; + ctx.fillRect(px - 0.5, AGENT_Y + 2, 1, AGENT_H / 2 - 4); + continue; + } + ctx.fillStyle = kind === "retire" ? PALETTE.late : PALETTE.text; + if (kind === "retire") ctx.fillRect(px - 1, AGENT_Y, 2, AGENT_H); + else ctx.fillRect(px - 1, AGENT_Y + 2, 2, AGENT_H / 2 - 4); + const end = px + 4 + ctx.measureText(m.label).width; + if (placed.some(([a, b]) => px < b + 4 && end > a)) continue; + placed.push([px, end]); + ctx.fillText(m.label, px + 4, hearY); + } + } + // When STT settled on each of your words. + ctx.fillStyle = PALETTE.text; + for (const w of call.userWords) { + if (w.at === undefined) continue; + const at = w.at - offset; + if (at < left || at > right) continue; + ctx.beginPath(); + ctx.arc(x(at), hearY, 2, 0, Math.PI * 2); + ctx.fill(); + } + } + + // Reply frames: crossing from the agent, then waiting in your buffer until they play. + const head = call.mediaNow(now); + for (const f of call.reply) { + const play = call.playAt(f, now); + const downLate = call.downLateness(f); + const late = f.late || (downLate !== undefined && downLate > WEBRTC_MS); + if (offset !== undefined && f.published !== undefined) { + const sent = f.published - offset; + if (visible(sent, f.arrive) && (late || Math.round(f.media / FRAME_MS) % 5 === 0)) { + ctx.strokeStyle = late ? PALETTE.late : PALETTE.wire; + ctx.beginPath(); + ctx.moveTo(x(sent), AGENT_Y + AGENT_H); + ctx.lineTo(x(f.arrive), SPEAKER_Y); + ctx.stroke(); + } + } + if (play === undefined || play + FRAME_MS < left || play > right) continue; + const played = f.play !== undefined || (head !== undefined && f.media <= head); + ctx.fillStyle = f.discarded ? PALETTE.discarded : late ? PALETTE.late : played ? PALETTE.done : PALETTE.have; + ctx.fillRect(x(play), SPEAKER_Y + (late ? 2 : 6), frameW, LANE_H - (late ? 4 : 12)); + } + + // The agent's words over the audio that says them. + if (head !== undefined) { + words(ctx, call.agentWords, x, SPEAKER_Y + LANE_H / 2, PALETTE.ink, left, right, (w) => { + const frame = call.reply.find((f) => f.media >= w.start && (w.track === undefined || f.track === w.track)); + return frame ? call.playAt(frame, now) : undefined; + }); + } + + ctx.restore(); + + // The playhead: everything left of it has happened, the buffer sits right of it. + ctx.fillStyle = PALETTE.text; + ctx.fillRect(x(now) - 1, MIC_Y - 2, 2, AXIS_Y - MIC_Y + 2); + + // Seconds from now, as many as fit. + ctx.fillStyle = PALETTE.dim; + ctx.textAlign = "center"; + const step = [1, 2, 5, 10, 30].find((s) => s * 1000 * pxPerMs >= 40) ?? 60; + for (let s = Math.ceil((left - now) / 1000 / step) * step; now + s * 1000 <= right; s += step) { + const tx = x(now + s * 1000); + if (tx < GUTTER + 8 || tx > width - 8) continue; + ctx.fillRect(tx, AXIS_Y, 1, 3); + ctx.fillText(s === 0 ? "now" : `${s > 0 ? "+" : ""}${s}s`, tx, AXIS_Y + 10); + } +} + +function words( + ctx: CanvasRenderingContext2D, + list: T[], + x: (t: number) => number, + y: number, + color: string, + left: number, + right: number, + at: (w: T) => number | undefined, +) { + ctx.fillStyle = color; + ctx.textAlign = "left"; + let edge = Number.NEGATIVE_INFINITY; + for (const w of list) { + const t = at(w); + if (t === undefined || t > right || t + (w.end - w.start) < left) continue; + const px = x(t); + // Skip a word that would overprint the previous one. + if (px < edge) continue; + ctx.fillText(w.text, px + 1, y); + edge = px + ctx.measureText(w.text).width + 4; + } +}