jfl 0.0.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/LICENSE +21 -0
- package/README.md +313 -0
- package/clawdbot-skill/README.md +328 -0
- package/clawdbot-skill/SKILL.md +362 -0
- package/clawdbot-skill/index.ts +486 -0
- package/clawdbot-skill/package.json +28 -0
- package/clawdbot-skill/skill.json +28 -0
- package/dist/commands/agents.d.ts +5 -0
- package/dist/commands/agents.d.ts.map +1 -0
- package/dist/commands/agents.js +399 -0
- package/dist/commands/agents.js.map +1 -0
- package/dist/commands/context-hub.d.ts +12 -0
- package/dist/commands/context-hub.d.ts.map +1 -0
- package/dist/commands/context-hub.js +642 -0
- package/dist/commands/context-hub.js.map +1 -0
- package/dist/commands/deploy.d.ts +5 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +370 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +178 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/hud.d.ts +4 -0
- package/dist/commands/hud.d.ts.map +1 -0
- package/dist/commands/hud.js +262 -0
- package/dist/commands/hud.js.map +1 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +553 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +23 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +818 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/ralph.d.ts +9 -0
- package/dist/commands/ralph.d.ts.map +1 -0
- package/dist/commands/ralph.js +67 -0
- package/dist/commands/ralph.js.map +1 -0
- package/dist/commands/repair.d.ts +7 -0
- package/dist/commands/repair.d.ts.map +1 -0
- package/dist/commands/repair.js +283 -0
- package/dist/commands/repair.js.map +1 -0
- package/dist/commands/session-mgmt.d.ts +33 -0
- package/dist/commands/session-mgmt.d.ts.map +1 -0
- package/dist/commands/session-mgmt.js +404 -0
- package/dist/commands/session-mgmt.js.map +1 -0
- package/dist/commands/session.d.ts +2 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +639 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/skills.d.ts +31 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +314 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +127 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/synopsis.d.ts +10 -0
- package/dist/commands/synopsis.d.ts.map +1 -0
- package/dist/commands/synopsis.js +277 -0
- package/dist/commands/synopsis.js.map +1 -0
- package/dist/commands/update.d.ts +10 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +165 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/voice.d.ts +410 -0
- package/dist/commands/voice.d.ts.map +1 -0
- package/dist/commands/voice.js +4763 -0
- package/dist/commands/voice.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +512 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/context-hub-mcp.d.ts +11 -0
- package/dist/mcp/context-hub-mcp.d.ts.map +1 -0
- package/dist/mcp/context-hub-mcp.js +548 -0
- package/dist/mcp/context-hub-mcp.js.map +1 -0
- package/dist/telegram/voice.d.ts +146 -0
- package/dist/telegram/voice.d.ts.map +1 -0
- package/dist/telegram/voice.js +351 -0
- package/dist/telegram/voice.js.map +1 -0
- package/dist/types/skills.d.ts +44 -0
- package/dist/types/skills.d.ts.map +1 -0
- package/dist/types/skills.js +5 -0
- package/dist/types/skills.js.map +1 -0
- package/dist/ui/banner.d.ts +18 -0
- package/dist/ui/banner.d.ts.map +1 -0
- package/dist/ui/banner.js +323 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/index.d.ts +8 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/prompts.d.ts +52 -0
- package/dist/ui/prompts.d.ts.map +1 -0
- package/dist/ui/prompts.js +72 -0
- package/dist/ui/prompts.js.map +1 -0
- package/dist/ui/theme.d.ts +82 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +142 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/utils/auth-guard.d.ts +66 -0
- package/dist/utils/auth-guard.d.ts.map +1 -0
- package/dist/utils/auth-guard.js +348 -0
- package/dist/utils/auth-guard.js.map +1 -0
- package/dist/utils/ensure-project.d.ts +11 -0
- package/dist/utils/ensure-project.d.ts.map +1 -0
- package/dist/utils/ensure-project.js +70 -0
- package/dist/utils/ensure-project.js.map +1 -0
- package/dist/utils/git.d.ts +73 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +219 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/github-auth.d.ts +54 -0
- package/dist/utils/github-auth.d.ts.map +1 -0
- package/dist/utils/github-auth.js +375 -0
- package/dist/utils/github-auth.js.map +1 -0
- package/dist/utils/github-repo.d.ts +30 -0
- package/dist/utils/github-repo.d.ts.map +1 -0
- package/dist/utils/github-repo.js +219 -0
- package/dist/utils/github-repo.js.map +1 -0
- package/dist/utils/platform-auth.d.ts +81 -0
- package/dist/utils/platform-auth.d.ts.map +1 -0
- package/dist/utils/platform-auth.js +191 -0
- package/dist/utils/platform-auth.js.map +1 -0
- package/dist/utils/project-config.d.ts +43 -0
- package/dist/utils/project-config.d.ts.map +1 -0
- package/dist/utils/project-config.js +97 -0
- package/dist/utils/project-config.js.map +1 -0
- package/dist/utils/skill-registry.d.ts +49 -0
- package/dist/utils/skill-registry.d.ts.map +1 -0
- package/dist/utils/skill-registry.js +192 -0
- package/dist/utils/skill-registry.js.map +1 -0
- package/dist/utils/wallet.d.ts +62 -0
- package/dist/utils/wallet.d.ts.map +1 -0
- package/dist/utils/wallet.js +252 -0
- package/dist/utils/wallet.js.map +1 -0
- package/dist/utils/x402-client.d.ts +86 -0
- package/dist/utils/x402-client.d.ts.map +1 -0
- package/dist/utils/x402-client.js +265 -0
- package/dist/utils/x402-client.js.map +1 -0
- package/package.json +76 -0
- package/scripts/postinstall.js +116 -0
- package/scripts/test-onboarding.sh +121 -0
- package/scripts/voice-start.sh +128 -0
- package/scripts/voice-stop.sh +33 -0
- package/template/.claude/settings.json +92 -0
- package/template/.claude/skills/agent-browser/SKILL.md +116 -0
- package/template/.claude/skills/brand-architect/SKILL.md +240 -0
- package/template/.claude/skills/brand-architect/config.yaml +137 -0
- package/template/.claude/skills/campaign-hud/config.yaml +112 -0
- package/template/.claude/skills/content-creator/SKILL.md +294 -0
- package/template/.claude/skills/debug/MULTI_AGENT.md +360 -0
- package/template/.claude/skills/debug/SKILL.md +549 -0
- package/template/.claude/skills/fly-deploy/SKILL.md +676 -0
- package/template/.claude/skills/founder-video/SKILL.md +467 -0
- package/template/.claude/skills/hud/SKILL.md +157 -0
- package/template/.claude/skills/ralph-tui/SKILL.md +210 -0
- package/template/.claude/skills/react-best-practices/AGENTS.md +2249 -0
- package/template/.claude/skills/react-best-practices/README.md +123 -0
- package/template/.claude/skills/react-best-practices/SKILL.md +125 -0
- package/template/.claude/skills/react-best-practices/metadata.json +15 -0
- package/template/.claude/skills/react-best-practices/rules/_sections.md +46 -0
- package/template/.claude/skills/react-best-practices/rules/_template.md +28 -0
- package/template/.claude/skills/react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/template/.claude/skills/react-best-practices/rules/advanced-use-latest.md +49 -0
- package/template/.claude/skills/react-best-practices/rules/async-api-routes.md +38 -0
- package/template/.claude/skills/react-best-practices/rules/async-defer-await.md +80 -0
- package/template/.claude/skills/react-best-practices/rules/async-dependencies.md +36 -0
- package/template/.claude/skills/react-best-practices/rules/async-parallel.md +28 -0
- package/template/.claude/skills/react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/template/.claude/skills/react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/template/.claude/skills/react-best-practices/rules/bundle-conditional.md +31 -0
- package/template/.claude/skills/react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/template/.claude/skills/react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/template/.claude/skills/react-best-practices/rules/bundle-preload.md +50 -0
- package/template/.claude/skills/react-best-practices/rules/client-event-listeners.md +74 -0
- package/template/.claude/skills/react-best-practices/rules/client-swr-dedup.md +56 -0
- package/template/.claude/skills/react-best-practices/rules/js-batch-dom-css.md +82 -0
- package/template/.claude/skills/react-best-practices/rules/js-cache-function-results.md +80 -0
- package/template/.claude/skills/react-best-practices/rules/js-cache-property-access.md +28 -0
- package/template/.claude/skills/react-best-practices/rules/js-cache-storage.md +70 -0
- package/template/.claude/skills/react-best-practices/rules/js-combine-iterations.md +32 -0
- package/template/.claude/skills/react-best-practices/rules/js-early-exit.md +50 -0
- package/template/.claude/skills/react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/template/.claude/skills/react-best-practices/rules/js-index-maps.md +37 -0
- package/template/.claude/skills/react-best-practices/rules/js-length-check-first.md +49 -0
- package/template/.claude/skills/react-best-practices/rules/js-min-max-loop.md +82 -0
- package/template/.claude/skills/react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/template/.claude/skills/react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-activity.md +26 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/template/.claude/skills/react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-dependencies.md +45 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-derived-state.md +29 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-memo.md +44 -0
- package/template/.claude/skills/react-best-practices/rules/rerender-transitions.md +40 -0
- package/template/.claude/skills/react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/template/.claude/skills/react-best-practices/rules/server-cache-lru.md +41 -0
- package/template/.claude/skills/react-best-practices/rules/server-cache-react.md +26 -0
- package/template/.claude/skills/react-best-practices/rules/server-parallel-fetching.md +79 -0
- package/template/.claude/skills/react-best-practices/rules/server-serialization.md +38 -0
- package/template/.claude/skills/remotion-best-practices/SKILL.md +43 -0
- package/template/.claude/skills/remotion-best-practices/rules/3d.md +86 -0
- package/template/.claude/skills/remotion-best-practices/rules/animations.md +29 -0
- package/template/.claude/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx +173 -0
- package/template/.claude/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx +100 -0
- package/template/.claude/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx +108 -0
- package/template/.claude/skills/remotion-best-practices/rules/assets.md +78 -0
- package/template/.claude/skills/remotion-best-practices/rules/audio.md +172 -0
- package/template/.claude/skills/remotion-best-practices/rules/calculate-metadata.md +104 -0
- package/template/.claude/skills/remotion-best-practices/rules/can-decode.md +75 -0
- package/template/.claude/skills/remotion-best-practices/rules/charts.md +58 -0
- package/template/.claude/skills/remotion-best-practices/rules/compositions.md +146 -0
- package/template/.claude/skills/remotion-best-practices/rules/display-captions.md +126 -0
- package/template/.claude/skills/remotion-best-practices/rules/extract-frames.md +229 -0
- package/template/.claude/skills/remotion-best-practices/rules/fonts.md +152 -0
- package/template/.claude/skills/remotion-best-practices/rules/get-audio-duration.md +58 -0
- package/template/.claude/skills/remotion-best-practices/rules/get-video-dimensions.md +68 -0
- package/template/.claude/skills/remotion-best-practices/rules/get-video-duration.md +58 -0
- package/template/.claude/skills/remotion-best-practices/rules/gifs.md +138 -0
- package/template/.claude/skills/remotion-best-practices/rules/images.md +130 -0
- package/template/.claude/skills/remotion-best-practices/rules/import-srt-captions.md +67 -0
- package/template/.claude/skills/remotion-best-practices/rules/lottie.md +68 -0
- package/template/.claude/skills/remotion-best-practices/rules/measuring-dom-nodes.md +35 -0
- package/template/.claude/skills/remotion-best-practices/rules/measuring-text.md +143 -0
- package/template/.claude/skills/remotion-best-practices/rules/sequencing.md +106 -0
- package/template/.claude/skills/remotion-best-practices/rules/tailwind.md +11 -0
- package/template/.claude/skills/remotion-best-practices/rules/text-animations.md +20 -0
- package/template/.claude/skills/remotion-best-practices/rules/timing.md +179 -0
- package/template/.claude/skills/remotion-best-practices/rules/transcribe-captions.md +19 -0
- package/template/.claude/skills/remotion-best-practices/rules/transitions.md +122 -0
- package/template/.claude/skills/remotion-best-practices/rules/trimming.md +53 -0
- package/template/.claude/skills/remotion-best-practices/rules/videos.md +171 -0
- package/template/.claude/skills/search/SKILL.md +220 -0
- package/template/.claude/skills/spec/SKILL.md +377 -0
- package/template/.claude/skills/startup/SKILL.md +310 -0
- package/template/.claude/skills/web-architect/SKILL.md +309 -0
- package/template/.claude/skills/x-algorithm/SKILL.md +305 -0
- package/template/.jfl/config.json +8 -0
- package/template/.mcp.json +11 -0
- package/template/CLAUDE.md +960 -0
- package/template/content/.gitkeep +0 -0
- package/template/context-hub +3 -0
- package/template/knowledge/BRAND_BRIEF.md +124 -0
- package/template/knowledge/BRAND_DECISIONS.md +168 -0
- package/template/knowledge/NARRATIVE.md +114 -0
- package/template/knowledge/ROADMAP.md +128 -0
- package/template/knowledge/THESIS.md +108 -0
- package/template/knowledge/VISION.md +74 -0
- package/template/knowledge/VOICE_AND_TONE.md +146 -0
- package/template/previews/.gitkeep +0 -0
- package/template/scripts/session/auto-commit.sh +245 -0
- package/template/scripts/session/auto-merge.sh +325 -0
- package/template/scripts/session/jfl-doctor.sh +587 -0
- package/template/scripts/session/session-end.sh +194 -0
- package/template/scripts/session/session-init.sh +163 -0
- package/template/scripts/session/session-sync.sh +167 -0
- package/template/scripts/session/test-context-preservation.sh +160 -0
- package/template/skills/agent-browser/SKILL.md +116 -0
- package/template/skills/brand-architect/SKILL.md +240 -0
- package/template/skills/brand-architect/config.yaml +137 -0
- package/template/skills/campaign-hud/config.yaml +112 -0
- package/template/skills/content-creator/SKILL.md +294 -0
- package/template/skills/debug/MULTI_AGENT.md +360 -0
- package/template/skills/debug/SKILL.md +549 -0
- package/template/skills/fly-deploy/SKILL.md +676 -0
- package/template/skills/founder-video/SKILL.md +467 -0
- package/template/skills/hud/SKILL.md +204 -0
- package/template/skills/ralph-tui/SKILL.md +210 -0
- package/template/skills/react-best-practices/AGENTS.md +2249 -0
- package/template/skills/react-best-practices/README.md +123 -0
- package/template/skills/react-best-practices/SKILL.md +125 -0
- package/template/skills/react-best-practices/metadata.json +15 -0
- package/template/skills/react-best-practices/rules/_sections.md +46 -0
- package/template/skills/react-best-practices/rules/_template.md +28 -0
- package/template/skills/react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/template/skills/react-best-practices/rules/advanced-use-latest.md +49 -0
- package/template/skills/react-best-practices/rules/async-api-routes.md +38 -0
- package/template/skills/react-best-practices/rules/async-defer-await.md +80 -0
- package/template/skills/react-best-practices/rules/async-dependencies.md +36 -0
- package/template/skills/react-best-practices/rules/async-parallel.md +28 -0
- package/template/skills/react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/template/skills/react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/template/skills/react-best-practices/rules/bundle-conditional.md +31 -0
- package/template/skills/react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/template/skills/react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/template/skills/react-best-practices/rules/bundle-preload.md +50 -0
- package/template/skills/react-best-practices/rules/client-event-listeners.md +74 -0
- package/template/skills/react-best-practices/rules/client-swr-dedup.md +56 -0
- package/template/skills/react-best-practices/rules/js-batch-dom-css.md +82 -0
- package/template/skills/react-best-practices/rules/js-cache-function-results.md +80 -0
- package/template/skills/react-best-practices/rules/js-cache-property-access.md +28 -0
- package/template/skills/react-best-practices/rules/js-cache-storage.md +70 -0
- package/template/skills/react-best-practices/rules/js-combine-iterations.md +32 -0
- package/template/skills/react-best-practices/rules/js-early-exit.md +50 -0
- package/template/skills/react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/template/skills/react-best-practices/rules/js-index-maps.md +37 -0
- package/template/skills/react-best-practices/rules/js-length-check-first.md +49 -0
- package/template/skills/react-best-practices/rules/js-min-max-loop.md +82 -0
- package/template/skills/react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/template/skills/react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/template/skills/react-best-practices/rules/rendering-activity.md +26 -0
- package/template/skills/react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/template/skills/react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/template/skills/react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/template/skills/react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/template/skills/react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/template/skills/react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/template/skills/react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/template/skills/react-best-practices/rules/rerender-dependencies.md +45 -0
- package/template/skills/react-best-practices/rules/rerender-derived-state.md +29 -0
- package/template/skills/react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/template/skills/react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/template/skills/react-best-practices/rules/rerender-memo.md +44 -0
- package/template/skills/react-best-practices/rules/rerender-transitions.md +40 -0
- package/template/skills/react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/template/skills/react-best-practices/rules/server-cache-lru.md +41 -0
- package/template/skills/react-best-practices/rules/server-cache-react.md +26 -0
- package/template/skills/react-best-practices/rules/server-parallel-fetching.md +79 -0
- package/template/skills/react-best-practices/rules/server-serialization.md +38 -0
- package/template/skills/remotion-best-practices/SKILL.md +43 -0
- package/template/skills/remotion-best-practices/rules/3d.md +86 -0
- package/template/skills/remotion-best-practices/rules/animations.md +29 -0
- package/template/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx +173 -0
- package/template/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx +100 -0
- package/template/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx +108 -0
- package/template/skills/remotion-best-practices/rules/assets.md +78 -0
- package/template/skills/remotion-best-practices/rules/audio.md +172 -0
- package/template/skills/remotion-best-practices/rules/calculate-metadata.md +104 -0
- package/template/skills/remotion-best-practices/rules/can-decode.md +75 -0
- package/template/skills/remotion-best-practices/rules/charts.md +58 -0
- package/template/skills/remotion-best-practices/rules/compositions.md +146 -0
- package/template/skills/remotion-best-practices/rules/display-captions.md +126 -0
- package/template/skills/remotion-best-practices/rules/extract-frames.md +229 -0
- package/template/skills/remotion-best-practices/rules/fonts.md +152 -0
- package/template/skills/remotion-best-practices/rules/get-audio-duration.md +58 -0
- package/template/skills/remotion-best-practices/rules/get-video-dimensions.md +68 -0
- package/template/skills/remotion-best-practices/rules/get-video-duration.md +58 -0
- package/template/skills/remotion-best-practices/rules/gifs.md +138 -0
- package/template/skills/remotion-best-practices/rules/images.md +130 -0
- package/template/skills/remotion-best-practices/rules/import-srt-captions.md +67 -0
- package/template/skills/remotion-best-practices/rules/lottie.md +68 -0
- package/template/skills/remotion-best-practices/rules/measuring-dom-nodes.md +35 -0
- package/template/skills/remotion-best-practices/rules/measuring-text.md +143 -0
- package/template/skills/remotion-best-practices/rules/sequencing.md +106 -0
- package/template/skills/remotion-best-practices/rules/tailwind.md +11 -0
- package/template/skills/remotion-best-practices/rules/text-animations.md +20 -0
- package/template/skills/remotion-best-practices/rules/timing.md +179 -0
- package/template/skills/remotion-best-practices/rules/transcribe-captions.md +19 -0
- package/template/skills/remotion-best-practices/rules/transitions.md +122 -0
- package/template/skills/remotion-best-practices/rules/trimming.md +53 -0
- package/template/skills/remotion-best-practices/rules/videos.md +171 -0
- package/template/skills/search/SKILL.md +220 -0
- package/template/skills/spec/SKILL.md +377 -0
- package/template/skills/startup/SKILL.md +310 -0
- package/template/skills/web-architect/SKILL.md +309 -0
- package/template/skills/x-algorithm/SKILL.md +305 -0
- package/template/suggestions/.gitkeep +0 -0
- package/template/templates/QUICKSTART_SKILL_TO_PRODUCT.md +242 -0
- package/template/templates/brand/BRAND_BRIEF.md +124 -0
- package/template/templates/brand/BRAND_DECISIONS.md +168 -0
- package/template/templates/brand/BRAND_GUIDELINES.md +251 -0
- package/template/templates/brand/VOICE_AND_TONE.md +146 -0
- package/template/templates/brand/global.css +240 -0
- package/template/templates/collaboration/CONTRIBUTOR.md +74 -0
- package/template/templates/collaboration/CRM.md +97 -0
- package/template/templates/collaboration/TASKS.md +83 -0
- package/template/templates/strategic/NARRATIVE.md +114 -0
- package/template/templates/strategic/ROADMAP.md +128 -0
- package/template/templates/strategic/THESIS.md +108 -0
- package/template/templates/strategic/VISION.md +74 -0
|
@@ -0,0 +1,4763 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import inquirer from "inquirer";
|
|
4
|
+
import { existsSync, mkdirSync, statSync, createWriteStream, unlinkSync, renameSync, readFileSync, writeFileSync, openSync, closeSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir, platform } from "os";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import https from "https";
|
|
9
|
+
import http from "http";
|
|
10
|
+
import { EventEmitter } from "events";
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import { spawn, execSync } from "child_process";
|
|
13
|
+
// @ts-ignore - node-global-key-listener doesn't have type definitions
|
|
14
|
+
import { GlobalKeyboardListener } from "node-global-key-listener";
|
|
15
|
+
// VAD Configuration
|
|
16
|
+
const VAD_SILENCE_THRESHOLD_DB = -40; // dB threshold for silence detection
|
|
17
|
+
const VAD_SILENCE_DURATION_MS = 1500; // Stop after 1.5 seconds of silence
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Voice Error Handling System
|
|
20
|
+
// =============================================================================
|
|
21
|
+
/**
|
|
22
|
+
* Error types for voice-related failures
|
|
23
|
+
*/
|
|
24
|
+
export var VoiceErrorType;
|
|
25
|
+
(function (VoiceErrorType) {
|
|
26
|
+
VoiceErrorType["SERVER_NOT_RUNNING"] = "SERVER_NOT_RUNNING";
|
|
27
|
+
VoiceErrorType["MIC_UNAVAILABLE"] = "MIC_UNAVAILABLE";
|
|
28
|
+
VoiceErrorType["CONNECTION_DROPPED"] = "CONNECTION_DROPPED";
|
|
29
|
+
VoiceErrorType["TRANSCRIPTION_EMPTY"] = "TRANSCRIPTION_EMPTY";
|
|
30
|
+
VoiceErrorType["PERMISSION_DENIED"] = "PERMISSION_DENIED";
|
|
31
|
+
VoiceErrorType["MODEL_NOT_FOUND"] = "MODEL_NOT_FOUND";
|
|
32
|
+
VoiceErrorType["AUTH_FAILED"] = "AUTH_FAILED";
|
|
33
|
+
VoiceErrorType["SOX_NOT_INSTALLED"] = "SOX_NOT_INSTALLED";
|
|
34
|
+
VoiceErrorType["RECORDING_FAILED"] = "RECORDING_FAILED";
|
|
35
|
+
VoiceErrorType["TIMEOUT"] = "TIMEOUT";
|
|
36
|
+
VoiceErrorType["ACCESSIBILITY_NOT_GRANTED"] = "ACCESSIBILITY_NOT_GRANTED";
|
|
37
|
+
VoiceErrorType["PLATFORM_NOT_SUPPORTED"] = "PLATFORM_NOT_SUPPORTED";
|
|
38
|
+
VoiceErrorType["WAYLAND_NOT_SUPPORTED"] = "WAYLAND_NOT_SUPPORTED";
|
|
39
|
+
})(VoiceErrorType || (VoiceErrorType = {}));
|
|
40
|
+
/**
|
|
41
|
+
* Error messages and recovery suggestions for each error type
|
|
42
|
+
*/
|
|
43
|
+
const VOICE_ERROR_MESSAGES = {
|
|
44
|
+
[VoiceErrorType.SERVER_NOT_RUNNING]: {
|
|
45
|
+
message: "Voice server is not running.",
|
|
46
|
+
suggestions: [
|
|
47
|
+
"Start the server with: jfl voice server start",
|
|
48
|
+
"Check if another process is using the port",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
[VoiceErrorType.MIC_UNAVAILABLE]: {
|
|
52
|
+
message: "Microphone not available.",
|
|
53
|
+
suggestions: [
|
|
54
|
+
platform() === "darwin"
|
|
55
|
+
? "Check Settings > Privacy & Security > Microphone"
|
|
56
|
+
: platform() === "win32"
|
|
57
|
+
? "Check Settings > Privacy > Microphone"
|
|
58
|
+
: "Check your audio settings and permissions",
|
|
59
|
+
"Ensure your microphone is connected and enabled",
|
|
60
|
+
"Try selecting a different device with: jfl voice devices",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
[VoiceErrorType.CONNECTION_DROPPED]: {
|
|
64
|
+
message: "Connection to voice server lost.",
|
|
65
|
+
suggestions: [
|
|
66
|
+
"Check if the server is still running: jfl voice server status",
|
|
67
|
+
"Restart the server: jfl voice server start",
|
|
68
|
+
"Check your network connection",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
[VoiceErrorType.TRANSCRIPTION_EMPTY]: {
|
|
72
|
+
message: "No speech detected.",
|
|
73
|
+
suggestions: [
|
|
74
|
+
"Try speaking louder or more clearly",
|
|
75
|
+
"Move closer to your microphone",
|
|
76
|
+
"Check your microphone is working: jfl voice test",
|
|
77
|
+
"Ensure background noise is minimized",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
[VoiceErrorType.PERMISSION_DENIED]: {
|
|
81
|
+
message: "Microphone permission denied.",
|
|
82
|
+
suggestions: platform() === "darwin"
|
|
83
|
+
? [
|
|
84
|
+
"Open System Settings > Privacy & Security > Microphone",
|
|
85
|
+
"Grant permission to Terminal (or your terminal app)",
|
|
86
|
+
"You may need to restart your terminal after granting permission",
|
|
87
|
+
]
|
|
88
|
+
: platform() === "win32"
|
|
89
|
+
? [
|
|
90
|
+
"Open Settings > Privacy > Microphone",
|
|
91
|
+
"Enable 'Allow apps to access your microphone'",
|
|
92
|
+
"Ensure your terminal app is allowed",
|
|
93
|
+
]
|
|
94
|
+
: [
|
|
95
|
+
"Check your system's audio permissions",
|
|
96
|
+
"On some systems, run: sudo usermod -aG audio $USER",
|
|
97
|
+
"Then log out and log back in",
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
[VoiceErrorType.MODEL_NOT_FOUND]: {
|
|
101
|
+
message: "Whisper model not found.",
|
|
102
|
+
suggestions: [
|
|
103
|
+
"Download a model with: jfl voice model download base",
|
|
104
|
+
"List available models: jfl voice model list",
|
|
105
|
+
"Run setup wizard: jfl voice setup",
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
[VoiceErrorType.AUTH_FAILED]: {
|
|
109
|
+
message: "Authentication failed. Server token may have changed.",
|
|
110
|
+
suggestions: [
|
|
111
|
+
"Restart the voice server: jfl voice server start",
|
|
112
|
+
"If the issue persists, delete ~/.jfl/voice-server.token and restart",
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
[VoiceErrorType.SOX_NOT_INSTALLED]: {
|
|
116
|
+
message: "Audio recording tool (sox) not found.",
|
|
117
|
+
suggestions: platform() === "darwin"
|
|
118
|
+
? ["Install with: brew install sox"]
|
|
119
|
+
: platform() === "win32"
|
|
120
|
+
? ["Install with: choco install sox.portable"]
|
|
121
|
+
: [
|
|
122
|
+
"Install with: sudo apt-get install sox libsox-fmt-all",
|
|
123
|
+
"Or for Fedora/RHEL: sudo dnf install sox",
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
[VoiceErrorType.RECORDING_FAILED]: {
|
|
127
|
+
message: "Failed to start recording.",
|
|
128
|
+
suggestions: [
|
|
129
|
+
"Check microphone connection",
|
|
130
|
+
"Try a different audio device: jfl voice devices",
|
|
131
|
+
"Check if another application is using the microphone",
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
[VoiceErrorType.TIMEOUT]: {
|
|
135
|
+
message: "Operation timed out.",
|
|
136
|
+
suggestions: [
|
|
137
|
+
"The server may be overloaded - try again",
|
|
138
|
+
"Check server status: jfl voice server status",
|
|
139
|
+
"For large audio files, the model may need more time",
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
[VoiceErrorType.ACCESSIBILITY_NOT_GRANTED]: {
|
|
143
|
+
message: "Accessibility permission not granted.",
|
|
144
|
+
suggestions: [
|
|
145
|
+
"Open System Settings > Privacy & Security > Accessibility",
|
|
146
|
+
"Add your terminal app (Terminal, iTerm2, etc.) to the allowed list",
|
|
147
|
+
"Toggle the permission off and on if already added",
|
|
148
|
+
"You may need to restart your terminal after granting permission",
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
[VoiceErrorType.PLATFORM_NOT_SUPPORTED]: {
|
|
152
|
+
message: "Global hotkey is not supported on this platform.",
|
|
153
|
+
suggestions: [
|
|
154
|
+
"Use 'jfl voice' for manual recording",
|
|
155
|
+
"Supported platforms: macOS, Linux (X11), Windows 10/11",
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
[VoiceErrorType.WAYLAND_NOT_SUPPORTED]: {
|
|
159
|
+
message: "Global hotkey is not supported on Wayland.",
|
|
160
|
+
suggestions: [
|
|
161
|
+
"Use 'jfl voice' for manual recording instead",
|
|
162
|
+
"Switch to an X11 session for hotkey support",
|
|
163
|
+
"Or use an X11-based desktop environment (GNOME on X11, KDE on X11)",
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Custom error class for voice-related errors
|
|
169
|
+
*/
|
|
170
|
+
export class VoiceError extends Error {
|
|
171
|
+
type;
|
|
172
|
+
originalError;
|
|
173
|
+
context;
|
|
174
|
+
recoverable;
|
|
175
|
+
audioBuffer; // Preserved audio for retry
|
|
176
|
+
constructor(type, options) {
|
|
177
|
+
const errorInfo = VOICE_ERROR_MESSAGES[type];
|
|
178
|
+
super(errorInfo.message);
|
|
179
|
+
this.name = "VoiceError";
|
|
180
|
+
this.type = type;
|
|
181
|
+
this.originalError = options?.originalError;
|
|
182
|
+
this.context = options?.context;
|
|
183
|
+
this.recoverable = options?.recoverable ?? false;
|
|
184
|
+
this.audioBuffer = options?.audioBuffer;
|
|
185
|
+
// Maintain proper stack trace
|
|
186
|
+
if (Error.captureStackTrace) {
|
|
187
|
+
Error.captureStackTrace(this, VoiceError);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get user-friendly suggestions for recovering from this error
|
|
192
|
+
*/
|
|
193
|
+
getSuggestions() {
|
|
194
|
+
return VOICE_ERROR_MESSAGES[this.type].suggestions;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get full error message including original error if present
|
|
198
|
+
*/
|
|
199
|
+
getFullMessage() {
|
|
200
|
+
let msg = this.message;
|
|
201
|
+
if (this.originalError) {
|
|
202
|
+
msg += ` (${this.originalError.message})`;
|
|
203
|
+
}
|
|
204
|
+
return msg;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Display a voice error with formatted output and suggestions
|
|
209
|
+
*/
|
|
210
|
+
export function handleVoiceError(error, spinner) {
|
|
211
|
+
// Stop spinner if active
|
|
212
|
+
if (spinner) {
|
|
213
|
+
spinner.stop();
|
|
214
|
+
}
|
|
215
|
+
// Handle VoiceError with full formatting
|
|
216
|
+
if (error instanceof VoiceError) {
|
|
217
|
+
console.log();
|
|
218
|
+
console.log(chalk.red(` ✗ ${error.getFullMessage()}`));
|
|
219
|
+
console.log();
|
|
220
|
+
const suggestions = error.getSuggestions();
|
|
221
|
+
if (suggestions.length > 0) {
|
|
222
|
+
console.log(chalk.yellow(" Suggestions:"));
|
|
223
|
+
for (const suggestion of suggestions) {
|
|
224
|
+
console.log(chalk.gray(` - ${suggestion}`));
|
|
225
|
+
}
|
|
226
|
+
console.log();
|
|
227
|
+
}
|
|
228
|
+
// Show additional context if in debug mode
|
|
229
|
+
if (process.env.DEBUG && error.context) {
|
|
230
|
+
console.log(chalk.gray(" Debug context:"));
|
|
231
|
+
console.log(chalk.gray(` ${JSON.stringify(error.context, null, 2)}`));
|
|
232
|
+
console.log();
|
|
233
|
+
}
|
|
234
|
+
// Indicate if the error is recoverable
|
|
235
|
+
if (error.recoverable && error.audioBuffer) {
|
|
236
|
+
console.log(chalk.cyan(" Audio has been preserved. You can retry the transcription."));
|
|
237
|
+
console.log();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Handle generic errors
|
|
242
|
+
console.log();
|
|
243
|
+
console.log(chalk.red(` ✗ Error: ${error.message}`));
|
|
244
|
+
console.log();
|
|
245
|
+
// Try to infer error type from message and provide suggestions
|
|
246
|
+
const inferredSuggestions = inferErrorSuggestions(error);
|
|
247
|
+
if (inferredSuggestions.length > 0) {
|
|
248
|
+
console.log(chalk.yellow(" Suggestions:"));
|
|
249
|
+
for (const suggestion of inferredSuggestions) {
|
|
250
|
+
console.log(chalk.gray(` - ${suggestion}`));
|
|
251
|
+
}
|
|
252
|
+
console.log();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Try to infer helpful suggestions from a generic error
|
|
258
|
+
*/
|
|
259
|
+
function inferErrorSuggestions(error) {
|
|
260
|
+
const message = error.message.toLowerCase();
|
|
261
|
+
if (message.includes("enoent") || message.includes("not found")) {
|
|
262
|
+
if (message.includes("sock") || message.includes("socket")) {
|
|
263
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.SERVER_NOT_RUNNING].suggestions;
|
|
264
|
+
}
|
|
265
|
+
if (message.includes("model") || message.includes("ggml")) {
|
|
266
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.MODEL_NOT_FOUND].suggestions;
|
|
267
|
+
}
|
|
268
|
+
if (message.includes("sox") || message.includes("rec")) {
|
|
269
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.SOX_NOT_INSTALLED].suggestions;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (message.includes("permission") || message.includes("denied") || message.includes("access")) {
|
|
273
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.PERMISSION_DENIED].suggestions;
|
|
274
|
+
}
|
|
275
|
+
if (message.includes("auth") || message.includes("token") || message.includes("401")) {
|
|
276
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.AUTH_FAILED].suggestions;
|
|
277
|
+
}
|
|
278
|
+
if (message.includes("timeout") || message.includes("timed out")) {
|
|
279
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.TIMEOUT].suggestions;
|
|
280
|
+
}
|
|
281
|
+
if (message.includes("connection") || message.includes("connect") || message.includes("econnrefused")) {
|
|
282
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.CONNECTION_DROPPED].suggestions;
|
|
283
|
+
}
|
|
284
|
+
if (message.includes("microphone") || message.includes("mic") || message.includes("audio")) {
|
|
285
|
+
return VOICE_ERROR_MESSAGES[VoiceErrorType.MIC_UNAVAILABLE].suggestions;
|
|
286
|
+
}
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Create appropriate VoiceError based on error analysis
|
|
291
|
+
*/
|
|
292
|
+
export function createVoiceError(error, context, audioBuffer) {
|
|
293
|
+
const message = error.message.toLowerCase();
|
|
294
|
+
// Determine error type from message patterns
|
|
295
|
+
let type;
|
|
296
|
+
if (message.includes("socket not found") || message.includes("voice server socket")) {
|
|
297
|
+
type = VoiceErrorType.SERVER_NOT_RUNNING;
|
|
298
|
+
}
|
|
299
|
+
else if (message.includes("auth") || message.includes("token") || message.includes("unauthorized")) {
|
|
300
|
+
type = VoiceErrorType.AUTH_FAILED;
|
|
301
|
+
}
|
|
302
|
+
else if (message.includes("permission") || message.includes("denied")) {
|
|
303
|
+
type = VoiceErrorType.PERMISSION_DENIED;
|
|
304
|
+
}
|
|
305
|
+
else if (message.includes("model") && (message.includes("not found") || message.includes("missing"))) {
|
|
306
|
+
type = VoiceErrorType.MODEL_NOT_FOUND;
|
|
307
|
+
}
|
|
308
|
+
else if (message.includes("no speech") || message.includes("empty transcript")) {
|
|
309
|
+
type = VoiceErrorType.TRANSCRIPTION_EMPTY;
|
|
310
|
+
}
|
|
311
|
+
else if (message.includes("connection") || message.includes("disconnect")) {
|
|
312
|
+
type = VoiceErrorType.CONNECTION_DROPPED;
|
|
313
|
+
}
|
|
314
|
+
else if (message.includes("sox") || message.includes("no audio recording tool")) {
|
|
315
|
+
type = VoiceErrorType.SOX_NOT_INSTALLED;
|
|
316
|
+
}
|
|
317
|
+
else if (message.includes("microphone") || message.includes("audio device")) {
|
|
318
|
+
type = VoiceErrorType.MIC_UNAVAILABLE;
|
|
319
|
+
}
|
|
320
|
+
else if (message.includes("timeout")) {
|
|
321
|
+
type = VoiceErrorType.TIMEOUT;
|
|
322
|
+
}
|
|
323
|
+
else if (message.includes("record")) {
|
|
324
|
+
type = VoiceErrorType.RECORDING_FAILED;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// Default to recording failed for unknown errors
|
|
328
|
+
type = VoiceErrorType.RECORDING_FAILED;
|
|
329
|
+
}
|
|
330
|
+
// Determine if recoverable (has audio buffer for retry)
|
|
331
|
+
const recoverable = audioBuffer !== undefined && audioBuffer.length > 0;
|
|
332
|
+
return new VoiceError(type, {
|
|
333
|
+
originalError: error,
|
|
334
|
+
context,
|
|
335
|
+
recoverable,
|
|
336
|
+
audioBuffer,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Check if server is running and return appropriate error if not
|
|
341
|
+
* Checks both TCP port 9090 and Unix socket for backwards compatibility
|
|
342
|
+
*/
|
|
343
|
+
export function checkServerRunning() {
|
|
344
|
+
// Check if server PID file exists and process is running
|
|
345
|
+
const pidPath = join(getJflDir(), "voice-server.pid");
|
|
346
|
+
if (existsSync(pidPath)) {
|
|
347
|
+
try {
|
|
348
|
+
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
349
|
+
// Check if process is running by sending signal 0
|
|
350
|
+
process.kill(pid, 0);
|
|
351
|
+
return null; // Server is running
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// PID file exists but process not running
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Fallback: check Unix socket for backwards compatibility
|
|
358
|
+
const socketPath = getVoiceSocketPath();
|
|
359
|
+
if (existsSync(socketPath)) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return new VoiceError(VoiceErrorType.SERVER_NOT_RUNNING);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Check if auth token exists and return appropriate error if not
|
|
366
|
+
*/
|
|
367
|
+
export function checkAuthToken() {
|
|
368
|
+
const token = readAuthToken();
|
|
369
|
+
if (!token) {
|
|
370
|
+
return new VoiceError(VoiceErrorType.AUTH_FAILED, {
|
|
371
|
+
context: { reason: "Token file not found or empty" },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Check if a model is available and return appropriate error if not
|
|
378
|
+
*/
|
|
379
|
+
export function checkModelAvailable(modelName) {
|
|
380
|
+
const model = modelName || getCurrentDefaultModel();
|
|
381
|
+
if (!isModelDownloaded(model)) {
|
|
382
|
+
return new VoiceError(VoiceErrorType.MODEL_NOT_FOUND, {
|
|
383
|
+
context: { modelName: model },
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
// Whisper model definitions
|
|
389
|
+
// Source: https://huggingface.co/ggerganov/whisper.cpp
|
|
390
|
+
const WHISPER_MODELS = {
|
|
391
|
+
tiny: {
|
|
392
|
+
name: "tiny",
|
|
393
|
+
displayName: "Tiny",
|
|
394
|
+
size: "75 MB",
|
|
395
|
+
sizeBytes: 75_000_000,
|
|
396
|
+
description: "Fastest, lowest accuracy. Good for quick testing.",
|
|
397
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
|
|
398
|
+
sha256: "be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21",
|
|
399
|
+
},
|
|
400
|
+
"tiny.en": {
|
|
401
|
+
name: "tiny.en",
|
|
402
|
+
displayName: "Tiny (English)",
|
|
403
|
+
size: "75 MB",
|
|
404
|
+
sizeBytes: 75_000_000,
|
|
405
|
+
description: "English-only tiny model. Slightly better for English.",
|
|
406
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
|
407
|
+
sha256: "921e4cf8b0c2c68d26b626b8b0adfe5f188ccd0e42f74ea3a3c4a02313978c93",
|
|
408
|
+
},
|
|
409
|
+
base: {
|
|
410
|
+
name: "base",
|
|
411
|
+
displayName: "Base",
|
|
412
|
+
size: "142 MB",
|
|
413
|
+
sizeBytes: 142_000_000,
|
|
414
|
+
description: "Balanced speed/accuracy. Recommended default.",
|
|
415
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
|
|
416
|
+
sha256: "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe",
|
|
417
|
+
},
|
|
418
|
+
"base.en": {
|
|
419
|
+
name: "base.en",
|
|
420
|
+
displayName: "Base (English)",
|
|
421
|
+
size: "142 MB",
|
|
422
|
+
sizeBytes: 142_000_000,
|
|
423
|
+
description: "English-only base model.",
|
|
424
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
|
425
|
+
sha256: "a03779c86df3323075f5e796b3f6af1e6faa6a45b5eb1ef6c3fba57b4ccd0f66",
|
|
426
|
+
},
|
|
427
|
+
small: {
|
|
428
|
+
name: "small",
|
|
429
|
+
displayName: "Small",
|
|
430
|
+
size: "466 MB",
|
|
431
|
+
sizeBytes: 466_000_000,
|
|
432
|
+
description: "Higher accuracy, slower. Good for important transcriptions.",
|
|
433
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
|
|
434
|
+
sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1c8c6f0da8",
|
|
435
|
+
},
|
|
436
|
+
"small.en": {
|
|
437
|
+
name: "small.en",
|
|
438
|
+
displayName: "Small (English)",
|
|
439
|
+
size: "466 MB",
|
|
440
|
+
sizeBytes: 466_000_000,
|
|
441
|
+
description: "English-only small model.",
|
|
442
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
|
443
|
+
sha256: "db8a495a91d927739e50b3fc1830cbe8b5d3ce7c499c1ab2c1a4d508d4f5bede",
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
const DEFAULT_MODEL = "base";
|
|
447
|
+
// Get JFL directory
|
|
448
|
+
export function getJflDir() {
|
|
449
|
+
return join(homedir(), ".jfl");
|
|
450
|
+
}
|
|
451
|
+
// Get models directory
|
|
452
|
+
function getModelsDir() {
|
|
453
|
+
return join(getJflDir(), "models");
|
|
454
|
+
}
|
|
455
|
+
// Get voice config path
|
|
456
|
+
function getVoiceConfigPath() {
|
|
457
|
+
return join(getJflDir(), "voice.yaml");
|
|
458
|
+
}
|
|
459
|
+
// Get voice socket path
|
|
460
|
+
function getVoiceSocketPath() {
|
|
461
|
+
return join(getJflDir(), "voice.sock");
|
|
462
|
+
}
|
|
463
|
+
// Get voice server token path
|
|
464
|
+
function getVoiceTokenPath() {
|
|
465
|
+
return join(getJflDir(), "voice-server.token");
|
|
466
|
+
}
|
|
467
|
+
// Get voice daemon PID file path
|
|
468
|
+
function getVoiceDaemonPidPath() {
|
|
469
|
+
return join(getJflDir(), "voice-daemon.pid");
|
|
470
|
+
}
|
|
471
|
+
// Get voice daemon log file path
|
|
472
|
+
function getVoiceDaemonLogPath() {
|
|
473
|
+
return join(getJflDir(), "voice-daemon.log");
|
|
474
|
+
}
|
|
475
|
+
// Read auth token from file
|
|
476
|
+
export function readAuthToken() {
|
|
477
|
+
const tokenPath = getVoiceTokenPath();
|
|
478
|
+
if (!existsSync(tokenPath)) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
return readFileSync(tokenPath, "utf-8").trim();
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Ensure directories exist
|
|
489
|
+
function ensureDirectories() {
|
|
490
|
+
const jflDir = getJflDir();
|
|
491
|
+
const modelsDir = getModelsDir();
|
|
492
|
+
if (!existsSync(jflDir)) {
|
|
493
|
+
mkdirSync(jflDir, { mode: 0o700 });
|
|
494
|
+
}
|
|
495
|
+
if (!existsSync(modelsDir)) {
|
|
496
|
+
mkdirSync(modelsDir, { mode: 0o755 });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Get path for a model file
|
|
500
|
+
function getModelPath(modelName) {
|
|
501
|
+
return join(getModelsDir(), `ggml-${modelName}.bin`);
|
|
502
|
+
}
|
|
503
|
+
// Get path for partial download file
|
|
504
|
+
function getPartialPath(modelName) {
|
|
505
|
+
return join(getModelsDir(), `ggml-${modelName}.bin.partial`);
|
|
506
|
+
}
|
|
507
|
+
// Check if model is downloaded
|
|
508
|
+
function isModelDownloaded(modelName) {
|
|
509
|
+
const modelPath = getModelPath(modelName);
|
|
510
|
+
if (!existsSync(modelPath))
|
|
511
|
+
return false;
|
|
512
|
+
const model = WHISPER_MODELS[modelName];
|
|
513
|
+
if (!model)
|
|
514
|
+
return false;
|
|
515
|
+
// Check file size is approximately correct (within 1%)
|
|
516
|
+
const stats = statSync(modelPath);
|
|
517
|
+
const expectedSize = model.sizeBytes;
|
|
518
|
+
const tolerance = expectedSize * 0.01;
|
|
519
|
+
return Math.abs(stats.size - expectedSize) < tolerance;
|
|
520
|
+
}
|
|
521
|
+
// Get current default model from config
|
|
522
|
+
function getCurrentDefaultModel() {
|
|
523
|
+
const configPath = getVoiceConfigPath();
|
|
524
|
+
if (!existsSync(configPath))
|
|
525
|
+
return DEFAULT_MODEL;
|
|
526
|
+
try {
|
|
527
|
+
const content = readFileSync(configPath, "utf-8");
|
|
528
|
+
const match = content.match(/^model:\s*(\S+)/m);
|
|
529
|
+
return match ? match[1] : DEFAULT_MODEL;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return DEFAULT_MODEL;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Set default model in config
|
|
536
|
+
function setDefaultModel(modelName) {
|
|
537
|
+
const configPath = getVoiceConfigPath();
|
|
538
|
+
ensureDirectories();
|
|
539
|
+
let content = "";
|
|
540
|
+
if (existsSync(configPath)) {
|
|
541
|
+
content = readFileSync(configPath, "utf-8");
|
|
542
|
+
if (content.match(/^model:/m)) {
|
|
543
|
+
content = content.replace(/^model:\s*\S+/m, `model: ${modelName}`);
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
content = `model: ${modelName}\n${content}`;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
content = `# JFL Voice Configuration\nmodel: ${modelName}\n`;
|
|
551
|
+
}
|
|
552
|
+
writeFileSync(configPath, content, { mode: 0o644 });
|
|
553
|
+
}
|
|
554
|
+
// Format bytes to human readable
|
|
555
|
+
function formatBytes(bytes) {
|
|
556
|
+
if (bytes < 1024)
|
|
557
|
+
return `${bytes} B`;
|
|
558
|
+
if (bytes < 1024 * 1024)
|
|
559
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
560
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
561
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
562
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
563
|
+
}
|
|
564
|
+
// Download file with progress and resume support
|
|
565
|
+
async function downloadFile(url, destPath, partialPath, expectedSize, onProgress) {
|
|
566
|
+
return new Promise((resolve, reject) => {
|
|
567
|
+
// Check for existing partial download
|
|
568
|
+
let startByte = 0;
|
|
569
|
+
if (existsSync(partialPath)) {
|
|
570
|
+
const stats = statSync(partialPath);
|
|
571
|
+
startByte = stats.size;
|
|
572
|
+
}
|
|
573
|
+
const headers = {};
|
|
574
|
+
if (startByte > 0) {
|
|
575
|
+
headers["Range"] = `bytes=${startByte}-`;
|
|
576
|
+
}
|
|
577
|
+
const protocol = url.startsWith("https") ? https : http;
|
|
578
|
+
const request = protocol.get(url, { headers }, (response) => {
|
|
579
|
+
// Handle redirects
|
|
580
|
+
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) {
|
|
581
|
+
const redirectUrl = response.headers.location;
|
|
582
|
+
if (redirectUrl) {
|
|
583
|
+
downloadFile(redirectUrl, destPath, partialPath, expectedSize, onProgress)
|
|
584
|
+
.then(resolve)
|
|
585
|
+
.catch(reject);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Handle 416 Range Not Satisfiable (file already complete)
|
|
590
|
+
if (response.statusCode === 416) {
|
|
591
|
+
if (existsSync(partialPath)) {
|
|
592
|
+
renameSync(partialPath, destPath);
|
|
593
|
+
}
|
|
594
|
+
resolve();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Check for successful response
|
|
598
|
+
if (response.statusCode !== 200 && response.statusCode !== 206) {
|
|
599
|
+
reject(new Error(`Download failed with status ${response.statusCode}`));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const totalSize = response.statusCode === 206
|
|
603
|
+
? expectedSize
|
|
604
|
+
: parseInt(response.headers["content-length"] || "0", 10);
|
|
605
|
+
const fileStream = createWriteStream(partialPath, {
|
|
606
|
+
flags: startByte > 0 ? "a" : "w",
|
|
607
|
+
mode: 0o644,
|
|
608
|
+
});
|
|
609
|
+
let downloaded = startByte;
|
|
610
|
+
response.on("data", (chunk) => {
|
|
611
|
+
downloaded += chunk.length;
|
|
612
|
+
onProgress(downloaded, totalSize || expectedSize);
|
|
613
|
+
});
|
|
614
|
+
response.pipe(fileStream);
|
|
615
|
+
fileStream.on("finish", () => {
|
|
616
|
+
fileStream.close();
|
|
617
|
+
// Move partial to final destination
|
|
618
|
+
renameSync(partialPath, destPath);
|
|
619
|
+
resolve();
|
|
620
|
+
});
|
|
621
|
+
fileStream.on("error", (err) => {
|
|
622
|
+
fileStream.close();
|
|
623
|
+
reject(err);
|
|
624
|
+
});
|
|
625
|
+
response.on("error", (err) => {
|
|
626
|
+
fileStream.close();
|
|
627
|
+
reject(err);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
request.on("error", (err) => {
|
|
631
|
+
reject(err);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
// Verify downloaded file SHA256
|
|
636
|
+
function verifyChecksum(filePath, expectedHash) {
|
|
637
|
+
const fileBuffer = readFileSync(filePath);
|
|
638
|
+
const hash = createHash("sha256").update(fileBuffer).digest("hex");
|
|
639
|
+
return hash === expectedHash;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* VoiceClient - Manages WebSocket connection to the whisper server
|
|
643
|
+
*
|
|
644
|
+
* Features:
|
|
645
|
+
* - Connects to Unix socket at ~/.jfl/voice.sock
|
|
646
|
+
* - Authenticates with token from ~/.jfl/voice-server.token
|
|
647
|
+
* - Streams 16-bit PCM audio at 16kHz mono
|
|
648
|
+
* - Receives partial and final transcripts
|
|
649
|
+
* - Auto-reconnects with exponential backoff
|
|
650
|
+
*
|
|
651
|
+
* Usage:
|
|
652
|
+
* ```typescript
|
|
653
|
+
* const client = new VoiceClient()
|
|
654
|
+
*
|
|
655
|
+
* client.onTranscript((text, isFinal) => {
|
|
656
|
+
* if (isFinal) console.log("Final:", text)
|
|
657
|
+
* else console.log("Partial:", text)
|
|
658
|
+
* })
|
|
659
|
+
*
|
|
660
|
+
* client.onError((error) => {
|
|
661
|
+
* console.error("Error:", error.message)
|
|
662
|
+
* })
|
|
663
|
+
*
|
|
664
|
+
* await client.connect()
|
|
665
|
+
*
|
|
666
|
+
* // Send audio chunks (16-bit PCM, 16kHz mono)
|
|
667
|
+
* client.sendAudio(audioBuffer)
|
|
668
|
+
*
|
|
669
|
+
* // When done
|
|
670
|
+
* client.disconnect()
|
|
671
|
+
* ```
|
|
672
|
+
*/
|
|
673
|
+
export class VoiceClient extends EventEmitter {
|
|
674
|
+
ws = null;
|
|
675
|
+
serverUrl;
|
|
676
|
+
authToken;
|
|
677
|
+
state = "disconnected";
|
|
678
|
+
reconnectAttempts = 0;
|
|
679
|
+
reconnectTimer = null;
|
|
680
|
+
shouldReconnect = false;
|
|
681
|
+
// Configuration
|
|
682
|
+
maxReconnectAttempts;
|
|
683
|
+
initialReconnectDelay;
|
|
684
|
+
maxReconnectDelay;
|
|
685
|
+
// Callback holders
|
|
686
|
+
transcriptCallbacks = [];
|
|
687
|
+
errorCallbacks = [];
|
|
688
|
+
connectionCallbacks = [];
|
|
689
|
+
constructor(options = {}) {
|
|
690
|
+
super();
|
|
691
|
+
// Prefer direct TCP connection over Unix socket for better binary data handling
|
|
692
|
+
this.serverUrl = options.serverUrl ?? "ws://127.0.0.1:9090";
|
|
693
|
+
this.authToken = options.authToken ?? null;
|
|
694
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
695
|
+
this.initialReconnectDelay = options.initialReconnectDelay ?? 1000;
|
|
696
|
+
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Get the current connection state
|
|
700
|
+
*/
|
|
701
|
+
getState() {
|
|
702
|
+
return this.state;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Check if connected
|
|
706
|
+
*/
|
|
707
|
+
isConnected() {
|
|
708
|
+
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Connect to the whisper server
|
|
712
|
+
*
|
|
713
|
+
* @throws VoiceError if auth token is missing (AUTH_FAILED)
|
|
714
|
+
* @throws VoiceError if connection fails
|
|
715
|
+
*/
|
|
716
|
+
async connect() {
|
|
717
|
+
if (this.state === "connected" || this.state === "connecting") {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// Get auth token if not provided
|
|
721
|
+
if (!this.authToken) {
|
|
722
|
+
this.authToken = readAuthToken();
|
|
723
|
+
}
|
|
724
|
+
if (!this.authToken) {
|
|
725
|
+
throw new VoiceError(VoiceErrorType.AUTH_FAILED, {
|
|
726
|
+
context: { tokenPath: getVoiceTokenPath(), reason: "Token not found" },
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
this.setState("connecting");
|
|
730
|
+
this.shouldReconnect = true;
|
|
731
|
+
this.reconnectAttempts = 0;
|
|
732
|
+
return this.doConnect();
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Internal connection method
|
|
736
|
+
*/
|
|
737
|
+
doConnect() {
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
try {
|
|
740
|
+
// Create WebSocket connection via TCP
|
|
741
|
+
// Server expects token as query parameter for WebSocket connections
|
|
742
|
+
const url = `${this.serverUrl}/?token=${this.authToken}`;
|
|
743
|
+
this.ws = new WebSocket(url);
|
|
744
|
+
const connectionTimeout = setTimeout(() => {
|
|
745
|
+
if (this.state === "connecting") {
|
|
746
|
+
this.ws?.terminate();
|
|
747
|
+
const error = new Error("Connection timeout");
|
|
748
|
+
this.handleError(error);
|
|
749
|
+
reject(error);
|
|
750
|
+
}
|
|
751
|
+
}, 10000);
|
|
752
|
+
this.ws.on("open", () => {
|
|
753
|
+
clearTimeout(connectionTimeout);
|
|
754
|
+
this.setState("connected");
|
|
755
|
+
this.reconnectAttempts = 0;
|
|
756
|
+
this.emit("connected");
|
|
757
|
+
resolve();
|
|
758
|
+
});
|
|
759
|
+
this.ws.on("message", (data) => {
|
|
760
|
+
this.handleMessage(data);
|
|
761
|
+
});
|
|
762
|
+
this.ws.on("error", (error) => {
|
|
763
|
+
clearTimeout(connectionTimeout);
|
|
764
|
+
this.handleError(error);
|
|
765
|
+
if (this.state === "connecting") {
|
|
766
|
+
reject(error);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
this.ws.on("close", (code, reason) => {
|
|
770
|
+
clearTimeout(connectionTimeout);
|
|
771
|
+
this.handleClose(code, reason.toString());
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
this.setState("disconnected");
|
|
776
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
777
|
+
this.handleError(err);
|
|
778
|
+
reject(err);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Disconnect from the whisper server
|
|
784
|
+
*/
|
|
785
|
+
disconnect() {
|
|
786
|
+
this.shouldReconnect = false;
|
|
787
|
+
this.clearReconnectTimer();
|
|
788
|
+
if (this.ws) {
|
|
789
|
+
// Remove listeners to prevent reconnection attempts
|
|
790
|
+
this.ws.removeAllListeners();
|
|
791
|
+
if (this.ws.readyState === WebSocket.OPEN ||
|
|
792
|
+
this.ws.readyState === WebSocket.CONNECTING) {
|
|
793
|
+
this.ws.close(1000, "Client disconnect");
|
|
794
|
+
}
|
|
795
|
+
this.ws = null;
|
|
796
|
+
}
|
|
797
|
+
this.setState("disconnected");
|
|
798
|
+
this.emit("disconnected");
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Send audio data to the whisper server
|
|
802
|
+
*
|
|
803
|
+
* @param buffer - 16-bit PCM audio data at 16kHz mono
|
|
804
|
+
* @throws Error if not connected
|
|
805
|
+
*/
|
|
806
|
+
sendAudio(buffer) {
|
|
807
|
+
if (!this.isConnected()) {
|
|
808
|
+
throw new Error("Not connected to voice server");
|
|
809
|
+
}
|
|
810
|
+
if (!this.ws) {
|
|
811
|
+
throw new Error("WebSocket is null");
|
|
812
|
+
}
|
|
813
|
+
// WORKAROUND: Node.js 25 + ws library bug
|
|
814
|
+
// Buffer.concat() creates buffers that don't send properly via WebSocket
|
|
815
|
+
// Writing to a temp file and reading back creates a buffer that works
|
|
816
|
+
// This is a known issue with certain Buffer pooling/sharing behaviors
|
|
817
|
+
const tempPath = `/tmp/.voice-send-${Date.now()}.tmp`;
|
|
818
|
+
try {
|
|
819
|
+
if (process.env.JFL_VOICE_DEBUG) {
|
|
820
|
+
console.log(`[VoiceClient] Writing ${buffer.length} bytes to temp file: ${tempPath}`);
|
|
821
|
+
}
|
|
822
|
+
writeFileSync(tempPath, buffer);
|
|
823
|
+
const sendBuffer = readFileSync(tempPath);
|
|
824
|
+
unlinkSync(tempPath);
|
|
825
|
+
if (process.env.JFL_VOICE_DEBUG) {
|
|
826
|
+
console.log(`[VoiceClient] sendAudio: ${sendBuffer.length} bytes (via temp file)`);
|
|
827
|
+
console.log(`[VoiceClient] Buffer.isBuffer: ${Buffer.isBuffer(sendBuffer)}`);
|
|
828
|
+
console.log(`[VoiceClient] ws.readyState: ${this.ws.readyState} (OPEN=1)`);
|
|
829
|
+
console.log(`[VoiceClient] ws.bufferedAmount before: ${this.ws.bufferedAmount}`);
|
|
830
|
+
}
|
|
831
|
+
// Send binary audio data - Buffer is automatically sent as binary
|
|
832
|
+
// Use callback to catch send errors
|
|
833
|
+
this.ws.send(sendBuffer, (err) => {
|
|
834
|
+
if (err) {
|
|
835
|
+
console.error(`[VoiceClient] send error:`, err);
|
|
836
|
+
}
|
|
837
|
+
else if (process.env.JFL_VOICE_DEBUG) {
|
|
838
|
+
console.log(`[VoiceClient] send callback: success`);
|
|
839
|
+
console.log(`[VoiceClient] ws.bufferedAmount after: ${this.ws?.bufferedAmount}`);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
catch (e) {
|
|
844
|
+
console.error(`[VoiceClient] Error in temp file workaround:`, e);
|
|
845
|
+
// Clean up temp file on error
|
|
846
|
+
try {
|
|
847
|
+
unlinkSync(tempPath);
|
|
848
|
+
}
|
|
849
|
+
catch { }
|
|
850
|
+
throw e;
|
|
851
|
+
}
|
|
852
|
+
if (process.env.JFL_VOICE_DEBUG) {
|
|
853
|
+
console.log(`[VoiceClient] ws.bufferedAmount after: ${this.ws.bufferedAmount}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Send end-of-audio signal to get final transcript
|
|
858
|
+
*/
|
|
859
|
+
endAudio() {
|
|
860
|
+
if (!this.isConnected() || !this.ws) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
// Send a JSON message indicating end of audio stream
|
|
864
|
+
this.ws.send(JSON.stringify({ type: "end_audio" }));
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Register callback for transcript events
|
|
868
|
+
*
|
|
869
|
+
* @param callback - Called with transcript text and whether it's final
|
|
870
|
+
*/
|
|
871
|
+
onTranscript(callback) {
|
|
872
|
+
this.transcriptCallbacks.push(callback);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Register callback for error events
|
|
876
|
+
*
|
|
877
|
+
* @param callback - Called with error
|
|
878
|
+
*/
|
|
879
|
+
onError(callback) {
|
|
880
|
+
this.errorCallbacks.push(callback);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Register callback for connection state changes
|
|
884
|
+
*
|
|
885
|
+
* @param callback - Called with new connection state
|
|
886
|
+
*/
|
|
887
|
+
onConnectionStateChange(callback) {
|
|
888
|
+
this.connectionCallbacks.push(callback);
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Remove all callbacks
|
|
892
|
+
*/
|
|
893
|
+
removeAllCallbacks() {
|
|
894
|
+
this.transcriptCallbacks = [];
|
|
895
|
+
this.errorCallbacks = [];
|
|
896
|
+
this.connectionCallbacks = [];
|
|
897
|
+
}
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
// Private methods
|
|
900
|
+
// ---------------------------------------------------------------------------
|
|
901
|
+
setState(state) {
|
|
902
|
+
if (this.state !== state) {
|
|
903
|
+
this.state = state;
|
|
904
|
+
for (const callback of this.connectionCallbacks) {
|
|
905
|
+
try {
|
|
906
|
+
callback(state);
|
|
907
|
+
}
|
|
908
|
+
catch (e) {
|
|
909
|
+
// Ignore callback errors
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
handleMessage(data) {
|
|
915
|
+
try {
|
|
916
|
+
// Parse JSON message
|
|
917
|
+
const message = JSON.parse(data.toString());
|
|
918
|
+
// Debug logging
|
|
919
|
+
if (process.env.JFL_VOICE_DEBUG) {
|
|
920
|
+
console.log(`[VoiceClient] Received: ${message.type}`, JSON.stringify(message));
|
|
921
|
+
}
|
|
922
|
+
if (message.type === "error") {
|
|
923
|
+
const errorMsg = message;
|
|
924
|
+
const error = new Error(errorMsg.error);
|
|
925
|
+
this.handleError(error);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (message.type === "partial" || message.type === "final") {
|
|
929
|
+
const transcript = message;
|
|
930
|
+
const isFinal = transcript.type === "final";
|
|
931
|
+
for (const callback of this.transcriptCallbacks) {
|
|
932
|
+
try {
|
|
933
|
+
callback(transcript.text, isFinal);
|
|
934
|
+
}
|
|
935
|
+
catch (e) {
|
|
936
|
+
// Ignore callback errors
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
this.emit("transcript", transcript.text, isFinal);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
catch (e) {
|
|
943
|
+
// Failed to parse message - could be binary data or malformed JSON
|
|
944
|
+
if (process.env.JFL_VOICE_DEBUG) {
|
|
945
|
+
console.error("Failed to parse server message:", e);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
handleError(error) {
|
|
950
|
+
// Notify error callbacks
|
|
951
|
+
for (const callback of this.errorCallbacks) {
|
|
952
|
+
try {
|
|
953
|
+
callback(error);
|
|
954
|
+
}
|
|
955
|
+
catch (e) {
|
|
956
|
+
// Ignore callback errors
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
this.emit("error", error);
|
|
960
|
+
}
|
|
961
|
+
handleClose(code, reason) {
|
|
962
|
+
const wasConnected = this.state === "connected";
|
|
963
|
+
this.ws = null;
|
|
964
|
+
if (process.env.DEBUG) {
|
|
965
|
+
console.log(`WebSocket closed: code=${code}, reason=${reason}`);
|
|
966
|
+
}
|
|
967
|
+
// Check if we should attempt to reconnect
|
|
968
|
+
if (this.shouldReconnect && wasConnected) {
|
|
969
|
+
this.scheduleReconnect();
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
this.setState("disconnected");
|
|
973
|
+
}
|
|
974
|
+
this.emit("close", code, reason);
|
|
975
|
+
}
|
|
976
|
+
scheduleReconnect() {
|
|
977
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
978
|
+
const error = new Error(`Failed to reconnect after ${this.maxReconnectAttempts} attempts`);
|
|
979
|
+
this.handleError(error);
|
|
980
|
+
this.setState("disconnected");
|
|
981
|
+
this.emit("reconnect_failed");
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
this.setState("reconnecting");
|
|
985
|
+
this.reconnectAttempts++;
|
|
986
|
+
// Calculate delay with exponential backoff
|
|
987
|
+
const delay = Math.min(this.initialReconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
|
|
988
|
+
if (process.env.DEBUG) {
|
|
989
|
+
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
990
|
+
}
|
|
991
|
+
this.emit("reconnecting", this.reconnectAttempts, delay);
|
|
992
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
993
|
+
if (!this.shouldReconnect) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
await this.doConnect();
|
|
998
|
+
this.emit("reconnected");
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
// doConnect will schedule another reconnect attempt on failure
|
|
1002
|
+
// through handleClose callback
|
|
1003
|
+
}
|
|
1004
|
+
}, delay);
|
|
1005
|
+
}
|
|
1006
|
+
clearReconnectTimer() {
|
|
1007
|
+
if (this.reconnectTimer) {
|
|
1008
|
+
clearTimeout(this.reconnectTimer);
|
|
1009
|
+
this.reconnectTimer = null;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* AudioRecorder - Cross-platform audio recording with sample rate conversion
|
|
1015
|
+
*
|
|
1016
|
+
* Records audio from the system microphone and outputs 16-bit PCM at 16kHz mono.
|
|
1017
|
+
* Works on macOS, Linux, and Windows using sox/rec/arecord backends.
|
|
1018
|
+
*
|
|
1019
|
+
* Prerequisites:
|
|
1020
|
+
* - macOS: `brew install sox`
|
|
1021
|
+
* - Linux: `sudo apt-get install sox libsox-fmt-all` or `alsa-utils`
|
|
1022
|
+
* - Windows: `choco install sox.portable`
|
|
1023
|
+
*
|
|
1024
|
+
* Usage:
|
|
1025
|
+
* ```typescript
|
|
1026
|
+
* const recorder = new AudioRecorder({ sampleRate: 16000 })
|
|
1027
|
+
*
|
|
1028
|
+
* recorder.on('data', (chunk: Buffer) => {
|
|
1029
|
+
* // Process 16-bit PCM audio data
|
|
1030
|
+
* voiceClient.sendAudio(chunk)
|
|
1031
|
+
* })
|
|
1032
|
+
*
|
|
1033
|
+
* recorder.on('error', (error: Error) => {
|
|
1034
|
+
* console.error('Recording error:', error)
|
|
1035
|
+
* })
|
|
1036
|
+
*
|
|
1037
|
+
* await recorder.start()
|
|
1038
|
+
*
|
|
1039
|
+
* // Later...
|
|
1040
|
+
* recorder.stop()
|
|
1041
|
+
* ```
|
|
1042
|
+
*/
|
|
1043
|
+
export class AudioRecorder extends EventEmitter {
|
|
1044
|
+
state = "idle";
|
|
1045
|
+
process = null;
|
|
1046
|
+
device;
|
|
1047
|
+
targetSampleRate;
|
|
1048
|
+
recorderBackend;
|
|
1049
|
+
currentPlatform;
|
|
1050
|
+
disconnectCheckInterval = null;
|
|
1051
|
+
constructor(options = {}) {
|
|
1052
|
+
super();
|
|
1053
|
+
this.device = options.device;
|
|
1054
|
+
this.targetSampleRate = options.sampleRate ?? 16000;
|
|
1055
|
+
this.currentPlatform = platform();
|
|
1056
|
+
this.recorderBackend = this.selectRecorder(options.recorder ?? "auto");
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* List available audio input devices
|
|
1060
|
+
*
|
|
1061
|
+
* @returns Promise resolving to array of available audio devices
|
|
1062
|
+
*/
|
|
1063
|
+
static async listDevices() {
|
|
1064
|
+
const currentPlatform = platform();
|
|
1065
|
+
try {
|
|
1066
|
+
if (currentPlatform === "darwin") {
|
|
1067
|
+
return await AudioRecorder.listDevicesMacOS();
|
|
1068
|
+
}
|
|
1069
|
+
else if (currentPlatform === "linux") {
|
|
1070
|
+
return await AudioRecorder.listDevicesLinux();
|
|
1071
|
+
}
|
|
1072
|
+
else if (currentPlatform === "win32") {
|
|
1073
|
+
return await AudioRecorder.listDevicesWindows();
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
throw new Error(`Unsupported platform: ${currentPlatform}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
// If listing fails, return empty array with a warning
|
|
1081
|
+
console.warn("Failed to list audio devices:", error);
|
|
1082
|
+
return [];
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* List audio devices on macOS using sox
|
|
1087
|
+
*/
|
|
1088
|
+
static async listDevicesMacOS() {
|
|
1089
|
+
const devices = [];
|
|
1090
|
+
try {
|
|
1091
|
+
// On macOS, sox -d uses coreaudio which lists devices differently
|
|
1092
|
+
// Try using system_profiler for detailed device list
|
|
1093
|
+
const output = execSync("system_profiler SPAudioDataType -json 2>/dev/null", { encoding: "utf-8", timeout: 5000 });
|
|
1094
|
+
const data = JSON.parse(output);
|
|
1095
|
+
const audioData = data.SPAudioDataType;
|
|
1096
|
+
if (audioData && Array.isArray(audioData)) {
|
|
1097
|
+
for (const device of audioData) {
|
|
1098
|
+
// Each audio device group may have input devices
|
|
1099
|
+
const name = device._name || "Unknown Device";
|
|
1100
|
+
const items = device._items || [];
|
|
1101
|
+
for (const item of items) {
|
|
1102
|
+
if (item.coreaudio_input_source) {
|
|
1103
|
+
devices.push({
|
|
1104
|
+
id: String(item.coreaudio_device_input || name),
|
|
1105
|
+
name: String(item._name || name),
|
|
1106
|
+
isDefault: item.coreaudio_default_audio_input_device === "yes",
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// Also check the main device entry
|
|
1111
|
+
if (device.coreaudio_input_source) {
|
|
1112
|
+
devices.push({
|
|
1113
|
+
id: String(device.coreaudio_device_input || name),
|
|
1114
|
+
name: String(name),
|
|
1115
|
+
isDefault: device.coreaudio_default_audio_input_device === "yes",
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
// Fallback: try using sox to list devices if available
|
|
1123
|
+
try {
|
|
1124
|
+
// Sox on macOS with coreaudio can list devices
|
|
1125
|
+
const soxOutput = execSync("rec -q --list-devices 2>&1 || true", {
|
|
1126
|
+
encoding: "utf-8",
|
|
1127
|
+
timeout: 5000,
|
|
1128
|
+
});
|
|
1129
|
+
// Parse sox output for device names
|
|
1130
|
+
const lines = soxOutput.split("\n");
|
|
1131
|
+
for (const line of lines) {
|
|
1132
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
1133
|
+
if (match) {
|
|
1134
|
+
devices.push({
|
|
1135
|
+
id: match[1],
|
|
1136
|
+
name: match[2].trim(),
|
|
1137
|
+
isDefault: match[1] === "0",
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
// Last resort: add a default device entry
|
|
1144
|
+
devices.push({
|
|
1145
|
+
id: "default",
|
|
1146
|
+
name: "Default Input Device",
|
|
1147
|
+
isDefault: true,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// If no devices found, add default
|
|
1152
|
+
if (devices.length === 0) {
|
|
1153
|
+
devices.push({
|
|
1154
|
+
id: "default",
|
|
1155
|
+
name: "Default Input Device",
|
|
1156
|
+
isDefault: true,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
return devices;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* List audio devices on Linux using arecord or pactl
|
|
1163
|
+
*/
|
|
1164
|
+
static async listDevicesLinux() {
|
|
1165
|
+
const devices = [];
|
|
1166
|
+
// Try PulseAudio/PipeWire first
|
|
1167
|
+
try {
|
|
1168
|
+
const pactlOutput = execSync("pactl list short sources 2>/dev/null", {
|
|
1169
|
+
encoding: "utf-8",
|
|
1170
|
+
timeout: 5000,
|
|
1171
|
+
});
|
|
1172
|
+
const lines = pactlOutput.trim().split("\n");
|
|
1173
|
+
for (const line of lines) {
|
|
1174
|
+
const parts = line.split("\t");
|
|
1175
|
+
if (parts.length >= 2) {
|
|
1176
|
+
const id = parts[1];
|
|
1177
|
+
// Skip monitor devices (output monitors)
|
|
1178
|
+
if (id.includes(".monitor"))
|
|
1179
|
+
continue;
|
|
1180
|
+
devices.push({
|
|
1181
|
+
id,
|
|
1182
|
+
name: id,
|
|
1183
|
+
isDefault: id.includes("@DEFAULT_SOURCE@") || devices.length === 0,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (devices.length > 0) {
|
|
1188
|
+
return devices;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
// PulseAudio not available, try ALSA
|
|
1193
|
+
}
|
|
1194
|
+
// Try ALSA
|
|
1195
|
+
try {
|
|
1196
|
+
const arecordOutput = execSync("arecord -l 2>/dev/null", {
|
|
1197
|
+
encoding: "utf-8",
|
|
1198
|
+
timeout: 5000,
|
|
1199
|
+
});
|
|
1200
|
+
const lines = arecordOutput.split("\n");
|
|
1201
|
+
for (const line of lines) {
|
|
1202
|
+
// Parse lines like: "card 0: PCH [HDA Intel PCH], device 0: ALC892 Analog [ALC892 Analog]"
|
|
1203
|
+
const match = line.match(/^card\s+(\d+):\s+([^,]+),\s+device\s+(\d+):\s+(.+)$/);
|
|
1204
|
+
if (match) {
|
|
1205
|
+
const cardNum = match[1];
|
|
1206
|
+
const deviceNum = match[3];
|
|
1207
|
+
const deviceName = match[4].trim();
|
|
1208
|
+
devices.push({
|
|
1209
|
+
id: `hw:${cardNum},${deviceNum}`,
|
|
1210
|
+
name: `${match[2].trim()} - ${deviceName}`,
|
|
1211
|
+
isDefault: devices.length === 0,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
// ALSA not available
|
|
1218
|
+
}
|
|
1219
|
+
// Fallback to default
|
|
1220
|
+
if (devices.length === 0) {
|
|
1221
|
+
devices.push({
|
|
1222
|
+
id: "default",
|
|
1223
|
+
name: "Default Input Device",
|
|
1224
|
+
isDefault: true,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
return devices;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* List audio devices on Windows using sox or powershell
|
|
1231
|
+
*/
|
|
1232
|
+
static async listDevicesWindows() {
|
|
1233
|
+
const devices = [];
|
|
1234
|
+
try {
|
|
1235
|
+
// Use PowerShell to list audio input devices
|
|
1236
|
+
const psCommand = `
|
|
1237
|
+
Get-WmiObject Win32_SoundDevice |
|
|
1238
|
+
Where-Object { $_.Status -eq 'OK' } |
|
|
1239
|
+
Select-Object DeviceID, Name |
|
|
1240
|
+
ConvertTo-Json
|
|
1241
|
+
`;
|
|
1242
|
+
const output = execSync(`powershell -Command "${psCommand}"`, {
|
|
1243
|
+
encoding: "utf-8",
|
|
1244
|
+
timeout: 10000,
|
|
1245
|
+
});
|
|
1246
|
+
const data = JSON.parse(output);
|
|
1247
|
+
const deviceList = Array.isArray(data) ? data : [data];
|
|
1248
|
+
for (let i = 0; i < deviceList.length; i++) {
|
|
1249
|
+
const device = deviceList[i];
|
|
1250
|
+
if (device && device.Name) {
|
|
1251
|
+
devices.push({
|
|
1252
|
+
id: device.DeviceID || String(i),
|
|
1253
|
+
name: device.Name,
|
|
1254
|
+
isDefault: i === 0,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
// Fallback to default
|
|
1261
|
+
}
|
|
1262
|
+
if (devices.length === 0) {
|
|
1263
|
+
devices.push({
|
|
1264
|
+
id: "-1",
|
|
1265
|
+
name: "Default Input Device",
|
|
1266
|
+
isDefault: true,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
return devices;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Select the appropriate recorder backend based on platform and availability
|
|
1273
|
+
*/
|
|
1274
|
+
selectRecorder(preference) {
|
|
1275
|
+
if (preference !== "auto") {
|
|
1276
|
+
// Verify the requested backend is available
|
|
1277
|
+
if (this.isRecorderAvailable(preference)) {
|
|
1278
|
+
return preference;
|
|
1279
|
+
}
|
|
1280
|
+
console.warn(`Recorder '${preference}' not available, falling back to auto-detection`);
|
|
1281
|
+
}
|
|
1282
|
+
// Auto-detect based on platform
|
|
1283
|
+
if (this.currentPlatform === "darwin") {
|
|
1284
|
+
// macOS: prefer rec (comes with sox), then sox
|
|
1285
|
+
if (this.isRecorderAvailable("rec"))
|
|
1286
|
+
return "rec";
|
|
1287
|
+
if (this.isRecorderAvailable("sox"))
|
|
1288
|
+
return "sox";
|
|
1289
|
+
}
|
|
1290
|
+
else if (this.currentPlatform === "linux") {
|
|
1291
|
+
// Linux: prefer arecord (ALSA), then sox
|
|
1292
|
+
if (this.isRecorderAvailable("arecord"))
|
|
1293
|
+
return "arecord";
|
|
1294
|
+
if (this.isRecorderAvailable("sox"))
|
|
1295
|
+
return "sox";
|
|
1296
|
+
if (this.isRecorderAvailable("rec"))
|
|
1297
|
+
return "rec";
|
|
1298
|
+
}
|
|
1299
|
+
else if (this.currentPlatform === "win32") {
|
|
1300
|
+
// Windows: prefer sox
|
|
1301
|
+
if (this.isRecorderAvailable("sox"))
|
|
1302
|
+
return "sox";
|
|
1303
|
+
if (this.isRecorderAvailable("rec"))
|
|
1304
|
+
return "rec";
|
|
1305
|
+
}
|
|
1306
|
+
// Default fallback - throw VoiceError
|
|
1307
|
+
throw new VoiceError(VoiceErrorType.SOX_NOT_INSTALLED);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Check if a recorder binary is available
|
|
1311
|
+
*/
|
|
1312
|
+
isRecorderAvailable(recorder) {
|
|
1313
|
+
try {
|
|
1314
|
+
const command = this.currentPlatform === "win32" ? `where ${recorder}` : `which ${recorder}`;
|
|
1315
|
+
execSync(command, { stdio: "ignore", timeout: 3000 });
|
|
1316
|
+
return true;
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Check if currently recording
|
|
1324
|
+
*/
|
|
1325
|
+
isRecording() {
|
|
1326
|
+
return this.state === "recording";
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Get current recording state
|
|
1330
|
+
*/
|
|
1331
|
+
getState() {
|
|
1332
|
+
return this.state;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Start recording audio
|
|
1336
|
+
*
|
|
1337
|
+
* @throws Error if already recording
|
|
1338
|
+
* @throws Error if recorder is not available
|
|
1339
|
+
*/
|
|
1340
|
+
async start() {
|
|
1341
|
+
if (this.state === "recording") {
|
|
1342
|
+
throw new Error("Already recording");
|
|
1343
|
+
}
|
|
1344
|
+
this.state = "recording";
|
|
1345
|
+
try {
|
|
1346
|
+
const args = this.buildRecorderArgs();
|
|
1347
|
+
const command = this.recorderBackend === "arecord" ? "arecord" : this.recorderBackend;
|
|
1348
|
+
if (process.env.DEBUG) {
|
|
1349
|
+
console.log(`Starting recorder: ${command} ${args.join(" ")}`);
|
|
1350
|
+
}
|
|
1351
|
+
this.process = spawn(command, args, {
|
|
1352
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1353
|
+
});
|
|
1354
|
+
if (!this.process.stdout) {
|
|
1355
|
+
throw new Error("Failed to open audio stream");
|
|
1356
|
+
}
|
|
1357
|
+
// Set up stdout as the audio data stream
|
|
1358
|
+
const stdout = this.process.stdout;
|
|
1359
|
+
// Buffer for collecting data
|
|
1360
|
+
let audioBuffer = Buffer.alloc(0);
|
|
1361
|
+
const chunkSize = 3200; // 100ms of 16kHz 16-bit mono audio
|
|
1362
|
+
stdout.on("data", (chunk) => {
|
|
1363
|
+
// Accumulate data - ensure chunk is a proper Buffer (Node may emit Uint8Array)
|
|
1364
|
+
audioBuffer = Buffer.concat([audioBuffer, Buffer.from(chunk)]);
|
|
1365
|
+
// Emit complete chunks
|
|
1366
|
+
while (audioBuffer.length >= chunkSize) {
|
|
1367
|
+
// IMPORTANT: Use Buffer.from() to create an independent copy, not subarray()
|
|
1368
|
+
// subarray() creates a view that shares the underlying ArrayBuffer
|
|
1369
|
+
// which can cause issues when the buffer is sent over WebSocket
|
|
1370
|
+
const emitChunk = Buffer.from(audioBuffer.subarray(0, chunkSize));
|
|
1371
|
+
audioBuffer = audioBuffer.subarray(chunkSize);
|
|
1372
|
+
this.emit("data", emitChunk);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
stdout.on("end", () => {
|
|
1376
|
+
// Emit any remaining data (as a copy)
|
|
1377
|
+
if (audioBuffer.length > 0) {
|
|
1378
|
+
this.emit("data", Buffer.from(audioBuffer));
|
|
1379
|
+
}
|
|
1380
|
+
this.handleProcessEnd();
|
|
1381
|
+
});
|
|
1382
|
+
stdout.on("error", (error) => {
|
|
1383
|
+
this.handleError(error);
|
|
1384
|
+
});
|
|
1385
|
+
// Handle stderr for warnings/errors
|
|
1386
|
+
if (this.process.stderr) {
|
|
1387
|
+
this.process.stderr.on("data", (data) => {
|
|
1388
|
+
const message = data.toString();
|
|
1389
|
+
// Only emit as error if it contains actual error indicators
|
|
1390
|
+
if (message.toLowerCase().includes("error") ||
|
|
1391
|
+
message.toLowerCase().includes("fail") ||
|
|
1392
|
+
message.toLowerCase().includes("cannot")) {
|
|
1393
|
+
this.handleError(new Error(`Recorder error: ${message}`));
|
|
1394
|
+
}
|
|
1395
|
+
else if (process.env.DEBUG) {
|
|
1396
|
+
console.warn("Recorder stderr:", message);
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
// Handle process errors
|
|
1401
|
+
this.process.on("error", (error) => {
|
|
1402
|
+
this.handleError(error);
|
|
1403
|
+
});
|
|
1404
|
+
this.process.on("exit", (code, signal) => {
|
|
1405
|
+
if (this.state === "recording") {
|
|
1406
|
+
// Unexpected exit
|
|
1407
|
+
if (code !== 0 && code !== null) {
|
|
1408
|
+
this.handleError(new Error(`Recorder exited with code ${code}`));
|
|
1409
|
+
}
|
|
1410
|
+
else if (signal) {
|
|
1411
|
+
this.handleError(new Error(`Recorder killed by signal ${signal}`));
|
|
1412
|
+
}
|
|
1413
|
+
else {
|
|
1414
|
+
this.handleProcessEnd();
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
// Start monitoring for device disconnect
|
|
1419
|
+
this.startDisconnectMonitor();
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
this.state = "error";
|
|
1423
|
+
throw error;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Stop recording
|
|
1428
|
+
*/
|
|
1429
|
+
stop() {
|
|
1430
|
+
if (this.state !== "recording") {
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
this.state = "stopping";
|
|
1434
|
+
this.stopDisconnectMonitor();
|
|
1435
|
+
if (this.process) {
|
|
1436
|
+
// Send SIGTERM for graceful shutdown
|
|
1437
|
+
try {
|
|
1438
|
+
this.process.kill("SIGTERM");
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
// Process may have already exited
|
|
1442
|
+
}
|
|
1443
|
+
// Force kill after timeout
|
|
1444
|
+
const killTimeout = setTimeout(() => {
|
|
1445
|
+
if (this.process) {
|
|
1446
|
+
try {
|
|
1447
|
+
this.process.kill("SIGKILL");
|
|
1448
|
+
}
|
|
1449
|
+
catch {
|
|
1450
|
+
// Ignore
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}, 1000);
|
|
1454
|
+
this.process.once("exit", () => {
|
|
1455
|
+
clearTimeout(killTimeout);
|
|
1456
|
+
this.process = null;
|
|
1457
|
+
this.state = "idle";
|
|
1458
|
+
this.emit("stopped");
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
this.state = "idle";
|
|
1463
|
+
this.emit("stopped");
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Build command-line arguments for the recorder
|
|
1468
|
+
*/
|
|
1469
|
+
buildRecorderArgs() {
|
|
1470
|
+
const args = [];
|
|
1471
|
+
if (this.recorderBackend === "arecord") {
|
|
1472
|
+
// ALSA arecord arguments
|
|
1473
|
+
args.push("-f", "S16_LE", // 16-bit signed little-endian
|
|
1474
|
+
"-r", String(this.targetSampleRate), "-c", "1", // Mono
|
|
1475
|
+
"-t", "raw", // Raw PCM output
|
|
1476
|
+
"-q" // Quiet mode
|
|
1477
|
+
);
|
|
1478
|
+
if (this.device) {
|
|
1479
|
+
args.push("-D", this.device);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
else if (this.recorderBackend === "sox" || this.recorderBackend === "rec") {
|
|
1483
|
+
// sox/rec arguments for recording
|
|
1484
|
+
// Format: rec [input-options] output-file [effects]
|
|
1485
|
+
// On macOS, hardware often can't record at 16kHz directly, so we record
|
|
1486
|
+
// at native rate and use sox's 'rate' effect to resample to target
|
|
1487
|
+
args.push("-q", // Quiet
|
|
1488
|
+
"-c", "1", // Mono
|
|
1489
|
+
"-b", "16", // 16-bit
|
|
1490
|
+
"-e", "signed-integer", // Signed integer encoding
|
|
1491
|
+
"-t", "raw", // Raw PCM output
|
|
1492
|
+
"-", // Output to stdout
|
|
1493
|
+
"rate", String(this.targetSampleRate) // Resample to target rate
|
|
1494
|
+
);
|
|
1495
|
+
if (this.device) {
|
|
1496
|
+
// Device specification differs by platform
|
|
1497
|
+
if (this.currentPlatform === "darwin") {
|
|
1498
|
+
// macOS: use -d for default device or specify device
|
|
1499
|
+
args.unshift("-d", this.device);
|
|
1500
|
+
}
|
|
1501
|
+
else if (this.currentPlatform === "linux") {
|
|
1502
|
+
// Linux: AUDIODEV environment or -d flag
|
|
1503
|
+
args.unshift("-d", this.device);
|
|
1504
|
+
}
|
|
1505
|
+
else if (this.currentPlatform === "win32") {
|
|
1506
|
+
// Windows: use -t waveaudio with device number
|
|
1507
|
+
args.unshift("-t", "waveaudio", this.device);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
else {
|
|
1511
|
+
// Default device - rec uses system default automatically on macOS
|
|
1512
|
+
if (this.currentPlatform === "linux") {
|
|
1513
|
+
// Linux: pulseaudio or alsa default
|
|
1514
|
+
args.unshift("-d");
|
|
1515
|
+
}
|
|
1516
|
+
else if (this.currentPlatform === "win32") {
|
|
1517
|
+
args.unshift("-t", "waveaudio", "-1");
|
|
1518
|
+
}
|
|
1519
|
+
// macOS: no flag needed, rec uses coreaudio default automatically
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return args;
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Start monitoring for device disconnect
|
|
1526
|
+
*/
|
|
1527
|
+
startDisconnectMonitor() {
|
|
1528
|
+
// Check every 2 seconds if the process is still healthy
|
|
1529
|
+
this.disconnectCheckInterval = setInterval(() => {
|
|
1530
|
+
if (this.state === "recording" && this.process) {
|
|
1531
|
+
// Check if process is still running
|
|
1532
|
+
try {
|
|
1533
|
+
// Sending signal 0 checks if process exists without affecting it
|
|
1534
|
+
process.kill(this.process.pid, 0);
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
// Process doesn't exist
|
|
1538
|
+
this.handleError(new Error("Audio device disconnected or recorder stopped unexpectedly"));
|
|
1539
|
+
this.stop();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}, 2000);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Stop disconnect monitoring
|
|
1546
|
+
*/
|
|
1547
|
+
stopDisconnectMonitor() {
|
|
1548
|
+
if (this.disconnectCheckInterval) {
|
|
1549
|
+
clearInterval(this.disconnectCheckInterval);
|
|
1550
|
+
this.disconnectCheckInterval = null;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Handle process end
|
|
1555
|
+
*/
|
|
1556
|
+
handleProcessEnd() {
|
|
1557
|
+
this.stopDisconnectMonitor();
|
|
1558
|
+
if (this.state === "recording") {
|
|
1559
|
+
this.state = "idle";
|
|
1560
|
+
this.emit("end");
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Handle recording error
|
|
1565
|
+
*/
|
|
1566
|
+
handleError(error) {
|
|
1567
|
+
this.stopDisconnectMonitor();
|
|
1568
|
+
this.state = "error";
|
|
1569
|
+
this.emit("error", error);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
// List models command
|
|
1573
|
+
export async function listModelsCommand() {
|
|
1574
|
+
console.log(chalk.bold("\n🎤 JFL Voice - Available Models\n"));
|
|
1575
|
+
ensureDirectories();
|
|
1576
|
+
const currentDefault = getCurrentDefaultModel();
|
|
1577
|
+
console.log(chalk.gray("Models are downloaded from Hugging Face (ggerganov/whisper.cpp)\n"));
|
|
1578
|
+
const modelNames = Object.keys(WHISPER_MODELS);
|
|
1579
|
+
// Table header
|
|
1580
|
+
console.log(chalk.gray(" ") +
|
|
1581
|
+
chalk.bold("Model".padEnd(14)) +
|
|
1582
|
+
chalk.bold("Size".padEnd(10)) +
|
|
1583
|
+
chalk.bold("Status".padEnd(14)) +
|
|
1584
|
+
chalk.bold("Description"));
|
|
1585
|
+
console.log(chalk.gray(" " + "─".repeat(70)));
|
|
1586
|
+
for (const name of modelNames) {
|
|
1587
|
+
const model = WHISPER_MODELS[name];
|
|
1588
|
+
const isDownloaded = isModelDownloaded(name);
|
|
1589
|
+
const isDefault = name === currentDefault;
|
|
1590
|
+
const statusIcon = isDownloaded ? chalk.green("✓") : chalk.gray("○");
|
|
1591
|
+
const defaultMarker = isDefault ? chalk.cyan(" (default)") : "";
|
|
1592
|
+
const status = isDownloaded ? chalk.green("downloaded") + defaultMarker : chalk.gray("not downloaded");
|
|
1593
|
+
console.log(` ${statusIcon} ` +
|
|
1594
|
+
chalk.white(name.padEnd(12)) +
|
|
1595
|
+
chalk.gray(model.size.padEnd(10)) +
|
|
1596
|
+
status.padEnd(24) +
|
|
1597
|
+
chalk.gray(model.description));
|
|
1598
|
+
}
|
|
1599
|
+
console.log();
|
|
1600
|
+
console.log(chalk.cyan("Commands:"));
|
|
1601
|
+
console.log(" jfl voice model download <name> Download a model");
|
|
1602
|
+
console.log(" jfl voice model default <name> Set default model");
|
|
1603
|
+
console.log();
|
|
1604
|
+
}
|
|
1605
|
+
// Download model command
|
|
1606
|
+
export async function downloadModelCommand(modelName, options) {
|
|
1607
|
+
console.log(chalk.bold("\n🎤 JFL Voice - Download Model\n"));
|
|
1608
|
+
// Validate model name
|
|
1609
|
+
if (!(modelName in WHISPER_MODELS)) {
|
|
1610
|
+
console.log(chalk.red(`Unknown model: ${modelName}`));
|
|
1611
|
+
console.log(chalk.gray("\nAvailable models:"));
|
|
1612
|
+
for (const name of Object.keys(WHISPER_MODELS)) {
|
|
1613
|
+
console.log(` - ${name}`);
|
|
1614
|
+
}
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const model = WHISPER_MODELS[modelName];
|
|
1618
|
+
ensureDirectories();
|
|
1619
|
+
const modelPath = getModelPath(modelName);
|
|
1620
|
+
const partialPath = getPartialPath(modelName);
|
|
1621
|
+
// Check if already downloaded
|
|
1622
|
+
if (isModelDownloaded(modelName) && !options?.force) {
|
|
1623
|
+
console.log(chalk.green(`✓ Model '${modelName}' is already downloaded`));
|
|
1624
|
+
console.log(chalk.gray(` Location: ${modelPath}`));
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
// Check for partial download
|
|
1628
|
+
let resuming = false;
|
|
1629
|
+
if (existsSync(partialPath)) {
|
|
1630
|
+
const stats = statSync(partialPath);
|
|
1631
|
+
const percent = ((stats.size / model.sizeBytes) * 100).toFixed(1);
|
|
1632
|
+
console.log(chalk.yellow(`Found incomplete download (${percent}% complete)`));
|
|
1633
|
+
const { resume } = await inquirer.prompt([
|
|
1634
|
+
{
|
|
1635
|
+
type: "confirm",
|
|
1636
|
+
name: "resume",
|
|
1637
|
+
message: "Resume download?",
|
|
1638
|
+
default: true,
|
|
1639
|
+
},
|
|
1640
|
+
]);
|
|
1641
|
+
if (!resume) {
|
|
1642
|
+
unlinkSync(partialPath);
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
resuming = true;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
console.log(chalk.gray(`Model: ${model.displayName}`));
|
|
1649
|
+
console.log(chalk.gray(`Size: ${model.size}`));
|
|
1650
|
+
console.log(chalk.gray(`URL: ${model.url}`));
|
|
1651
|
+
if (resuming) {
|
|
1652
|
+
console.log(chalk.cyan("Resuming download..."));
|
|
1653
|
+
}
|
|
1654
|
+
console.log();
|
|
1655
|
+
const spinner = ora({
|
|
1656
|
+
text: "Starting download...",
|
|
1657
|
+
prefixText: " ",
|
|
1658
|
+
}).start();
|
|
1659
|
+
const startTime = Date.now();
|
|
1660
|
+
let lastUpdate = 0;
|
|
1661
|
+
try {
|
|
1662
|
+
await downloadFile(model.url, modelPath, partialPath, model.sizeBytes, (downloaded, total) => {
|
|
1663
|
+
const now = Date.now();
|
|
1664
|
+
// Update at most every 100ms to avoid flickering
|
|
1665
|
+
if (now - lastUpdate < 100)
|
|
1666
|
+
return;
|
|
1667
|
+
lastUpdate = now;
|
|
1668
|
+
const percent = ((downloaded / total) * 100).toFixed(1);
|
|
1669
|
+
const elapsed = (now - startTime) / 1000;
|
|
1670
|
+
const speed = downloaded / elapsed;
|
|
1671
|
+
const remaining = (total - downloaded) / speed;
|
|
1672
|
+
spinner.text = `Downloading: ${percent}% (${formatBytes(downloaded)}/${formatBytes(total)}) - ${formatBytes(speed)}/s - ${remaining.toFixed(0)}s remaining`;
|
|
1673
|
+
});
|
|
1674
|
+
spinner.text = "Verifying checksum...";
|
|
1675
|
+
// Verify checksum
|
|
1676
|
+
if (!verifyChecksum(modelPath, model.sha256)) {
|
|
1677
|
+
spinner.fail("Checksum verification failed");
|
|
1678
|
+
console.log(chalk.red("\nThe downloaded file is corrupted."));
|
|
1679
|
+
console.log(chalk.gray("Try downloading again with: jfl voice model download " + modelName + " --force"));
|
|
1680
|
+
unlinkSync(modelPath);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
spinner.succeed("Download complete!");
|
|
1684
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
1685
|
+
console.log();
|
|
1686
|
+
console.log(chalk.green(`✓ Model '${modelName}' downloaded successfully`));
|
|
1687
|
+
console.log(chalk.gray(` Location: ${modelPath}`));
|
|
1688
|
+
console.log(chalk.gray(` Time: ${elapsed}s`));
|
|
1689
|
+
// Offer to set as default if base model
|
|
1690
|
+
if (modelName === "base" && getCurrentDefaultModel() !== "base") {
|
|
1691
|
+
const { setDefault } = await inquirer.prompt([
|
|
1692
|
+
{
|
|
1693
|
+
type: "confirm",
|
|
1694
|
+
name: "setDefault",
|
|
1695
|
+
message: "Set 'base' as your default model?",
|
|
1696
|
+
default: true,
|
|
1697
|
+
},
|
|
1698
|
+
]);
|
|
1699
|
+
if (setDefault) {
|
|
1700
|
+
setDefaultModel("base");
|
|
1701
|
+
console.log(chalk.green("✓ Default model set to 'base'"));
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
catch (error) {
|
|
1706
|
+
spinner.fail("Download failed");
|
|
1707
|
+
console.error(chalk.red(error));
|
|
1708
|
+
console.log(chalk.gray("\nYou can resume the download by running the same command again."));
|
|
1709
|
+
}
|
|
1710
|
+
console.log();
|
|
1711
|
+
}
|
|
1712
|
+
// Set default model command
|
|
1713
|
+
export async function setDefaultModelCommand(modelName) {
|
|
1714
|
+
// Validate model name
|
|
1715
|
+
if (!(modelName in WHISPER_MODELS)) {
|
|
1716
|
+
console.log(chalk.red(`Unknown model: ${modelName}`));
|
|
1717
|
+
console.log(chalk.gray("\nAvailable models:"));
|
|
1718
|
+
for (const name of Object.keys(WHISPER_MODELS)) {
|
|
1719
|
+
console.log(` - ${name}`);
|
|
1720
|
+
}
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
// Check if model is downloaded
|
|
1724
|
+
if (!isModelDownloaded(modelName)) {
|
|
1725
|
+
console.log(chalk.yellow(`Model '${modelName}' is not downloaded yet.`));
|
|
1726
|
+
const { download } = await inquirer.prompt([
|
|
1727
|
+
{
|
|
1728
|
+
type: "confirm",
|
|
1729
|
+
name: "download",
|
|
1730
|
+
message: "Download it now?",
|
|
1731
|
+
default: true,
|
|
1732
|
+
},
|
|
1733
|
+
]);
|
|
1734
|
+
if (download) {
|
|
1735
|
+
await downloadModelCommand(modelName);
|
|
1736
|
+
}
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
setDefaultModel(modelName);
|
|
1740
|
+
console.log(chalk.green(`✓ Default model set to '${modelName}'`));
|
|
1741
|
+
}
|
|
1742
|
+
// List audio devices command
|
|
1743
|
+
export async function listDevicesCommand() {
|
|
1744
|
+
console.log(chalk.bold("\n🎤 JFL Voice - Audio Devices\n"));
|
|
1745
|
+
const spinner = ora({
|
|
1746
|
+
text: "Detecting audio devices...",
|
|
1747
|
+
prefixText: " ",
|
|
1748
|
+
}).start();
|
|
1749
|
+
try {
|
|
1750
|
+
const devices = await AudioRecorder.listDevices();
|
|
1751
|
+
spinner.stop();
|
|
1752
|
+
if (devices.length === 0) {
|
|
1753
|
+
console.log(chalk.yellow(" No audio input devices found."));
|
|
1754
|
+
console.log();
|
|
1755
|
+
console.log(chalk.gray(" Make sure you have a microphone connected and permissions granted."));
|
|
1756
|
+
console.log();
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
console.log(chalk.gray(` Found ${devices.length} audio input device${devices.length > 1 ? "s" : ""}:\n`));
|
|
1760
|
+
// Table header
|
|
1761
|
+
console.log(chalk.gray(" ") +
|
|
1762
|
+
chalk.bold("Device".padEnd(40)) +
|
|
1763
|
+
chalk.bold("ID".padEnd(30)) +
|
|
1764
|
+
chalk.bold("Default"));
|
|
1765
|
+
console.log(chalk.gray(" " + "─".repeat(75)));
|
|
1766
|
+
for (const device of devices) {
|
|
1767
|
+
const defaultMarker = device.isDefault ? chalk.green("✓") : chalk.gray("-");
|
|
1768
|
+
const deviceName = String(device.name || "Unknown").substring(0, 38).padEnd(40);
|
|
1769
|
+
const deviceId = String(device.id || "default").substring(0, 28).padEnd(30);
|
|
1770
|
+
console.log(` ${deviceName}` +
|
|
1771
|
+
chalk.gray(deviceId) +
|
|
1772
|
+
defaultMarker);
|
|
1773
|
+
}
|
|
1774
|
+
console.log();
|
|
1775
|
+
console.log(chalk.cyan("Usage:"));
|
|
1776
|
+
console.log(" jfl voice test Test with default device");
|
|
1777
|
+
console.log(" jfl voice test --device <id> Test with specific device");
|
|
1778
|
+
console.log();
|
|
1779
|
+
}
|
|
1780
|
+
catch (error) {
|
|
1781
|
+
spinner.fail("Failed to list devices");
|
|
1782
|
+
console.error(chalk.red(` ${error}`));
|
|
1783
|
+
console.log();
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
// Voice test command - records audio and sends to whisper server for transcription
|
|
1787
|
+
export async function voiceTestCommand(options) {
|
|
1788
|
+
console.log(chalk.bold("\n🎤 JFL Voice - Test\n"));
|
|
1789
|
+
const duration = 3; // Fixed 3 second recording
|
|
1790
|
+
// Step 1: Check if whisper server is running
|
|
1791
|
+
const serverError = checkServerRunning();
|
|
1792
|
+
if (serverError) {
|
|
1793
|
+
handleVoiceError(serverError);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
// Step 2: Check for auth token
|
|
1797
|
+
const authError = checkAuthToken();
|
|
1798
|
+
if (authError) {
|
|
1799
|
+
handleVoiceError(authError);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
const authToken = readAuthToken();
|
|
1803
|
+
const socketPath = getVoiceSocketPath();
|
|
1804
|
+
// Step 3: Check for sox/rec availability
|
|
1805
|
+
let recorder;
|
|
1806
|
+
try {
|
|
1807
|
+
recorder = new AudioRecorder({
|
|
1808
|
+
device: options?.device,
|
|
1809
|
+
sampleRate: 16000,
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
catch (error) {
|
|
1813
|
+
if (error instanceof VoiceError) {
|
|
1814
|
+
handleVoiceError(error);
|
|
1815
|
+
}
|
|
1816
|
+
else {
|
|
1817
|
+
handleVoiceError(createVoiceError(error instanceof Error ? error : new Error(String(error))));
|
|
1818
|
+
}
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
console.log(chalk.gray(" Recording for 3 seconds..."));
|
|
1822
|
+
console.log(chalk.gray(" Speak clearly into your microphone!\n"));
|
|
1823
|
+
// Collect audio data
|
|
1824
|
+
const audioChunks = [];
|
|
1825
|
+
let peakLevel = 0;
|
|
1826
|
+
recorder.on("data", (chunk) => {
|
|
1827
|
+
audioChunks.push(chunk);
|
|
1828
|
+
// Calculate peak level from 16-bit samples
|
|
1829
|
+
for (let i = 0; i < chunk.length - 1; i += 2) {
|
|
1830
|
+
const sample = chunk.readInt16LE(i);
|
|
1831
|
+
const level = Math.abs(sample) / 32768;
|
|
1832
|
+
if (level > peakLevel) {
|
|
1833
|
+
peakLevel = level;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
});
|
|
1837
|
+
let recordingError = null;
|
|
1838
|
+
recorder.on("error", (error) => {
|
|
1839
|
+
recordingError = error;
|
|
1840
|
+
});
|
|
1841
|
+
// Step 4: Start recording with countdown
|
|
1842
|
+
try {
|
|
1843
|
+
await recorder.start();
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
|
|
1847
|
+
originalError: error instanceof Error ? error : new Error(String(error)),
|
|
1848
|
+
context: { device: options?.device },
|
|
1849
|
+
});
|
|
1850
|
+
handleVoiceError(voiceError);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
// Countdown display
|
|
1854
|
+
for (let i = duration; i > 0; i--) {
|
|
1855
|
+
process.stdout.write(` Recording... ${chalk.cyan(String(i))}...\r`);
|
|
1856
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1857
|
+
}
|
|
1858
|
+
process.stdout.write(" \r"); // Clear countdown line
|
|
1859
|
+
recorder.stop();
|
|
1860
|
+
// Wait for recorder to fully stop
|
|
1861
|
+
await new Promise((resolve) => {
|
|
1862
|
+
const checkStopped = () => {
|
|
1863
|
+
if (recorder.getState() === "idle") {
|
|
1864
|
+
resolve();
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
setTimeout(checkStopped, 50);
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
setTimeout(checkStopped, 100);
|
|
1871
|
+
});
|
|
1872
|
+
if (recordingError !== null) {
|
|
1873
|
+
const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
|
|
1874
|
+
originalError: recordingError,
|
|
1875
|
+
context: { device: options?.device },
|
|
1876
|
+
});
|
|
1877
|
+
handleVoiceError(voiceError);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
// Check if we got any audio
|
|
1881
|
+
const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1882
|
+
if (totalBytes === 0) {
|
|
1883
|
+
const voiceError = new VoiceError(VoiceErrorType.MIC_UNAVAILABLE, {
|
|
1884
|
+
context: { totalBytes: 0, device: options?.device },
|
|
1885
|
+
});
|
|
1886
|
+
handleVoiceError(voiceError);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
// Check audio levels
|
|
1890
|
+
if (peakLevel < 0.01) {
|
|
1891
|
+
// Low audio but not an error, just a warning
|
|
1892
|
+
console.log(chalk.yellow(" ⚠ Very low audio level detected"));
|
|
1893
|
+
console.log(chalk.gray(" Suggestion: Try speaking louder or check microphone"));
|
|
1894
|
+
console.log();
|
|
1895
|
+
}
|
|
1896
|
+
// Combine all audio chunks and normalize
|
|
1897
|
+
const audioBuffer = normalizeAudio(Buffer.concat(audioChunks));
|
|
1898
|
+
// Step 5: Connect to whisper server and send audio
|
|
1899
|
+
console.log(chalk.gray(" Transcribing..."));
|
|
1900
|
+
const client = new VoiceClient({
|
|
1901
|
+
socketPath,
|
|
1902
|
+
authToken,
|
|
1903
|
+
maxReconnectAttempts: 1, // Don't retry for test
|
|
1904
|
+
});
|
|
1905
|
+
let transcription = "";
|
|
1906
|
+
let transcriptionReceived = false;
|
|
1907
|
+
let transcriptionError = null;
|
|
1908
|
+
client.onTranscript((text, isFinal) => {
|
|
1909
|
+
// Store the latest transcription (partial or final)
|
|
1910
|
+
// Filter out blank audio markers
|
|
1911
|
+
if (text && text.trim() && !text.includes("[BLANK_AUDIO]")) {
|
|
1912
|
+
transcription = text;
|
|
1913
|
+
}
|
|
1914
|
+
if (isFinal) {
|
|
1915
|
+
transcriptionReceived = true;
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
client.onError((error) => {
|
|
1919
|
+
transcriptionError = error;
|
|
1920
|
+
});
|
|
1921
|
+
try {
|
|
1922
|
+
await client.connect();
|
|
1923
|
+
// Send audio data and wait for it to be sent
|
|
1924
|
+
client.sendAudio(audioBuffer);
|
|
1925
|
+
// Wait for data to fully transmit before sending end signal
|
|
1926
|
+
// WebSocket bufferedAmount=0 doesn't guarantee the server received it
|
|
1927
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1928
|
+
// Signal end of audio
|
|
1929
|
+
client.endAudio();
|
|
1930
|
+
// Wait for transcription with timeout
|
|
1931
|
+
const timeout = 10000; // 10 seconds
|
|
1932
|
+
const startTime = Date.now();
|
|
1933
|
+
while (!transcriptionReceived && !transcriptionError) {
|
|
1934
|
+
if (Date.now() - startTime > timeout) {
|
|
1935
|
+
// If we have a partial transcription, use it instead of erroring
|
|
1936
|
+
if (transcription) {
|
|
1937
|
+
break;
|
|
1938
|
+
}
|
|
1939
|
+
transcriptionError = new VoiceError(VoiceErrorType.TIMEOUT, {
|
|
1940
|
+
context: { timeout, operation: "transcription" },
|
|
1941
|
+
recoverable: true,
|
|
1942
|
+
audioBuffer,
|
|
1943
|
+
});
|
|
1944
|
+
break;
|
|
1945
|
+
}
|
|
1946
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1947
|
+
}
|
|
1948
|
+
client.disconnect();
|
|
1949
|
+
}
|
|
1950
|
+
catch (error) {
|
|
1951
|
+
client.disconnect();
|
|
1952
|
+
const voiceError = error instanceof VoiceError
|
|
1953
|
+
? error
|
|
1954
|
+
: createVoiceError(error instanceof Error ? error : new Error(String(error)), { operation: "connect" }, audioBuffer);
|
|
1955
|
+
handleVoiceError(voiceError);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
// Step 6: Display results
|
|
1959
|
+
console.log();
|
|
1960
|
+
if (transcriptionError) {
|
|
1961
|
+
const voiceError = transcriptionError instanceof VoiceError
|
|
1962
|
+
? transcriptionError
|
|
1963
|
+
: createVoiceError(transcriptionError, { operation: "transcription" }, audioBuffer);
|
|
1964
|
+
handleVoiceError(voiceError);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (!transcription || transcription.trim() === "") {
|
|
1968
|
+
const voiceError = new VoiceError(VoiceErrorType.TRANSCRIPTION_EMPTY, {
|
|
1969
|
+
context: { audioLength: totalBytes, peakLevel },
|
|
1970
|
+
recoverable: true,
|
|
1971
|
+
audioBuffer,
|
|
1972
|
+
});
|
|
1973
|
+
handleVoiceError(voiceError);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
// Success! Show the transcription
|
|
1977
|
+
console.log(chalk.green(" ✓ Transcription successful!\n"));
|
|
1978
|
+
console.log(chalk.white(" You said:"));
|
|
1979
|
+
console.log(chalk.cyan(` "${transcription.trim()}"`));
|
|
1980
|
+
console.log();
|
|
1981
|
+
// Show audio stats
|
|
1982
|
+
const durationActual = totalBytes / (16000 * 2); // 16kHz, 16-bit
|
|
1983
|
+
console.log(chalk.gray(` Audio: ${durationActual.toFixed(1)}s, peak level ${(peakLevel * 100).toFixed(0)}%`));
|
|
1984
|
+
console.log();
|
|
1985
|
+
}
|
|
1986
|
+
// Test audio recording command (without transcription, for debugging)
|
|
1987
|
+
export async function testRecordingCommand(options) {
|
|
1988
|
+
console.log(chalk.bold("\n🎤 JFL Voice - Recording Test\n"));
|
|
1989
|
+
const duration = options?.duration ?? 5;
|
|
1990
|
+
// Check for sox/rec availability
|
|
1991
|
+
try {
|
|
1992
|
+
const recorder = new AudioRecorder({
|
|
1993
|
+
device: options?.device,
|
|
1994
|
+
sampleRate: 16000,
|
|
1995
|
+
});
|
|
1996
|
+
console.log(chalk.gray(` Recording for ${duration} seconds...`));
|
|
1997
|
+
console.log(chalk.gray(` Format: 16-bit PCM, 16kHz, mono`));
|
|
1998
|
+
if (options?.device) {
|
|
1999
|
+
console.log(chalk.gray(` Device: ${options.device}`));
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
console.log(chalk.gray(` Device: default`));
|
|
2003
|
+
}
|
|
2004
|
+
console.log();
|
|
2005
|
+
let totalBytes = 0;
|
|
2006
|
+
let chunkCount = 0;
|
|
2007
|
+
let peakLevel = 0;
|
|
2008
|
+
recorder.on("data", (chunk) => {
|
|
2009
|
+
totalBytes += chunk.length;
|
|
2010
|
+
chunkCount++;
|
|
2011
|
+
// Calculate peak level from 16-bit samples
|
|
2012
|
+
for (let i = 0; i < chunk.length - 1; i += 2) {
|
|
2013
|
+
const sample = chunk.readInt16LE(i);
|
|
2014
|
+
const level = Math.abs(sample) / 32768;
|
|
2015
|
+
if (level > peakLevel) {
|
|
2016
|
+
peakLevel = level;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
recorder.on("error", (error) => {
|
|
2021
|
+
console.error(chalk.red(`\n Recording error: ${error.message}`));
|
|
2022
|
+
});
|
|
2023
|
+
const spinner = ora({
|
|
2024
|
+
text: "Recording...",
|
|
2025
|
+
prefixText: " ",
|
|
2026
|
+
}).start();
|
|
2027
|
+
await recorder.start();
|
|
2028
|
+
// Record for specified duration
|
|
2029
|
+
await new Promise((resolve) => {
|
|
2030
|
+
const startTime = Date.now();
|
|
2031
|
+
const updateInterval = setInterval(() => {
|
|
2032
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
2033
|
+
const remaining = Math.max(0, duration - elapsed);
|
|
2034
|
+
// Create a visual level meter
|
|
2035
|
+
const levelBars = Math.round(peakLevel * 20);
|
|
2036
|
+
const meter = "█".repeat(levelBars) + "░".repeat(20 - levelBars);
|
|
2037
|
+
spinner.text = `Recording... ${remaining.toFixed(1)}s remaining [${meter}]`;
|
|
2038
|
+
if (elapsed >= duration) {
|
|
2039
|
+
clearInterval(updateInterval);
|
|
2040
|
+
recorder.stop();
|
|
2041
|
+
resolve();
|
|
2042
|
+
}
|
|
2043
|
+
}, 100);
|
|
2044
|
+
});
|
|
2045
|
+
spinner.succeed("Recording complete!");
|
|
2046
|
+
console.log();
|
|
2047
|
+
// Show stats
|
|
2048
|
+
const durationActual = totalBytes / (16000 * 2); // 16kHz, 16-bit
|
|
2049
|
+
console.log(chalk.gray(" Statistics:"));
|
|
2050
|
+
console.log(` - Duration: ${durationActual.toFixed(2)}s`);
|
|
2051
|
+
console.log(` - Data received: ${formatBytes(totalBytes)}`);
|
|
2052
|
+
console.log(` - Chunks: ${chunkCount}`);
|
|
2053
|
+
console.log(` - Peak level: ${(peakLevel * 100).toFixed(1)}%`);
|
|
2054
|
+
if (peakLevel < 0.01) {
|
|
2055
|
+
console.log();
|
|
2056
|
+
console.log(chalk.yellow(" ⚠️ No audio detected. Check your microphone:"));
|
|
2057
|
+
console.log(chalk.gray(" - Is the microphone connected and enabled?"));
|
|
2058
|
+
console.log(chalk.gray(" - Does the application have microphone permission?"));
|
|
2059
|
+
console.log(chalk.gray(" - Try speaking louder or moving closer to the mic."));
|
|
2060
|
+
}
|
|
2061
|
+
else if (peakLevel < 0.1) {
|
|
2062
|
+
console.log();
|
|
2063
|
+
console.log(chalk.yellow(" ⚠️ Audio level is low. Consider:"));
|
|
2064
|
+
console.log(chalk.gray(" - Speaking louder"));
|
|
2065
|
+
console.log(chalk.gray(" - Increasing microphone gain"));
|
|
2066
|
+
console.log(chalk.gray(" - Moving closer to the microphone"));
|
|
2067
|
+
}
|
|
2068
|
+
else {
|
|
2069
|
+
console.log();
|
|
2070
|
+
console.log(chalk.green(" ✓ Audio input is working correctly!"));
|
|
2071
|
+
}
|
|
2072
|
+
console.log();
|
|
2073
|
+
}
|
|
2074
|
+
catch (error) {
|
|
2075
|
+
if (error instanceof VoiceError) {
|
|
2076
|
+
handleVoiceError(error);
|
|
2077
|
+
}
|
|
2078
|
+
else {
|
|
2079
|
+
handleVoiceError(createVoiceError(error instanceof Error ? error : new Error(String(error))));
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
// Default preview configuration
|
|
2084
|
+
const DEFAULT_PREVIEW_CONFIG = {
|
|
2085
|
+
timeout: 2.5, // seconds
|
|
2086
|
+
};
|
|
2087
|
+
// Default security configuration
|
|
2088
|
+
const DEFAULT_SECURITY_CONFIG = {
|
|
2089
|
+
maxRecordingDuration: 60, // 60 seconds max recording
|
|
2090
|
+
clipboardClearDelay: 5, // Clear clipboard 5 seconds after paste
|
|
2091
|
+
};
|
|
2092
|
+
// Default hotkey configuration
|
|
2093
|
+
const DEFAULT_HOTKEY_CONFIG = {
|
|
2094
|
+
mode: "auto",
|
|
2095
|
+
holdThreshold: 300, // ms
|
|
2096
|
+
};
|
|
2097
|
+
// Read voice config from YAML file
|
|
2098
|
+
function readVoiceConfig() {
|
|
2099
|
+
const configPath = getVoiceConfigPath();
|
|
2100
|
+
if (!existsSync(configPath)) {
|
|
2101
|
+
return null;
|
|
2102
|
+
}
|
|
2103
|
+
try {
|
|
2104
|
+
const content = readFileSync(configPath, "utf-8");
|
|
2105
|
+
const config = {
|
|
2106
|
+
model: "base",
|
|
2107
|
+
device: "default",
|
|
2108
|
+
sampleRate: 16000,
|
|
2109
|
+
autoStart: false,
|
|
2110
|
+
hotkey: { ...DEFAULT_HOTKEY_CONFIG },
|
|
2111
|
+
preview: { ...DEFAULT_PREVIEW_CONFIG },
|
|
2112
|
+
security: { ...DEFAULT_SECURITY_CONFIG },
|
|
2113
|
+
};
|
|
2114
|
+
// Parse YAML manually (simple key: value format)
|
|
2115
|
+
// Supports nested hotkey, preview, and security sections
|
|
2116
|
+
const lines = content.split("\n");
|
|
2117
|
+
let currentSection = "none";
|
|
2118
|
+
for (const line of lines) {
|
|
2119
|
+
const trimmed = line.trim();
|
|
2120
|
+
if (trimmed.startsWith("#") || trimmed === "")
|
|
2121
|
+
continue;
|
|
2122
|
+
// Check if entering a section
|
|
2123
|
+
if (trimmed === "hotkey:") {
|
|
2124
|
+
currentSection = "hotkey";
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
if (trimmed === "preview:") {
|
|
2128
|
+
currentSection = "preview";
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
if (trimmed === "security:") {
|
|
2132
|
+
currentSection = "security";
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
// Check if leaving section (new top-level key)
|
|
2136
|
+
if (!line.startsWith(" ") && !line.startsWith("\t") && trimmed.includes(":")) {
|
|
2137
|
+
currentSection = "none";
|
|
2138
|
+
}
|
|
2139
|
+
if (!trimmed.includes(":"))
|
|
2140
|
+
continue;
|
|
2141
|
+
const [key, ...valueParts] = trimmed.split(":");
|
|
2142
|
+
const value = valueParts.join(":").trim();
|
|
2143
|
+
if (currentSection === "hotkey") {
|
|
2144
|
+
// Parse hotkey sub-keys
|
|
2145
|
+
if (key === "mode" && (value === "auto" || value === "tap" || value === "hold")) {
|
|
2146
|
+
config.hotkey.mode = value;
|
|
2147
|
+
}
|
|
2148
|
+
else if (key === "holdThreshold") {
|
|
2149
|
+
config.hotkey.holdThreshold = parseInt(value, 10) || 300;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
else if (currentSection === "preview") {
|
|
2153
|
+
// Parse preview sub-keys
|
|
2154
|
+
if (key === "timeout") {
|
|
2155
|
+
const parsed = parseFloat(value);
|
|
2156
|
+
// Validate: 0 (disabled) or 1-10 seconds
|
|
2157
|
+
if (!isNaN(parsed) && (parsed === 0 || (parsed >= 1 && parsed <= 10))) {
|
|
2158
|
+
config.preview.timeout = parsed;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
else if (currentSection === "security") {
|
|
2163
|
+
// Parse security sub-keys (VS-SEC-3)
|
|
2164
|
+
if (key === "maxRecordingDuration") {
|
|
2165
|
+
const parsed = parseInt(value, 10);
|
|
2166
|
+
// Validate: 10-300 seconds (reasonable bounds)
|
|
2167
|
+
if (!isNaN(parsed) && parsed >= 10 && parsed <= 300) {
|
|
2168
|
+
config.security.maxRecordingDuration = parsed;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
else if (key === "clipboardClearDelay") {
|
|
2172
|
+
const parsed = parseInt(value, 10);
|
|
2173
|
+
// Validate: 1-60 seconds
|
|
2174
|
+
if (!isNaN(parsed) && parsed >= 1 && parsed <= 60) {
|
|
2175
|
+
config.security.clipboardClearDelay = parsed;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
else {
|
|
2180
|
+
// Parse top-level keys
|
|
2181
|
+
if (key === "model")
|
|
2182
|
+
config.model = value;
|
|
2183
|
+
else if (key === "device")
|
|
2184
|
+
config.device = value;
|
|
2185
|
+
else if (key === "sampleRate")
|
|
2186
|
+
config.sampleRate = parseInt(value, 10) || 16000;
|
|
2187
|
+
else if (key === "autoStart")
|
|
2188
|
+
config.autoStart = value === "true";
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
return config;
|
|
2192
|
+
}
|
|
2193
|
+
catch {
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
// Write voice config to YAML file
|
|
2198
|
+
function writeVoiceConfig(config) {
|
|
2199
|
+
ensureDirectories();
|
|
2200
|
+
const configPath = getVoiceConfigPath();
|
|
2201
|
+
// Ensure config sections have defaults if not provided
|
|
2202
|
+
const hotkeyConfig = config.hotkey || DEFAULT_HOTKEY_CONFIG;
|
|
2203
|
+
const previewConfig = config.preview || DEFAULT_PREVIEW_CONFIG;
|
|
2204
|
+
const securityConfig = config.security || DEFAULT_SECURITY_CONFIG;
|
|
2205
|
+
const content = `# JFL Voice Configuration
|
|
2206
|
+
# Generated by: jfl voice setup
|
|
2207
|
+
# Re-run setup to change settings: jfl voice setup
|
|
2208
|
+
|
|
2209
|
+
model: ${config.model}
|
|
2210
|
+
device: ${config.device}
|
|
2211
|
+
sampleRate: ${config.sampleRate}
|
|
2212
|
+
autoStart: ${config.autoStart}
|
|
2213
|
+
|
|
2214
|
+
# Hotkey settings for voice hotkey mode
|
|
2215
|
+
# mode: auto (smart detection), tap (tap-to-toggle), or hold (hold-to-talk)
|
|
2216
|
+
# holdThreshold: ms to hold before entering hold-to-talk mode (default: 300)
|
|
2217
|
+
hotkey:
|
|
2218
|
+
mode: ${hotkeyConfig.mode}
|
|
2219
|
+
holdThreshold: ${hotkeyConfig.holdThreshold}
|
|
2220
|
+
|
|
2221
|
+
# Preview settings for transcript review before sending
|
|
2222
|
+
# timeout: seconds to wait before auto-sending (1-10, or 0 to disable auto-send)
|
|
2223
|
+
# When preview is shown: Enter=send immediately, Esc=cancel, any other key=edit mode
|
|
2224
|
+
preview:
|
|
2225
|
+
timeout: ${previewConfig.timeout}
|
|
2226
|
+
|
|
2227
|
+
# Security settings for clipboard hygiene and recording limits (VS-SEC-3)
|
|
2228
|
+
# maxRecordingDuration: seconds before auto-stop (10-300, default: 60)
|
|
2229
|
+
# clipboardClearDelay: seconds after paste before clearing clipboard (1-60, default: 5)
|
|
2230
|
+
security:
|
|
2231
|
+
maxRecordingDuration: ${securityConfig.maxRecordingDuration}
|
|
2232
|
+
clipboardClearDelay: ${securityConfig.clipboardClearDelay}
|
|
2233
|
+
`;
|
|
2234
|
+
writeFileSync(configPath, content, { mode: 0o644 });
|
|
2235
|
+
}
|
|
2236
|
+
// Check microphone permissions on macOS
|
|
2237
|
+
async function checkMicPermissions() {
|
|
2238
|
+
if (platform() !== "darwin") {
|
|
2239
|
+
return { granted: true, message: "Permissions check not required on this platform" };
|
|
2240
|
+
}
|
|
2241
|
+
try {
|
|
2242
|
+
// On macOS, try a quick recording test to trigger permission prompt
|
|
2243
|
+
// If sox can record, permissions are granted
|
|
2244
|
+
execSync("rec -q -r 16000 -c 1 -b 16 -e signed-integer -t raw - trim 0 0.1 2>/dev/null | head -c 1", {
|
|
2245
|
+
timeout: 5000,
|
|
2246
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2247
|
+
});
|
|
2248
|
+
return { granted: true, message: "Microphone access granted" };
|
|
2249
|
+
}
|
|
2250
|
+
catch (error) {
|
|
2251
|
+
// Check if it's a permission error
|
|
2252
|
+
try {
|
|
2253
|
+
// Try to check system preferences
|
|
2254
|
+
const result = execSync("defaults read com.apple.controlcenter 'NSStatusItem Visible Microphone' 2>/dev/null || echo 'unknown'", { encoding: "utf-8", timeout: 3000 }).trim();
|
|
2255
|
+
if (result === "unknown") {
|
|
2256
|
+
return {
|
|
2257
|
+
granted: false,
|
|
2258
|
+
message: "Unable to determine microphone permission status. Try recording to trigger permission prompt.",
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
// Ignore
|
|
2264
|
+
}
|
|
2265
|
+
return {
|
|
2266
|
+
granted: false,
|
|
2267
|
+
message: "Microphone access may not be granted. Go to System Preferences > Privacy & Security > Microphone",
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
// Test audio device with a short recording
|
|
2272
|
+
async function testAudioDevice(device, durationSecs = 3) {
|
|
2273
|
+
return new Promise((resolve) => {
|
|
2274
|
+
try {
|
|
2275
|
+
const recorder = new AudioRecorder({
|
|
2276
|
+
device: device === "default" ? undefined : device,
|
|
2277
|
+
sampleRate: 16000,
|
|
2278
|
+
});
|
|
2279
|
+
let totalBytes = 0;
|
|
2280
|
+
let peakLevel = 0;
|
|
2281
|
+
let hasError = false;
|
|
2282
|
+
recorder.on("data", (chunk) => {
|
|
2283
|
+
totalBytes += chunk.length;
|
|
2284
|
+
// Calculate peak level from 16-bit samples
|
|
2285
|
+
for (let i = 0; i < chunk.length - 1; i += 2) {
|
|
2286
|
+
const sample = chunk.readInt16LE(i);
|
|
2287
|
+
const level = Math.abs(sample) / 32768;
|
|
2288
|
+
if (level > peakLevel) {
|
|
2289
|
+
peakLevel = level;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
recorder.on("error", (error) => {
|
|
2294
|
+
hasError = true;
|
|
2295
|
+
recorder.stop();
|
|
2296
|
+
resolve({ success: false, peakLevel: 0, error: error.message });
|
|
2297
|
+
});
|
|
2298
|
+
recorder.start().then(() => {
|
|
2299
|
+
// Record for specified duration
|
|
2300
|
+
setTimeout(() => {
|
|
2301
|
+
recorder.stop();
|
|
2302
|
+
if (!hasError) {
|
|
2303
|
+
resolve({
|
|
2304
|
+
success: totalBytes > 0,
|
|
2305
|
+
peakLevel,
|
|
2306
|
+
error: totalBytes === 0 ? "No audio data received" : undefined,
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
}, durationSecs * 1000);
|
|
2310
|
+
}).catch((error) => {
|
|
2311
|
+
resolve({ success: false, peakLevel: 0, error: String(error) });
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
catch (error) {
|
|
2315
|
+
resolve({ success: false, peakLevel: 0, error: String(error) });
|
|
2316
|
+
}
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
// First-time setup wizard
|
|
2320
|
+
export async function voiceSetupCommand() {
|
|
2321
|
+
console.log(chalk.bold("\n🎤 JFL Voice - Setup Wizard\n"));
|
|
2322
|
+
// Check for existing config
|
|
2323
|
+
const existingConfig = readVoiceConfig();
|
|
2324
|
+
if (existingConfig) {
|
|
2325
|
+
console.log(chalk.gray(" Existing configuration found:"));
|
|
2326
|
+
console.log(chalk.gray(` Model: ${existingConfig.model}`));
|
|
2327
|
+
console.log(chalk.gray(` Device: ${existingConfig.device}`));
|
|
2328
|
+
console.log(chalk.gray(` Sample Rate: ${existingConfig.sampleRate}Hz`));
|
|
2329
|
+
console.log(chalk.gray(` Auto Start: ${existingConfig.autoStart}`));
|
|
2330
|
+
console.log();
|
|
2331
|
+
const { reconfigure } = await inquirer.prompt([
|
|
2332
|
+
{
|
|
2333
|
+
type: "confirm",
|
|
2334
|
+
name: "reconfigure",
|
|
2335
|
+
message: "Reconfigure voice settings?",
|
|
2336
|
+
default: false,
|
|
2337
|
+
},
|
|
2338
|
+
]);
|
|
2339
|
+
if (!reconfigure) {
|
|
2340
|
+
console.log(chalk.gray("\n Setup cancelled. Using existing configuration.\n"));
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
console.log();
|
|
2344
|
+
}
|
|
2345
|
+
console.log(chalk.gray(" This wizard will help you set up voice input for JFL."));
|
|
2346
|
+
console.log(chalk.gray(" You'll configure:"));
|
|
2347
|
+
console.log(chalk.gray(" 1. Whisper model (for speech-to-text)"));
|
|
2348
|
+
console.log(chalk.gray(" 2. Microphone device"));
|
|
2349
|
+
console.log(chalk.gray(" 3. Test the audio pipeline"));
|
|
2350
|
+
console.log();
|
|
2351
|
+
ensureDirectories();
|
|
2352
|
+
// ============================================================================
|
|
2353
|
+
// Step 1: Select and download whisper model
|
|
2354
|
+
// ============================================================================
|
|
2355
|
+
console.log(chalk.cyan.bold(" Step 1: Select Whisper Model\n"));
|
|
2356
|
+
// Build model choices with download status
|
|
2357
|
+
const modelChoices = [
|
|
2358
|
+
{
|
|
2359
|
+
name: `${chalk.white("tiny")} (75 MB) - Fastest, lowest accuracy ${isModelDownloaded("tiny") ? chalk.green("[downloaded]") : ""}`,
|
|
2360
|
+
value: "tiny",
|
|
2361
|
+
},
|
|
2362
|
+
{
|
|
2363
|
+
name: `${chalk.white("tiny.en")} (75 MB) - English-only tiny ${isModelDownloaded("tiny.en") ? chalk.green("[downloaded]") : ""}`,
|
|
2364
|
+
value: "tiny.en",
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
name: `${chalk.white("base")} (142 MB) - Balanced, recommended ${isModelDownloaded("base") ? chalk.green("[downloaded]") : chalk.yellow("[recommended]")}`,
|
|
2368
|
+
value: "base",
|
|
2369
|
+
},
|
|
2370
|
+
{
|
|
2371
|
+
name: `${chalk.white("base.en")} (142 MB) - English-only base ${isModelDownloaded("base.en") ? chalk.green("[downloaded]") : ""}`,
|
|
2372
|
+
value: "base.en",
|
|
2373
|
+
},
|
|
2374
|
+
{
|
|
2375
|
+
name: `${chalk.white("small")} (466 MB) - Higher accuracy, slower ${isModelDownloaded("small") ? chalk.green("[downloaded]") : ""}`,
|
|
2376
|
+
value: "small",
|
|
2377
|
+
},
|
|
2378
|
+
{
|
|
2379
|
+
name: `${chalk.white("small.en")} (466 MB) - English-only small ${isModelDownloaded("small.en") ? chalk.green("[downloaded]") : ""}`,
|
|
2380
|
+
value: "small.en",
|
|
2381
|
+
},
|
|
2382
|
+
];
|
|
2383
|
+
const { selectedModel } = await inquirer.prompt([
|
|
2384
|
+
{
|
|
2385
|
+
type: "list",
|
|
2386
|
+
name: "selectedModel",
|
|
2387
|
+
message: "Select a whisper model:",
|
|
2388
|
+
choices: modelChoices,
|
|
2389
|
+
default: "base",
|
|
2390
|
+
},
|
|
2391
|
+
]);
|
|
2392
|
+
// Download model if needed
|
|
2393
|
+
if (!isModelDownloaded(selectedModel)) {
|
|
2394
|
+
console.log();
|
|
2395
|
+
const { confirmDownload } = await inquirer.prompt([
|
|
2396
|
+
{
|
|
2397
|
+
type: "confirm",
|
|
2398
|
+
name: "confirmDownload",
|
|
2399
|
+
message: `Download ${selectedModel} model (${WHISPER_MODELS[selectedModel].size})?`,
|
|
2400
|
+
default: true,
|
|
2401
|
+
},
|
|
2402
|
+
]);
|
|
2403
|
+
if (!confirmDownload) {
|
|
2404
|
+
console.log(chalk.yellow("\n Model not downloaded. Setup incomplete.\n"));
|
|
2405
|
+
console.log(chalk.gray(" Run 'jfl voice setup' again when ready.\n"));
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
// Download the model
|
|
2409
|
+
await downloadModelCommand(selectedModel);
|
|
2410
|
+
// Verify download succeeded
|
|
2411
|
+
if (!isModelDownloaded(selectedModel)) {
|
|
2412
|
+
console.log(chalk.red("\n Model download failed. Setup incomplete.\n"));
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
else {
|
|
2417
|
+
console.log(chalk.green(`\n ✓ Model '${selectedModel}' is already downloaded.\n`));
|
|
2418
|
+
}
|
|
2419
|
+
// ============================================================================
|
|
2420
|
+
// Step 2: Select microphone device
|
|
2421
|
+
// ============================================================================
|
|
2422
|
+
console.log(chalk.cyan.bold(" Step 2: Select Microphone\n"));
|
|
2423
|
+
const spinner = ora({
|
|
2424
|
+
text: "Detecting audio devices...",
|
|
2425
|
+
prefixText: " ",
|
|
2426
|
+
}).start();
|
|
2427
|
+
const devices = await AudioRecorder.listDevices();
|
|
2428
|
+
spinner.stop();
|
|
2429
|
+
if (devices.length === 0) {
|
|
2430
|
+
console.log(chalk.yellow(" No audio input devices found."));
|
|
2431
|
+
console.log(chalk.gray(" Make sure you have a microphone connected.\n"));
|
|
2432
|
+
const { continueWithDefault } = await inquirer.prompt([
|
|
2433
|
+
{
|
|
2434
|
+
type: "confirm",
|
|
2435
|
+
name: "continueWithDefault",
|
|
2436
|
+
message: "Continue with default device anyway?",
|
|
2437
|
+
default: true,
|
|
2438
|
+
},
|
|
2439
|
+
]);
|
|
2440
|
+
if (!continueWithDefault) {
|
|
2441
|
+
console.log(chalk.yellow("\n Setup cancelled.\n"));
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
let selectedDevice = "default";
|
|
2446
|
+
if (devices.length > 0) {
|
|
2447
|
+
// Build device choices
|
|
2448
|
+
const deviceChoices = devices.map((device) => ({
|
|
2449
|
+
name: `${device.name}${device.isDefault ? chalk.cyan(" (system default)") : ""}`,
|
|
2450
|
+
value: device.id,
|
|
2451
|
+
}));
|
|
2452
|
+
// Add "default" option at the top
|
|
2453
|
+
deviceChoices.unshift({
|
|
2454
|
+
name: `${chalk.white("default")} - Use system default device`,
|
|
2455
|
+
value: "default",
|
|
2456
|
+
});
|
|
2457
|
+
const { device } = await inquirer.prompt([
|
|
2458
|
+
{
|
|
2459
|
+
type: "list",
|
|
2460
|
+
name: "device",
|
|
2461
|
+
message: "Select microphone:",
|
|
2462
|
+
choices: deviceChoices,
|
|
2463
|
+
default: "default",
|
|
2464
|
+
},
|
|
2465
|
+
]);
|
|
2466
|
+
selectedDevice = device;
|
|
2467
|
+
}
|
|
2468
|
+
// ============================================================================
|
|
2469
|
+
// Step 3: Check permissions (macOS)
|
|
2470
|
+
// ============================================================================
|
|
2471
|
+
if (platform() === "darwin") {
|
|
2472
|
+
console.log(chalk.cyan.bold("\n Step 3: Check Permissions\n"));
|
|
2473
|
+
const permSpinner = ora({
|
|
2474
|
+
text: "Checking microphone permissions...",
|
|
2475
|
+
prefixText: " ",
|
|
2476
|
+
}).start();
|
|
2477
|
+
const permStatus = await checkMicPermissions();
|
|
2478
|
+
if (permStatus.granted) {
|
|
2479
|
+
permSpinner.succeed("Microphone permission granted");
|
|
2480
|
+
}
|
|
2481
|
+
else {
|
|
2482
|
+
permSpinner.warn("Microphone permission may be needed");
|
|
2483
|
+
console.log(chalk.yellow(`\n ${permStatus.message}`));
|
|
2484
|
+
console.log();
|
|
2485
|
+
const { openPrefs } = await inquirer.prompt([
|
|
2486
|
+
{
|
|
2487
|
+
type: "confirm",
|
|
2488
|
+
name: "openPrefs",
|
|
2489
|
+
message: "Open System Preferences > Privacy & Security?",
|
|
2490
|
+
default: true,
|
|
2491
|
+
},
|
|
2492
|
+
]);
|
|
2493
|
+
if (openPrefs) {
|
|
2494
|
+
try {
|
|
2495
|
+
execSync("open x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", {
|
|
2496
|
+
stdio: "ignore",
|
|
2497
|
+
});
|
|
2498
|
+
console.log(chalk.gray("\n Grant microphone access to Terminal (or your terminal app).\n"));
|
|
2499
|
+
const { permGranted } = await inquirer.prompt([
|
|
2500
|
+
{
|
|
2501
|
+
type: "confirm",
|
|
2502
|
+
name: "permGranted",
|
|
2503
|
+
message: "Did you grant microphone permission?",
|
|
2504
|
+
default: true,
|
|
2505
|
+
},
|
|
2506
|
+
]);
|
|
2507
|
+
if (!permGranted) {
|
|
2508
|
+
console.log(chalk.yellow("\n You may need to grant permission for voice input to work.\n"));
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
catch {
|
|
2512
|
+
console.log(chalk.gray(" Could not open System Preferences automatically."));
|
|
2513
|
+
console.log(chalk.gray(" Please manually go to System Preferences > Privacy & Security > Microphone\n"));
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
// ============================================================================
|
|
2519
|
+
// Step 4: Test the microphone
|
|
2520
|
+
// ============================================================================
|
|
2521
|
+
console.log(chalk.cyan.bold("\n Step 4: Test Microphone\n"));
|
|
2522
|
+
const { runTest } = await inquirer.prompt([
|
|
2523
|
+
{
|
|
2524
|
+
type: "confirm",
|
|
2525
|
+
name: "runTest",
|
|
2526
|
+
message: "Test the microphone for 3 seconds?",
|
|
2527
|
+
default: true,
|
|
2528
|
+
},
|
|
2529
|
+
]);
|
|
2530
|
+
let testPassed = false;
|
|
2531
|
+
if (runTest) {
|
|
2532
|
+
console.log(chalk.gray("\n Recording for 3 seconds... Speak into your microphone!\n"));
|
|
2533
|
+
const testSpinner = ora({
|
|
2534
|
+
text: "Recording...",
|
|
2535
|
+
prefixText: " ",
|
|
2536
|
+
}).start();
|
|
2537
|
+
const result = await testAudioDevice(selectedDevice, 3);
|
|
2538
|
+
if (result.success) {
|
|
2539
|
+
testSpinner.succeed("Recording complete!");
|
|
2540
|
+
console.log();
|
|
2541
|
+
// Show level meter
|
|
2542
|
+
const levelBars = Math.round(result.peakLevel * 20);
|
|
2543
|
+
const meter = "█".repeat(levelBars) + "░".repeat(20 - levelBars);
|
|
2544
|
+
console.log(chalk.gray(` Peak level: [${meter}] ${(result.peakLevel * 100).toFixed(1)}%`));
|
|
2545
|
+
if (result.peakLevel < 0.01) {
|
|
2546
|
+
console.log(chalk.yellow("\n ⚠️ No audio detected. Check your microphone connection."));
|
|
2547
|
+
}
|
|
2548
|
+
else if (result.peakLevel < 0.1) {
|
|
2549
|
+
console.log(chalk.yellow("\n ⚠️ Audio level is low. Consider increasing microphone gain."));
|
|
2550
|
+
testPassed = true;
|
|
2551
|
+
}
|
|
2552
|
+
else {
|
|
2553
|
+
console.log(chalk.green("\n ✓ Audio input is working correctly!"));
|
|
2554
|
+
testPassed = true;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
testSpinner.fail("Test failed");
|
|
2559
|
+
console.log(chalk.red(`\n Error: ${result.error}`));
|
|
2560
|
+
console.log(chalk.gray("\n Make sure you have sox installed:"));
|
|
2561
|
+
console.log(chalk.gray(" macOS: brew install sox"));
|
|
2562
|
+
console.log(chalk.gray(" Linux: sudo apt-get install sox libsox-fmt-all"));
|
|
2563
|
+
console.log(chalk.gray(" Windows: choco install sox.portable"));
|
|
2564
|
+
}
|
|
2565
|
+
// Option to retry with different device
|
|
2566
|
+
if (!testPassed && devices.length > 1) {
|
|
2567
|
+
console.log();
|
|
2568
|
+
const { tryAnother } = await inquirer.prompt([
|
|
2569
|
+
{
|
|
2570
|
+
type: "confirm",
|
|
2571
|
+
name: "tryAnother",
|
|
2572
|
+
message: "Try a different device?",
|
|
2573
|
+
default: true,
|
|
2574
|
+
},
|
|
2575
|
+
]);
|
|
2576
|
+
if (tryAnother) {
|
|
2577
|
+
// Let them pick again
|
|
2578
|
+
const remainingDevices = devices.filter((d) => d.id !== selectedDevice);
|
|
2579
|
+
const retryChoices = remainingDevices.map((device) => ({
|
|
2580
|
+
name: `${device.name}${device.isDefault ? chalk.cyan(" (system default)") : ""}`,
|
|
2581
|
+
value: device.id,
|
|
2582
|
+
}));
|
|
2583
|
+
if (retryChoices.length > 0) {
|
|
2584
|
+
const { retryDevice } = await inquirer.prompt([
|
|
2585
|
+
{
|
|
2586
|
+
type: "list",
|
|
2587
|
+
name: "retryDevice",
|
|
2588
|
+
message: "Select another device:",
|
|
2589
|
+
choices: retryChoices,
|
|
2590
|
+
},
|
|
2591
|
+
]);
|
|
2592
|
+
selectedDevice = retryDevice;
|
|
2593
|
+
console.log(chalk.gray("\n Recording for 3 seconds with new device...\n"));
|
|
2594
|
+
const retrySpinner = ora({
|
|
2595
|
+
text: "Recording...",
|
|
2596
|
+
prefixText: " ",
|
|
2597
|
+
}).start();
|
|
2598
|
+
const retryResult = await testAudioDevice(selectedDevice, 3);
|
|
2599
|
+
if (retryResult.success && retryResult.peakLevel >= 0.01) {
|
|
2600
|
+
retrySpinner.succeed("Recording complete!");
|
|
2601
|
+
const levelBars2 = Math.round(retryResult.peakLevel * 20);
|
|
2602
|
+
const meter2 = "█".repeat(levelBars2) + "░".repeat(20 - levelBars2);
|
|
2603
|
+
console.log(chalk.gray(`\n Peak level: [${meter2}] ${(retryResult.peakLevel * 100).toFixed(1)}%`));
|
|
2604
|
+
console.log(chalk.green("\n ✓ Audio input is working with this device!"));
|
|
2605
|
+
testPassed = true;
|
|
2606
|
+
}
|
|
2607
|
+
else {
|
|
2608
|
+
retrySpinner.fail("Test failed with this device too");
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
else {
|
|
2615
|
+
console.log(chalk.gray("\n Skipping microphone test."));
|
|
2616
|
+
testPassed = true; // Assume it works if user skips
|
|
2617
|
+
}
|
|
2618
|
+
// ============================================================================
|
|
2619
|
+
// Step 5: Save configuration
|
|
2620
|
+
// ============================================================================
|
|
2621
|
+
console.log(chalk.cyan.bold("\n Step 5: Save Configuration\n"));
|
|
2622
|
+
// Ask about auto-start preference
|
|
2623
|
+
const { autoStart } = await inquirer.prompt([
|
|
2624
|
+
{
|
|
2625
|
+
type: "confirm",
|
|
2626
|
+
name: "autoStart",
|
|
2627
|
+
message: "Auto-start whisper server when using voice? (recommended)",
|
|
2628
|
+
default: false,
|
|
2629
|
+
},
|
|
2630
|
+
]);
|
|
2631
|
+
// Build final config (preserve existing hotkey, preview, and security settings if any)
|
|
2632
|
+
const existingVoiceConfig = readVoiceConfig();
|
|
2633
|
+
const config = {
|
|
2634
|
+
model: selectedModel,
|
|
2635
|
+
device: selectedDevice,
|
|
2636
|
+
sampleRate: 16000,
|
|
2637
|
+
autoStart,
|
|
2638
|
+
hotkey: existingVoiceConfig?.hotkey || { ...DEFAULT_HOTKEY_CONFIG },
|
|
2639
|
+
preview: existingVoiceConfig?.preview || { ...DEFAULT_PREVIEW_CONFIG },
|
|
2640
|
+
security: existingVoiceConfig?.security || { ...DEFAULT_SECURITY_CONFIG },
|
|
2641
|
+
};
|
|
2642
|
+
// Save config
|
|
2643
|
+
writeVoiceConfig(config);
|
|
2644
|
+
setDefaultModel(selectedModel);
|
|
2645
|
+
console.log(chalk.green("\n ✓ Configuration saved to ~/.jfl/voice.yaml"));
|
|
2646
|
+
console.log();
|
|
2647
|
+
console.log(chalk.gray(" Configuration:"));
|
|
2648
|
+
console.log(chalk.gray(` Model: ${config.model}`));
|
|
2649
|
+
console.log(chalk.gray(` Device: ${config.device}`));
|
|
2650
|
+
console.log(chalk.gray(` Sample Rate: ${config.sampleRate}Hz`));
|
|
2651
|
+
console.log(chalk.gray(` Auto Start: ${config.autoStart}`));
|
|
2652
|
+
// ============================================================================
|
|
2653
|
+
// Summary
|
|
2654
|
+
// ============================================================================
|
|
2655
|
+
console.log(chalk.bold("\n ✅ Setup Complete!\n"));
|
|
2656
|
+
if (testPassed) {
|
|
2657
|
+
console.log(chalk.green(" Voice input is ready to use."));
|
|
2658
|
+
}
|
|
2659
|
+
else {
|
|
2660
|
+
console.log(chalk.yellow(" Voice input configured, but microphone test did not pass."));
|
|
2661
|
+
console.log(chalk.gray(" Run 'jfl voice test' to troubleshoot."));
|
|
2662
|
+
}
|
|
2663
|
+
console.log();
|
|
2664
|
+
console.log(chalk.cyan(" Next steps:"));
|
|
2665
|
+
console.log(chalk.gray(" jfl voice test Test microphone again"));
|
|
2666
|
+
console.log(chalk.gray(" jfl voice server start Start the whisper server"));
|
|
2667
|
+
console.log(chalk.gray(" jfl voice setup Re-run this wizard"));
|
|
2668
|
+
console.log();
|
|
2669
|
+
}
|
|
2670
|
+
// =============================================================================
|
|
2671
|
+
// Voice Slash Command - /voice with VAD
|
|
2672
|
+
// =============================================================================
|
|
2673
|
+
/** Show voice command help */
|
|
2674
|
+
function showVoiceHelp() {
|
|
2675
|
+
console.log(chalk.bold("\n🎤 JFL Voice\n"));
|
|
2676
|
+
console.log(chalk.gray("Voice input for JFL CLI with automatic silence detection.\n"));
|
|
2677
|
+
console.log(chalk.cyan("Quick Start:"));
|
|
2678
|
+
console.log(" jfl voice Start recording (stops on silence)");
|
|
2679
|
+
console.log(" jfl voice record Same as above");
|
|
2680
|
+
console.log(chalk.cyan("\nModel Management:"));
|
|
2681
|
+
console.log(" jfl voice model list List available models");
|
|
2682
|
+
console.log(" jfl voice model download <name> Download a model");
|
|
2683
|
+
console.log(" jfl voice model default <name> Set default model");
|
|
2684
|
+
console.log(chalk.cyan("\nHotkey Mode (macOS):"));
|
|
2685
|
+
console.log(" jfl voice hotkey Start global hotkey listener");
|
|
2686
|
+
console.log(" jfl voice hotkey --mode <mode> Set hotkey mode: auto, tap, or hold");
|
|
2687
|
+
console.log(chalk.gray(" Ctrl+Shift+Space triggers recording"));
|
|
2688
|
+
console.log(chalk.gray(" auto: Tap to toggle, or hold to talk"));
|
|
2689
|
+
console.log(chalk.gray(" tap: Tap to start/stop recording"));
|
|
2690
|
+
console.log(chalk.gray(" hold: Hold to record, release to stop"));
|
|
2691
|
+
console.log(chalk.gray(" Requires Accessibility permission"));
|
|
2692
|
+
console.log(chalk.cyan("\nDaemon Mode (macOS):"));
|
|
2693
|
+
console.log(" jfl voice daemon start Start hotkey listener in background");
|
|
2694
|
+
console.log(" jfl voice daemon stop Stop the background daemon");
|
|
2695
|
+
console.log(" jfl voice daemon status Show daemon status and uptime");
|
|
2696
|
+
console.log(" jfl voice daemon start --mode <m> Start daemon with mode: auto, tap, hold");
|
|
2697
|
+
console.log(chalk.gray(" Daemon survives terminal close"));
|
|
2698
|
+
console.log(chalk.gray(" PID stored in ~/.jfl/voice-daemon.pid"));
|
|
2699
|
+
console.log(chalk.cyan("\nAudio Input:"));
|
|
2700
|
+
console.log(" jfl voice devices List audio input devices");
|
|
2701
|
+
console.log(" jfl voice test Test voice input (record + transcribe)");
|
|
2702
|
+
console.log(" jfl voice test --device <id> Test with specific device");
|
|
2703
|
+
console.log(" jfl voice recording Test recording only (no transcription)");
|
|
2704
|
+
console.log(" jfl voice recording --duration N Record for N seconds (default: 5)");
|
|
2705
|
+
console.log(chalk.cyan("\nServer Commands: (coming soon)"));
|
|
2706
|
+
console.log(chalk.gray(" jfl voice server start Start whisper server"));
|
|
2707
|
+
console.log(chalk.gray(" jfl voice server stop Stop whisper server"));
|
|
2708
|
+
console.log(chalk.gray(" jfl voice server status Show server status"));
|
|
2709
|
+
console.log(chalk.cyan("\nSetup:"));
|
|
2710
|
+
console.log(" jfl voice setup First-time setup wizard");
|
|
2711
|
+
console.log(" jfl voice help Show this help");
|
|
2712
|
+
console.log();
|
|
2713
|
+
}
|
|
2714
|
+
/** Convert linear amplitude to dB */
|
|
2715
|
+
function amplitudeToDb(amplitude) {
|
|
2716
|
+
if (amplitude <= 0)
|
|
2717
|
+
return -100;
|
|
2718
|
+
return 20 * Math.log10(amplitude);
|
|
2719
|
+
}
|
|
2720
|
+
/** Calculate peak amplitude from 16-bit PCM audio chunk */
|
|
2721
|
+
function calculatePeakAmplitude(chunk) {
|
|
2722
|
+
let peak = 0;
|
|
2723
|
+
for (let i = 0; i < chunk.length - 1; i += 2) {
|
|
2724
|
+
const sample = chunk.readInt16LE(i);
|
|
2725
|
+
const amplitude = Math.abs(sample) / 32768;
|
|
2726
|
+
if (amplitude > peak) {
|
|
2727
|
+
peak = amplitude;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return peak;
|
|
2731
|
+
}
|
|
2732
|
+
// =============================================================================
|
|
2733
|
+
// VS-012: Waveform Visualization
|
|
2734
|
+
// =============================================================================
|
|
2735
|
+
/** Unicode block characters for waveform visualization (sorted by height) */
|
|
2736
|
+
const WAVEFORM_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
2737
|
+
/** Rolling buffer size for waveform display */
|
|
2738
|
+
const WAVEFORM_BUFFER_SIZE = 7;
|
|
2739
|
+
/** Rolling buffer to store recent audio levels for waveform display */
|
|
2740
|
+
let waveformBuffer = [];
|
|
2741
|
+
/**
|
|
2742
|
+
* Map a dB level to a waveform character
|
|
2743
|
+
* @param dbLevel - Audio level in dB (typically -60 to 0)
|
|
2744
|
+
* @returns A Unicode block character representing the level
|
|
2745
|
+
*/
|
|
2746
|
+
function dbToWaveformChar(dbLevel) {
|
|
2747
|
+
// Map dB range (-60 to 0) to character index (0 to 7)
|
|
2748
|
+
// -60 dB or below = lowest bar, 0 dB = highest bar
|
|
2749
|
+
const minDb = -60;
|
|
2750
|
+
const maxDb = 0;
|
|
2751
|
+
const clampedDb = Math.max(minDb, Math.min(maxDb, dbLevel));
|
|
2752
|
+
// Normalize to 0-1 range
|
|
2753
|
+
const normalized = (clampedDb - minDb) / (maxDb - minDb);
|
|
2754
|
+
// Map to character index
|
|
2755
|
+
const index = Math.floor(normalized * (WAVEFORM_BLOCKS.length - 1));
|
|
2756
|
+
return WAVEFORM_BLOCKS[index];
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Add a level to the waveform buffer
|
|
2760
|
+
* @param dbLevel - Audio level in dB
|
|
2761
|
+
*/
|
|
2762
|
+
function addToWaveformBuffer(dbLevel) {
|
|
2763
|
+
waveformBuffer.push(dbLevel);
|
|
2764
|
+
if (waveformBuffer.length > WAVEFORM_BUFFER_SIZE) {
|
|
2765
|
+
waveformBuffer.shift();
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Reset the waveform buffer (call at start of new recording)
|
|
2770
|
+
*/
|
|
2771
|
+
function resetWaveformBuffer() {
|
|
2772
|
+
waveformBuffer = [];
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Render the waveform visualization from the rolling buffer
|
|
2776
|
+
* @returns A string like "▁▃▅▇▅▃▁" representing recent audio levels
|
|
2777
|
+
*/
|
|
2778
|
+
function renderWaveform() {
|
|
2779
|
+
if (waveformBuffer.length === 0) {
|
|
2780
|
+
// Return minimal bars when no data yet
|
|
2781
|
+
return WAVEFORM_BLOCKS[0].repeat(WAVEFORM_BUFFER_SIZE);
|
|
2782
|
+
}
|
|
2783
|
+
// Pad with low values if buffer isn't full yet
|
|
2784
|
+
const paddedBuffer = [...waveformBuffer];
|
|
2785
|
+
while (paddedBuffer.length < WAVEFORM_BUFFER_SIZE) {
|
|
2786
|
+
paddedBuffer.unshift(-60); // Pad with silence at the start
|
|
2787
|
+
}
|
|
2788
|
+
return paddedBuffer.map(db => dbToWaveformChar(db)).join("");
|
|
2789
|
+
}
|
|
2790
|
+
/**
|
|
2791
|
+
* Check if terminal supports Unicode waveform characters
|
|
2792
|
+
* @returns true if waveform should be displayed
|
|
2793
|
+
*/
|
|
2794
|
+
function supportsWaveform() {
|
|
2795
|
+
// Check for dumb terminal
|
|
2796
|
+
if (process.env.TERM === "dumb") {
|
|
2797
|
+
return false;
|
|
2798
|
+
}
|
|
2799
|
+
// Check for Windows cmd.exe (not PowerShell or Windows Terminal)
|
|
2800
|
+
if (process.platform === "win32" && !process.env.WT_SESSION && !process.env.TERM_PROGRAM) {
|
|
2801
|
+
return false;
|
|
2802
|
+
}
|
|
2803
|
+
return true;
|
|
2804
|
+
}
|
|
2805
|
+
/** Copy text to clipboard (cross-platform) */
|
|
2806
|
+
function copyToClipboard(text) {
|
|
2807
|
+
const currentPlatform = platform();
|
|
2808
|
+
try {
|
|
2809
|
+
if (currentPlatform === "darwin") {
|
|
2810
|
+
execSync("pbcopy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
2811
|
+
return true;
|
|
2812
|
+
}
|
|
2813
|
+
else if (currentPlatform === "linux") {
|
|
2814
|
+
// Try xclip first, then xsel
|
|
2815
|
+
try {
|
|
2816
|
+
execSync("xclip -selection clipboard", { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
2817
|
+
return true;
|
|
2818
|
+
}
|
|
2819
|
+
catch {
|
|
2820
|
+
try {
|
|
2821
|
+
execSync("xsel --clipboard --input", { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
2822
|
+
return true;
|
|
2823
|
+
}
|
|
2824
|
+
catch {
|
|
2825
|
+
return false;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
else if (currentPlatform === "win32") {
|
|
2830
|
+
execSync("clip", { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
2831
|
+
return true;
|
|
2832
|
+
}
|
|
2833
|
+
return false;
|
|
2834
|
+
}
|
|
2835
|
+
catch {
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* Clear the clipboard contents (VS-SEC-3: clipboard hygiene)
|
|
2841
|
+
* Uses osascript on macOS to set clipboard to empty string
|
|
2842
|
+
* Returns true on success, false on failure
|
|
2843
|
+
*/
|
|
2844
|
+
function clearClipboard() {
|
|
2845
|
+
const currentPlatform = platform();
|
|
2846
|
+
try {
|
|
2847
|
+
if (currentPlatform === "darwin") {
|
|
2848
|
+
// Use osascript to clear clipboard on macOS
|
|
2849
|
+
execSync(`osascript -e 'set the clipboard to ""'`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
2850
|
+
return true;
|
|
2851
|
+
}
|
|
2852
|
+
else if (currentPlatform === "linux") {
|
|
2853
|
+
// Try xclip first, then xsel
|
|
2854
|
+
try {
|
|
2855
|
+
execSync("xclip -selection clipboard", { input: "", stdio: ["pipe", "ignore", "ignore"] });
|
|
2856
|
+
return true;
|
|
2857
|
+
}
|
|
2858
|
+
catch {
|
|
2859
|
+
try {
|
|
2860
|
+
execSync("xsel --clipboard --input", { input: "", stdio: ["pipe", "ignore", "ignore"] });
|
|
2861
|
+
return true;
|
|
2862
|
+
}
|
|
2863
|
+
catch {
|
|
2864
|
+
return false;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
else if (currentPlatform === "win32") {
|
|
2869
|
+
// Use PowerShell to clear clipboard on Windows
|
|
2870
|
+
execSync("powershell.exe -command \"Set-Clipboard -Value ''\"", { stdio: ["pipe", "ignore", "ignore"] });
|
|
2871
|
+
return true;
|
|
2872
|
+
}
|
|
2873
|
+
return false;
|
|
2874
|
+
}
|
|
2875
|
+
catch {
|
|
2876
|
+
return false;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
/**
|
|
2880
|
+
* Schedule clipboard clearing after a delay (VS-SEC-3)
|
|
2881
|
+
* @param delaySeconds Seconds to wait before clearing (default: 5)
|
|
2882
|
+
* @returns Timer reference for potential cancellation
|
|
2883
|
+
*/
|
|
2884
|
+
function scheduleClipboardClear(delaySeconds = 5) {
|
|
2885
|
+
return setTimeout(() => {
|
|
2886
|
+
const cleared = clearClipboard();
|
|
2887
|
+
if (process.env.DEBUG && cleared) {
|
|
2888
|
+
console.log(chalk.gray(" [debug] Clipboard cleared for security"));
|
|
2889
|
+
}
|
|
2890
|
+
}, delaySeconds * 1000);
|
|
2891
|
+
}
|
|
2892
|
+
/**
|
|
2893
|
+
* Securely zero out a Buffer's contents (VS-SEC-3: buffer hygiene)
|
|
2894
|
+
* Overwrites the buffer with zeros to prevent sensitive audio data from lingering in memory
|
|
2895
|
+
* @param buffer The Buffer to zero out
|
|
2896
|
+
*/
|
|
2897
|
+
function zeroBuffer(buffer) {
|
|
2898
|
+
if (buffer && buffer.length > 0) {
|
|
2899
|
+
buffer.fill(0);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Securely zero out an array of Buffers (VS-SEC-3)
|
|
2904
|
+
* @param buffers Array of Buffers to zero out
|
|
2905
|
+
*/
|
|
2906
|
+
function zeroBuffers(buffers) {
|
|
2907
|
+
for (const buffer of buffers) {
|
|
2908
|
+
zeroBuffer(buffer);
|
|
2909
|
+
}
|
|
2910
|
+
// Clear the array reference
|
|
2911
|
+
buffers.length = 0;
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Normalize audio buffer to improve transcription quality.
|
|
2915
|
+
* Many microphones record at very low levels which causes blank transcriptions.
|
|
2916
|
+
* This normalizes to a target RMS for better whisper recognition.
|
|
2917
|
+
*
|
|
2918
|
+
* @param audioBuffer - 16-bit PCM audio buffer
|
|
2919
|
+
* @returns Normalized buffer (may be the same buffer if no normalization needed)
|
|
2920
|
+
*/
|
|
2921
|
+
function normalizeAudio(audioBuffer) {
|
|
2922
|
+
const samples = audioBuffer.length / 2;
|
|
2923
|
+
let sumSquares = 0;
|
|
2924
|
+
let maxSample = 0;
|
|
2925
|
+
for (let i = 0; i < samples; i++) {
|
|
2926
|
+
const sample = audioBuffer.readInt16LE(i * 2);
|
|
2927
|
+
sumSquares += sample * sample;
|
|
2928
|
+
if (Math.abs(sample) > maxSample)
|
|
2929
|
+
maxSample = Math.abs(sample);
|
|
2930
|
+
}
|
|
2931
|
+
const rms = Math.sqrt(sumSquares / samples);
|
|
2932
|
+
// Target RMS of 3000 for good whisper recognition
|
|
2933
|
+
const targetRms = 3000;
|
|
2934
|
+
if (rms > 0 && rms < targetRms) {
|
|
2935
|
+
const maxGain = 32767 / (maxSample || 1);
|
|
2936
|
+
const desiredGain = targetRms / rms;
|
|
2937
|
+
// Allow up to 3x the clipping-safe gain, accepting some peak clipping
|
|
2938
|
+
const gain = Math.min(desiredGain, maxGain * 3);
|
|
2939
|
+
if (gain > 1.2) {
|
|
2940
|
+
const amplified = Buffer.alloc(audioBuffer.length);
|
|
2941
|
+
for (let i = 0; i < samples; i++) {
|
|
2942
|
+
let sample = audioBuffer.readInt16LE(i * 2);
|
|
2943
|
+
sample = Math.round(Math.max(-32768, Math.min(32767, sample * gain)));
|
|
2944
|
+
amplified.writeInt16LE(sample, i * 2);
|
|
2945
|
+
}
|
|
2946
|
+
if (process.env.JFL_VOICE_DEBUG) {
|
|
2947
|
+
console.log(`[Voice] Audio normalized: gain=${gain.toFixed(2)}x, rms=${rms.toFixed(0)}->${(rms * gain).toFixed(0)}`);
|
|
2948
|
+
}
|
|
2949
|
+
return amplified;
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
if (process.env.JFL_VOICE_DEBUG && rms > 0) {
|
|
2953
|
+
console.log(`[Voice] Audio level ok: rms=${rms.toFixed(0)}, max=${maxSample}`);
|
|
2954
|
+
}
|
|
2955
|
+
return audioBuffer;
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Get the name of the currently focused application
|
|
2959
|
+
* - macOS: via osascript
|
|
2960
|
+
* - Linux: via xdotool (X11 only)
|
|
2961
|
+
* - Windows: via PowerShell
|
|
2962
|
+
* VS-010/VS-011: Cross-platform focused app detection
|
|
2963
|
+
* Returns null if unable to determine
|
|
2964
|
+
*/
|
|
2965
|
+
function getFocusedApp() {
|
|
2966
|
+
const currentPlatform = platform();
|
|
2967
|
+
try {
|
|
2968
|
+
if (currentPlatform === "darwin") {
|
|
2969
|
+
const result = execSync(`osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
2970
|
+
return result.trim();
|
|
2971
|
+
}
|
|
2972
|
+
else if (currentPlatform === "linux") {
|
|
2973
|
+
// VS-010: Get focused window on Linux X11 using xdotool
|
|
2974
|
+
const windowId = execSync(`xdotool getactivewindow`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
2975
|
+
if (windowId) {
|
|
2976
|
+
const windowName = execSync(`xdotool getwindowname ${windowId}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
2977
|
+
return windowName || null;
|
|
2978
|
+
}
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
else if (currentPlatform === "win32") {
|
|
2982
|
+
// VS-011: Get focused window on Windows using PowerShell
|
|
2983
|
+
const result = execSync(`powershell.exe -command "(Get-Process | Where-Object {$_.MainWindowHandle -eq (Add-Type -MemberDefinition '[DllImport(\\\"user32.dll\\\")]public static extern IntPtr GetForegroundWindow();' -Name 'Win32' -Namespace 'Native' -PassThru)::GetForegroundWindow()}).MainWindowTitle"`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
2984
|
+
return result.trim() || null;
|
|
2985
|
+
}
|
|
2986
|
+
return null;
|
|
2987
|
+
}
|
|
2988
|
+
catch {
|
|
2989
|
+
return null;
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Get the platform-appropriate paste shortcut string for display
|
|
2994
|
+
* VS-010/VS-011: Cross-platform paste shortcut labels
|
|
2995
|
+
*/
|
|
2996
|
+
function getPasteShortcut() {
|
|
2997
|
+
const currentPlatform = platform();
|
|
2998
|
+
if (currentPlatform === "darwin") {
|
|
2999
|
+
return "Cmd+V";
|
|
3000
|
+
}
|
|
3001
|
+
else if (currentPlatform === "linux") {
|
|
3002
|
+
return "Ctrl+Shift+V";
|
|
3003
|
+
}
|
|
3004
|
+
else {
|
|
3005
|
+
return "Ctrl+V";
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
3009
|
+
* Simulate paste keystroke
|
|
3010
|
+
* - macOS: Cmd+V via osascript
|
|
3011
|
+
* - Linux: Ctrl+Shift+V via xdotool (X11 only)
|
|
3012
|
+
* - Windows: Ctrl+V via PowerShell SendKeys
|
|
3013
|
+
* VS-010/VS-011: Cross-platform paste simulation
|
|
3014
|
+
* Returns true on success, false on failure
|
|
3015
|
+
*/
|
|
3016
|
+
function simulatePaste() {
|
|
3017
|
+
const currentPlatform = platform();
|
|
3018
|
+
try {
|
|
3019
|
+
if (currentPlatform === "darwin") {
|
|
3020
|
+
execSync(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
3021
|
+
return true;
|
|
3022
|
+
}
|
|
3023
|
+
else if (currentPlatform === "linux") {
|
|
3024
|
+
// VS-010: Linux paste via xdotool (X11 only)
|
|
3025
|
+
// Use Ctrl+Shift+V for terminal compatibility
|
|
3026
|
+
execSync(`xdotool key --clearmodifiers ctrl+shift+v`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
3027
|
+
return true;
|
|
3028
|
+
}
|
|
3029
|
+
else if (currentPlatform === "win32") {
|
|
3030
|
+
// VS-011: Windows paste via PowerShell SendKeys
|
|
3031
|
+
// ^v is Ctrl+V in SendKeys notation
|
|
3032
|
+
execSync(`powershell.exe -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('^v')"`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
3033
|
+
return true;
|
|
3034
|
+
}
|
|
3035
|
+
return false;
|
|
3036
|
+
}
|
|
3037
|
+
catch {
|
|
3038
|
+
return false;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Show a desktop notification with title and message
|
|
3043
|
+
* - macOS: via osascript
|
|
3044
|
+
* - Linux: via notify-send (libnotify)
|
|
3045
|
+
* - Windows: via PowerShell toast notification
|
|
3046
|
+
* VS-010/VS-011: Cross-platform notification support
|
|
3047
|
+
* Returns true on success, false on failure
|
|
3048
|
+
*/
|
|
3049
|
+
function showNotification(title, message) {
|
|
3050
|
+
const currentPlatform = platform();
|
|
3051
|
+
try {
|
|
3052
|
+
if (currentPlatform === "darwin") {
|
|
3053
|
+
// Escape backslashes and double quotes for AppleScript strings
|
|
3054
|
+
const escapedMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3055
|
+
const escapedTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3056
|
+
execSync(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
3057
|
+
return true;
|
|
3058
|
+
}
|
|
3059
|
+
else if (currentPlatform === "linux") {
|
|
3060
|
+
// VS-010: Linux notification via notify-send (part of libnotify)
|
|
3061
|
+
// Escape single quotes for shell
|
|
3062
|
+
const escapedMessage = message.replace(/'/g, "'\\''");
|
|
3063
|
+
const escapedTitle = title.replace(/'/g, "'\\''");
|
|
3064
|
+
execSync(`notify-send '${escapedTitle}' '${escapedMessage}'`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
3065
|
+
return true;
|
|
3066
|
+
}
|
|
3067
|
+
else if (currentPlatform === "win32") {
|
|
3068
|
+
// VS-011: Windows toast notification via PowerShell
|
|
3069
|
+
// Escape for PowerShell string
|
|
3070
|
+
const escapedMessage = message.replace(/'/g, "''").replace(/`/g, "``");
|
|
3071
|
+
const escapedTitle = title.replace(/'/g, "''").replace(/`/g, "``");
|
|
3072
|
+
execSync(`powershell.exe -command "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $texts = $xml.GetElementsByTagName('text'); $texts[0].AppendChild($xml.CreateTextNode('${escapedTitle}')); $texts[1].AppendChild($xml.CreateTextNode('${escapedMessage}')); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('JFL Voice').Show([Windows.UI.Notifications.ToastNotification]::new($xml))"`, { stdio: ["pipe", "ignore", "ignore"] });
|
|
3073
|
+
return true;
|
|
3074
|
+
}
|
|
3075
|
+
return false;
|
|
3076
|
+
}
|
|
3077
|
+
catch {
|
|
3078
|
+
return false;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
// =============================================================================
|
|
3082
|
+
// VS-UX-2: System Recording Indicator
|
|
3083
|
+
// =============================================================================
|
|
3084
|
+
/**
|
|
3085
|
+
* Show system recording indicator via desktop notification
|
|
3086
|
+
* This provides a visual indicator that recording is active, even when terminal is not visible
|
|
3087
|
+
* VS-010/VS-011: Now works on macOS, Linux, and Windows
|
|
3088
|
+
* Returns true on success, false on failure
|
|
3089
|
+
*/
|
|
3090
|
+
export function showRecordingIndicator() {
|
|
3091
|
+
return showNotification("Voice Recording", "Recording started... Press Ctrl+Shift+Space to stop");
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Hide system recording indicator (show stopped notification)
|
|
3095
|
+
* VS-010/VS-011: Now works on macOS, Linux, and Windows
|
|
3096
|
+
* Returns true on success, false on failure
|
|
3097
|
+
*/
|
|
3098
|
+
export function hideRecordingIndicator(reason) {
|
|
3099
|
+
const messages = {
|
|
3100
|
+
stopped: "Recording stopped",
|
|
3101
|
+
cancelled: "Recording cancelled",
|
|
3102
|
+
completed: "Recording complete - transcribing...",
|
|
3103
|
+
};
|
|
3104
|
+
const message = messages[reason || "stopped"] || "Recording stopped";
|
|
3105
|
+
return showNotification("Voice Recording", message);
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Preview transcript with configurable auto-send countdown
|
|
3109
|
+
*
|
|
3110
|
+
* Behavior:
|
|
3111
|
+
* - Shows transcript with countdown (if timeout > 0)
|
|
3112
|
+
* - Enter: send immediately
|
|
3113
|
+
* - Esc: cancel entirely
|
|
3114
|
+
* - Any other key: pause countdown and enter edit mode
|
|
3115
|
+
* - In edit mode: user can modify text, Enter to send, Esc to cancel
|
|
3116
|
+
* - If countdown reaches 0: send automatically
|
|
3117
|
+
*
|
|
3118
|
+
* @param transcript - The transcribed text to preview
|
|
3119
|
+
* @param timeoutSeconds - Countdown duration (0 = disabled, require explicit Enter)
|
|
3120
|
+
* @returns PreviewResult indicating user action and final text
|
|
3121
|
+
*/
|
|
3122
|
+
async function previewTranscript(transcript, timeoutSeconds) {
|
|
3123
|
+
return new Promise((resolve) => {
|
|
3124
|
+
// Set up raw mode for immediate key detection
|
|
3125
|
+
const stdin = process.stdin;
|
|
3126
|
+
const stdout = process.stdout;
|
|
3127
|
+
// If stdin is not a TTY (e.g., running as daemon), skip preview and auto-send
|
|
3128
|
+
if (!stdin.isTTY) {
|
|
3129
|
+
resolve({ action: "send", text: transcript });
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
3132
|
+
// Store original mode to restore later
|
|
3133
|
+
const wasRaw = stdin.isRaw;
|
|
3134
|
+
stdin.setRawMode(true);
|
|
3135
|
+
stdin.resume();
|
|
3136
|
+
stdin.setEncoding("utf8");
|
|
3137
|
+
let currentText = transcript;
|
|
3138
|
+
let countdownValue = timeoutSeconds;
|
|
3139
|
+
let countdownInterval = null;
|
|
3140
|
+
let inEditMode = false;
|
|
3141
|
+
let editBuffer = "";
|
|
3142
|
+
let cursorPos = 0;
|
|
3143
|
+
// Helper to clear the current line and write new content
|
|
3144
|
+
const clearAndWrite = (text) => {
|
|
3145
|
+
stdout.write("\r\x1b[K" + text);
|
|
3146
|
+
};
|
|
3147
|
+
// Helper to show the preview line
|
|
3148
|
+
const showPreview = () => {
|
|
3149
|
+
if (inEditMode) {
|
|
3150
|
+
// Edit mode: show editable text with cursor
|
|
3151
|
+
const before = editBuffer.slice(0, cursorPos);
|
|
3152
|
+
const cursor = editBuffer[cursorPos] || " ";
|
|
3153
|
+
const after = editBuffer.slice(cursorPos + 1);
|
|
3154
|
+
clearAndWrite(chalk.gray(" Edit: ") +
|
|
3155
|
+
chalk.cyan(before) +
|
|
3156
|
+
chalk.bgCyan.black(cursor) +
|
|
3157
|
+
chalk.cyan(after) +
|
|
3158
|
+
chalk.gray(" [Enter=send, Esc=cancel]"));
|
|
3159
|
+
}
|
|
3160
|
+
else if (timeoutSeconds === 0) {
|
|
3161
|
+
// No countdown - require explicit action
|
|
3162
|
+
clearAndWrite(chalk.gray(" ") +
|
|
3163
|
+
chalk.cyan(`"${currentText}"`) +
|
|
3164
|
+
chalk.gray(" [Enter=send, Esc=cancel, any key=edit]"));
|
|
3165
|
+
}
|
|
3166
|
+
else {
|
|
3167
|
+
// Show countdown
|
|
3168
|
+
const countdownDisplay = countdownValue.toFixed(1);
|
|
3169
|
+
clearAndWrite(chalk.gray(" ") +
|
|
3170
|
+
chalk.cyan(`"${currentText}"`) +
|
|
3171
|
+
chalk.yellow(` Sending in ${countdownDisplay}s...`) +
|
|
3172
|
+
chalk.gray(" [Enter=send, Esc=cancel, any key=edit]"));
|
|
3173
|
+
}
|
|
3174
|
+
};
|
|
3175
|
+
// Cleanup function
|
|
3176
|
+
const cleanup = () => {
|
|
3177
|
+
if (countdownInterval) {
|
|
3178
|
+
clearInterval(countdownInterval);
|
|
3179
|
+
countdownInterval = null;
|
|
3180
|
+
}
|
|
3181
|
+
stdin.setRawMode(wasRaw || false);
|
|
3182
|
+
stdin.removeListener("data", onData);
|
|
3183
|
+
stdout.write("\n");
|
|
3184
|
+
};
|
|
3185
|
+
// Start countdown if enabled
|
|
3186
|
+
if (timeoutSeconds > 0) {
|
|
3187
|
+
countdownInterval = setInterval(() => {
|
|
3188
|
+
countdownValue -= 0.1;
|
|
3189
|
+
if (countdownValue <= 0) {
|
|
3190
|
+
cleanup();
|
|
3191
|
+
resolve({ action: "send", text: currentText });
|
|
3192
|
+
}
|
|
3193
|
+
else {
|
|
3194
|
+
showPreview();
|
|
3195
|
+
}
|
|
3196
|
+
}, 100);
|
|
3197
|
+
}
|
|
3198
|
+
// Handle key input
|
|
3199
|
+
const onData = (key) => {
|
|
3200
|
+
// Handle special keys
|
|
3201
|
+
const keyCode = key.charCodeAt(0);
|
|
3202
|
+
if (inEditMode) {
|
|
3203
|
+
// Edit mode key handling
|
|
3204
|
+
if (key === "\r" || key === "\n") {
|
|
3205
|
+
// Enter - send the edited text
|
|
3206
|
+
cleanup();
|
|
3207
|
+
resolve({ action: "send", text: editBuffer });
|
|
3208
|
+
}
|
|
3209
|
+
else if (key === "\x1b") {
|
|
3210
|
+
// Check for escape sequences (arrow keys, etc.)
|
|
3211
|
+
// Simple escape = cancel
|
|
3212
|
+
// Arrow keys come as \x1b[A, \x1b[B, \x1b[C, \x1b[D
|
|
3213
|
+
// We'll handle simple escape for now
|
|
3214
|
+
cleanup();
|
|
3215
|
+
resolve({ action: "cancel" });
|
|
3216
|
+
}
|
|
3217
|
+
else if (key === "\x7f" || key === "\b") {
|
|
3218
|
+
// Backspace - delete character before cursor
|
|
3219
|
+
if (cursorPos > 0) {
|
|
3220
|
+
editBuffer = editBuffer.slice(0, cursorPos - 1) + editBuffer.slice(cursorPos);
|
|
3221
|
+
cursorPos--;
|
|
3222
|
+
showPreview();
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
else if (key === "\x1b[D") {
|
|
3226
|
+
// Left arrow
|
|
3227
|
+
if (cursorPos > 0) {
|
|
3228
|
+
cursorPos--;
|
|
3229
|
+
showPreview();
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
else if (key === "\x1b[C") {
|
|
3233
|
+
// Right arrow
|
|
3234
|
+
if (cursorPos < editBuffer.length) {
|
|
3235
|
+
cursorPos++;
|
|
3236
|
+
showPreview();
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
else if (key === "\x03") {
|
|
3240
|
+
// Ctrl+C - cancel
|
|
3241
|
+
cleanup();
|
|
3242
|
+
resolve({ action: "cancel" });
|
|
3243
|
+
}
|
|
3244
|
+
else if (keyCode >= 32 && keyCode < 127) {
|
|
3245
|
+
// Printable character - insert at cursor
|
|
3246
|
+
editBuffer = editBuffer.slice(0, cursorPos) + key + editBuffer.slice(cursorPos);
|
|
3247
|
+
cursorPos++;
|
|
3248
|
+
showPreview();
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
else {
|
|
3252
|
+
// Preview mode key handling
|
|
3253
|
+
if (key === "\r" || key === "\n") {
|
|
3254
|
+
// Enter - send immediately
|
|
3255
|
+
cleanup();
|
|
3256
|
+
resolve({ action: "send", text: currentText });
|
|
3257
|
+
}
|
|
3258
|
+
else if (key === "\x1b") {
|
|
3259
|
+
// Escape - cancel
|
|
3260
|
+
cleanup();
|
|
3261
|
+
resolve({ action: "cancel" });
|
|
3262
|
+
}
|
|
3263
|
+
else if (key === "\x03") {
|
|
3264
|
+
// Ctrl+C - cancel
|
|
3265
|
+
cleanup();
|
|
3266
|
+
resolve({ action: "cancel" });
|
|
3267
|
+
}
|
|
3268
|
+
else {
|
|
3269
|
+
// Any other key - enter edit mode
|
|
3270
|
+
if (countdownInterval) {
|
|
3271
|
+
clearInterval(countdownInterval);
|
|
3272
|
+
countdownInterval = null;
|
|
3273
|
+
}
|
|
3274
|
+
inEditMode = true;
|
|
3275
|
+
editBuffer = currentText;
|
|
3276
|
+
// If printable character, start with it
|
|
3277
|
+
if (keyCode >= 32 && keyCode < 127) {
|
|
3278
|
+
editBuffer = currentText + key;
|
|
3279
|
+
cursorPos = editBuffer.length;
|
|
3280
|
+
}
|
|
3281
|
+
else {
|
|
3282
|
+
cursorPos = editBuffer.length;
|
|
3283
|
+
}
|
|
3284
|
+
showPreview();
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
};
|
|
3288
|
+
stdin.on("data", onData);
|
|
3289
|
+
// Show initial preview
|
|
3290
|
+
console.log(); // New line before preview
|
|
3291
|
+
showPreview();
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
/**
|
|
3295
|
+
* Voice Slash Command - /voice
|
|
3296
|
+
*
|
|
3297
|
+
* Records audio with VAD (voice activity detection), transcribes it,
|
|
3298
|
+
* and offers to send to Claude Code or copy to clipboard.
|
|
3299
|
+
*/
|
|
3300
|
+
export async function voiceSlashCommand(options = {}) {
|
|
3301
|
+
const silenceThresholdDb = options.silenceThresholdDb ?? VAD_SILENCE_THRESHOLD_DB;
|
|
3302
|
+
const silenceDurationMs = options.silenceDurationMs ?? VAD_SILENCE_DURATION_MS;
|
|
3303
|
+
const maxDurationSecs = options.maxDurationSecs ?? 60;
|
|
3304
|
+
console.log(chalk.bold("\n🎤 Voice Recording\n"));
|
|
3305
|
+
console.log(chalk.gray(" Speak into your microphone. Recording will stop automatically"));
|
|
3306
|
+
console.log(chalk.gray(` after ${(silenceDurationMs / 1000).toFixed(1)}s of silence, or press Ctrl+C to stop.\n`));
|
|
3307
|
+
// Step 1: Check if whisper server is running
|
|
3308
|
+
const serverError = checkServerRunning();
|
|
3309
|
+
if (serverError) {
|
|
3310
|
+
handleVoiceError(serverError);
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
// Step 2: Check for auth token
|
|
3314
|
+
const authError = checkAuthToken();
|
|
3315
|
+
if (authError) {
|
|
3316
|
+
handleVoiceError(authError);
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
const authToken = readAuthToken();
|
|
3320
|
+
const socketPath = getVoiceSocketPath();
|
|
3321
|
+
// Step 3: Initialize audio recorder
|
|
3322
|
+
let recorder;
|
|
3323
|
+
try {
|
|
3324
|
+
recorder = new AudioRecorder({
|
|
3325
|
+
device: options.device,
|
|
3326
|
+
sampleRate: 16000,
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
catch (error) {
|
|
3330
|
+
if (error instanceof VoiceError) {
|
|
3331
|
+
handleVoiceError(error);
|
|
3332
|
+
}
|
|
3333
|
+
else {
|
|
3334
|
+
handleVoiceError(createVoiceError(error instanceof Error ? error : new Error(String(error))));
|
|
3335
|
+
}
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
// Collect audio data
|
|
3339
|
+
const audioChunks = [];
|
|
3340
|
+
let peakLevel = 0;
|
|
3341
|
+
let recordingError = null;
|
|
3342
|
+
// VAD state
|
|
3343
|
+
let silenceStartTime = null;
|
|
3344
|
+
let hasVoiceActivity = false;
|
|
3345
|
+
const startTime = Date.now();
|
|
3346
|
+
// VS-012: Reset waveform buffer for new recording
|
|
3347
|
+
resetWaveformBuffer();
|
|
3348
|
+
const useWaveform = supportsWaveform();
|
|
3349
|
+
// Spinner for recording indicator
|
|
3350
|
+
const spinner = ora({
|
|
3351
|
+
text: chalk.cyan("Recording...") + chalk.gray(" (waiting for voice)"),
|
|
3352
|
+
prefixText: " ",
|
|
3353
|
+
spinner: "dots",
|
|
3354
|
+
});
|
|
3355
|
+
// Handle Ctrl+C gracefully
|
|
3356
|
+
let interrupted = false;
|
|
3357
|
+
const handleInterrupt = () => {
|
|
3358
|
+
interrupted = true;
|
|
3359
|
+
recorder.stop();
|
|
3360
|
+
};
|
|
3361
|
+
process.on("SIGINT", handleInterrupt);
|
|
3362
|
+
process.on("SIGTERM", handleInterrupt);
|
|
3363
|
+
// Set up recorder event handlers
|
|
3364
|
+
recorder.on("data", (chunk) => {
|
|
3365
|
+
audioChunks.push(chunk);
|
|
3366
|
+
// Calculate peak level
|
|
3367
|
+
const chunkPeak = calculatePeakAmplitude(chunk);
|
|
3368
|
+
if (chunkPeak > peakLevel) {
|
|
3369
|
+
peakLevel = chunkPeak;
|
|
3370
|
+
}
|
|
3371
|
+
// VAD: Check if audio level is above silence threshold
|
|
3372
|
+
const peakDb = amplitudeToDb(chunkPeak);
|
|
3373
|
+
const isSilent = peakDb < silenceThresholdDb;
|
|
3374
|
+
// VS-012: Add level to waveform buffer on every chunk
|
|
3375
|
+
addToWaveformBuffer(peakDb);
|
|
3376
|
+
// VS-012: Update spinner with waveform on every chunk for real-time feedback
|
|
3377
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
3378
|
+
if (useWaveform) {
|
|
3379
|
+
const waveform = renderWaveform();
|
|
3380
|
+
spinner.text = chalk.cyan("Recording") + chalk.gray(` ${waveform} (${elapsed}s)`);
|
|
3381
|
+
}
|
|
3382
|
+
if (!isSilent) {
|
|
3383
|
+
// Voice activity detected
|
|
3384
|
+
hasVoiceActivity = true;
|
|
3385
|
+
silenceStartTime = null;
|
|
3386
|
+
}
|
|
3387
|
+
else if (hasVoiceActivity) {
|
|
3388
|
+
// Silence detected after voice activity
|
|
3389
|
+
if (silenceStartTime === null) {
|
|
3390
|
+
silenceStartTime = Date.now();
|
|
3391
|
+
}
|
|
3392
|
+
else {
|
|
3393
|
+
const silenceDuration = Date.now() - silenceStartTime;
|
|
3394
|
+
// Update spinner to show silence detection
|
|
3395
|
+
const remaining = Math.max(0, silenceDurationMs - silenceDuration);
|
|
3396
|
+
if (remaining > 0) {
|
|
3397
|
+
if (useWaveform) {
|
|
3398
|
+
const waveform = renderWaveform();
|
|
3399
|
+
spinner.text = chalk.cyan("Recording") + chalk.gray(` ${waveform}`) + chalk.yellow(` (silence: ${(remaining / 1000).toFixed(1)}s)`);
|
|
3400
|
+
}
|
|
3401
|
+
else {
|
|
3402
|
+
spinner.text = chalk.cyan("Recording") + chalk.yellow(` (silence: ${(remaining / 1000).toFixed(1)}s until stop)`);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
// Stop recording after silence duration
|
|
3406
|
+
if (silenceDuration >= silenceDurationMs) {
|
|
3407
|
+
recorder.stop();
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
// Check max duration
|
|
3412
|
+
const elapsedSecs = (Date.now() - startTime) / 1000;
|
|
3413
|
+
if (elapsedSecs >= maxDurationSecs) {
|
|
3414
|
+
recorder.stop();
|
|
3415
|
+
}
|
|
3416
|
+
});
|
|
3417
|
+
recorder.on("error", (error) => {
|
|
3418
|
+
recordingError = error;
|
|
3419
|
+
recorder.stop();
|
|
3420
|
+
});
|
|
3421
|
+
// Start recording
|
|
3422
|
+
try {
|
|
3423
|
+
await recorder.start();
|
|
3424
|
+
spinner.start();
|
|
3425
|
+
// VS-UX-2: Show system recording indicator (notification on macOS)
|
|
3426
|
+
showRecordingIndicator();
|
|
3427
|
+
}
|
|
3428
|
+
catch (error) {
|
|
3429
|
+
const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
|
|
3430
|
+
originalError: error instanceof Error ? error : new Error(String(error)),
|
|
3431
|
+
context: { device: options.device },
|
|
3432
|
+
});
|
|
3433
|
+
handleVoiceError(voiceError);
|
|
3434
|
+
process.removeListener("SIGINT", handleInterrupt);
|
|
3435
|
+
process.removeListener("SIGTERM", handleInterrupt);
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
// Wait for recording to stop
|
|
3439
|
+
await new Promise((resolve) => {
|
|
3440
|
+
const checkStopped = () => {
|
|
3441
|
+
if (recorder.getState() === "idle" || recorder.getState() === "error") {
|
|
3442
|
+
resolve();
|
|
3443
|
+
}
|
|
3444
|
+
else {
|
|
3445
|
+
setTimeout(checkStopped, 50);
|
|
3446
|
+
}
|
|
3447
|
+
};
|
|
3448
|
+
// Small delay to let the stop signal propagate
|
|
3449
|
+
setTimeout(checkStopped, 100);
|
|
3450
|
+
});
|
|
3451
|
+
// Clean up interrupt handler
|
|
3452
|
+
process.removeListener("SIGINT", handleInterrupt);
|
|
3453
|
+
process.removeListener("SIGTERM", handleInterrupt);
|
|
3454
|
+
// Stop spinner
|
|
3455
|
+
spinner.stop();
|
|
3456
|
+
// Handle interruption
|
|
3457
|
+
if (interrupted) {
|
|
3458
|
+
// VS-UX-2: Show cancelled indicator
|
|
3459
|
+
hideRecordingIndicator("cancelled");
|
|
3460
|
+
console.log(chalk.yellow("\n Recording stopped by user.\n"));
|
|
3461
|
+
}
|
|
3462
|
+
// Handle recording error
|
|
3463
|
+
if (recordingError !== null) {
|
|
3464
|
+
// VS-UX-2: Show stopped indicator on error
|
|
3465
|
+
hideRecordingIndicator("stopped");
|
|
3466
|
+
const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
|
|
3467
|
+
originalError: recordingError,
|
|
3468
|
+
context: { device: options.device },
|
|
3469
|
+
});
|
|
3470
|
+
handleVoiceError(voiceError);
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
// Check if we got any audio
|
|
3474
|
+
const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
3475
|
+
if (totalBytes === 0) {
|
|
3476
|
+
// VS-UX-2: Show stopped indicator when no audio
|
|
3477
|
+
hideRecordingIndicator("stopped");
|
|
3478
|
+
const voiceError = new VoiceError(VoiceErrorType.MIC_UNAVAILABLE, {
|
|
3479
|
+
context: { totalBytes: 0, device: options.device },
|
|
3480
|
+
});
|
|
3481
|
+
handleVoiceError(voiceError);
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
// Check if there was any voice activity
|
|
3485
|
+
if (!hasVoiceActivity) {
|
|
3486
|
+
// VS-UX-2: Show stopped indicator when no voice detected
|
|
3487
|
+
hideRecordingIndicator("stopped");
|
|
3488
|
+
const voiceError = new VoiceError(VoiceErrorType.TRANSCRIPTION_EMPTY, {
|
|
3489
|
+
context: { reason: "No voice activity detected", peakLevel },
|
|
3490
|
+
});
|
|
3491
|
+
handleVoiceError(voiceError);
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
// Combine all audio chunks and normalize for better transcription
|
|
3495
|
+
const audioBuffer = normalizeAudio(Buffer.concat(audioChunks));
|
|
3496
|
+
const durationSecs = totalBytes / (16000 * 2); // 16kHz, 16-bit
|
|
3497
|
+
// VS-UX-2: Show completed indicator when moving to transcription
|
|
3498
|
+
hideRecordingIndicator("completed");
|
|
3499
|
+
console.log(chalk.gray(`\n Recorded ${durationSecs.toFixed(1)}s of audio.`));
|
|
3500
|
+
console.log(chalk.gray(" Transcribing...\n"));
|
|
3501
|
+
// Step 4: Connect to whisper server and send audio
|
|
3502
|
+
const transcribeSpinner = ora({
|
|
3503
|
+
text: "Transcribing...",
|
|
3504
|
+
prefixText: " ",
|
|
3505
|
+
}).start();
|
|
3506
|
+
const client = new VoiceClient({
|
|
3507
|
+
socketPath,
|
|
3508
|
+
authToken,
|
|
3509
|
+
maxReconnectAttempts: 1,
|
|
3510
|
+
});
|
|
3511
|
+
let transcription = "";
|
|
3512
|
+
let transcriptionReceived = false;
|
|
3513
|
+
let transcriptionError = null;
|
|
3514
|
+
client.onTranscript((text, isFinal) => {
|
|
3515
|
+
// Accept partial transcriptions too (in case final never arrives)
|
|
3516
|
+
if (text && text.trim() && !text.includes("[BLANK_AUDIO]")) {
|
|
3517
|
+
transcription = text;
|
|
3518
|
+
}
|
|
3519
|
+
if (isFinal) {
|
|
3520
|
+
transcriptionReceived = true;
|
|
3521
|
+
}
|
|
3522
|
+
});
|
|
3523
|
+
client.onError((error) => {
|
|
3524
|
+
transcriptionError = error;
|
|
3525
|
+
});
|
|
3526
|
+
try {
|
|
3527
|
+
await client.connect();
|
|
3528
|
+
// Send audio data and wait for it to be sent
|
|
3529
|
+
client.sendAudio(audioBuffer);
|
|
3530
|
+
// Wait for audio to be sent before signaling end
|
|
3531
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
3532
|
+
// Signal end of audio
|
|
3533
|
+
client.endAudio();
|
|
3534
|
+
// Wait for transcription with timeout
|
|
3535
|
+
const timeout = 30000; // 30 seconds
|
|
3536
|
+
const transcribeStartTime = Date.now();
|
|
3537
|
+
while (!transcriptionReceived && !transcriptionError) {
|
|
3538
|
+
if (Date.now() - transcribeStartTime > timeout) {
|
|
3539
|
+
transcriptionError = new VoiceError(VoiceErrorType.TIMEOUT, {
|
|
3540
|
+
context: { timeout, operation: "transcription" },
|
|
3541
|
+
recoverable: true,
|
|
3542
|
+
audioBuffer,
|
|
3543
|
+
});
|
|
3544
|
+
break;
|
|
3545
|
+
}
|
|
3546
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
3547
|
+
}
|
|
3548
|
+
client.disconnect();
|
|
3549
|
+
}
|
|
3550
|
+
catch (error) {
|
|
3551
|
+
client.disconnect();
|
|
3552
|
+
const voiceError = error instanceof VoiceError
|
|
3553
|
+
? error
|
|
3554
|
+
: createVoiceError(error instanceof Error ? error : new Error(String(error)), { operation: "connect" }, audioBuffer);
|
|
3555
|
+
handleVoiceError(voiceError, transcribeSpinner);
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
// Handle transcription error
|
|
3559
|
+
if (transcriptionError) {
|
|
3560
|
+
const voiceError = transcriptionError instanceof VoiceError
|
|
3561
|
+
? transcriptionError
|
|
3562
|
+
: createVoiceError(transcriptionError, { operation: "transcription" }, audioBuffer);
|
|
3563
|
+
handleVoiceError(voiceError, transcribeSpinner);
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
// Handle empty transcription
|
|
3567
|
+
if (!transcription || transcription.trim() === "") {
|
|
3568
|
+
const voiceError = new VoiceError(VoiceErrorType.TRANSCRIPTION_EMPTY, {
|
|
3569
|
+
context: { audioLength: totalBytes, durationSecs, peakLevel },
|
|
3570
|
+
recoverable: true,
|
|
3571
|
+
audioBuffer,
|
|
3572
|
+
});
|
|
3573
|
+
handleVoiceError(voiceError, transcribeSpinner);
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
transcribeSpinner.succeed("Transcription complete!");
|
|
3577
|
+
// Step 5: Display transcription
|
|
3578
|
+
console.log();
|
|
3579
|
+
console.log(chalk.bold(" Transcription:"));
|
|
3580
|
+
console.log();
|
|
3581
|
+
console.log(chalk.cyan(` "${transcription.trim()}"`));
|
|
3582
|
+
console.log();
|
|
3583
|
+
// Step 6: Ask what to do with the transcription
|
|
3584
|
+
const { action } = await inquirer.prompt([
|
|
3585
|
+
{
|
|
3586
|
+
type: "list",
|
|
3587
|
+
name: "action",
|
|
3588
|
+
message: "What would you like to do?",
|
|
3589
|
+
choices: [
|
|
3590
|
+
{ name: "Send to Claude Code (paste into conversation)", value: "send" },
|
|
3591
|
+
{ name: "Copy to clipboard", value: "copy" },
|
|
3592
|
+
{ name: "Cancel (do nothing)", value: "cancel" },
|
|
3593
|
+
],
|
|
3594
|
+
default: "send",
|
|
3595
|
+
},
|
|
3596
|
+
]);
|
|
3597
|
+
if (action === "send") {
|
|
3598
|
+
// For "send to Claude Code", we simply output the text so the user can paste it
|
|
3599
|
+
// In a full implementation, this could integrate with Claude Code's API
|
|
3600
|
+
console.log(chalk.green("\n ✓ Text ready to send to Claude Code:\n"));
|
|
3601
|
+
console.log(chalk.white(` ${transcription.trim()}`));
|
|
3602
|
+
console.log();
|
|
3603
|
+
console.log(chalk.gray(" Copy and paste this into your Claude Code conversation."));
|
|
3604
|
+
console.log();
|
|
3605
|
+
}
|
|
3606
|
+
else if (action === "copy") {
|
|
3607
|
+
const copied = copyToClipboard(transcription.trim());
|
|
3608
|
+
if (copied) {
|
|
3609
|
+
console.log(chalk.green("\n ✓ Copied to clipboard!"));
|
|
3610
|
+
}
|
|
3611
|
+
else {
|
|
3612
|
+
console.log(chalk.yellow("\n ⚠ Could not copy to clipboard."));
|
|
3613
|
+
console.log(chalk.gray(" On Linux, install xclip or xsel for clipboard support."));
|
|
3614
|
+
console.log(chalk.gray("\n Text:"));
|
|
3615
|
+
console.log(chalk.white(` ${transcription.trim()}`));
|
|
3616
|
+
}
|
|
3617
|
+
console.log();
|
|
3618
|
+
}
|
|
3619
|
+
else {
|
|
3620
|
+
console.log(chalk.gray("\n Cancelled.\n"));
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
// =============================================================================
|
|
3624
|
+
// Hotkey Command - Global Keyboard Shortcut for Voice Recording
|
|
3625
|
+
// =============================================================================
|
|
3626
|
+
/**
|
|
3627
|
+
* Check if running on Wayland (Linux only)
|
|
3628
|
+
* VS-010: Wayland detection for Linux hotkey support
|
|
3629
|
+
* @returns true if running on Wayland, false otherwise
|
|
3630
|
+
*/
|
|
3631
|
+
function isWayland() {
|
|
3632
|
+
if (platform() !== "linux") {
|
|
3633
|
+
return false;
|
|
3634
|
+
}
|
|
3635
|
+
// Check for Wayland indicators
|
|
3636
|
+
// WAYLAND_DISPLAY is set when running under a Wayland compositor
|
|
3637
|
+
// XDG_SESSION_TYPE is set to "wayland" on Wayland sessions
|
|
3638
|
+
return !!(process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === "wayland");
|
|
3639
|
+
}
|
|
3640
|
+
/**
|
|
3641
|
+
* Check if Accessibility/keyboard permission is available
|
|
3642
|
+
* - macOS: Requires Accessibility permission for global hotkey capture
|
|
3643
|
+
* - Linux X11: Uses XGrabKey, no special permissions needed
|
|
3644
|
+
* - Linux Wayland: Not supported (returns false)
|
|
3645
|
+
* - Windows: Uses RegisterHotKey, no admin privileges needed
|
|
3646
|
+
*/
|
|
3647
|
+
function checkAccessibilityPermission() {
|
|
3648
|
+
const currentPlatform = platform();
|
|
3649
|
+
if (currentPlatform === "darwin") {
|
|
3650
|
+
try {
|
|
3651
|
+
// Use osascript to check if Accessibility permission is granted
|
|
3652
|
+
// This method attempts to list processes, which requires accessibility
|
|
3653
|
+
const result = execSync(`osascript -e 'tell application "System Events" to get name of first process'`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
|
|
3654
|
+
return true;
|
|
3655
|
+
}
|
|
3656
|
+
catch (error) {
|
|
3657
|
+
// If the script fails, accessibility permission is likely not granted
|
|
3658
|
+
return false;
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
else if (currentPlatform === "linux") {
|
|
3662
|
+
// On Linux X11, we just need to verify X11 is available
|
|
3663
|
+
// The node-global-key-listener uses XGrabKey which doesn't need special permissions
|
|
3664
|
+
// Just check that we're not on Wayland (handled separately)
|
|
3665
|
+
return !isWayland();
|
|
3666
|
+
}
|
|
3667
|
+
else if (currentPlatform === "win32") {
|
|
3668
|
+
// On Windows, RegisterHotKey doesn't require special permissions
|
|
3669
|
+
// Works without admin privileges
|
|
3670
|
+
return true;
|
|
3671
|
+
}
|
|
3672
|
+
return false;
|
|
3673
|
+
}
|
|
3674
|
+
/**
|
|
3675
|
+
* Open system settings for keyboard/accessibility permissions
|
|
3676
|
+
* - macOS: Opens Accessibility pane in System Settings
|
|
3677
|
+
* - Linux: Prints instructions (no GUI settings for X11)
|
|
3678
|
+
* - Windows: Prints instructions (no special permissions needed)
|
|
3679
|
+
* VS-010/VS-011: Cross-platform settings guidance
|
|
3680
|
+
*/
|
|
3681
|
+
function openAccessibilitySettings() {
|
|
3682
|
+
const currentPlatform = platform();
|
|
3683
|
+
if (currentPlatform === "darwin") {
|
|
3684
|
+
try {
|
|
3685
|
+
// macOS 13+ uses System Settings, older versions use System Preferences
|
|
3686
|
+
execSync(`open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"`, { stdio: "ignore" });
|
|
3687
|
+
}
|
|
3688
|
+
catch {
|
|
3689
|
+
// Fallback for older macOS versions
|
|
3690
|
+
try {
|
|
3691
|
+
execSync(`open "/System/Library/PreferencePanes/Security.prefPane"`, { stdio: "ignore" });
|
|
3692
|
+
}
|
|
3693
|
+
catch {
|
|
3694
|
+
console.log(chalk.gray(" Could not open System Settings automatically."));
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
else if (currentPlatform === "linux") {
|
|
3699
|
+
// VS-010: Linux doesn't have a system settings pane for X11 keyboard access
|
|
3700
|
+
// Just print helpful information
|
|
3701
|
+
console.log(chalk.gray(" Linux X11 hotkey requirements:"));
|
|
3702
|
+
console.log(chalk.gray(" - Install xdotool: sudo apt-get install xdotool"));
|
|
3703
|
+
console.log(chalk.gray(" - Ensure you're running X11, not Wayland"));
|
|
3704
|
+
console.log(chalk.gray(" - Check session type: echo $XDG_SESSION_TYPE"));
|
|
3705
|
+
}
|
|
3706
|
+
else if (currentPlatform === "win32") {
|
|
3707
|
+
// VS-011: Windows doesn't need special permissions for RegisterHotKey
|
|
3708
|
+
console.log(chalk.gray(" Windows hotkey should work without special permissions."));
|
|
3709
|
+
console.log(chalk.gray(" If you have issues, try running as Administrator."));
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
// =============================================================================
|
|
3713
|
+
// VS-013: Voice Daemon Commands
|
|
3714
|
+
// =============================================================================
|
|
3715
|
+
/**
|
|
3716
|
+
* Read the daemon PID from the PID file
|
|
3717
|
+
* @returns The PID number or null if not found/invalid
|
|
3718
|
+
*/
|
|
3719
|
+
function readDaemonPid() {
|
|
3720
|
+
const pidPath = getVoiceDaemonPidPath();
|
|
3721
|
+
if (!existsSync(pidPath)) {
|
|
3722
|
+
return null;
|
|
3723
|
+
}
|
|
3724
|
+
try {
|
|
3725
|
+
const pidStr = readFileSync(pidPath, "utf-8").trim();
|
|
3726
|
+
const pid = parseInt(pidStr, 10);
|
|
3727
|
+
if (isNaN(pid) || pid <= 0) {
|
|
3728
|
+
return null;
|
|
3729
|
+
}
|
|
3730
|
+
return pid;
|
|
3731
|
+
}
|
|
3732
|
+
catch {
|
|
3733
|
+
return null;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* Check if a process with the given PID is running
|
|
3738
|
+
* @param pid - Process ID to check
|
|
3739
|
+
* @returns true if process is running, false otherwise
|
|
3740
|
+
*/
|
|
3741
|
+
function isProcessRunning(pid) {
|
|
3742
|
+
try {
|
|
3743
|
+
// Sending signal 0 doesn't kill the process, just checks if it exists
|
|
3744
|
+
process.kill(pid, 0);
|
|
3745
|
+
return true;
|
|
3746
|
+
}
|
|
3747
|
+
catch (error) {
|
|
3748
|
+
return false;
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* Get daemon uptime if running
|
|
3753
|
+
* @returns Uptime string or null if not running
|
|
3754
|
+
*/
|
|
3755
|
+
function getDaemonUptime() {
|
|
3756
|
+
const pidPath = getVoiceDaemonPidPath();
|
|
3757
|
+
if (!existsSync(pidPath)) {
|
|
3758
|
+
return null;
|
|
3759
|
+
}
|
|
3760
|
+
try {
|
|
3761
|
+
const stats = statSync(pidPath);
|
|
3762
|
+
const startTime = stats.mtime.getTime();
|
|
3763
|
+
const uptime = Date.now() - startTime;
|
|
3764
|
+
// Format uptime
|
|
3765
|
+
const seconds = Math.floor(uptime / 1000) % 60;
|
|
3766
|
+
const minutes = Math.floor(uptime / (1000 * 60)) % 60;
|
|
3767
|
+
const hours = Math.floor(uptime / (1000 * 60 * 60)) % 24;
|
|
3768
|
+
const days = Math.floor(uptime / (1000 * 60 * 60 * 24));
|
|
3769
|
+
if (days > 0) {
|
|
3770
|
+
return `${days}d ${hours}h ${minutes}m`;
|
|
3771
|
+
}
|
|
3772
|
+
else if (hours > 0) {
|
|
3773
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
3774
|
+
}
|
|
3775
|
+
else if (minutes > 0) {
|
|
3776
|
+
return `${minutes}m ${seconds}s`;
|
|
3777
|
+
}
|
|
3778
|
+
else {
|
|
3779
|
+
return `${seconds}s`;
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
catch {
|
|
3783
|
+
return null;
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* Daemon Start Command - Launch voice hotkey listener in background
|
|
3788
|
+
*
|
|
3789
|
+
* Spawns a detached child process running the hotkey listener that survives
|
|
3790
|
+
* terminal close. PID is stored in ~/.jfl/voice-daemon.pid
|
|
3791
|
+
*/
|
|
3792
|
+
export async function daemonStartCommand(options = {}) {
|
|
3793
|
+
// Check platform - daemon only supported on macOS for now
|
|
3794
|
+
if (platform() !== "darwin") {
|
|
3795
|
+
const error = new VoiceError(VoiceErrorType.PLATFORM_NOT_SUPPORTED);
|
|
3796
|
+
handleVoiceError(error);
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
console.log(chalk.bold("\n🎤 Voice Daemon\n"));
|
|
3800
|
+
// Check if daemon is already running
|
|
3801
|
+
const existingPid = readDaemonPid();
|
|
3802
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
3803
|
+
console.log(chalk.yellow(" Daemon is already running."));
|
|
3804
|
+
console.log(chalk.gray(` PID: ${existingPid}`));
|
|
3805
|
+
console.log(chalk.gray(` Uptime: ${getDaemonUptime()}`));
|
|
3806
|
+
console.log();
|
|
3807
|
+
console.log(chalk.gray(" Use 'jfl voice daemon stop' to stop it."));
|
|
3808
|
+
console.log(chalk.gray(" Use 'jfl voice daemon status' to check status."));
|
|
3809
|
+
console.log();
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
// Check Accessibility permission first
|
|
3813
|
+
console.log(chalk.gray(" Checking Accessibility permission..."));
|
|
3814
|
+
if (!checkAccessibilityPermission()) {
|
|
3815
|
+
console.log();
|
|
3816
|
+
const error = new VoiceError(VoiceErrorType.ACCESSIBILITY_NOT_GRANTED);
|
|
3817
|
+
handleVoiceError(error);
|
|
3818
|
+
console.log(chalk.cyan(" Opening System Settings..."));
|
|
3819
|
+
openAccessibilitySettings();
|
|
3820
|
+
console.log();
|
|
3821
|
+
console.log(chalk.yellow(" After granting permission:"));
|
|
3822
|
+
console.log(chalk.gray(" 1. Add your terminal app to Accessibility"));
|
|
3823
|
+
console.log(chalk.gray(" 2. Restart your terminal"));
|
|
3824
|
+
console.log(chalk.gray(" 3. Run 'jfl voice daemon start' again"));
|
|
3825
|
+
console.log();
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3828
|
+
console.log(chalk.green(" ✓ Accessibility permission granted"));
|
|
3829
|
+
// Check other prerequisites (server, auth)
|
|
3830
|
+
const serverError = checkServerRunning();
|
|
3831
|
+
if (serverError) {
|
|
3832
|
+
handleVoiceError(serverError);
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
const authError = checkAuthToken();
|
|
3836
|
+
if (authError) {
|
|
3837
|
+
handleVoiceError(authError);
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
console.log(chalk.gray(" Starting daemon..."));
|
|
3841
|
+
// Get the path to the current executable (jfl CLI)
|
|
3842
|
+
const jflPath = process.argv[1];
|
|
3843
|
+
const nodePath = process.argv[0];
|
|
3844
|
+
// Build the command arguments
|
|
3845
|
+
const args = ["voice", "hotkey"];
|
|
3846
|
+
if (options.mode) {
|
|
3847
|
+
args.push("--mode", options.mode);
|
|
3848
|
+
}
|
|
3849
|
+
// Spawn detached process
|
|
3850
|
+
const logPath = getVoiceDaemonLogPath();
|
|
3851
|
+
const pidPath = getVoiceDaemonPidPath();
|
|
3852
|
+
ensureDirectories();
|
|
3853
|
+
// Create log file for daemon output
|
|
3854
|
+
const logFd = openSync(logPath, "a");
|
|
3855
|
+
const child = spawn(nodePath, [jflPath, ...args], {
|
|
3856
|
+
detached: true,
|
|
3857
|
+
stdio: ["ignore", logFd, logFd],
|
|
3858
|
+
env: {
|
|
3859
|
+
...process.env,
|
|
3860
|
+
JFL_VOICE_DAEMON: "1", // Mark this as daemon mode
|
|
3861
|
+
},
|
|
3862
|
+
});
|
|
3863
|
+
// Write PID to file
|
|
3864
|
+
if (child.pid) {
|
|
3865
|
+
writeFileSync(pidPath, child.pid.toString(), { mode: 0o644 });
|
|
3866
|
+
// Unref so parent can exit independently
|
|
3867
|
+
child.unref();
|
|
3868
|
+
// Close the log file descriptor in the parent
|
|
3869
|
+
closeSync(logFd);
|
|
3870
|
+
// Give it a moment to start and check if it's running
|
|
3871
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
3872
|
+
if (isProcessRunning(child.pid)) {
|
|
3873
|
+
console.log(chalk.green("\n ✓ Daemon started successfully!"));
|
|
3874
|
+
console.log(chalk.gray(` PID: ${child.pid}`));
|
|
3875
|
+
console.log(chalk.gray(` Log: ${logPath}`));
|
|
3876
|
+
console.log();
|
|
3877
|
+
console.log(chalk.cyan(" Hotkey: Ctrl+Shift+Space"));
|
|
3878
|
+
console.log(chalk.gray(" The daemon will continue running after you close this terminal."));
|
|
3879
|
+
console.log();
|
|
3880
|
+
console.log(chalk.gray(" Commands:"));
|
|
3881
|
+
console.log(chalk.gray(" jfl voice daemon status Check daemon status"));
|
|
3882
|
+
console.log(chalk.gray(" jfl voice daemon stop Stop the daemon"));
|
|
3883
|
+
console.log();
|
|
3884
|
+
}
|
|
3885
|
+
else {
|
|
3886
|
+
// Daemon may have exited immediately - check log for errors
|
|
3887
|
+
console.log(chalk.red("\n ✗ Daemon failed to start"));
|
|
3888
|
+
console.log(chalk.gray(` Check log for details: ${logPath}`));
|
|
3889
|
+
// Clean up PID file
|
|
3890
|
+
try {
|
|
3891
|
+
unlinkSync(pidPath);
|
|
3892
|
+
}
|
|
3893
|
+
catch { }
|
|
3894
|
+
console.log();
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
else {
|
|
3898
|
+
closeSync(logFd);
|
|
3899
|
+
console.log(chalk.red("\n ✗ Failed to spawn daemon process"));
|
|
3900
|
+
console.log();
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
/**
|
|
3904
|
+
* Daemon Stop Command - Stop the voice daemon gracefully
|
|
3905
|
+
*
|
|
3906
|
+
* Reads PID from ~/.jfl/voice-daemon.pid and sends SIGTERM
|
|
3907
|
+
*/
|
|
3908
|
+
export async function daemonStopCommand() {
|
|
3909
|
+
console.log(chalk.bold("\n🎤 Voice Daemon\n"));
|
|
3910
|
+
const pid = readDaemonPid();
|
|
3911
|
+
const pidPath = getVoiceDaemonPidPath();
|
|
3912
|
+
if (!pid) {
|
|
3913
|
+
console.log(chalk.yellow(" Daemon is not running (no PID file)."));
|
|
3914
|
+
console.log();
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3917
|
+
if (!isProcessRunning(pid)) {
|
|
3918
|
+
console.log(chalk.yellow(" Daemon is not running (stale PID file)."));
|
|
3919
|
+
console.log(chalk.gray(" Cleaning up PID file..."));
|
|
3920
|
+
try {
|
|
3921
|
+
unlinkSync(pidPath);
|
|
3922
|
+
}
|
|
3923
|
+
catch { }
|
|
3924
|
+
console.log(chalk.green(" ✓ Cleaned up"));
|
|
3925
|
+
console.log();
|
|
3926
|
+
return;
|
|
3927
|
+
}
|
|
3928
|
+
console.log(chalk.gray(` Stopping daemon (PID: ${pid})...`));
|
|
3929
|
+
try {
|
|
3930
|
+
// Send SIGTERM for graceful shutdown
|
|
3931
|
+
process.kill(pid, "SIGTERM");
|
|
3932
|
+
// Wait for process to stop (up to 5 seconds)
|
|
3933
|
+
let stopped = false;
|
|
3934
|
+
for (let i = 0; i < 50; i++) {
|
|
3935
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
3936
|
+
if (!isProcessRunning(pid)) {
|
|
3937
|
+
stopped = true;
|
|
3938
|
+
break;
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
if (stopped) {
|
|
3942
|
+
console.log(chalk.green(" ✓ Daemon stopped successfully"));
|
|
3943
|
+
// Clean up PID file
|
|
3944
|
+
try {
|
|
3945
|
+
unlinkSync(pidPath);
|
|
3946
|
+
}
|
|
3947
|
+
catch { }
|
|
3948
|
+
}
|
|
3949
|
+
else {
|
|
3950
|
+
console.log(chalk.yellow(" Daemon did not stop gracefully, sending SIGKILL..."));
|
|
3951
|
+
try {
|
|
3952
|
+
process.kill(pid, "SIGKILL");
|
|
3953
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
3954
|
+
console.log(chalk.green(" ✓ Daemon killed"));
|
|
3955
|
+
try {
|
|
3956
|
+
unlinkSync(pidPath);
|
|
3957
|
+
}
|
|
3958
|
+
catch { }
|
|
3959
|
+
}
|
|
3960
|
+
catch (error) {
|
|
3961
|
+
console.log(chalk.red(" ✗ Failed to kill daemon"));
|
|
3962
|
+
console.log(chalk.gray(` You may need to manually kill PID ${pid}`));
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
catch (error) {
|
|
3967
|
+
console.log(chalk.red(" ✗ Failed to stop daemon"));
|
|
3968
|
+
if (error instanceof Error) {
|
|
3969
|
+
console.log(chalk.gray(` ${error.message}`));
|
|
3970
|
+
}
|
|
3971
|
+
console.log(chalk.gray(` You may need to manually kill PID ${pid}`));
|
|
3972
|
+
}
|
|
3973
|
+
console.log();
|
|
3974
|
+
}
|
|
3975
|
+
/**
|
|
3976
|
+
* Daemon Status Command - Show daemon running status
|
|
3977
|
+
*
|
|
3978
|
+
* Checks if daemon is running based on PID file and process existence
|
|
3979
|
+
*/
|
|
3980
|
+
export async function daemonStatusCommand() {
|
|
3981
|
+
console.log(chalk.bold("\n🎤 Voice Daemon Status\n"));
|
|
3982
|
+
const pid = readDaemonPid();
|
|
3983
|
+
const pidPath = getVoiceDaemonPidPath();
|
|
3984
|
+
const logPath = getVoiceDaemonLogPath();
|
|
3985
|
+
if (!pid) {
|
|
3986
|
+
console.log(chalk.yellow(" Status: stopped"));
|
|
3987
|
+
console.log(chalk.gray(" (no PID file found)"));
|
|
3988
|
+
console.log();
|
|
3989
|
+
console.log(chalk.gray(" Start with: jfl voice daemon start"));
|
|
3990
|
+
console.log();
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
if (!isProcessRunning(pid)) {
|
|
3994
|
+
console.log(chalk.yellow(" Status: stopped (stale)"));
|
|
3995
|
+
console.log(chalk.gray(` PID file exists but process ${pid} is not running`));
|
|
3996
|
+
console.log();
|
|
3997
|
+
console.log(chalk.gray(" Cleaning up stale PID file..."));
|
|
3998
|
+
try {
|
|
3999
|
+
unlinkSync(pidPath);
|
|
4000
|
+
console.log(chalk.green(" ✓ Cleaned up"));
|
|
4001
|
+
}
|
|
4002
|
+
catch { }
|
|
4003
|
+
console.log();
|
|
4004
|
+
console.log(chalk.gray(" Start with: jfl voice daemon start"));
|
|
4005
|
+
console.log();
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
// Daemon is running
|
|
4009
|
+
const uptime = getDaemonUptime();
|
|
4010
|
+
console.log(chalk.green(" Status: running"));
|
|
4011
|
+
console.log(chalk.gray(` PID: ${pid}`));
|
|
4012
|
+
if (uptime) {
|
|
4013
|
+
console.log(chalk.gray(` Uptime: ${uptime}`));
|
|
4014
|
+
}
|
|
4015
|
+
console.log(chalk.gray(` Log: ${logPath}`));
|
|
4016
|
+
console.log();
|
|
4017
|
+
console.log(chalk.cyan(" Hotkey: Ctrl+Shift+Space"));
|
|
4018
|
+
console.log();
|
|
4019
|
+
console.log(chalk.gray(" Commands:"));
|
|
4020
|
+
console.log(chalk.gray(" jfl voice daemon stop Stop the daemon"));
|
|
4021
|
+
console.log();
|
|
4022
|
+
// Show last few lines of log if it exists
|
|
4023
|
+
if (existsSync(logPath)) {
|
|
4024
|
+
try {
|
|
4025
|
+
const logContent = readFileSync(logPath, "utf-8");
|
|
4026
|
+
const lines = logContent.trim().split("\n");
|
|
4027
|
+
const lastLines = lines.slice(-5);
|
|
4028
|
+
if (lastLines.length > 0 && lastLines[0]) {
|
|
4029
|
+
console.log(chalk.gray(" Recent log:"));
|
|
4030
|
+
for (const line of lastLines) {
|
|
4031
|
+
console.log(chalk.gray(` ${line.substring(0, 80)}`));
|
|
4032
|
+
}
|
|
4033
|
+
console.log();
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
catch { }
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
/**
|
|
4040
|
+
* Hotkey Command - Start global hotkey listener
|
|
4041
|
+
*
|
|
4042
|
+
* Listens for Ctrl+Shift+Space globally (even when other apps have focus).
|
|
4043
|
+
* Supports multiple modes:
|
|
4044
|
+
* - auto: Smart detection - tap to toggle, or hold for hold-to-talk
|
|
4045
|
+
* - tap: Tap to start/stop recording
|
|
4046
|
+
* - hold: Hold to record, release to stop
|
|
4047
|
+
* VS-010/VS-011: Supported on macOS, Linux (X11), and Windows.
|
|
4048
|
+
* Requires Accessibility permission on macOS. On Linux Wayland, hotkeys are not supported.
|
|
4049
|
+
*/
|
|
4050
|
+
export async function hotkeyCommand(options = {}) {
|
|
4051
|
+
const currentPlatform = platform();
|
|
4052
|
+
// VS-010: Check for Linux Wayland (not supported)
|
|
4053
|
+
if (currentPlatform === "linux" && isWayland()) {
|
|
4054
|
+
const error = new VoiceError(VoiceErrorType.WAYLAND_NOT_SUPPORTED);
|
|
4055
|
+
handleVoiceError(error);
|
|
4056
|
+
return;
|
|
4057
|
+
}
|
|
4058
|
+
// Check platform - hotkey supported on macOS, Linux (X11), and Windows
|
|
4059
|
+
if (currentPlatform !== "darwin" && currentPlatform !== "linux" && currentPlatform !== "win32") {
|
|
4060
|
+
const error = new VoiceError(VoiceErrorType.PLATFORM_NOT_SUPPORTED);
|
|
4061
|
+
handleVoiceError(error);
|
|
4062
|
+
return;
|
|
4063
|
+
}
|
|
4064
|
+
// Load hotkey config from voice.yaml, with command-line override
|
|
4065
|
+
const voiceConfig = readVoiceConfig();
|
|
4066
|
+
const hotkeyConfig = voiceConfig?.hotkey || DEFAULT_HOTKEY_CONFIG;
|
|
4067
|
+
const securityConfig = voiceConfig?.security || DEFAULT_SECURITY_CONFIG;
|
|
4068
|
+
const activeMode = options.mode || hotkeyConfig.mode;
|
|
4069
|
+
const holdThreshold = hotkeyConfig.holdThreshold;
|
|
4070
|
+
console.log(chalk.bold("\n🎤 Voice Hotkey Mode\n"));
|
|
4071
|
+
console.log(chalk.gray(" Global hotkey: Ctrl+Shift+Space"));
|
|
4072
|
+
// Show mode-specific instructions
|
|
4073
|
+
if (activeMode === "tap") {
|
|
4074
|
+
console.log(chalk.gray(" Mode: tap-to-toggle"));
|
|
4075
|
+
console.log(chalk.gray(" First tap starts recording, second tap stops.\n"));
|
|
4076
|
+
}
|
|
4077
|
+
else if (activeMode === "hold") {
|
|
4078
|
+
console.log(chalk.gray(" Mode: hold-to-talk"));
|
|
4079
|
+
console.log(chalk.gray(" Hold to record, release to stop.\n"));
|
|
4080
|
+
}
|
|
4081
|
+
else {
|
|
4082
|
+
console.log(chalk.gray(" Mode: auto (smart detection)"));
|
|
4083
|
+
console.log(chalk.gray(` Quick tap (<${holdThreshold}ms): toggle recording`));
|
|
4084
|
+
console.log(chalk.gray(` Hold (>${holdThreshold}ms): hold-to-talk\n`));
|
|
4085
|
+
}
|
|
4086
|
+
// Check Accessibility/keyboard permission (platform-specific)
|
|
4087
|
+
if (currentPlatform === "darwin") {
|
|
4088
|
+
console.log(chalk.gray(" Checking Accessibility permission..."));
|
|
4089
|
+
}
|
|
4090
|
+
else if (currentPlatform === "linux") {
|
|
4091
|
+
console.log(chalk.gray(" Checking X11 environment..."));
|
|
4092
|
+
}
|
|
4093
|
+
else if (currentPlatform === "win32") {
|
|
4094
|
+
console.log(chalk.gray(" Checking keyboard access..."));
|
|
4095
|
+
}
|
|
4096
|
+
if (!checkAccessibilityPermission()) {
|
|
4097
|
+
console.log();
|
|
4098
|
+
if (currentPlatform === "darwin") {
|
|
4099
|
+
const error = new VoiceError(VoiceErrorType.ACCESSIBILITY_NOT_GRANTED);
|
|
4100
|
+
handleVoiceError(error);
|
|
4101
|
+
console.log(chalk.cyan(" Opening System Settings..."));
|
|
4102
|
+
openAccessibilitySettings();
|
|
4103
|
+
console.log();
|
|
4104
|
+
console.log(chalk.yellow(" After granting permission:"));
|
|
4105
|
+
console.log(chalk.gray(" 1. Add your terminal app to Accessibility"));
|
|
4106
|
+
console.log(chalk.gray(" 2. Restart your terminal"));
|
|
4107
|
+
console.log(chalk.gray(" 3. Run 'jfl voice hotkey' again"));
|
|
4108
|
+
}
|
|
4109
|
+
else if (currentPlatform === "linux") {
|
|
4110
|
+
// VS-010: Linux X11 requirements
|
|
4111
|
+
console.log(chalk.red(" X11 environment not detected or xdotool not available."));
|
|
4112
|
+
console.log();
|
|
4113
|
+
console.log(chalk.yellow(" Requirements for Linux hotkey support:"));
|
|
4114
|
+
console.log(chalk.gray(" 1. Must be running an X11 session (not Wayland)"));
|
|
4115
|
+
console.log(chalk.gray(" 2. Install xdotool: sudo apt-get install xdotool"));
|
|
4116
|
+
console.log(chalk.gray(" 3. Run 'jfl voice hotkey' again"));
|
|
4117
|
+
console.log();
|
|
4118
|
+
console.log(chalk.gray(" To check your session type: echo $XDG_SESSION_TYPE"));
|
|
4119
|
+
}
|
|
4120
|
+
else if (currentPlatform === "win32") {
|
|
4121
|
+
// VS-011: Windows should work without special permissions
|
|
4122
|
+
console.log(chalk.red(" Keyboard access check failed."));
|
|
4123
|
+
console.log(chalk.gray(" This is unexpected on Windows. Please try restarting your terminal."));
|
|
4124
|
+
}
|
|
4125
|
+
console.log();
|
|
4126
|
+
return;
|
|
4127
|
+
}
|
|
4128
|
+
if (currentPlatform === "darwin") {
|
|
4129
|
+
console.log(chalk.green(" ✓ Accessibility permission granted\n"));
|
|
4130
|
+
}
|
|
4131
|
+
else if (currentPlatform === "linux") {
|
|
4132
|
+
console.log(chalk.green(" ✓ X11 environment detected\n"));
|
|
4133
|
+
}
|
|
4134
|
+
else if (currentPlatform === "win32") {
|
|
4135
|
+
console.log(chalk.green(" ✓ Keyboard access available\n"));
|
|
4136
|
+
}
|
|
4137
|
+
// Check other prerequisites (server, auth, model)
|
|
4138
|
+
const serverError = checkServerRunning();
|
|
4139
|
+
if (serverError) {
|
|
4140
|
+
handleVoiceError(serverError);
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
4143
|
+
const authError = checkAuthToken();
|
|
4144
|
+
if (authError) {
|
|
4145
|
+
handleVoiceError(authError);
|
|
4146
|
+
return;
|
|
4147
|
+
}
|
|
4148
|
+
// Initialize keyboard listener
|
|
4149
|
+
let keyboardListener;
|
|
4150
|
+
try {
|
|
4151
|
+
keyboardListener = new GlobalKeyboardListener();
|
|
4152
|
+
}
|
|
4153
|
+
catch (error) {
|
|
4154
|
+
console.log(chalk.red("\n Failed to initialize keyboard listener."));
|
|
4155
|
+
if (currentPlatform === "darwin") {
|
|
4156
|
+
console.log(chalk.gray(" This may be due to missing Accessibility permission."));
|
|
4157
|
+
console.log();
|
|
4158
|
+
openAccessibilitySettings();
|
|
4159
|
+
}
|
|
4160
|
+
else if (currentPlatform === "linux") {
|
|
4161
|
+
// VS-010: Linux-specific error guidance
|
|
4162
|
+
console.log(chalk.gray(" On Linux X11, this requires the X11 display server."));
|
|
4163
|
+
console.log(chalk.gray(" Ensure you are running an X11 session and not Wayland."));
|
|
4164
|
+
console.log();
|
|
4165
|
+
console.log(chalk.yellow(" To check your session type:"));
|
|
4166
|
+
console.log(chalk.gray(" echo $XDG_SESSION_TYPE"));
|
|
4167
|
+
}
|
|
4168
|
+
else if (currentPlatform === "win32") {
|
|
4169
|
+
// VS-011: Windows-specific error guidance
|
|
4170
|
+
console.log(chalk.gray(" On Windows, this should work without special permissions."));
|
|
4171
|
+
console.log(chalk.gray(" Try running your terminal as Administrator if the issue persists."));
|
|
4172
|
+
}
|
|
4173
|
+
console.log();
|
|
4174
|
+
return;
|
|
4175
|
+
}
|
|
4176
|
+
// State management for hotkey
|
|
4177
|
+
let isRecording = false;
|
|
4178
|
+
let recordingPromise = null;
|
|
4179
|
+
let currentRecorder = null;
|
|
4180
|
+
let audioChunks = [];
|
|
4181
|
+
let hasVoiceActivity = false;
|
|
4182
|
+
let silenceStartTime = null;
|
|
4183
|
+
let recordingStartTime = null;
|
|
4184
|
+
let recordingSpinner = null;
|
|
4185
|
+
let focusedAppAtStart = null; // Track which app was focused when recording started
|
|
4186
|
+
// VAD settings
|
|
4187
|
+
const silenceThresholdDb = VAD_SILENCE_THRESHOLD_DB;
|
|
4188
|
+
const silenceDurationMs = VAD_SILENCE_DURATION_MS;
|
|
4189
|
+
// VS-SEC-3: Configurable max recording duration from security config
|
|
4190
|
+
const maxDurationSecs = securityConfig.maxRecordingDuration;
|
|
4191
|
+
const warningThresholdSecs = Math.max(10, maxDurationSecs - 10); // Warning 10 seconds before limit
|
|
4192
|
+
let warningShown = false; // Track if warning has been displayed
|
|
4193
|
+
// Helper function to start recording
|
|
4194
|
+
const startRecording = async () => {
|
|
4195
|
+
if (isRecording)
|
|
4196
|
+
return;
|
|
4197
|
+
// Capture the focused app before we start recording (VS-SEC-2)
|
|
4198
|
+
focusedAppAtStart = getFocusedApp();
|
|
4199
|
+
if (process.env.DEBUG && focusedAppAtStart) {
|
|
4200
|
+
console.log(chalk.gray(` [debug] Recording started in: ${focusedAppAtStart}`));
|
|
4201
|
+
}
|
|
4202
|
+
console.log(chalk.cyan("\n Recording started... (press Ctrl+Shift+Space to stop)\n"));
|
|
4203
|
+
// Play sound and show notification for daemon mode feedback
|
|
4204
|
+
if (!process.stdin.isTTY) {
|
|
4205
|
+
// Play start sound (Pop = friendly "go" sound)
|
|
4206
|
+
try {
|
|
4207
|
+
execSync('afplay /System/Library/Sounds/Pop.aiff &', { stdio: 'ignore' });
|
|
4208
|
+
}
|
|
4209
|
+
catch { }
|
|
4210
|
+
showNotification("🎤 Recording", "Speak now... Press Ctrl+Shift+Space to stop");
|
|
4211
|
+
}
|
|
4212
|
+
isRecording = true;
|
|
4213
|
+
audioChunks = [];
|
|
4214
|
+
hasVoiceActivity = false;
|
|
4215
|
+
silenceStartTime = null;
|
|
4216
|
+
recordingStartTime = Date.now();
|
|
4217
|
+
warningShown = false; // VS-SEC-3: Reset warning flag for new recording
|
|
4218
|
+
// VS-012: Reset waveform buffer for new recording
|
|
4219
|
+
resetWaveformBuffer();
|
|
4220
|
+
const useWaveform = supportsWaveform();
|
|
4221
|
+
try {
|
|
4222
|
+
currentRecorder = new AudioRecorder({
|
|
4223
|
+
device: options.device,
|
|
4224
|
+
sampleRate: 16000,
|
|
4225
|
+
});
|
|
4226
|
+
}
|
|
4227
|
+
catch (error) {
|
|
4228
|
+
console.log(chalk.red(" Failed to initialize recorder"));
|
|
4229
|
+
isRecording = false;
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
recordingSpinner = ora({
|
|
4233
|
+
text: chalk.cyan("Recording...") + chalk.gray(" (waiting for voice)"),
|
|
4234
|
+
prefixText: " ",
|
|
4235
|
+
spinner: "dots",
|
|
4236
|
+
});
|
|
4237
|
+
// Set up recorder event handlers
|
|
4238
|
+
currentRecorder.on("data", (chunk) => {
|
|
4239
|
+
audioChunks.push(chunk);
|
|
4240
|
+
// Calculate peak level
|
|
4241
|
+
const chunkPeak = calculatePeakAmplitude(chunk);
|
|
4242
|
+
const peakDb = amplitudeToDb(chunkPeak);
|
|
4243
|
+
const isSilent = peakDb < silenceThresholdDb;
|
|
4244
|
+
// VS-012: Add level to waveform buffer on every chunk
|
|
4245
|
+
addToWaveformBuffer(peakDb);
|
|
4246
|
+
// VS-012: Update spinner with waveform on every chunk
|
|
4247
|
+
if (recordingSpinner && recordingStartTime) {
|
|
4248
|
+
const elapsed = ((Date.now() - recordingStartTime) / 1000).toFixed(1);
|
|
4249
|
+
if (useWaveform) {
|
|
4250
|
+
const waveform = renderWaveform();
|
|
4251
|
+
recordingSpinner.text = chalk.cyan("Recording") + chalk.gray(` ${waveform} (${elapsed}s)`);
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
if (!isSilent) {
|
|
4255
|
+
hasVoiceActivity = true;
|
|
4256
|
+
silenceStartTime = null;
|
|
4257
|
+
}
|
|
4258
|
+
else if (hasVoiceActivity) {
|
|
4259
|
+
if (silenceStartTime === null) {
|
|
4260
|
+
silenceStartTime = Date.now();
|
|
4261
|
+
}
|
|
4262
|
+
else {
|
|
4263
|
+
const silenceDuration = Date.now() - silenceStartTime;
|
|
4264
|
+
if (silenceDuration >= silenceDurationMs) {
|
|
4265
|
+
// Auto-stop on silence
|
|
4266
|
+
stopRecording();
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
// Check max duration (VS-SEC-3)
|
|
4271
|
+
if (recordingStartTime) {
|
|
4272
|
+
const elapsed = (Date.now() - recordingStartTime) / 1000;
|
|
4273
|
+
// Show warning 10 seconds before limit
|
|
4274
|
+
if (elapsed >= warningThresholdSecs && !warningShown) {
|
|
4275
|
+
warningShown = true;
|
|
4276
|
+
const remaining = Math.ceil(maxDurationSecs - elapsed);
|
|
4277
|
+
console.log(chalk.yellow(`\n ⚠ Recording will stop in ${remaining} seconds`));
|
|
4278
|
+
}
|
|
4279
|
+
// Auto-stop at max duration
|
|
4280
|
+
if (elapsed >= maxDurationSecs) {
|
|
4281
|
+
console.log(chalk.yellow(`\n ⚠ Maximum recording duration (${maxDurationSecs}s) reached`));
|
|
4282
|
+
stopRecording();
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
});
|
|
4286
|
+
currentRecorder.on("error", (error) => {
|
|
4287
|
+
console.log(chalk.red(`\n Recording error: ${error.message}`));
|
|
4288
|
+
isRecording = false;
|
|
4289
|
+
});
|
|
4290
|
+
try {
|
|
4291
|
+
await currentRecorder.start();
|
|
4292
|
+
recordingSpinner.start();
|
|
4293
|
+
// VS-UX-2: Show system recording indicator (notification on macOS)
|
|
4294
|
+
showRecordingIndicator();
|
|
4295
|
+
}
|
|
4296
|
+
catch (error) {
|
|
4297
|
+
console.log(chalk.red(" Failed to start recording"));
|
|
4298
|
+
isRecording = false;
|
|
4299
|
+
}
|
|
4300
|
+
};
|
|
4301
|
+
// Helper function to stop recording and transcribe
|
|
4302
|
+
const stopRecording = async () => {
|
|
4303
|
+
if (!isRecording || !currentRecorder)
|
|
4304
|
+
return;
|
|
4305
|
+
currentRecorder.stop();
|
|
4306
|
+
isRecording = false;
|
|
4307
|
+
// Wait for recorder to fully stop
|
|
4308
|
+
await new Promise((resolve) => {
|
|
4309
|
+
const checkStopped = () => {
|
|
4310
|
+
if (!currentRecorder || currentRecorder.getState() === "idle" || currentRecorder.getState() === "error") {
|
|
4311
|
+
resolve();
|
|
4312
|
+
}
|
|
4313
|
+
else {
|
|
4314
|
+
setTimeout(checkStopped, 50);
|
|
4315
|
+
}
|
|
4316
|
+
};
|
|
4317
|
+
setTimeout(checkStopped, 100);
|
|
4318
|
+
});
|
|
4319
|
+
if (recordingSpinner) {
|
|
4320
|
+
recordingSpinner.stop();
|
|
4321
|
+
recordingSpinner = null;
|
|
4322
|
+
}
|
|
4323
|
+
// Check if we have audio
|
|
4324
|
+
const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
4325
|
+
if (totalBytes === 0 || !hasVoiceActivity) {
|
|
4326
|
+
// VS-SEC-3: Zero audio buffers even on early return
|
|
4327
|
+
zeroBuffers(audioChunks);
|
|
4328
|
+
// VS-UX-2: Show stopped indicator when no audio
|
|
4329
|
+
hideRecordingIndicator("stopped");
|
|
4330
|
+
console.log(chalk.yellow(" No audio captured or no voice detected.\n"));
|
|
4331
|
+
console.log(chalk.gray(" Press Ctrl+Shift+Space to try again, or Ctrl+C to quit.\n"));
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
4334
|
+
// VS-UX-2: Show completed indicator when moving to transcription
|
|
4335
|
+
hideRecordingIndicator("completed");
|
|
4336
|
+
// Combine audio and normalize for better transcription
|
|
4337
|
+
const audioBuffer = normalizeAudio(Buffer.concat(audioChunks));
|
|
4338
|
+
// VS-SEC-3: Zero the individual chunks immediately after combining
|
|
4339
|
+
zeroBuffers(audioChunks);
|
|
4340
|
+
const durationSecs = totalBytes / (16000 * 2);
|
|
4341
|
+
console.log(chalk.gray(`\n Recorded ${durationSecs.toFixed(1)}s of audio.`));
|
|
4342
|
+
console.log(chalk.gray(" Transcribing...\n"));
|
|
4343
|
+
// Transcribe
|
|
4344
|
+
const transcribeSpinner = ora({
|
|
4345
|
+
text: "Transcribing...",
|
|
4346
|
+
prefixText: " ",
|
|
4347
|
+
}).start();
|
|
4348
|
+
const authToken = readAuthToken();
|
|
4349
|
+
const socketPath = getVoiceSocketPath();
|
|
4350
|
+
const client = new VoiceClient({
|
|
4351
|
+
socketPath,
|
|
4352
|
+
authToken,
|
|
4353
|
+
maxReconnectAttempts: 1,
|
|
4354
|
+
});
|
|
4355
|
+
let transcription = "";
|
|
4356
|
+
let transcriptionReceived = false;
|
|
4357
|
+
let transcriptionError = null;
|
|
4358
|
+
client.onTranscript((text, isFinal) => {
|
|
4359
|
+
// Accept partial transcriptions too (in case final never arrives)
|
|
4360
|
+
if (text && text.trim() && !text.includes("[BLANK_AUDIO]")) {
|
|
4361
|
+
transcription = text;
|
|
4362
|
+
}
|
|
4363
|
+
if (isFinal) {
|
|
4364
|
+
transcriptionReceived = true;
|
|
4365
|
+
}
|
|
4366
|
+
});
|
|
4367
|
+
client.onError((error) => {
|
|
4368
|
+
transcriptionError = error;
|
|
4369
|
+
});
|
|
4370
|
+
try {
|
|
4371
|
+
await client.connect();
|
|
4372
|
+
client.sendAudio(audioBuffer);
|
|
4373
|
+
// Wait for audio to be sent before signaling end
|
|
4374
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
4375
|
+
client.endAudio();
|
|
4376
|
+
const timeout = 10000; // Reduced from 30s
|
|
4377
|
+
const startTime = Date.now();
|
|
4378
|
+
while (!transcriptionReceived && !transcriptionError) {
|
|
4379
|
+
// If we have a transcription, accept it even without final flag (wait 1s for final)
|
|
4380
|
+
if (transcription && Date.now() - startTime > 1000) {
|
|
4381
|
+
break;
|
|
4382
|
+
}
|
|
4383
|
+
if (Date.now() - startTime > timeout) {
|
|
4384
|
+
transcriptionError = new Error("Transcription timeout");
|
|
4385
|
+
break;
|
|
4386
|
+
}
|
|
4387
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
4388
|
+
}
|
|
4389
|
+
client.disconnect();
|
|
4390
|
+
// VS-SEC-3: Zero combined audio buffer immediately after transcription
|
|
4391
|
+
zeroBuffer(audioBuffer);
|
|
4392
|
+
}
|
|
4393
|
+
catch (error) {
|
|
4394
|
+
client.disconnect();
|
|
4395
|
+
// VS-SEC-3: Zero combined audio buffer on error
|
|
4396
|
+
zeroBuffer(audioBuffer);
|
|
4397
|
+
transcribeSpinner.fail("Transcription failed");
|
|
4398
|
+
console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}\n`));
|
|
4399
|
+
console.log(chalk.gray(" Press Ctrl+Shift+Space to try again, or Ctrl+C to quit.\n"));
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
if (transcriptionError || !transcription || transcription.trim() === "") {
|
|
4403
|
+
transcribeSpinner.fail("No transcription result");
|
|
4404
|
+
console.log(chalk.gray(" Press Ctrl+Shift+Space to try again, or Ctrl+C to quit.\n"));
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
transcribeSpinner.succeed("Transcription complete!");
|
|
4408
|
+
const trimmedTranscription = transcription.trim();
|
|
4409
|
+
// VS-008: Preview transcript with configurable auto-send
|
|
4410
|
+
const previewConfig = voiceConfig?.preview || DEFAULT_PREVIEW_CONFIG;
|
|
4411
|
+
const previewResult = await previewTranscript(trimmedTranscription, previewConfig.timeout);
|
|
4412
|
+
if (previewResult.action === "cancel") {
|
|
4413
|
+
console.log(chalk.yellow(" Cancelled."));
|
|
4414
|
+
console.log(chalk.gray(" Press Ctrl+Shift+Space to record again, or Ctrl+C to quit.\n"));
|
|
4415
|
+
return;
|
|
4416
|
+
}
|
|
4417
|
+
// Use the final text (may have been edited by user)
|
|
4418
|
+
const finalText = previewResult.text;
|
|
4419
|
+
// VS-SEC-2: Focus verification before paste
|
|
4420
|
+
// Check if the same app is still focused
|
|
4421
|
+
const currentFocusedApp = getFocusedApp();
|
|
4422
|
+
const focusUnchanged = focusedAppAtStart && currentFocusedApp && focusedAppAtStart === currentFocusedApp;
|
|
4423
|
+
if (process.env.DEBUG) {
|
|
4424
|
+
console.log(chalk.gray(` [debug] Focus at start: ${focusedAppAtStart}`));
|
|
4425
|
+
console.log(chalk.gray(` [debug] Focus now: ${currentFocusedApp}`));
|
|
4426
|
+
console.log(chalk.gray(` [debug] Focus unchanged: ${focusUnchanged}`));
|
|
4427
|
+
}
|
|
4428
|
+
if (focusUnchanged) {
|
|
4429
|
+
// VS-007: Same app focused - copy to clipboard and simulate paste
|
|
4430
|
+
const copied = copyToClipboard(finalText);
|
|
4431
|
+
if (copied) {
|
|
4432
|
+
// Small delay to ensure clipboard is ready
|
|
4433
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
4434
|
+
const pasted = simulatePaste();
|
|
4435
|
+
if (pasted) {
|
|
4436
|
+
console.log(chalk.green(`\n ✓ Pasted to ${currentFocusedApp}!`));
|
|
4437
|
+
// Play success sound and show notification for daemon mode
|
|
4438
|
+
if (!process.stdin.isTTY) {
|
|
4439
|
+
try {
|
|
4440
|
+
execSync('afplay /System/Library/Sounds/Glass.aiff &', { stdio: 'ignore' });
|
|
4441
|
+
}
|
|
4442
|
+
catch { }
|
|
4443
|
+
const preview = finalText.length > 50 ? finalText.slice(0, 47) + "..." : finalText;
|
|
4444
|
+
showNotification("✓ Pasted", preview);
|
|
4445
|
+
}
|
|
4446
|
+
// VS-SEC-3: Schedule clipboard clear after successful paste
|
|
4447
|
+
scheduleClipboardClear(securityConfig.clipboardClearDelay);
|
|
4448
|
+
if (process.env.DEBUG) {
|
|
4449
|
+
console.log(chalk.gray(` [debug] Clipboard will be cleared in ${securityConfig.clipboardClearDelay}s`));
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
else {
|
|
4453
|
+
console.log(chalk.green("\n ✓ Copied to clipboard!"));
|
|
4454
|
+
console.log(chalk.yellow(` ⚠ Could not auto-paste (${getPasteShortcut()}). Text is on clipboard.`));
|
|
4455
|
+
// Show notification for daemon mode
|
|
4456
|
+
if (!process.stdin.isTTY) {
|
|
4457
|
+
const preview = finalText.length > 50 ? finalText.slice(0, 47) + "..." : finalText;
|
|
4458
|
+
showNotification("📋 Copied", `${preview}\n\nPress ${getPasteShortcut()} to paste`);
|
|
4459
|
+
}
|
|
4460
|
+
// VS-SEC-3: Still schedule clipboard clear even if paste failed
|
|
4461
|
+
scheduleClipboardClear(securityConfig.clipboardClearDelay);
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
else {
|
|
4465
|
+
console.log(chalk.yellow("\n ⚠ Could not copy to clipboard"));
|
|
4466
|
+
// Display result for manual copy
|
|
4467
|
+
console.log();
|
|
4468
|
+
console.log(chalk.bold(" Transcription:"));
|
|
4469
|
+
console.log();
|
|
4470
|
+
console.log(chalk.cyan(` "${finalText}"`));
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
else {
|
|
4474
|
+
// Focus changed - show notification and don't auto-paste (security measure)
|
|
4475
|
+
console.log();
|
|
4476
|
+
console.log(chalk.yellow(" ⚠ Focus changed during recording"));
|
|
4477
|
+
if (focusedAppAtStart && currentFocusedApp) {
|
|
4478
|
+
console.log(chalk.gray(` Started in: ${focusedAppAtStart}`));
|
|
4479
|
+
console.log(chalk.gray(` Now in: ${currentFocusedApp}`));
|
|
4480
|
+
}
|
|
4481
|
+
// Copy to clipboard anyway for user convenience
|
|
4482
|
+
const copied = copyToClipboard(finalText);
|
|
4483
|
+
// Show notification with transcription
|
|
4484
|
+
const notified = showNotification("Voice Transcription", finalText.length > 100
|
|
4485
|
+
? finalText.substring(0, 97) + "..."
|
|
4486
|
+
: finalText);
|
|
4487
|
+
if (notified) {
|
|
4488
|
+
console.log(chalk.cyan("\n 📋 Notification shown with transcription"));
|
|
4489
|
+
}
|
|
4490
|
+
if (copied) {
|
|
4491
|
+
console.log(chalk.green(` ✓ Copied to clipboard (${getPasteShortcut()} to paste manually)`));
|
|
4492
|
+
// VS-SEC-3: Schedule clipboard clear after copy
|
|
4493
|
+
scheduleClipboardClear(securityConfig.clipboardClearDelay);
|
|
4494
|
+
}
|
|
4495
|
+
// Display result
|
|
4496
|
+
console.log();
|
|
4497
|
+
console.log(chalk.bold(" Transcription:"));
|
|
4498
|
+
console.log();
|
|
4499
|
+
console.log(chalk.cyan(` "${finalText}"`));
|
|
4500
|
+
}
|
|
4501
|
+
console.log();
|
|
4502
|
+
console.log(chalk.gray(" Press Ctrl+Shift+Space to record again, or Ctrl+C to quit.\n"));
|
|
4503
|
+
};
|
|
4504
|
+
// Track modifier keys state
|
|
4505
|
+
let ctrlPressed = false;
|
|
4506
|
+
let shiftPressed = false;
|
|
4507
|
+
// Hold-to-talk state tracking
|
|
4508
|
+
let keyDownTime = null;
|
|
4509
|
+
let holdTimer = null;
|
|
4510
|
+
let isInHoldMode = false; // True when user has held key past threshold
|
|
4511
|
+
// Clear hold timer
|
|
4512
|
+
const clearHoldTimer = () => {
|
|
4513
|
+
if (holdTimer) {
|
|
4514
|
+
clearTimeout(holdTimer);
|
|
4515
|
+
holdTimer = null;
|
|
4516
|
+
}
|
|
4517
|
+
};
|
|
4518
|
+
// Add keyboard listener
|
|
4519
|
+
keyboardListener.addListener((event, isDown) => {
|
|
4520
|
+
// Update modifier key states
|
|
4521
|
+
if (event.name === "LEFT CTRL" || event.name === "RIGHT CTRL") {
|
|
4522
|
+
ctrlPressed = event.state === "DOWN";
|
|
4523
|
+
}
|
|
4524
|
+
if (event.name === "LEFT SHIFT" || event.name === "RIGHT SHIFT") {
|
|
4525
|
+
shiftPressed = event.state === "DOWN";
|
|
4526
|
+
}
|
|
4527
|
+
// Check for Ctrl+Shift+Space
|
|
4528
|
+
const isHotkeyCombo = event.name === "SPACE" && ctrlPressed && shiftPressed;
|
|
4529
|
+
if (!isHotkeyCombo)
|
|
4530
|
+
return;
|
|
4531
|
+
if (event.state === "DOWN") {
|
|
4532
|
+
// Key pressed down
|
|
4533
|
+
if (keyDownTime !== null) {
|
|
4534
|
+
// Already tracking a press, ignore (debounce)
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
keyDownTime = Date.now();
|
|
4538
|
+
if (activeMode === "tap") {
|
|
4539
|
+
// Pure tap mode: toggle on keydown
|
|
4540
|
+
if (isRecording) {
|
|
4541
|
+
stopRecording();
|
|
4542
|
+
}
|
|
4543
|
+
else {
|
|
4544
|
+
startRecording();
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
else if (activeMode === "hold") {
|
|
4548
|
+
// Pure hold mode: start recording immediately on keydown
|
|
4549
|
+
if (!isRecording) {
|
|
4550
|
+
isInHoldMode = true;
|
|
4551
|
+
startRecording();
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
else {
|
|
4555
|
+
// Auto mode: wait for threshold to determine behavior
|
|
4556
|
+
// Start a timer to enter hold mode
|
|
4557
|
+
holdTimer = setTimeout(() => {
|
|
4558
|
+
// Timer fired - we're in hold mode now
|
|
4559
|
+
isInHoldMode = true;
|
|
4560
|
+
if (!isRecording) {
|
|
4561
|
+
startRecording();
|
|
4562
|
+
}
|
|
4563
|
+
}, holdThreshold);
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
else if (event.state === "UP") {
|
|
4567
|
+
// Key released
|
|
4568
|
+
const pressDuration = keyDownTime !== null ? Date.now() - keyDownTime : 0;
|
|
4569
|
+
keyDownTime = null;
|
|
4570
|
+
clearHoldTimer();
|
|
4571
|
+
if (activeMode === "tap") {
|
|
4572
|
+
// Pure tap mode: already handled on keydown, nothing to do on keyup
|
|
4573
|
+
// Reset state
|
|
4574
|
+
isInHoldMode = false;
|
|
4575
|
+
}
|
|
4576
|
+
else if (activeMode === "hold") {
|
|
4577
|
+
// Pure hold mode: stop recording on keyup
|
|
4578
|
+
if (isRecording) {
|
|
4579
|
+
stopRecording();
|
|
4580
|
+
}
|
|
4581
|
+
isInHoldMode = false;
|
|
4582
|
+
}
|
|
4583
|
+
else {
|
|
4584
|
+
// Auto mode: check if this was a tap or hold
|
|
4585
|
+
if (isInHoldMode) {
|
|
4586
|
+
// Was holding - stop recording on release
|
|
4587
|
+
if (isRecording) {
|
|
4588
|
+
stopRecording();
|
|
4589
|
+
}
|
|
4590
|
+
isInHoldMode = false;
|
|
4591
|
+
}
|
|
4592
|
+
else {
|
|
4593
|
+
// Was a quick tap (released before threshold)
|
|
4594
|
+
// Toggle recording
|
|
4595
|
+
if (isRecording) {
|
|
4596
|
+
stopRecording();
|
|
4597
|
+
}
|
|
4598
|
+
else {
|
|
4599
|
+
startRecording();
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
});
|
|
4605
|
+
console.log(chalk.green(" ✓ Hotkey listener started"));
|
|
4606
|
+
if (activeMode === "tap") {
|
|
4607
|
+
console.log(chalk.gray(" Press Ctrl+Shift+Space to start/stop recording"));
|
|
4608
|
+
}
|
|
4609
|
+
else if (activeMode === "hold") {
|
|
4610
|
+
console.log(chalk.gray(" Hold Ctrl+Shift+Space to record, release to stop"));
|
|
4611
|
+
}
|
|
4612
|
+
else {
|
|
4613
|
+
console.log(chalk.gray(" Tap Ctrl+Shift+Space to toggle, or hold to talk"));
|
|
4614
|
+
}
|
|
4615
|
+
console.log(chalk.gray(" Press Ctrl+C to quit\n"));
|
|
4616
|
+
// Handle Ctrl+C to exit
|
|
4617
|
+
const cleanup = () => {
|
|
4618
|
+
console.log(chalk.yellow("\n Stopping hotkey listener...\n"));
|
|
4619
|
+
clearHoldTimer();
|
|
4620
|
+
keyboardListener.kill();
|
|
4621
|
+
if (currentRecorder) {
|
|
4622
|
+
currentRecorder.stop();
|
|
4623
|
+
}
|
|
4624
|
+
process.exit(0);
|
|
4625
|
+
};
|
|
4626
|
+
process.on("SIGINT", cleanup);
|
|
4627
|
+
process.on("SIGTERM", cleanup);
|
|
4628
|
+
// Keep the process running with an interval (needed for detached daemon mode)
|
|
4629
|
+
const keepAlive = setInterval(() => {
|
|
4630
|
+
// Keepalive - prevents Node from exiting when detached
|
|
4631
|
+
}, 60000);
|
|
4632
|
+
await new Promise(() => {
|
|
4633
|
+
// This promise never resolves - we run until Ctrl+C
|
|
4634
|
+
});
|
|
4635
|
+
}
|
|
4636
|
+
// Helper functions used by hotkeyCommand (reference existing functions)
|
|
4637
|
+
// calculatePeakAmplitude is already defined elsewhere in this file
|
|
4638
|
+
// amplitudeToDb is already defined elsewhere in this file
|
|
4639
|
+
// copyToClipboard is already defined elsewhere in this file
|
|
4640
|
+
// Main voice command handler
|
|
4641
|
+
export async function voiceCommand(action, subaction, arg, options) {
|
|
4642
|
+
// If no action, run the voice slash command (default behavior)
|
|
4643
|
+
if (!action) {
|
|
4644
|
+
await voiceSlashCommand({ device: options?.device });
|
|
4645
|
+
return;
|
|
4646
|
+
}
|
|
4647
|
+
// Handle help explicitly
|
|
4648
|
+
if (action === "help" || options?.help) {
|
|
4649
|
+
showVoiceHelp();
|
|
4650
|
+
return;
|
|
4651
|
+
}
|
|
4652
|
+
// Handle "record" as alias for the default voice slash command
|
|
4653
|
+
if (action === "record") {
|
|
4654
|
+
await voiceSlashCommand({ device: options?.device });
|
|
4655
|
+
return;
|
|
4656
|
+
}
|
|
4657
|
+
if (action === "model") {
|
|
4658
|
+
if (!subaction || subaction === "list") {
|
|
4659
|
+
await listModelsCommand();
|
|
4660
|
+
}
|
|
4661
|
+
else if (subaction === "download") {
|
|
4662
|
+
if (!arg) {
|
|
4663
|
+
console.log(chalk.red("Missing model name."));
|
|
4664
|
+
console.log(chalk.gray("Usage: jfl voice model download <name>"));
|
|
4665
|
+
console.log(chalk.gray("\nExample: jfl voice model download base"));
|
|
4666
|
+
return;
|
|
4667
|
+
}
|
|
4668
|
+
await downloadModelCommand(arg, options);
|
|
4669
|
+
}
|
|
4670
|
+
else if (subaction === "default") {
|
|
4671
|
+
if (!arg) {
|
|
4672
|
+
const current = getCurrentDefaultModel();
|
|
4673
|
+
console.log(chalk.gray(`Current default model: ${chalk.white(current)}`));
|
|
4674
|
+
console.log(chalk.gray("Usage: jfl voice model default <name>"));
|
|
4675
|
+
return;
|
|
4676
|
+
}
|
|
4677
|
+
await setDefaultModelCommand(arg);
|
|
4678
|
+
}
|
|
4679
|
+
else {
|
|
4680
|
+
console.log(chalk.red(`Unknown model command: ${subaction}`));
|
|
4681
|
+
console.log(chalk.gray("\nAvailable commands: list, download, default"));
|
|
4682
|
+
}
|
|
4683
|
+
return;
|
|
4684
|
+
}
|
|
4685
|
+
if (action === "devices") {
|
|
4686
|
+
await listDevicesCommand();
|
|
4687
|
+
return;
|
|
4688
|
+
}
|
|
4689
|
+
if (action === "test") {
|
|
4690
|
+
await voiceTestCommand({
|
|
4691
|
+
device: options?.device,
|
|
4692
|
+
});
|
|
4693
|
+
return;
|
|
4694
|
+
}
|
|
4695
|
+
if (action === "recording") {
|
|
4696
|
+
await testRecordingCommand({
|
|
4697
|
+
device: options?.device,
|
|
4698
|
+
duration: options?.duration,
|
|
4699
|
+
});
|
|
4700
|
+
return;
|
|
4701
|
+
}
|
|
4702
|
+
if (action === "setup") {
|
|
4703
|
+
await voiceSetupCommand();
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4706
|
+
if (action === "hotkey") {
|
|
4707
|
+
// Validate mode option if provided
|
|
4708
|
+
const validModes = ["auto", "tap", "hold"];
|
|
4709
|
+
let mode;
|
|
4710
|
+
if (options?.mode) {
|
|
4711
|
+
if (validModes.includes(options.mode)) {
|
|
4712
|
+
mode = options.mode;
|
|
4713
|
+
}
|
|
4714
|
+
else {
|
|
4715
|
+
console.log(chalk.red(`Invalid mode: ${options.mode}`));
|
|
4716
|
+
console.log(chalk.gray("Valid modes: auto, tap, hold"));
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
await hotkeyCommand({ device: options?.device, mode });
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
// VS-013: Daemon commands for background hotkey listening
|
|
4724
|
+
if (action === "daemon") {
|
|
4725
|
+
// Validate mode option if provided
|
|
4726
|
+
const validModes = ["auto", "tap", "hold"];
|
|
4727
|
+
let mode;
|
|
4728
|
+
if (options?.mode) {
|
|
4729
|
+
if (validModes.includes(options.mode)) {
|
|
4730
|
+
mode = options.mode;
|
|
4731
|
+
}
|
|
4732
|
+
else {
|
|
4733
|
+
console.log(chalk.red(`Invalid mode: ${options.mode}`));
|
|
4734
|
+
console.log(chalk.gray("Valid modes: auto, tap, hold"));
|
|
4735
|
+
return;
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
if (!subaction || subaction === "status") {
|
|
4739
|
+
await daemonStatusCommand();
|
|
4740
|
+
}
|
|
4741
|
+
else if (subaction === "start") {
|
|
4742
|
+
await daemonStartCommand({ mode });
|
|
4743
|
+
}
|
|
4744
|
+
else if (subaction === "stop") {
|
|
4745
|
+
await daemonStopCommand();
|
|
4746
|
+
}
|
|
4747
|
+
else {
|
|
4748
|
+
console.log(chalk.red(`Unknown daemon command: ${subaction}`));
|
|
4749
|
+
console.log(chalk.gray("\nAvailable commands: start, stop, status"));
|
|
4750
|
+
}
|
|
4751
|
+
return;
|
|
4752
|
+
}
|
|
4753
|
+
// Placeholder for future commands
|
|
4754
|
+
if (action === "server") {
|
|
4755
|
+
console.log(chalk.yellow(`\n⚠️ 'jfl voice ${action}' is coming soon.\n`));
|
|
4756
|
+
console.log(chalk.gray("For now, use 'jfl voice model' commands to manage whisper models."));
|
|
4757
|
+
console.log();
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4760
|
+
console.log(chalk.red(`Unknown voice command: ${action}`));
|
|
4761
|
+
console.log(chalk.gray("Run 'jfl voice' for help."));
|
|
4762
|
+
}
|
|
4763
|
+
//# sourceMappingURL=voice.js.map
|