@workerstack/repo 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +0 -3
- package/agents/global-settings/antigravity.json +3 -0
- package/agents/global-settings/claude.json +6 -0
- package/agents/global-settings/codex.toml +2 -0
- package/agents/global-settings/cursor.json +3 -0
- package/agents/global-settings/grok.toml +36 -0
- package/agents/global-settings/manifest.json +36 -0
- package/agents/grok/hooks/orchestrate-parent.json +60 -0
- package/agents/grok/hooks/rtk.json +16 -0
- package/agents/grok/hooks/rulesync.json +3 -0
- package/agents/grok/roles/scout.toml +5 -0
- package/agents/hooks.jsonc +67 -0
- package/agents/mcp-policy.jsonc +16 -0
- package/agents/rules/overview.md +21 -0
- package/agents/settings/claude.json +5 -0
- package/agents/skills/ask/SKILL.md +13 -0
- package/agents/skills/auto/SKILL.md +13 -0
- package/agents/skills/away/SKILL.md +19 -0
- package/agents/skills/blast-radius/SKILL.md +54 -0
- package/agents/skills/bro/SKILL.md +9 -0
- package/agents/skills/browser/SKILL.md +16 -0
- package/agents/skills/clasp/SKILL.md +11 -0
- package/agents/skills/commit/SKILL.md +35 -0
- package/agents/skills/commit-and-push/SKILL.md +9 -0
- package/agents/skills/commit-push/SKILL.md +59 -0
- package/agents/skills/commit-push-deploy/SKILL.md +74 -0
- package/agents/skills/commit-push-release/SKILL.md +75 -0
- package/agents/skills/correct/SKILL.md +39 -0
- package/agents/skills/do/SKILL.md +21 -0
- package/agents/skills/explain/SKILL.md +13 -0
- package/agents/skills/google-workspace/SKILL.md +12 -0
- package/agents/skills/handoff/SKILL.md +81 -0
- package/agents/skills/implement-plan/SKILL.md +29 -0
- package/agents/skills/implement-roadmap/SKILL.md +27 -0
- package/agents/skills/jev/SKILL.md +17 -0
- package/agents/skills/listen/SKILL.md +15 -0
- package/agents/skills/pickup/SKILL.md +58 -0
- package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
- package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
- package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
- package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
- package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
- package/agents/skills/principle-experience-first/SKILL.md +22 -0
- package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
- package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
- package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
- package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
- package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
- package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
- package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
- package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
- package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
- package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
- package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
- package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
- package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
- package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
- package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
- package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
- package/agents/skills/review-plan/SKILL.md +28 -0
- package/agents/skills/review-roadmap/SKILL.md +28 -0
- package/agents/skills/show-me/SKILL.md +21 -0
- package/agents/skills/speak/SKILL.md +15 -0
- package/agents/skills/su/SKILL.md +11 -0
- package/agents/skills/teach/SKILL.md +25 -0
- package/agents/skills/tmp/SKILL.md +54 -0
- package/agents/skills/typescript-best-practices/SKILL.md +32 -0
- package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
- package/agents/skills/unslop/SKILL.md +62 -0
- package/agents/skills/write-plan/SKILL.md +33 -0
- package/agents/skills/write-roadmap/SKILL.md +66 -0
- package/agents/subagents/implementer-deep.md +57 -0
- package/agents/subagents/implementer-fast.md +57 -0
- package/agents/subagents/orchestrator.md +43 -0
- package/agents/subagents/researcher.md +47 -0
- package/agents/subagents/scout.md +44 -0
- package/bin/repo.ts +4 -0
- package/cli/actionlint-default.test.ts +38 -0
- package/cli/actionlint-download.test.ts +162 -0
- package/cli/actionlint-real.test.ts +33 -0
- package/cli/actionlint.constants.ts +43 -0
- package/cli/actionlint.test.ts +108 -0
- package/cli/actionlint.ts +178 -0
- package/cli/bin.test.ts +54 -0
- package/cli/bin.ts +40 -0
- package/cli/check-changelog.test.ts +40 -0
- package/cli/check-changelog.ts +12 -0
- package/cli/check-comments.test.ts +157 -0
- package/cli/check-comments.ts +123 -0
- package/cli/check-env-modules.test.ts +85 -0
- package/cli/check-env-modules.ts +56 -0
- package/cli/check-magic.test.ts +139 -0
- package/cli/check-magic.ts +120 -0
- package/cli/check-manifests.constants.ts +4 -0
- package/cli/check-manifests.test.ts +210 -0
- package/cli/check-manifests.ts +71 -0
- package/cli/check-nightly.test.ts +103 -0
- package/cli/check-nightly.ts +37 -0
- package/cli/check-ratchets-missing.test.ts +46 -0
- package/cli/check-ratchets-mutation.test.ts +49 -0
- package/cli/check-ratchets.constants.ts +20 -0
- package/cli/check-ratchets.test.ts +190 -0
- package/cli/check-ratchets.ts +193 -0
- package/cli/check-rejections.test.ts +220 -0
- package/cli/check-rejections.ts +153 -0
- package/cli/check-reports.ts +37 -0
- package/cli/check-tsconfig.test.ts +507 -0
- package/cli/check-tsconfig.ts +205 -0
- package/cli/commands.test.ts +114 -0
- package/cli/commands.ts +218 -0
- package/cli/commit-changelog.ts +27 -0
- package/cli/commit-msg-changelog.test.ts +93 -0
- package/cli/commit-msg-range.test.ts +126 -0
- package/cli/commit-msg-red-proof.test.ts +147 -0
- package/cli/commit-msg-repo.test.ts +153 -0
- package/cli/commit-msg.test.ts +101 -0
- package/cli/commit-msg.ts +194 -0
- package/cli/commit-range.constants.ts +17 -0
- package/cli/commit-range.ts +57 -0
- package/cli/config-knip.test.ts +70 -0
- package/cli/config-nightly.test.ts +84 -0
- package/cli/config-tdd.test.ts +46 -0
- package/cli/config.test.ts +576 -0
- package/cli/config.ts +261 -0
- package/cli/conventional.ts +9 -0
- package/cli/coverage-config.ts +62 -0
- package/cli/coverage.constants.ts +6 -0
- package/cli/coverage.test.ts +199 -0
- package/cli/coverage.ts +127 -0
- package/cli/env.constants.ts +10 -0
- package/cli/env.test.ts +56 -0
- package/cli/env.ts +72 -0
- package/cli/fixtures/base-config.ts +13 -0
- package/cli/fixtures/gate-world.ts +48 -0
- package/cli/fixtures/knip-run.ts +38 -0
- package/cli/fixtures/mutate-fakes.ts +137 -0
- package/cli/fixtures/planted-repo.ts +50 -0
- package/cli/fixtures/publish-world.ts +71 -0
- package/cli/fixtures/red-proof-repo.ts +72 -0
- package/cli/fixtures/release-context.ts +15 -0
- package/cli/fixtures/release-world.ts +111 -0
- package/cli/fixtures/stub-bin.ts +40 -0
- package/cli/guard-diagnostics.test.ts +157 -0
- package/cli/guard-diagnostics.ts +195 -0
- package/cli/init.test.ts +487 -0
- package/cli/init.ts +275 -0
- package/cli/knip-check-ignores.test.ts +106 -0
- package/cli/knip-check-real.test.ts +65 -0
- package/cli/knip-check.constants.ts +46 -0
- package/cli/knip-check.test.ts +114 -0
- package/cli/knip-check.ts +169 -0
- package/cli/knip-schema.ts +67 -0
- package/cli/known-failures.constants.ts +22 -0
- package/cli/known-failures.test.ts +138 -0
- package/cli/known-failures.ts +131 -0
- package/cli/lint/as-needs-reason.ts +41 -0
- package/cli/lint/boundaries.ts +73 -0
- package/cli/lint/class-file.ts +39 -0
- package/cli/lint/config-file.ts +32 -0
- package/cli/lint/entry-file.ts +54 -0
- package/cli/lint/env-in-config.ts +66 -0
- package/cli/lint/filename-matches-export.ts +63 -0
- package/cli/lint/index-file.ts +22 -0
- package/cli/lint/index.ts +47 -0
- package/cli/lint/is-reason.ts +6 -0
- package/cli/lint/manifest.ts +46 -0
- package/cli/lint/names.ts +59 -0
- package/cli/lint/no-commented-out-code.ts +105 -0
- package/cli/lint/no-mock-of-subject.ts +64 -0
- package/cli/lint/no-module-side-effects.ts +94 -0
- package/cli/lint/no-module-state.ts +58 -0
- package/cli/lint/no-side-effect-import.ts +17 -0
- package/cli/lint/no-v8-ignore.ts +18 -0
- package/cli/lint/script-rules.ts +9 -0
- package/cli/lint/skip-on-platform.constants.ts +8 -0
- package/cli/lint/skip-on-platform.ts +118 -0
- package/cli/lint/todo-needs-id.ts +63 -0
- package/cli/lint/types-file.ts +28 -0
- package/cli/lint-schema.ts +32 -0
- package/cli/magic/fixtures/magic-repo.ts +64 -0
- package/cli/magic/magic-hit.ts +2 -0
- package/cli/magic/magic-nodes.ts +44 -0
- package/cli/magic/magic-numbers.test.ts +99 -0
- package/cli/magic/magic-numbers.ts +117 -0
- package/cli/magic/magic-strings.test.ts +38 -0
- package/cli/magic/magic-strings.ts +81 -0
- package/cli/main.test.ts +853 -0
- package/cli/main.ts +292 -0
- package/cli/mutate-args.ts +79 -0
- package/cli/mutate-check.test.ts +147 -0
- package/cli/mutate-deps.ts +42 -0
- package/cli/mutate-edges.test.ts +150 -0
- package/cli/mutate-files.test.ts +100 -0
- package/cli/mutate-files.ts +31 -0
- package/cli/mutate-floors.test.ts +91 -0
- package/cli/mutate-floors.ts +104 -0
- package/cli/mutate-real-deps.ts +57 -0
- package/cli/mutate-real.test.ts +96 -0
- package/cli/mutate-report.ts +96 -0
- package/cli/mutate-run.ts +128 -0
- package/cli/mutate-shards.ts +64 -0
- package/cli/mutate.constants.ts +67 -0
- package/cli/mutate.test.ts +182 -0
- package/cli/mutate.ts +64 -0
- package/cli/nested-vitest-flags.ts +8 -0
- package/cli/nightly-config.constants.ts +14 -0
- package/cli/nightly-config.ts +62 -0
- package/cli/prompt.test.ts +202 -0
- package/cli/prompt.ts +1 -0
- package/cli/red-proof-edges.test.ts +78 -0
- package/cli/red-proof-failures.test.ts +82 -0
- package/cli/red-proof-tree.test.ts +157 -0
- package/cli/red-proof-verdict.test.ts +44 -0
- package/cli/red-proof.constants.ts +14 -0
- package/cli/red-proof.ts +202 -0
- package/cli/release/changelog-parse.ts +83 -0
- package/cli/release/changelog.test.ts +141 -0
- package/cli/release/changelog.ts +106 -0
- package/cli/release/gate-refusals.test.ts +85 -0
- package/cli/release/gate.test.ts +63 -0
- package/cli/release/gate.ts +70 -0
- package/cli/release/git.ts +66 -0
- package/cli/release/github.test.ts +128 -0
- package/cli/release/github.ts +46 -0
- package/cli/release/list-dir.test.ts +11 -0
- package/cli/release/list-dir.ts +6 -0
- package/cli/release/main.test.ts +48 -0
- package/cli/release/main.ts +51 -0
- package/cli/release/manifest.test.ts +24 -0
- package/cli/release/manifest.ts +34 -0
- package/cli/release/pack.test.ts +107 -0
- package/cli/release/pack.ts +76 -0
- package/cli/release/plan.ts +127 -0
- package/cli/release/publish-run.test.ts +98 -0
- package/cli/release/publish.test.ts +127 -0
- package/cli/release/publish.ts +125 -0
- package/cli/release/refuse.ts +5 -0
- package/cli/release/release-messages.constants.ts +96 -0
- package/cli/release/release-refusals.test.ts +149 -0
- package/cli/release/release.constants.ts +128 -0
- package/cli/release/release.test.ts +199 -0
- package/cli/release/release.ts +145 -0
- package/cli/release/version.test.ts +118 -0
- package/cli/release/version.ts +69 -0
- package/cli/release/wait.test.ts +101 -0
- package/cli/release/wait.ts +95 -0
- package/cli/script-diagnostics.test.ts +76 -0
- package/cli/script-diagnostics.ts +62 -0
- package/cli/scripts/board.ts +519 -0
- package/cli/scripts/browser.ts +994 -0
- package/cli/scripts/change-set.ts +105 -0
- package/cli/scripts/check-globals.ts +199 -0
- package/cli/scripts/ci-skips.constants.ts +17 -0
- package/cli/scripts/ci-skips.ts +118 -0
- package/cli/scripts/ci-wait-run.ts +28 -0
- package/cli/scripts/ci-wait.constants.ts +44 -0
- package/cli/scripts/ci-wait.ts +175 -0
- package/cli/scripts/ci.constants.ts +15 -0
- package/cli/scripts/ci.ts +208 -0
- package/cli/scripts/clean.ts +363 -0
- package/cli/scripts/comments-step.ts +15 -0
- package/cli/scripts/doctor.ts +828 -0
- package/cli/scripts/emit-agent-shell-tools.ts +91 -0
- package/cli/scripts/emit-mcp-policy.ts +402 -0
- package/cli/scripts/folder-map.ts +353 -0
- package/cli/scripts/gate-fat-tools.ts +1095 -0
- package/cli/scripts/gitignore-guard.ts +338 -0
- package/cli/scripts/google-workspace.ts +779 -0
- package/cli/scripts/hook-command.ts +72 -0
- package/cli/scripts/hook-input.ts +178 -0
- package/cli/scripts/hook-io.constants.ts +5 -0
- package/cli/scripts/hook-io.ts +46 -0
- package/cli/scripts/hooks-union.ts +469 -0
- package/cli/scripts/interactive.ts +30 -0
- package/cli/scripts/jev.ts +420 -0
- package/cli/scripts/job.ts +934 -0
- package/cli/scripts/jsonc.ts +9 -0
- package/cli/scripts/lessons.ts +564 -0
- package/cli/scripts/lint-baseline.ts +233 -0
- package/cli/scripts/lint-main.ts +24 -0
- package/cli/scripts/lint-skips.ts +139 -0
- package/cli/scripts/lint.constants.ts +6 -0
- package/cli/scripts/lint.ts +142 -0
- package/cli/scripts/listen.ts +962 -0
- package/cli/scripts/managed-text.ts +47 -0
- package/cli/scripts/orchestrate-parent.ts +1155 -0
- package/cli/scripts/parse-need.ts +81 -0
- package/cli/scripts/persist-handoff.ts +228 -0
- package/cli/scripts/persist-implementation.ts +276 -0
- package/cli/scripts/persist-plan.ts +888 -0
- package/cli/scripts/persist-roadmap.ts +478 -0
- package/cli/scripts/persist-tmp.ts +177 -0
- package/cli/scripts/persist.ts +54 -0
- package/cli/scripts/phase-files.ts +121 -0
- package/cli/scripts/prompt.ts +38 -0
- package/cli/scripts/purge-global-mcps.ts +313 -0
- package/cli/scripts/rejections-step.ts +37 -0
- package/cli/scripts/rtk-grok.ts +102 -0
- package/cli/scripts/scrub-spawn-prompt.ts +103 -0
- package/cli/scripts/scrub-text-lib.ts +58 -0
- package/cli/scripts/session-map.ts +69 -0
- package/cli/scripts/setup.ts +862 -0
- package/cli/scripts/skill-conflicts.ts +82 -0
- package/cli/scripts/speak.ts +370 -0
- package/cli/scripts/strategy.ts +168 -0
- package/cli/scripts/sync-global-settings.ts +316 -0
- package/cli/scripts/turn-steps.ts +249 -0
- package/cli/scripts/turn.ts +157 -0
- package/cli/scripts/typecheck-step.ts +43 -0
- package/cli/scripts/vscode-tasks.ts +175 -0
- package/cli/scripts/workspace-root.ts +160 -0
- package/cli/scripts/worktree.ts +806 -0
- package/cli/spawn.test.ts +34 -0
- package/cli/spawn.ts +27 -0
- package/cli/staged-change.constants.ts +11 -0
- package/cli/staged-change.ts +79 -0
- package/cli/stryker-preload.test.ts +50 -0
- package/cli/stryker-preload.ts +9 -0
- package/cli/sync/agents/frontmatter.test.ts +197 -0
- package/cli/sync/agents/frontmatter.ts +140 -0
- package/cli/sync/agents/hooks.test.ts +517 -0
- package/cli/sync/agents/hooks.ts +401 -0
- package/cli/sync/agents/rules.test.ts +229 -0
- package/cli/sync/agents/rules.ts +84 -0
- package/cli/sync/agents/sources.test.ts +375 -0
- package/cli/sync/agents/sources.ts +244 -0
- package/cli/sync/agents/subagents.test.ts +149 -0
- package/cli/sync/agents/subagents.ts +158 -0
- package/cli/sync/agents-text.test.ts +31 -0
- package/cli/sync/agents-text.ts +36 -0
- package/cli/sync/fixtures/copied.README.md +35 -0
- package/cli/sync/fixtures/copied.tgz +0 -0
- package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
- package/cli/sync/fixtures/maps-ws.tgz +0 -0
- package/cli/sync/git-files.test.ts +340 -0
- package/cli/sync/git-files.ts +197 -0
- package/cli/sync/git-hooks.test.ts +94 -0
- package/cli/sync/golden.test.ts +15 -0
- package/cli/sync/hooks-mode.test.ts +99 -0
- package/cli/sync/hosts.ts +28 -0
- package/cli/sync/knip-config.constants.ts +4 -0
- package/cli/sync/knip-config.test.ts +147 -0
- package/cli/sync/knip-config.ts +108 -0
- package/cli/sync/manifest.test.ts +78 -0
- package/cli/sync/manifest.ts +60 -0
- package/cli/sync/maps.test.ts +304 -0
- package/cli/sync/maps.ts +297 -0
- package/cli/sync/migrate.test.ts +2000 -0
- package/cli/sync/migrate.ts +1104 -0
- package/cli/sync/nightly-jobs.test.ts +63 -0
- package/cli/sync/nightly.constants.ts +48 -0
- package/cli/sync/nightly.test.ts +100 -0
- package/cli/sync/nightly.ts +75 -0
- package/cli/sync/outputs.test.ts +351 -0
- package/cli/sync/outputs.ts +168 -0
- package/cli/sync/oxlint-config.test.ts +178 -0
- package/cli/sync/oxlint-config.ts +168 -0
- package/cli/sync/release-jobs.test.ts +29 -0
- package/cli/sync/release-reports.test.ts +31 -0
- package/cli/sync/release-workflow.constants.ts +61 -0
- package/cli/sync/release-workflow.test.ts +192 -0
- package/cli/sync/release-workflow.ts +86 -0
- package/cli/sync/release.constants.ts +93 -0
- package/cli/sync/release.test.ts +187 -0
- package/cli/sync/release.ts +112 -0
- package/cli/sync/repo-dir.test.ts +371 -0
- package/cli/sync/repo-dir.ts +205 -0
- package/cli/sync/standards.constants.ts +8 -0
- package/cli/sync/standards.test.ts +206 -0
- package/cli/sync/standards.ts +179 -0
- package/cli/sync/stock-history.json +2796 -0
- package/cli/sync/stock-history.test.ts +66 -0
- package/cli/sync/stock-history.ts +84 -0
- package/cli/sync/stock.test.ts +47 -0
- package/cli/sync/stock.ts +24 -0
- package/cli/sync/trees.test.ts +621 -0
- package/cli/sync/trees.ts +280 -0
- package/cli/sync/validate.test.ts +299 -0
- package/cli/sync/validate.ts +402 -0
- package/cli/sync/workspace.test.ts +180 -0
- package/cli/sync/workspace.ts +138 -0
- package/cli/sync/write.test.ts +341 -0
- package/cli/sync/write.ts +231 -0
- package/cli/sync.test.ts +1495 -0
- package/cli/sync.ts +360 -0
- package/cli/tdd-scope.test.ts +36 -0
- package/cli/tdd-scope.ts +41 -0
- package/cli/test-files.ts +16 -0
- package/cli/ts-program.ts +69 -0
- package/cli/tsconfig.constants.ts +8 -0
- package/cli/workerstack-config.types.ts +21 -0
- package/dist/cli/env.d.ts +10 -0
- package/dist/cli/env.js +64 -0
- package/dist/presets/temp-root-env.d.ts +10 -0
- package/dist/presets/temp-root-env.js +20 -0
- package/dist/presets/temp-root.constants.d.ts +6 -0
- package/dist/presets/temp-root.constants.js +6 -0
- package/dist/presets/temp-root.d.ts +2 -0
- package/dist/presets/temp-root.js +60 -0
- package/dist/presets/vitest.constants.d.ts +16 -0
- package/dist/presets/vitest.constants.js +16 -0
- package/dist/presets/vitest.d.ts +11 -0
- package/dist/presets/vitest.js +67 -0
- package/dist/presets/workers-plugin.d.ts +10 -0
- package/dist/presets/workers-plugin.js +54 -0
- package/package.json +141 -4
- package/references/INDEX.md +100 -0
- package/references/browser.md +66 -0
- package/references/cascade.md +15 -0
- package/references/clasp.md +21 -0
- package/references/cloudflare.md +36 -0
- package/references/google-workspace.md +79 -0
- package/references/hooks.md +103 -0
- package/references/implementation-format.md +119 -0
- package/references/jev.md +56 -0
- package/references/lessons.md +73 -0
- package/references/listen.md +80 -0
- package/references/map.md +107 -0
- package/references/output.md +47 -0
- package/references/plan-format.md +264 -0
- package/references/review-loop.md +48 -0
- package/references/roadmap-format.md +104 -0
- package/references/rtk.md +27 -0
- package/references/scope.md +76 -0
- package/references/setup.md +42 -0
- package/references/speak.md +59 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/bun.d.ts +1 -0
- package/tsconfig/bun.json +8 -0
- package/tsconfig/reset.d.ts +1 -0
- package/tsconfig/strict.json +12 -0
- package/tsconfig/web.json +7 -0
- package/tsconfig/worker.d.ts +1 -0
- package/tsconfig/worker.json +8 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Speech-to-text (input only). Mic/file → OpenAI Whisper. */
|
|
3
|
+
import { spawn, spawnSync, type SpawnOptions } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { resolveRoots } from "./workspace-root.ts";
|
|
9
|
+
import { loadOptionalConsumerEnv, whichBin } from "./doctor.ts";
|
|
10
|
+
import { isRecord } from "./hook-input.ts";
|
|
11
|
+
import { readEnv } from "../env.ts";
|
|
12
|
+
import type { Env } from "../env.ts";
|
|
13
|
+
|
|
14
|
+
export interface ListenCfg {
|
|
15
|
+
filePath: string;
|
|
16
|
+
apiKey: string;
|
|
17
|
+
model: string;
|
|
18
|
+
device: string;
|
|
19
|
+
seconds: number;
|
|
20
|
+
ffmpegFormat: string;
|
|
21
|
+
whisperModel: string;
|
|
22
|
+
stt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Stt = { kind: "local"; bin: string; model: string } | { kind: "openai"; model: string };
|
|
26
|
+
|
|
27
|
+
export type WhichFn = (name: string, pathEnv?: string, platform?: NodeJS.Platform) => string;
|
|
28
|
+
|
|
29
|
+
/** The slice of a fetch Response this script reads. */
|
|
30
|
+
export interface FetchResult {
|
|
31
|
+
ok?: boolean;
|
|
32
|
+
status?: number;
|
|
33
|
+
text?: () => Promise<string>;
|
|
34
|
+
json?: () => Promise<unknown>;
|
|
35
|
+
arrayBuffer?: () => Promise<ArrayBuffer | Uint8Array>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FetchInit {
|
|
39
|
+
method?: string;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
body?: FormData;
|
|
42
|
+
redirect?: "follow" | "manual" | "error";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type FetchFn = (url: string, init: FetchInit) => Promise<FetchResult>;
|
|
46
|
+
|
|
47
|
+
export interface SpawnSyncResult {
|
|
48
|
+
status: number | null;
|
|
49
|
+
stdout?: string | Buffer | null;
|
|
50
|
+
stderr?: string | Buffer | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SpawnSyncOpts {
|
|
54
|
+
encoding?: "utf8";
|
|
55
|
+
input?: string;
|
|
56
|
+
shell?: boolean;
|
|
57
|
+
timeout?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type SpawnSyncFn = (bin: string, args: string[], opts: SpawnSyncOpts) => SpawnSyncResult;
|
|
61
|
+
|
|
62
|
+
/** The slice of a ChildProcess this script uses; tests pass partial fakes. */
|
|
63
|
+
export interface ChildLike {
|
|
64
|
+
pid?: number | undefined;
|
|
65
|
+
stdin?: { write(chunk: string): unknown; end(): unknown } | null;
|
|
66
|
+
kill?(signal?: NodeJS.Signals): unknown;
|
|
67
|
+
on?(event: string, cb: (arg: unknown, signal?: unknown) => void): unknown;
|
|
68
|
+
unref?(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SpawnOpts {
|
|
72
|
+
detached?: boolean;
|
|
73
|
+
stdio?: unknown[];
|
|
74
|
+
cwd?: string | undefined;
|
|
75
|
+
env?: Env;
|
|
76
|
+
windowsHide?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type SpawnFn = (bin: string, args: string[], opts: SpawnOpts) => ChildLike;
|
|
80
|
+
|
|
81
|
+
function nodeSpawn(bin: string, args: string[], opts: SpawnOpts): ChildLike {
|
|
82
|
+
return spawn(bin, args, opts as SpawnOptions);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type KillFn = (pid: number, signal: string | number) => unknown;
|
|
86
|
+
|
|
87
|
+
export interface ListenLog {
|
|
88
|
+
log(msg: string): void;
|
|
89
|
+
error(msg: string): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ParsedArgv {
|
|
93
|
+
_: string[];
|
|
94
|
+
flags: Record<string, string>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SttOpts {
|
|
98
|
+
cfg: ListenCfg;
|
|
99
|
+
whichFn: WhichFn;
|
|
100
|
+
pathEnv: string | undefined;
|
|
101
|
+
platform: NodeJS.Platform;
|
|
102
|
+
home: string;
|
|
103
|
+
cwd: string;
|
|
104
|
+
fetchFn?: FetchFn | undefined;
|
|
105
|
+
log?: ListenLog | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AudioDevice {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface CaptureResult {
|
|
114
|
+
status: number | null;
|
|
115
|
+
signal: string | null;
|
|
116
|
+
stderr: string;
|
|
117
|
+
error: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ListenMainOpts {
|
|
121
|
+
env?: Env;
|
|
122
|
+
home?: string | undefined;
|
|
123
|
+
cwd?: string | undefined;
|
|
124
|
+
platform?: NodeJS.Platform;
|
|
125
|
+
fetchFn?: FetchFn | undefined;
|
|
126
|
+
spawnFn?: SpawnFn;
|
|
127
|
+
spawnSyncFn?: SpawnSyncFn;
|
|
128
|
+
killFn?: KillFn;
|
|
129
|
+
whichFn?: WhichFn;
|
|
130
|
+
log?: ListenLog | undefined;
|
|
131
|
+
pathEnv?: string;
|
|
132
|
+
nodeBin?: string;
|
|
133
|
+
waitPid?: (pid: number, killFn: KillFn) => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function errMessage(e: unknown): string {
|
|
137
|
+
const m = isRecord(e) ? e.message : undefined;
|
|
138
|
+
return String(m || e);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const DEFAULT_MODEL = "whisper-1";
|
|
142
|
+
export const DEFAULT_GGML = "ggml-base.en.bin";
|
|
143
|
+
export const WHISPER_BINS: string[] = ["whisper-cli", "whisper-cpp"];
|
|
144
|
+
export const DEFAULT_SECONDS = 8;
|
|
145
|
+
export const MAX_SECONDS = 120;
|
|
146
|
+
export const USAGE =
|
|
147
|
+
"usage: listen whoami | devices | rec [--seconds N] [--device D] [--clip] | start [--device D] | stop [--clip] | toggle [--clip] | status | file --path P [--clip] | latest";
|
|
148
|
+
export const SETUP = "bun repo setup listen";
|
|
149
|
+
export const LISTEN_DIR = ".tmp/repo/listen";
|
|
150
|
+
export const NEED_STT =
|
|
151
|
+
"missing whisper-cli (mac: brew install whisper-cpp) or OPENAI_API_KEY / ~/.workerstack/speak.json openai.apiKey. setup: bun repo setup listen";
|
|
152
|
+
export const NEED_FFMPEG =
|
|
153
|
+
"missing ffmpeg. mac: brew install ffmpeg · linux: sudo apt install ffmpeg · win: winget install Gyan.FFmpeg. setup: bun repo setup listen";
|
|
154
|
+
export const NEED_OPENAI =
|
|
155
|
+
"missing OPENAI_API_KEY (or ~/.workerstack/speak.json openai.apiKey). setup: bun repo setup listen";
|
|
156
|
+
export const NEED_GGML =
|
|
157
|
+
"whisper-cli found but no ggml model (auto-download failed). set WHISPER_MODEL or retry. setup: bun repo setup listen";
|
|
158
|
+
|
|
159
|
+
export function formatHttpError(status: unknown, body: unknown): string {
|
|
160
|
+
const n = Number(status);
|
|
161
|
+
const raw = String(body || "");
|
|
162
|
+
const auth = n === 401 || n === 403 || /unauthorized|invalid.?api.?key|authentication|missing_permissions/i.test(raw);
|
|
163
|
+
if (auth) {
|
|
164
|
+
return `openai key missing or rejected (${n || status}). setup: ${SETUP}`;
|
|
165
|
+
}
|
|
166
|
+
return `${status} ${raw.slice(0, 200)}`.trim();
|
|
167
|
+
}
|
|
168
|
+
export const GGML_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin";
|
|
169
|
+
const TRANSCRIBE_URL = "https://api.openai.com/v1/audio/transcriptions";
|
|
170
|
+
|
|
171
|
+
export function parseArgv(argv: unknown): ParsedArgv {
|
|
172
|
+
const out: ParsedArgv = { _: [], flags: {} };
|
|
173
|
+
const args: string[] = Array.isArray(argv) ? argv.filter((a): a is string => typeof a === "string") : [];
|
|
174
|
+
for (let i = 0; i < args.length; i++) {
|
|
175
|
+
const a = args[i]!; // i < args.length
|
|
176
|
+
if (a === "--") {
|
|
177
|
+
out._.push(...args.slice(i + 1));
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
if (a.startsWith("--")) {
|
|
181
|
+
const key = a.slice(2);
|
|
182
|
+
const nxt = args[i + 1];
|
|
183
|
+
if (nxt && !nxt.startsWith("--")) {
|
|
184
|
+
out.flags[key] = nxt;
|
|
185
|
+
i += 1;
|
|
186
|
+
} else out.flags[key] = "1";
|
|
187
|
+
} else out._.push(a);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readJson(p: string): Record<string, unknown> {
|
|
193
|
+
try {
|
|
194
|
+
const j: unknown = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
195
|
+
if (isRecord(j)) return j;
|
|
196
|
+
} catch {
|
|
197
|
+
/* none */
|
|
198
|
+
}
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function str(v: unknown, fallback = ""): string {
|
|
203
|
+
const s = String(v ?? "").trim();
|
|
204
|
+
return s || fallback;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function clampSeconds(v: unknown, fallback: number = DEFAULT_SECONDS): number {
|
|
208
|
+
const n = Number(v);
|
|
209
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
210
|
+
return Math.min(MAX_SECONDS, Math.max(1, Math.round(n)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function loadCfg({ env, home }: { env: Env; home: string }): ListenCfg {
|
|
214
|
+
const filePath = path.join(home, ".workerstack", "speak.json");
|
|
215
|
+
const file = readJson(filePath);
|
|
216
|
+
const openai = isRecord(file.openai) ? file.openai : {};
|
|
217
|
+
const listen = isRecord(file.listen) ? file.listen : {};
|
|
218
|
+
return {
|
|
219
|
+
filePath,
|
|
220
|
+
apiKey: str(env.OPENAI_API_KEY || openai.apiKey),
|
|
221
|
+
model: str(env.LISTEN_MODEL || listen.model, DEFAULT_MODEL),
|
|
222
|
+
device: str(env.LISTEN_DEVICE || listen.device),
|
|
223
|
+
seconds: clampSeconds(env.LISTEN_SECONDS || listen.seconds, DEFAULT_SECONDS),
|
|
224
|
+
ffmpegFormat: str(env.LISTEN_FFMPEG_FORMAT || listen.format).toLowerCase(),
|
|
225
|
+
whisperModel: str(env.WHISPER_MODEL || env.LISTEN_WHISPER_MODEL || listen.whisperModel),
|
|
226
|
+
stt: str(env.LISTEN_STT || listen.stt, "auto").toLowerCase(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function findWhisperBin(whichFn: WhichFn, pathEnv: string | undefined, platform: NodeJS.Platform): string {
|
|
231
|
+
for (const n of WHISPER_BINS) {
|
|
232
|
+
const hit = whichFn(n, pathEnv, platform);
|
|
233
|
+
if (hit) return hit;
|
|
234
|
+
}
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isFile(p: string): boolean {
|
|
239
|
+
try {
|
|
240
|
+
return fs.statSync(p).isFile();
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function findWhisperModel({ cfg, home, cwd }: { cfg: ListenCfg; home: string; cwd: string }): string {
|
|
247
|
+
const names = [
|
|
248
|
+
cfg.whisperModel,
|
|
249
|
+
path.join(home, ".workerstack", "whisper", DEFAULT_GGML),
|
|
250
|
+
path.join(home, "models", DEFAULT_GGML),
|
|
251
|
+
cwd ? path.join(cwd, "models", DEFAULT_GGML) : "",
|
|
252
|
+
];
|
|
253
|
+
for (const p of names) {
|
|
254
|
+
const s = str(p);
|
|
255
|
+
if (isFile(s)) return path.resolve(s);
|
|
256
|
+
}
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function probeStt({ cfg, whichFn, pathEnv, platform, home, cwd }: SttOpts): Stt | null {
|
|
261
|
+
const force = cfg.stt;
|
|
262
|
+
const bin = findWhisperBin(whichFn, pathEnv, platform);
|
|
263
|
+
const modelPath = findWhisperModel({ cfg, home, cwd });
|
|
264
|
+
const local: Stt | null = bin && modelPath ? { kind: "local", bin, model: modelPath } : null;
|
|
265
|
+
const openai: Stt | null = cfg.apiKey ? { kind: "openai", model: cfg.model } : null;
|
|
266
|
+
if (force === "local") return local;
|
|
267
|
+
if (force === "openai") return openai;
|
|
268
|
+
return local || openai;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function resolveStt(opts: SttOpts): Stt {
|
|
272
|
+
const force = opts.cfg.stt;
|
|
273
|
+
const hit = probeStt(opts);
|
|
274
|
+
if (hit) return hit;
|
|
275
|
+
const bin = findWhisperBin(opts.whichFn, opts.pathEnv, opts.platform);
|
|
276
|
+
if (force === "local" || (bin && force !== "openai")) {
|
|
277
|
+
throw new Error(NEED_GGML);
|
|
278
|
+
}
|
|
279
|
+
if (force === "openai") throw new Error(NEED_OPENAI);
|
|
280
|
+
throw new Error(NEED_STT);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function defaultGgmlPath(home: string): string {
|
|
284
|
+
return path.join(home, ".workerstack", "whisper", DEFAULT_GGML);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type GgmlOpts = {
|
|
288
|
+
home: string;
|
|
289
|
+
fetchFn?: FetchFn | undefined;
|
|
290
|
+
log?: { error(msg: string): void } | undefined;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export async function downloadGgml({ home, fetchFn = globalThis.fetch, log }: GgmlOpts): Promise<string> {
|
|
294
|
+
const dest = defaultGgmlPath(home);
|
|
295
|
+
if (isFile(dest)) return dest;
|
|
296
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
|
297
|
+
const part = `${dest}.part`;
|
|
298
|
+
log?.error(`downloading ${DEFAULT_GGML} (~142MB) to ${dest}`);
|
|
299
|
+
const res = await fetchFn(GGML_URL, {
|
|
300
|
+
headers: { "User-Agent": "repo-listen" },
|
|
301
|
+
redirect: "follow",
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) {
|
|
304
|
+
const status = res.status != null ? res.status : "err";
|
|
305
|
+
throw new Error(`could not download ggml model (${status}). setup: ${SETUP}`);
|
|
306
|
+
}
|
|
307
|
+
const buf =
|
|
308
|
+
typeof res.arrayBuffer === "function" ? Buffer.from(new Uint8Array(await res.arrayBuffer())) : Buffer.alloc(0);
|
|
309
|
+
if (!buf.length) throw new Error("ggml download empty");
|
|
310
|
+
fs.writeFileSync(part, buf);
|
|
311
|
+
fs.renameSync(part, dest);
|
|
312
|
+
return dest;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function ensureStt(opts: SttOpts): Promise<Stt> {
|
|
316
|
+
const force = opts.cfg.stt;
|
|
317
|
+
const bin = findWhisperBin(opts.whichFn, opts.pathEnv, opts.platform);
|
|
318
|
+
let modelPath = findWhisperModel({
|
|
319
|
+
cfg: opts.cfg,
|
|
320
|
+
home: opts.home,
|
|
321
|
+
cwd: opts.cwd,
|
|
322
|
+
});
|
|
323
|
+
if (force !== "openai" && bin && !modelPath) {
|
|
324
|
+
try {
|
|
325
|
+
modelPath = await downloadGgml({
|
|
326
|
+
home: opts.home,
|
|
327
|
+
fetchFn: opts.fetchFn,
|
|
328
|
+
log: opts.log,
|
|
329
|
+
});
|
|
330
|
+
} catch (e) {
|
|
331
|
+
if (force === "local" || !opts.cfg.apiKey) throw e;
|
|
332
|
+
return { kind: "openai", model: opts.cfg.model };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const cfg = modelPath ? { ...opts.cfg, whisperModel: modelPath } : opts.cfg;
|
|
336
|
+
return resolveStt({ ...opts, cfg });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function listenDir(cwd: string): string {
|
|
340
|
+
const d = path.join(resolveRoots({}, { cwd }).root, LISTEN_DIR);
|
|
341
|
+
fs.mkdirSync(d, { recursive: true });
|
|
342
|
+
return d;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function sessionPath(cwd: string): string {
|
|
346
|
+
return path.join(listenDir(cwd), "session.json");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function latestPath(cwd: string): string {
|
|
350
|
+
return path.join(listenDir(cwd), "latest.txt");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function defaultDevice(platform: NodeJS.Platform): string {
|
|
354
|
+
if (platform === "darwin") return ":0";
|
|
355
|
+
if (platform === "win32") return "";
|
|
356
|
+
return "default";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function darwinAudioSpec(device: unknown): string {
|
|
360
|
+
const d = str(device, ":0");
|
|
361
|
+
if (/^none:/i.test(d)) return `:${d.slice(5)}`;
|
|
362
|
+
if (d.startsWith(":")) return d;
|
|
363
|
+
if (/^\d+$/.test(d)) return `:${d}`;
|
|
364
|
+
return d.includes(":") ? d : `:${d}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function ffmpegInputArgs(platform: NodeJS.Platform, device: unknown, ffmpegFormat: string): string[] {
|
|
368
|
+
const d = str(device, defaultDevice(platform));
|
|
369
|
+
if (platform === "darwin") {
|
|
370
|
+
return ["-f", "avfoundation", "-i", darwinAudioSpec(d)];
|
|
371
|
+
}
|
|
372
|
+
if (platform === "win32") {
|
|
373
|
+
if (!d) {
|
|
374
|
+
throw new Error("missing --device or LISTEN_DEVICE. run: bun repo listen devices. setup: bun repo setup listen");
|
|
375
|
+
}
|
|
376
|
+
const spec = d.startsWith("audio=") ? d : `audio=${d}`;
|
|
377
|
+
return ["-f", "dshow", "-i", spec];
|
|
378
|
+
}
|
|
379
|
+
const fmt = ffmpegFormat || (d.startsWith("hw:") ? "alsa" : "pulse");
|
|
380
|
+
return ["-f", fmt, "-i", d];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function ffmpegRecordArgs({
|
|
384
|
+
platform,
|
|
385
|
+
device,
|
|
386
|
+
out,
|
|
387
|
+
seconds,
|
|
388
|
+
ffmpegFormat,
|
|
389
|
+
}: {
|
|
390
|
+
platform: NodeJS.Platform;
|
|
391
|
+
device: unknown;
|
|
392
|
+
out: string;
|
|
393
|
+
seconds: number | undefined;
|
|
394
|
+
ffmpegFormat: string;
|
|
395
|
+
}): string[] {
|
|
396
|
+
const t = seconds ? ["-t", String(clampSeconds(seconds))] : [];
|
|
397
|
+
const noIn = seconds ? ["-nostdin"] : [];
|
|
398
|
+
return [
|
|
399
|
+
"-hide_banner",
|
|
400
|
+
"-loglevel",
|
|
401
|
+
"warning",
|
|
402
|
+
...noIn,
|
|
403
|
+
...ffmpegInputArgs(platform, device, ffmpegFormat),
|
|
404
|
+
"-ac",
|
|
405
|
+
"1",
|
|
406
|
+
"-ar",
|
|
407
|
+
"16000",
|
|
408
|
+
"-c:a",
|
|
409
|
+
"pcm_s16le",
|
|
410
|
+
...t,
|
|
411
|
+
"-y",
|
|
412
|
+
out,
|
|
413
|
+
];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function recCaptureOpts(errFd?: number | null): {
|
|
417
|
+
detached: true;
|
|
418
|
+
stdio: (string | number)[];
|
|
419
|
+
windowsHide: true;
|
|
420
|
+
} {
|
|
421
|
+
return {
|
|
422
|
+
detached: true,
|
|
423
|
+
stdio: ["ignore", "ignore", errFd == null ? "ignore" : errFd],
|
|
424
|
+
windowsHide: true,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function recCapture({
|
|
429
|
+
bin,
|
|
430
|
+
args,
|
|
431
|
+
seconds,
|
|
432
|
+
spawnFn,
|
|
433
|
+
killFn,
|
|
434
|
+
errFd,
|
|
435
|
+
}: {
|
|
436
|
+
bin: string;
|
|
437
|
+
args: string[];
|
|
438
|
+
seconds: unknown;
|
|
439
|
+
spawnFn: SpawnFn;
|
|
440
|
+
killFn: KillFn;
|
|
441
|
+
errFd: number | null;
|
|
442
|
+
}): Promise<CaptureResult> {
|
|
443
|
+
const s = clampSeconds(seconds);
|
|
444
|
+
return new Promise<CaptureResult>((resolve) => {
|
|
445
|
+
const child = spawnFn(bin, args, recCaptureOpts(errFd));
|
|
446
|
+
let done = false;
|
|
447
|
+
const finish = (status: number | null, signal: string | null, error: unknown): void => {
|
|
448
|
+
if (done) return;
|
|
449
|
+
done = true;
|
|
450
|
+
clearTimeout(timer);
|
|
451
|
+
resolve({ status, signal, stderr: "", error });
|
|
452
|
+
};
|
|
453
|
+
const timer = setTimeout(
|
|
454
|
+
() => {
|
|
455
|
+
try {
|
|
456
|
+
if (child.pid) killFn(-child.pid, "SIGKILL");
|
|
457
|
+
} catch {
|
|
458
|
+
try {
|
|
459
|
+
child.kill?.("SIGKILL");
|
|
460
|
+
} catch {
|
|
461
|
+
/* none */
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
finish(null, "SIGKILL", { code: "ETIMEDOUT" });
|
|
465
|
+
},
|
|
466
|
+
(s + 2) * 1000,
|
|
467
|
+
);
|
|
468
|
+
if (typeof child.on === "function") {
|
|
469
|
+
child.on("error", (error: unknown) => finish(null, null, error));
|
|
470
|
+
child.on("exit", (code: unknown, signal: unknown) =>
|
|
471
|
+
finish(typeof code === "number" ? code : null, typeof signal === "string" ? signal : null, null),
|
|
472
|
+
);
|
|
473
|
+
} else {
|
|
474
|
+
finish(0, null, null);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function parseDevices(text: unknown, platform: NodeJS.Platform): AudioDevice[] {
|
|
480
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
481
|
+
const out: AudioDevice[] = [];
|
|
482
|
+
if (platform === "darwin") {
|
|
483
|
+
let audio = false;
|
|
484
|
+
for (const line of lines) {
|
|
485
|
+
if (/AVFoundation audio devices/i.test(line) || /audio devices:\s*$/i.test(line)) {
|
|
486
|
+
audio = true;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (/video devices/i.test(line)) {
|
|
490
|
+
audio = false;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (!audio) continue;
|
|
494
|
+
const m = line.match(/\[(\d+)\]\s+(.+)$/);
|
|
495
|
+
if (m) out.push({ id: `:${m[1]}`, name: m[2]!.trim() }); // both regex groups are mandatory
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
if (platform === "win32") {
|
|
500
|
+
let audio = false;
|
|
501
|
+
for (const line of lines) {
|
|
502
|
+
if (/DirectShow audio devices/i.test(line)) {
|
|
503
|
+
audio = true;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (/DirectShow video devices/i.test(line)) {
|
|
507
|
+
audio = false;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (!audio) continue;
|
|
511
|
+
const m = line.match(/"([^"]+)"/);
|
|
512
|
+
if (m) out.push({ id: m[1]!, name: m[1]! }); // the group is mandatory
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
for (const line of lines) {
|
|
517
|
+
const m = line.match(/^\s*(\d+)\s+(\S+)/);
|
|
518
|
+
if (m) out.push({ id: m[2]!, name: m[2]! }); // both groups are mandatory
|
|
519
|
+
}
|
|
520
|
+
if (!out.length) out.push({ id: "default", name: "pulse default" });
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function readSession(cwd: string): Record<string, unknown> {
|
|
525
|
+
return readJson(sessionPath(cwd));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function writeSession(cwd: string, obj: Record<string, unknown>): void {
|
|
529
|
+
fs.writeFileSync(sessionPath(cwd), JSON.stringify(obj, null, 2) + "\n");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function clearSession(cwd: string): void {
|
|
533
|
+
fs.rmSync(sessionPath(cwd), { force: true });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function pidAlive(pid: unknown, killFn: KillFn): boolean {
|
|
537
|
+
const n = Number(pid);
|
|
538
|
+
if (!Number.isFinite(n) || n <= 0) return false;
|
|
539
|
+
try {
|
|
540
|
+
killFn(n, 0);
|
|
541
|
+
return true;
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function needFfmpeg(bin: string | undefined): string {
|
|
548
|
+
if (!bin) {
|
|
549
|
+
throw new Error(NEED_FFMPEG);
|
|
550
|
+
}
|
|
551
|
+
return bin;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function transcribeLocal({
|
|
555
|
+
file,
|
|
556
|
+
bin,
|
|
557
|
+
model,
|
|
558
|
+
spawnSyncFn,
|
|
559
|
+
}: {
|
|
560
|
+
file: string;
|
|
561
|
+
bin: string;
|
|
562
|
+
model: string;
|
|
563
|
+
spawnSyncFn: SpawnSyncFn;
|
|
564
|
+
}): string {
|
|
565
|
+
if (!isFile(file)) throw new Error("transcribe needs an audio file");
|
|
566
|
+
const prefix = path.join(path.dirname(file), `${path.basename(file, path.extname(file))}.whisper`);
|
|
567
|
+
const txt = `${prefix}.txt`;
|
|
568
|
+
const r = spawnSyncFn(bin, ["-m", model, "-f", file, "-otxt", "-of", prefix, "-np", "-nt"], { encoding: "utf8" });
|
|
569
|
+
let text = "";
|
|
570
|
+
if (isFile(txt)) {
|
|
571
|
+
text = fs.readFileSync(txt, "utf8").trim();
|
|
572
|
+
fs.rmSync(txt, { force: true });
|
|
573
|
+
}
|
|
574
|
+
if (!text) text = str(r.stdout);
|
|
575
|
+
if (text) return text;
|
|
576
|
+
throw new Error(str(r.stderr, "whisper-cli failed"));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export async function transcribe({
|
|
580
|
+
file,
|
|
581
|
+
cfg,
|
|
582
|
+
stt,
|
|
583
|
+
fetchFn,
|
|
584
|
+
spawnSyncFn,
|
|
585
|
+
}: {
|
|
586
|
+
file: string;
|
|
587
|
+
cfg: ListenCfg;
|
|
588
|
+
stt: Stt;
|
|
589
|
+
fetchFn: FetchFn;
|
|
590
|
+
spawnSyncFn: SpawnSyncFn;
|
|
591
|
+
}): Promise<string> {
|
|
592
|
+
if (!fs.existsSync(file)) throw new Error("transcribe needs an audio file");
|
|
593
|
+
if (stt.kind === "local") {
|
|
594
|
+
return transcribeLocal({ file, bin: stt.bin, model: stt.model, spawnSyncFn });
|
|
595
|
+
}
|
|
596
|
+
if (!cfg.apiKey) throw new Error(NEED_STT);
|
|
597
|
+
const buf = fs.readFileSync(file);
|
|
598
|
+
const fd = new FormData();
|
|
599
|
+
fd.append("file", new Blob([buf], { type: "audio/wav" }), path.basename(file));
|
|
600
|
+
fd.append("model", cfg.model);
|
|
601
|
+
const res = await fetchFn(TRANSCRIBE_URL, {
|
|
602
|
+
method: "POST",
|
|
603
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}` },
|
|
604
|
+
body: fd,
|
|
605
|
+
});
|
|
606
|
+
if (!res.ok) {
|
|
607
|
+
let t = "";
|
|
608
|
+
try {
|
|
609
|
+
t = typeof res.text === "function" ? await res.text() : "";
|
|
610
|
+
} catch {
|
|
611
|
+
/* none */
|
|
612
|
+
}
|
|
613
|
+
const status = res.status != null ? res.status : "err";
|
|
614
|
+
throw new Error(formatHttpError(status, t));
|
|
615
|
+
}
|
|
616
|
+
const j: unknown = typeof res.json === "function" ? await res.json() : {};
|
|
617
|
+
return str(isRecord(j) ? j.text : undefined, typeof j === "string" ? j : "");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function copyClip(
|
|
621
|
+
body: string,
|
|
622
|
+
{ platform, spawnSyncFn }: { platform: NodeJS.Platform; spawnSyncFn: SpawnSyncFn },
|
|
623
|
+
): void {
|
|
624
|
+
if (platform === "darwin") {
|
|
625
|
+
const r = spawnSyncFn("pbcopy", [], { input: body, encoding: "utf8" });
|
|
626
|
+
if (r.status !== 0) throw new Error("pbcopy failed");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (platform === "win32") {
|
|
630
|
+
const r = spawnSyncFn("clip", [], { input: body, encoding: "utf8", shell: true });
|
|
631
|
+
if (r.status !== 0) throw new Error("clip failed");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const sinks: [string, string[]][] = [
|
|
635
|
+
["wl-copy", []],
|
|
636
|
+
["xclip", ["-selection", "clipboard"]],
|
|
637
|
+
["xsel", ["--clipboard", "--input"]],
|
|
638
|
+
];
|
|
639
|
+
for (const [bin, args] of sinks) {
|
|
640
|
+
const r = spawnSyncFn(bin, args, { input: body, encoding: "utf8" });
|
|
641
|
+
if (r.status === 0) return;
|
|
642
|
+
}
|
|
643
|
+
throw new Error("no wl-copy, xclip, or xsel on PATH");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function saveLatest(cwd: string, text: string): string {
|
|
647
|
+
const p = latestPath(cwd);
|
|
648
|
+
fs.writeFileSync(p, `${text}\n`); // a transcript is trimmed, so it never ends in a newline
|
|
649
|
+
return p;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function finishTranscript({
|
|
653
|
+
wav,
|
|
654
|
+
cfg,
|
|
655
|
+
cwd,
|
|
656
|
+
clip,
|
|
657
|
+
fetchFn,
|
|
658
|
+
platform,
|
|
659
|
+
spawnSyncFn,
|
|
660
|
+
stt,
|
|
661
|
+
log,
|
|
662
|
+
}: {
|
|
663
|
+
wav: string;
|
|
664
|
+
cfg: ListenCfg;
|
|
665
|
+
cwd: string;
|
|
666
|
+
clip: boolean;
|
|
667
|
+
fetchFn: FetchFn;
|
|
668
|
+
platform: NodeJS.Platform;
|
|
669
|
+
spawnSyncFn: SpawnSyncFn;
|
|
670
|
+
stt: Stt;
|
|
671
|
+
log: ListenLog;
|
|
672
|
+
}): Promise<string> {
|
|
673
|
+
const text = await transcribe({ file: wav, cfg, stt, fetchFn, spawnSyncFn });
|
|
674
|
+
const latest = saveLatest(cwd, text);
|
|
675
|
+
if (clip) copyClip(text, { platform, spawnSyncFn });
|
|
676
|
+
log.log(text);
|
|
677
|
+
const rel = path.relative(cwd, latest).replace(/\\/g, "/");
|
|
678
|
+
log.log(`transcript=${rel.startsWith("..") ? latest : rel}`);
|
|
679
|
+
return text;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function listDevicesText({
|
|
683
|
+
platform,
|
|
684
|
+
ffmpegBin,
|
|
685
|
+
spawnSyncFn,
|
|
686
|
+
}: {
|
|
687
|
+
platform: NodeJS.Platform;
|
|
688
|
+
ffmpegBin: string | undefined;
|
|
689
|
+
spawnSyncFn: SpawnSyncFn;
|
|
690
|
+
}): AudioDevice[] {
|
|
691
|
+
const ffmpeg = needFfmpeg(ffmpegBin);
|
|
692
|
+
if (platform === "darwin") {
|
|
693
|
+
const r = spawnSyncFn(ffmpeg, ["-f", "avfoundation", "-list_devices", "true", "-i", ""], {
|
|
694
|
+
encoding: "utf8",
|
|
695
|
+
});
|
|
696
|
+
return parseDevices(`${r.stderr || ""}\n${r.stdout || ""}`, "darwin");
|
|
697
|
+
}
|
|
698
|
+
if (platform === "win32") {
|
|
699
|
+
const r = spawnSyncFn(ffmpeg, ["-list_devices", "true", "-f", "dshow", "-i", "dummy"], { encoding: "utf8" });
|
|
700
|
+
return parseDevices(`${r.stderr || ""}\n${r.stdout || ""}`, "win32");
|
|
701
|
+
}
|
|
702
|
+
const pactl = spawnSyncFn("pactl", ["list", "short", "sources"], { encoding: "utf8" });
|
|
703
|
+
if (pactl.status === 0 && str(pactl.stdout)) {
|
|
704
|
+
return parseDevices(pactl.stdout, "linux");
|
|
705
|
+
}
|
|
706
|
+
return parseDevices("", "linux");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function waitPidExit(pid: number, killFn: KillFn): void {
|
|
710
|
+
const start = Date.now();
|
|
711
|
+
while (Date.now() - start < 8000) {
|
|
712
|
+
if (!pidAlive(pid, killFn)) return;
|
|
713
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // sleep 50ms
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export async function main(argv: string[], opts: ListenMainOpts = {}): Promise<number> {
|
|
718
|
+
const { _, flags } = parseArgv(argv);
|
|
719
|
+
if (!opts.env) await loadOptionalConsumerEnv();
|
|
720
|
+
const env = opts.env || readEnv();
|
|
721
|
+
const home = opts.home || str(env.HOME || env.USERPROFILE, os.homedir());
|
|
722
|
+
const cwd = str(flags.cwd || opts.cwd, process.cwd());
|
|
723
|
+
const platform = opts.platform || process.platform;
|
|
724
|
+
const fetchFn: FetchFn = opts.fetchFn || globalThis.fetch;
|
|
725
|
+
const spawnFn: SpawnFn = opts.spawnFn || nodeSpawn;
|
|
726
|
+
const spawnSyncFn: SpawnSyncFn = opts.spawnSyncFn || spawnSync;
|
|
727
|
+
const killFn: KillFn = opts.killFn || process.kill;
|
|
728
|
+
const whichFn: WhichFn = opts.whichFn || whichBin;
|
|
729
|
+
const log: ListenLog = opts.log || console;
|
|
730
|
+
const pathEnv = opts.pathEnv || env.PATH || readEnv().PATH;
|
|
731
|
+
const ffmpegBin = whichFn("ffmpeg", pathEnv, platform);
|
|
732
|
+
const nodeBin = opts.nodeBin || process.execPath;
|
|
733
|
+
const cfg = loadCfg({ env, home });
|
|
734
|
+
const device = str(flags.device, cfg.device);
|
|
735
|
+
const clip = flags.clip === "1" || flags.clip === "true";
|
|
736
|
+
const verb = String(_[0] || "").toLowerCase();
|
|
737
|
+
const sttOpts: SttOpts = { cfg, whichFn, pathEnv, platform, home, cwd, fetchFn, log };
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
if (verb === "whoami") {
|
|
741
|
+
const ff = ffmpegBin || "missing";
|
|
742
|
+
let stt: Stt | null;
|
|
743
|
+
try {
|
|
744
|
+
stt = await ensureStt(sttOpts);
|
|
745
|
+
} catch {
|
|
746
|
+
stt = probeStt(sttOpts);
|
|
747
|
+
}
|
|
748
|
+
if (!stt) {
|
|
749
|
+
log.log(`stt=none ffmpeg=${ff}`);
|
|
750
|
+
log.error(NEED_STT);
|
|
751
|
+
return 1;
|
|
752
|
+
}
|
|
753
|
+
if (stt.kind === "local") {
|
|
754
|
+
log.log(`stt=local bin=${stt.bin} model=${stt.model} ffmpeg=${ff}`);
|
|
755
|
+
return 0;
|
|
756
|
+
}
|
|
757
|
+
log.log(`stt=openai model=${stt.model} ffmpeg=${ff}`);
|
|
758
|
+
return 0;
|
|
759
|
+
}
|
|
760
|
+
if (verb === "devices") {
|
|
761
|
+
const list = listDevicesText({ platform, ffmpegBin, spawnSyncFn });
|
|
762
|
+
if (!list.length) log.log("no audio devices");
|
|
763
|
+
else for (const d of list) log.log(`${d.id} ${d.name}`);
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
if (verb === "latest") {
|
|
767
|
+
const p = latestPath(cwd);
|
|
768
|
+
if (!fs.existsSync(p)) {
|
|
769
|
+
log.error("no transcript");
|
|
770
|
+
return 1;
|
|
771
|
+
}
|
|
772
|
+
log.log(fs.readFileSync(p, "utf8").trimEnd());
|
|
773
|
+
return 0;
|
|
774
|
+
}
|
|
775
|
+
if (verb === "status") {
|
|
776
|
+
const s = readSession(cwd);
|
|
777
|
+
if (!s.pid) {
|
|
778
|
+
log.log("idle");
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
const alive = pidAlive(s.pid, killFn);
|
|
782
|
+
log.log(`${alive ? "recording" : "stale"} pid=${String(s.pid)} wav=${String(s.wav || "-")}`);
|
|
783
|
+
if (!alive) clearSession(cwd);
|
|
784
|
+
return 0;
|
|
785
|
+
}
|
|
786
|
+
if (verb === "file") {
|
|
787
|
+
const file = str(flags.path || flags.file || flags.out || _[1]);
|
|
788
|
+
if (!file) throw new Error("file needs --path");
|
|
789
|
+
await finishTranscript({
|
|
790
|
+
wav: file,
|
|
791
|
+
cfg,
|
|
792
|
+
cwd,
|
|
793
|
+
clip,
|
|
794
|
+
fetchFn,
|
|
795
|
+
platform,
|
|
796
|
+
spawnSyncFn,
|
|
797
|
+
stt: await ensureStt(sttOpts),
|
|
798
|
+
log,
|
|
799
|
+
});
|
|
800
|
+
return 0;
|
|
801
|
+
}
|
|
802
|
+
if (verb === "_record") {
|
|
803
|
+
const out = str(flags.out);
|
|
804
|
+
if (!out) throw new Error("_record needs --out");
|
|
805
|
+
const ffmpeg = needFfmpeg(ffmpegBin);
|
|
806
|
+
const seconds = flags.seconds ? clampSeconds(flags.seconds) : 0;
|
|
807
|
+
const args = ffmpegRecordArgs({
|
|
808
|
+
platform,
|
|
809
|
+
device,
|
|
810
|
+
out,
|
|
811
|
+
seconds: seconds || undefined,
|
|
812
|
+
ffmpegFormat: cfg.ffmpegFormat,
|
|
813
|
+
});
|
|
814
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
815
|
+
writeSession(cwd, { pid: process.pid, wav: out, started: Date.now() });
|
|
816
|
+
const child = spawnFn(ffmpeg, args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
817
|
+
const stop = () => {
|
|
818
|
+
try {
|
|
819
|
+
if (child.stdin) child.stdin.write("q\n");
|
|
820
|
+
} catch {
|
|
821
|
+
/* none */
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
if (child.stdin) child.stdin.end();
|
|
825
|
+
} catch {
|
|
826
|
+
/* none */
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
process.on("SIGINT", stop);
|
|
830
|
+
process.on("SIGTERM", stop);
|
|
831
|
+
const code = await new Promise<number>((resolve, reject) => {
|
|
832
|
+
child.on?.("error", reject);
|
|
833
|
+
child.on?.("exit", (c: unknown) => resolve(typeof c === "number" ? c : 0));
|
|
834
|
+
});
|
|
835
|
+
return code && code !== 255 ? 1 : 0;
|
|
836
|
+
}
|
|
837
|
+
if (verb === "start") {
|
|
838
|
+
needFfmpeg(ffmpegBin);
|
|
839
|
+
const cur = readSession(cwd);
|
|
840
|
+
if (cur.pid && pidAlive(cur.pid, killFn)) {
|
|
841
|
+
log.error(`already recording pid=${String(cur.pid)}`);
|
|
842
|
+
return 1;
|
|
843
|
+
}
|
|
844
|
+
const wav = path.join(listenDir(cwd), `rec-${Date.now()}.wav`);
|
|
845
|
+
const child = spawnFn(
|
|
846
|
+
nodeBin,
|
|
847
|
+
[
|
|
848
|
+
fileURLToPath(new URL("../../bin/repo.ts", import.meta.url)),
|
|
849
|
+
"listen",
|
|
850
|
+
"_record",
|
|
851
|
+
"--out",
|
|
852
|
+
wav,
|
|
853
|
+
...(device ? ["--device", device] : []),
|
|
854
|
+
"--cwd",
|
|
855
|
+
cwd,
|
|
856
|
+
],
|
|
857
|
+
{
|
|
858
|
+
detached: true,
|
|
859
|
+
stdio: ["ignore", "ignore", "ignore"], // same as "ignore"
|
|
860
|
+
cwd,
|
|
861
|
+
env,
|
|
862
|
+
windowsHide: true,
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
if (typeof child.unref === "function") child.unref();
|
|
866
|
+
writeSession(cwd, { pid: child.pid, wav, started: Date.now() });
|
|
867
|
+
log.log(`recording pid=${child.pid}\n${wav}`);
|
|
868
|
+
return 0;
|
|
869
|
+
}
|
|
870
|
+
if (verb === "stop") {
|
|
871
|
+
const s = readSession(cwd);
|
|
872
|
+
if (!s.pid) throw new Error("not recording");
|
|
873
|
+
const sessionPid = Number(s.pid);
|
|
874
|
+
if (pidAlive(sessionPid, killFn)) {
|
|
875
|
+
try {
|
|
876
|
+
killFn(sessionPid, "SIGINT");
|
|
877
|
+
} catch {
|
|
878
|
+
/* none */
|
|
879
|
+
}
|
|
880
|
+
if (typeof opts.waitPid === "function") opts.waitPid(sessionPid, killFn);
|
|
881
|
+
else waitPidExit(sessionPid, killFn);
|
|
882
|
+
}
|
|
883
|
+
clearSession(cwd);
|
|
884
|
+
const wav = str(s.wav);
|
|
885
|
+
if (!wav || !fs.existsSync(wav)) throw new Error("no wav from session");
|
|
886
|
+
await finishTranscript({
|
|
887
|
+
wav,
|
|
888
|
+
cfg,
|
|
889
|
+
cwd,
|
|
890
|
+
clip,
|
|
891
|
+
fetchFn,
|
|
892
|
+
platform,
|
|
893
|
+
spawnSyncFn,
|
|
894
|
+
stt: await ensureStt(sttOpts),
|
|
895
|
+
log,
|
|
896
|
+
});
|
|
897
|
+
return 0;
|
|
898
|
+
}
|
|
899
|
+
if (verb === "toggle") {
|
|
900
|
+
const s = readSession(cwd);
|
|
901
|
+
if (s.pid && pidAlive(s.pid, killFn)) {
|
|
902
|
+
return main(["stop", ...(clip ? ["--clip"] : []), ...(flags.cwd ? ["--cwd", cwd] : [])], opts);
|
|
903
|
+
}
|
|
904
|
+
return main(["start", ...(device ? ["--device", device] : []), ...(flags.cwd ? ["--cwd", cwd] : [])], opts);
|
|
905
|
+
}
|
|
906
|
+
if (verb === "rec") {
|
|
907
|
+
const ffmpeg = needFfmpeg(ffmpegBin);
|
|
908
|
+
const stt = await ensureStt(sttOpts);
|
|
909
|
+
const seconds = clampSeconds(flags.seconds || cfg.seconds);
|
|
910
|
+
const wav = str(flags.out) || path.join(listenDir(cwd), `rec-${Date.now()}.wav`);
|
|
911
|
+
const args = ffmpegRecordArgs({
|
|
912
|
+
platform,
|
|
913
|
+
device,
|
|
914
|
+
out: wav,
|
|
915
|
+
seconds,
|
|
916
|
+
ffmpegFormat: cfg.ffmpegFormat,
|
|
917
|
+
});
|
|
918
|
+
log.error(`recording ${seconds}s on ${args[args.indexOf("-i") + 1]}…`);
|
|
919
|
+
const ffLog = path.join(listenDir(cwd), "ffmpeg.log");
|
|
920
|
+
const errFd = fs.openSync(ffLog, "w");
|
|
921
|
+
let r: CaptureResult;
|
|
922
|
+
try {
|
|
923
|
+
r = await recCapture({
|
|
924
|
+
bin: ffmpeg,
|
|
925
|
+
args,
|
|
926
|
+
seconds,
|
|
927
|
+
spawnFn,
|
|
928
|
+
killFn,
|
|
929
|
+
errFd,
|
|
930
|
+
});
|
|
931
|
+
} finally {
|
|
932
|
+
fs.closeSync(errFd);
|
|
933
|
+
}
|
|
934
|
+
const ffErr = str(fs.readFileSync(ffLog, "utf8")).slice(0, 400);
|
|
935
|
+
if (isRecord(r.error) && r.error.code === "ETIMEDOUT") {
|
|
936
|
+
throw new Error(str(ffErr, "ffmpeg rec timed out"));
|
|
937
|
+
}
|
|
938
|
+
if (r.status !== 0 && !fs.existsSync(wav)) {
|
|
939
|
+
throw new Error(str(ffErr, "ffmpeg rec failed"));
|
|
940
|
+
}
|
|
941
|
+
if (!fs.existsSync(wav)) throw new Error("ffmpeg wrote no wav");
|
|
942
|
+
log.error("transcribing…");
|
|
943
|
+
await finishTranscript({
|
|
944
|
+
wav,
|
|
945
|
+
cfg,
|
|
946
|
+
cwd,
|
|
947
|
+
clip,
|
|
948
|
+
fetchFn,
|
|
949
|
+
platform,
|
|
950
|
+
spawnSyncFn,
|
|
951
|
+
stt,
|
|
952
|
+
log,
|
|
953
|
+
});
|
|
954
|
+
return 0;
|
|
955
|
+
}
|
|
956
|
+
log.error(USAGE);
|
|
957
|
+
return 1;
|
|
958
|
+
} catch (e) {
|
|
959
|
+
log.error(errMessage(e));
|
|
960
|
+
return 1;
|
|
961
|
+
}
|
|
962
|
+
}
|