Phase 2: Core transcription pipeline and audio playback

- Implement faster-whisper TranscribeService with word-level timestamps,
  progress reporting, and hardware auto-detection
- Wire up Rust SidecarManager for Python process lifecycle (spawn, IPC, shutdown)
- Add transcribe_file Tauri command bridging frontend to Python sidecar
- Integrate wavesurfer.js WaveformPlayer with play/pause, skip, seek controls
- Build TranscriptEditor with word-level click-to-seek and active highlighting
- Connect file import flow: prompt → asset load → transcribe → display
- Add typed tauri-bridge service with TranscriptionResult interface
- Add Python tests for hardware detection and transcription result formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 15:53:09 -08:00
parent 503cc6c0cf
commit 48fe41b064
18 changed files with 1775 additions and 32 deletions

715
package-lock.json generated
View File

@@ -10,7 +10,11 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2" "@tauri-apps/plugin-opener": "^2",
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"wavesurfer.js": "^7.12.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
@@ -523,6 +527,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -1218,6 +1228,379 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
"integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz",
"integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz",
"integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz",
"integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz",
"integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz",
"integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz",
"integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz",
"integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz",
"integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz",
"integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz",
"integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz",
"integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz",
"integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz",
"integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz",
"integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz",
"integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz",
"integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz",
"integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz",
"integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz",
"integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz",
"integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz",
"integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz",
"integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/pm": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
"integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz",
"integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/extension-blockquote": "^3.20.0",
"@tiptap/extension-bold": "^3.20.0",
"@tiptap/extension-bullet-list": "^3.20.0",
"@tiptap/extension-code": "^3.20.0",
"@tiptap/extension-code-block": "^3.20.0",
"@tiptap/extension-document": "^3.20.0",
"@tiptap/extension-dropcursor": "^3.20.0",
"@tiptap/extension-gapcursor": "^3.20.0",
"@tiptap/extension-hard-break": "^3.20.0",
"@tiptap/extension-heading": "^3.20.0",
"@tiptap/extension-horizontal-rule": "^3.20.0",
"@tiptap/extension-italic": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-list": "^3.20.0",
"@tiptap/extension-list-item": "^3.20.0",
"@tiptap/extension-list-keymap": "^3.20.0",
"@tiptap/extension-ordered-list": "^3.20.0",
"@tiptap/extension-paragraph": "^3.20.0",
"@tiptap/extension-strike": "^3.20.0",
"@tiptap/extension-text": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/extensions": "^3.20.0",
"@tiptap/pm": "^3.20.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1250,6 +1633,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1385,6 +1790,12 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -1488,6 +1899,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1533,6 +1950,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1582,6 +2011,18 @@
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.25.12"
} }
}, },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -1679,6 +2120,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -1703,6 +2159,29 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1749,6 +2228,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1815,6 +2300,210 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -1874,6 +2563,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/sade": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -2090,6 +2785,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@@ -2281,6 +2982,18 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wavesurfer.js": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz",
"integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==",
"license": "BSD-3-Clause"
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

@@ -16,7 +16,11 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2" "@tauri-apps/plugin-opener": "^2",
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"wavesurfer.js": "^7.12.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",

View File

@@ -9,7 +9,9 @@ description = "Python sidecar for Voice to Notes — transcription, diarization,
requires-python = ">=3.11" requires-python = ">=3.11"
license = "MIT" license = "MIT"
dependencies = [] dependencies = [
"faster-whisper>=1.1.0",
]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [

View File

@@ -0,0 +1,20 @@
"""Tests for hardware detection."""
from voice_to_notes.hardware.detect import HardwareInfo, detect_hardware
def test_hardware_info_defaults():
"""Test HardwareInfo default values."""
info = HardwareInfo()
assert info.has_cuda is False
assert info.recommended_model == "base"
assert info.recommended_device == "cpu"
def test_detect_hardware_returns_info():
"""Test that detect_hardware returns a valid HardwareInfo."""
info = detect_hardware()
assert isinstance(info, HardwareInfo)
assert info.cpu_cores > 0
# Should default to CPU since we're likely in a test env without GPU
assert info.recommended_device in ("cpu", "cuda")

View File

@@ -0,0 +1,51 @@
"""Tests for transcription service."""
from voice_to_notes.services.transcribe import (
SegmentResult,
TranscriptionResult,
WordResult,
result_to_payload,
)
def test_result_to_payload():
"""Test converting TranscriptionResult to IPC payload."""
result = TranscriptionResult(
segments=[
SegmentResult(
text="hello world",
start_ms=0,
end_ms=2000,
words=[
WordResult(word="hello", start_ms=0, end_ms=500, confidence=0.95),
WordResult(word="world", start_ms=600, end_ms=2000, confidence=0.92),
],
),
],
language="en",
language_probability=0.98,
duration_ms=2000,
)
payload = result_to_payload(result)
assert payload["language"] == "en"
assert payload["duration_ms"] == 2000
assert len(payload["segments"]) == 1
seg = payload["segments"][0]
assert seg["text"] == "hello world"
assert seg["start_ms"] == 0
assert seg["end_ms"] == 2000
assert len(seg["words"]) == 2
assert seg["words"][0]["word"] == "hello"
assert seg["words"][0]["confidence"] == 0.95
def test_result_to_payload_empty():
"""Test empty transcription result."""
result = TranscriptionResult()
payload = result_to_payload(result)
assert payload["segments"] == []
assert payload["language"] == ""
assert payload["duration_ms"] == 0

View File

@@ -2,8 +2,74 @@
from __future__ import annotations from __future__ import annotations
# TODO: Implement hardware detection import os
# - Check torch.cuda.is_available() import sys
# - Detect VRAM size from dataclasses import dataclass
# - Detect CPU cores and available RAM
# - Return recommended model configuration
@dataclass
class HardwareInfo:
"""Detected hardware capabilities."""
has_cuda: bool = False
cuda_device_name: str = ""
vram_mb: int = 0
ram_mb: int = 0
cpu_cores: int = 0
recommended_model: str = "base"
recommended_device: str = "cpu"
recommended_compute_type: str = "int8"
def detect_hardware() -> HardwareInfo:
"""Detect available hardware and recommend model configuration."""
info = HardwareInfo()
# CPU info
info.cpu_cores = os.cpu_count() or 1
# RAM info
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal:"):
# Value is in kB
info.ram_mb = int(line.split()[1]) // 1024
break
except (FileNotFoundError, ValueError):
pass
# CUDA detection
try:
import torch
if torch.cuda.is_available():
info.has_cuda = True
info.cuda_device_name = torch.cuda.get_device_name(0)
info.vram_mb = torch.cuda.get_device_properties(0).total_mem // (1024 * 1024)
except ImportError:
print("[sidecar] torch not available, GPU detection skipped", file=sys.stderr, flush=True)
# Model recommendation based on hardware
if info.has_cuda and info.vram_mb >= 8000:
info.recommended_model = "large-v3-turbo"
info.recommended_device = "cuda"
info.recommended_compute_type = "int8"
elif info.has_cuda and info.vram_mb >= 4000:
info.recommended_model = "medium"
info.recommended_device = "cuda"
info.recommended_compute_type = "int8"
elif info.ram_mb >= 16000:
info.recommended_model = "medium"
info.recommended_device = "cpu"
info.recommended_compute_type = "int8"
elif info.ram_mb >= 8000:
info.recommended_model = "small"
info.recommended_device = "cpu"
info.recommended_compute_type = "int8"
else:
info.recommended_model = "base"
info.recommended_device = "cpu"
info.recommended_compute_type = "int8"
return info

View File

@@ -37,3 +37,49 @@ class HandlerRegistry:
def ping_handler(msg: IPCMessage) -> IPCMessage: def ping_handler(msg: IPCMessage) -> IPCMessage:
"""Simple ping handler for testing connectivity.""" """Simple ping handler for testing connectivity."""
return IPCMessage(id=msg.id, type="pong", payload={"echo": msg.payload}) return IPCMessage(id=msg.id, type="pong", payload={"echo": msg.payload})
def make_transcribe_handler() -> HandlerFunc:
"""Create a transcription handler with a persistent TranscribeService."""
from voice_to_notes.services.transcribe import TranscribeService, result_to_payload
service = TranscribeService()
def handler(msg: IPCMessage) -> IPCMessage:
payload = msg.payload
result = service.transcribe(
request_id=msg.id,
file_path=payload["file"],
model_name=payload.get("model", "base"),
device=payload.get("device", "cpu"),
compute_type=payload.get("compute_type", "int8"),
language=payload.get("language"),
)
return IPCMessage(
id=msg.id,
type="transcribe.result",
payload=result_to_payload(result),
)
return handler
def hardware_detect_handler(msg: IPCMessage) -> IPCMessage:
"""Detect hardware capabilities and return recommendations."""
from voice_to_notes.hardware.detect import detect_hardware
info = detect_hardware()
return IPCMessage(
id=msg.id,
type="hardware.info",
payload={
"has_cuda": info.has_cuda,
"cuda_device_name": info.cuda_device_name,
"vram_mb": info.vram_mb,
"ram_mb": info.ram_mb,
"cpu_cores": info.cpu_cores,
"recommended_model": info.recommended_model,
"recommended_device": info.recommended_device,
"recommended_compute_type": info.recommended_compute_type,
},
)

View File

@@ -5,7 +5,12 @@ from __future__ import annotations
import signal import signal
import sys import sys
from voice_to_notes.ipc.handlers import HandlerRegistry, ping_handler from voice_to_notes.ipc.handlers import (
HandlerRegistry,
hardware_detect_handler,
make_transcribe_handler,
ping_handler,
)
from voice_to_notes.ipc.messages import ready_message from voice_to_notes.ipc.messages import ready_message
from voice_to_notes.ipc.protocol import read_message, write_message from voice_to_notes.ipc.protocol import read_message, write_message
@@ -14,7 +19,9 @@ def create_registry() -> HandlerRegistry:
"""Set up the message handler registry.""" """Set up the message handler registry."""
registry = HandlerRegistry() registry = HandlerRegistry()
registry.register("ping", ping_handler) registry.register("ping", ping_handler)
# TODO: Register transcribe, diarize, pipeline, ai, export handlers registry.register("transcribe.start", make_transcribe_handler())
registry.register("hardware.detect", hardware_detect_handler)
# TODO: Register diarize, pipeline, ai, export handlers
return registry return registry

View File

@@ -1,13 +1,193 @@
"""Transcription service — faster-whisper + wav2vec2 pipeline.""" """Transcription service — faster-whisper pipeline with word-level timestamps."""
from __future__ import annotations from __future__ import annotations
import sys
import time
from dataclasses import dataclass, field
from typing import Any
from faster_whisper import WhisperModel
from voice_to_notes.ipc.messages import progress_message
from voice_to_notes.ipc.protocol import write_message
@dataclass
class WordResult:
"""A single word with timestamp."""
word: str
start_ms: int
end_ms: int
confidence: float
@dataclass
class SegmentResult:
"""A transcription segment with words."""
text: str
start_ms: int
end_ms: int
words: list[WordResult] = field(default_factory=list)
@dataclass
class TranscriptionResult:
"""Full transcription output."""
segments: list[SegmentResult] = field(default_factory=list)
language: str = ""
language_probability: float = 0.0
duration_ms: int = 0
class TranscribeService: class TranscribeService:
"""Handles audio transcription via faster-whisper.""" """Handles audio transcription via faster-whisper."""
# TODO: Implement faster-whisper integration def __init__(self) -> None:
# - Load model based on hardware detection self._model: WhisperModel | None = None
# - Transcribe audio with word-level timestamps self._current_model_name: str = ""
# - Report progress via IPC self._current_device: str = ""
pass self._current_compute_type: str = ""
def _ensure_model(
self,
model_name: str = "base",
device: str = "cpu",
compute_type: str = "int8",
) -> WhisperModel:
"""Load or reuse the Whisper model."""
if (
self._model is not None
and self._current_model_name == model_name
and self._current_device == device
and self._current_compute_type == compute_type
):
return self._model
print(
f"[sidecar] Loading model {model_name} on {device} ({compute_type})",
file=sys.stderr,
flush=True,
)
self._model = WhisperModel(
model_name,
device=device,
compute_type=compute_type,
)
self._current_model_name = model_name
self._current_device = device
self._current_compute_type = compute_type
return self._model
def transcribe(
self,
request_id: str,
file_path: str,
model_name: str = "base",
device: str = "cpu",
compute_type: str = "int8",
language: str | None = None,
) -> TranscriptionResult:
"""Transcribe an audio file with word-level timestamps.
Sends progress messages via IPC during processing.
"""
# Stage: loading model
write_message(progress_message(request_id, 0, "loading_model", f"Loading {model_name}..."))
model = self._ensure_model(model_name, device, compute_type)
# Stage: transcribing
write_message(progress_message(request_id, 10, "transcribing", "Starting transcription..."))
start_time = time.time()
segments_iter, info = model.transcribe(
file_path,
language=language,
word_timestamps=True,
vad_filter=True,
)
result = TranscriptionResult(
language=info.language,
language_probability=info.language_probability,
duration_ms=int(info.duration * 1000),
)
# Process segments with progress reporting
total_duration = info.duration if info.duration > 0 else 1.0
segment_count = 0
for segment in segments_iter:
segment_count += 1
progress_pct = min(10 + int((segment.end / total_duration) * 80), 90)
words = []
if segment.words:
for w in segment.words:
words.append(
WordResult(
word=w.word.strip(),
start_ms=int(w.start * 1000),
end_ms=int(w.end * 1000),
confidence=round(w.probability, 4),
)
)
result.segments.append(
SegmentResult(
text=segment.text.strip(),
start_ms=int(segment.start * 1000),
end_ms=int(segment.end * 1000),
words=words,
)
)
# Send progress every few segments
if segment_count % 5 == 0:
write_message(
progress_message(
request_id,
progress_pct,
"transcribing",
f"Processed {segment_count} segments...",
)
)
elapsed = time.time() - start_time
print(
f"[sidecar] Transcription complete: {segment_count} segments in {elapsed:.1f}s",
file=sys.stderr,
flush=True,
)
write_message(progress_message(request_id, 100, "done", "Transcription complete"))
return result
def result_to_payload(result: TranscriptionResult) -> dict[str, Any]:
"""Convert TranscriptionResult to IPC payload dict."""
return {
"segments": [
{
"text": seg.text,
"start_ms": seg.start_ms,
"end_ms": seg.end_ms,
"words": [
{
"word": w.word,
"start_ms": w.start_ms,
"end_ms": w.end_ms,
"confidence": w.confidence,
}
for w in seg.words
],
}
for seg in result.segments
],
"language": result.language,
"language_probability": result.language_probability,
"duration_ms": result.duration_ms,
}

View File

@@ -1,2 +1,52 @@
// Transcription commands — start/stop/monitor transcription via Python sidecar use serde_json::{json, Value};
// TODO: Implement when sidecar IPC is connected
use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager;
/// Start transcription of an audio file via the Python sidecar.
///
/// This is a blocking command — it starts the sidecar if needed,
/// sends the transcribe request, and waits for the result.
#[tauri::command]
pub fn transcribe_file(
file_path: String,
model: Option<String>,
device: Option<String>,
language: Option<String>,
) -> Result<Value, String> {
// Determine Python sidecar path (relative to app)
let python_path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(
&request_id,
"transcribe.start",
json!({
"file": file_path,
"model": model.unwrap_or_else(|| "base".to_string()),
"device": device.unwrap_or_else(|| "cpu".to_string()),
"compute_type": "int8",
"language": language,
}),
);
let response = manager.send_and_receive(&msg)?;
if response.msg_type == "error" {
return Err(format!(
"Transcription error: {}",
response.payload.get("message").and_then(|v| v.as_str()).unwrap_or("unknown")
));
}
Ok(response.payload)
}

View File

@@ -1,8 +1,10 @@
pub mod commands; pub mod commands;
pub mod db; pub mod db;
pub mod sidecar;
pub mod state; pub mod state;
use commands::project::{create_project, get_project, list_projects}; use commands::project::{create_project, get_project, list_projects};
use commands::transcribe::transcribe_file;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -12,6 +14,7 @@ pub fn run() {
create_project, create_project,
get_project, get_project,
list_projects, list_projects,
transcribe_file,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -0,0 +1,16 @@
use std::io::Write;
use super::messages::IPCMessage;
/// Serialize and write an IPC message to a writer (stdin pipe).
pub fn send_message<W: Write>(writer: &mut W, msg: &IPCMessage) -> Result<(), String> {
let json = serde_json::to_string(msg).map_err(|e| e.to_string())?;
writer
.write_all(json.as_bytes())
.map_err(|e| format!("Write error: {e}"))?;
writer
.write_all(b"\n")
.map_err(|e| format!("Write error: {e}"))?;
writer.flush().map_err(|e| format!("Flush error: {e}"))?;
Ok(())
}

View File

@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// IPC message exchanged between Rust and Python sidecar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IPCMessage {
pub id: String,
#[serde(rename = "type")]
pub msg_type: String,
pub payload: Value,
}
impl IPCMessage {
pub fn new(id: &str, msg_type: &str, payload: Value) -> Self {
Self {
id: id.to_string(),
msg_type: msg_type.to_string(),
payload,
}
}
}

View File

@@ -0,0 +1,150 @@
pub mod ipc;
pub mod messages;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use crate::sidecar::messages::IPCMessage;
/// Manages the Python sidecar process lifecycle.
pub struct SidecarManager {
process: Mutex<Option<Child>>,
}
impl SidecarManager {
pub fn new() -> Self {
Self {
process: Mutex::new(None),
}
}
/// Spawn the Python sidecar process.
pub fn start(&self, python_path: &str) -> Result<(), String> {
let child = Command::new("python3")
.arg("-m")
.arg("voice_to_notes.main")
.current_dir(python_path)
.env("PYTHONPATH", python_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // Let sidecar logs go to parent's stderr
.spawn()
.map_err(|e| format!("Failed to start sidecar: {e}"))?;
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
*proc = Some(child);
// Wait for the "ready" message
self.wait_for_ready()?;
Ok(())
}
/// Wait for the sidecar to send its ready message.
fn wait_for_ready(&self) -> Result<(), String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = *proc {
if let Some(ref mut stdout) = child.stdout {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let line = line.map_err(|e| format!("Read error: {e}"))?;
if line.is_empty() {
continue;
}
if let Ok(msg) = serde_json::from_str::<IPCMessage>(&line) {
if msg.msg_type == "ready" {
return Ok(());
}
}
// If we got a non-ready message, something's wrong but don't block forever
break;
}
}
}
Err("Sidecar did not send ready message".to_string())
}
/// Send a message to the sidecar and read the response.
/// This is a blocking call.
pub fn send_and_receive(&self, msg: &IPCMessage) -> Result<IPCMessage, String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = *proc {
// Write message to stdin
if let Some(ref mut stdin) = child.stdin {
let json = serde_json::to_string(msg).map_err(|e| e.to_string())?;
stdin
.write_all(json.as_bytes())
.map_err(|e| format!("Write error: {e}"))?;
stdin
.write_all(b"\n")
.map_err(|e| format!("Write error: {e}"))?;
stdin.flush().map_err(|e| format!("Flush error: {e}"))?;
} else {
return Err("Sidecar stdin not available".to_string());
}
// Read response from stdout
if let Some(ref mut stdout) = child.stdout {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
// Read lines until we get a response (skip progress messages, collect them)
loop {
line.clear();
let bytes_read = reader
.read_line(&mut line)
.map_err(|e| format!("Read error: {e}"))?;
if bytes_read == 0 {
return Err("Sidecar closed stdout".to_string());
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let response: IPCMessage =
serde_json::from_str(trimmed).map_err(|e| format!("Parse error: {e}"))?;
// If it's a progress message, we could emit it as an event
// For now, skip progress and return the final result/error
if response.msg_type != "progress" {
return Ok(response);
}
}
} else {
return Err("Sidecar stdout not available".to_string());
}
} else {
Err("Sidecar not running".to_string())
}
}
/// Stop the sidecar process.
pub fn stop(&self) -> Result<(), String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = proc.take() {
// Close stdin to signal EOF
drop(child.stdin.take());
// Wait briefly for clean exit, then kill
match child.wait() {
Ok(_) => Ok(()),
Err(e) => {
let _ = child.kill();
Err(format!("Sidecar did not exit cleanly: {e}"))
}
}
} else {
Ok(())
}
}
pub fn is_running(&self) -> bool {
let proc = self.process.lock().ok();
proc.map_or(false, |p| p.is_some())
}
}
impl Drop for SidecarManager {
fn drop(&mut self) {
let _ = self.stop();
}
}

View File

@@ -1,18 +1,154 @@
<div class="transcript-editor"> <script lang="ts">
<p>Transcript Editor</p> import { segments, speakers } from '$lib/stores/transcript';
<p class="placeholder">TipTap rich text editor will be integrated here</p> import { currentTimeMs } from '$lib/stores/playback';
import type { Segment, Word, Speaker } from '$lib/types/transcript';
interface Props {
onWordClick?: (timeMs: number) => void;
onTextEdit?: (segmentId: string, newText: string) => void;
}
let { onWordClick, onTextEdit }: Props = $props();
let transcriptContainer: HTMLDivElement;
function getSpeakerName(speakerId: string | null, speakerList: Speaker[]): string {
if (!speakerId) return 'Unknown';
const speaker = speakerList.find(s => s.id === speakerId);
return speaker?.display_name || speaker?.label || 'Unknown';
}
function getSpeakerColor(speakerId: string | null, speakerList: Speaker[]): string {
if (!speakerId) return '#888';
const speaker = speakerList.find(s => s.id === speakerId);
return speaker?.color || '#888';
}
function formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function isWordActive(word: Word, currentMs: number): boolean {
return currentMs >= word.start_ms && currentMs <= word.end_ms;
}
function isSegmentActive(segment: Segment, currentMs: number): boolean {
return currentMs >= segment.start_ms && currentMs <= segment.end_ms;
}
function handleWordClick(word: Word) {
onWordClick?.(word.start_ms);
}
</script>
<div class="transcript-editor" bind:this={transcriptContainer}>
{#if $segments.length === 0}
<div class="empty-state">
<p>No transcript yet</p>
<p class="hint">Import an audio file and run transcription to get started</p>
</div>
{:else}
{#each $segments as segment (segment.id)}
<div
class="segment"
class:active={isSegmentActive(segment, $currentTimeMs)}
>
<div class="segment-header">
<span
class="speaker-label"
style="border-left-color: {getSpeakerColor(segment.speaker_id, $speakers)}"
>
{getSpeakerName(segment.speaker_id, $speakers)}
</span>
<span class="timestamp">{formatTimestamp(segment.start_ms)}</span>
</div>
<div class="segment-text">
{#each segment.words as word (word.id)}
<span
class="word"
class:word-active={isWordActive(word, $currentTimeMs)}
onclick={() => handleWordClick(word)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') handleWordClick(word); }}
>{word.word} </span>
{:else}
<span class="segment-plain-text">{segment.text}</span>
{/each}
</div>
</div>
{/each}
{/if}
</div> </div>
<style> <style>
.transcript-editor { .transcript-editor {
flex: 1;
overflow-y: auto;
padding: 1rem; padding: 1rem;
background: #16213e; background: #16213e;
border-radius: 8px; border-radius: 8px;
color: #e0e0e0; color: #e0e0e0;
flex: 1;
} }
.placeholder { .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666; color: #666;
}
.hint {
font-size: 0.875rem; font-size: 0.875rem;
color: #555;
}
.segment {
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.segment.active {
background: rgba(233, 69, 96, 0.1);
}
.segment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.speaker-label {
font-weight: 600;
font-size: 0.875rem;
border-left: 3px solid;
padding-left: 0.5rem;
}
.timestamp {
color: #666;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.segment-text {
line-height: 1.6;
padding-left: 0.75rem;
}
.word {
cursor: pointer;
border-radius: 2px;
padding: 0 1px;
transition: background-color 0.15s;
}
.word:hover {
background: rgba(233, 69, 96, 0.2);
}
.word-active {
background: rgba(233, 69, 96, 0.35);
color: #fff;
}
.segment-plain-text {
color: #ccc;
} }
</style> </style>

View File

@@ -1,17 +1,144 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import WaveSurfer from 'wavesurfer.js';
import { isPlaying, currentTimeMs, durationMs } from '$lib/stores/playback';
interface Props {
audioUrl?: string;
onSeek?: (timeMs: number) => void;
}
let { audioUrl = '', onSeek }: Props = $props();
let container: HTMLDivElement;
let wavesurfer: WaveSurfer | null = $state(null);
let currentTime = $state('0:00');
let totalTime = $state('0:00');
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
onMount(() => {
wavesurfer = WaveSurfer.create({
container,
waveColor: '#4a5568',
progressColor: '#e94560',
cursorColor: '#e94560',
height: 80,
barWidth: 2,
barGap: 1,
barRadius: 2,
});
wavesurfer.on('timeupdate', (time: number) => {
currentTimeMs.set(Math.round(time * 1000));
currentTime = formatTime(time);
});
wavesurfer.on('ready', () => {
const dur = wavesurfer!.getDuration();
durationMs.set(Math.round(dur * 1000));
totalTime = formatTime(dur);
});
wavesurfer.on('play', () => isPlaying.set(true));
wavesurfer.on('pause', () => isPlaying.set(false));
wavesurfer.on('finish', () => isPlaying.set(false));
if (audioUrl) {
wavesurfer.load(audioUrl);
}
});
onDestroy(() => {
wavesurfer?.destroy();
});
function togglePlayPause() {
wavesurfer?.playPause();
}
function skipBack() {
if (wavesurfer) {
const time = Math.max(0, wavesurfer.getCurrentTime() - 5);
wavesurfer.setTime(time);
}
}
function skipForward() {
if (wavesurfer) {
const time = Math.min(wavesurfer.getDuration(), wavesurfer.getCurrentTime() + 5);
wavesurfer.setTime(time);
}
}
/** Seek to a specific time in milliseconds. Called from transcript click-to-seek. */
export function seekTo(timeMs: number) {
if (wavesurfer) {
wavesurfer.setTime(timeMs / 1000);
if (!wavesurfer.isPlaying()) {
wavesurfer.play();
}
}
}
/** Load a new audio file. */
export function loadAudio(url: string) {
wavesurfer?.load(url);
}
</script>
<div class="waveform-player"> <div class="waveform-player">
<p>Waveform Player</p> <div class="waveform-container" bind:this={container}></div>
<p class="placeholder">wavesurfer.js will be integrated here</p> <div class="controls">
<button class="control-btn" onclick={skipBack} title="Back 5s"></button>
<button class="control-btn play-btn" onclick={togglePlayPause} title="Play/Pause">
{#if $isPlaying}{:else}{/if}
</button>
<button class="control-btn" onclick={skipForward} title="Forward 5s"></button>
<span class="time">{currentTime} / {totalTime}</span>
</div>
</div> </div>
<style> <style>
.waveform-player { .waveform-player {
padding: 1rem;
background: #1a1a2e; background: #1a1a2e;
border-radius: 8px; border-radius: 8px;
color: #e0e0e0; padding: 0.75rem;
} }
.placeholder { .waveform-container {
color: #666; border-radius: 4px;
overflow: hidden;
}
.controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.control-btn {
background: #0f3460;
border: none;
color: #e0e0e0;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.control-btn:hover {
background: #1a4a7a;
}
.play-btn {
padding: 0.4rem 1rem;
font-size: 1.2rem;
}
.time {
color: #999;
font-size: 0.875rem; font-size: 0.875rem;
margin-left: auto;
font-variant-numeric: tabular-nums;
} }
</style> </style>

View File

@@ -12,3 +12,29 @@ export async function getProject(id: string): Promise<Project | null> {
export async function listProjects(): Promise<Project[]> { export async function listProjects(): Promise<Project[]> {
return invoke('list_projects'); return invoke('list_projects');
} }
export interface TranscriptionResult {
segments: Array<{
text: string;
start_ms: number;
end_ms: number;
words: Array<{
word: string;
start_ms: number;
end_ms: number;
confidence: number;
}>;
}>;
language: string;
language_probability: number;
duration_ms: number;
}
export async function transcribeFile(
filePath: string,
model?: string,
device?: string,
language?: string,
): Promise<TranscriptionResult> {
return invoke('transcribe_file', { filePath, model, device, language });
}

View File

@@ -1,14 +1,104 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte'; import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte'; import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
import SpeakerManager from '$lib/components/SpeakerManager.svelte'; import SpeakerManager from '$lib/components/SpeakerManager.svelte';
import AIChatPanel from '$lib/components/AIChatPanel.svelte'; import AIChatPanel from '$lib/components/AIChatPanel.svelte';
import ProgressOverlay from '$lib/components/ProgressOverlay.svelte';
import { segments, speakers } from '$lib/stores/transcript';
import type { Segment, Word } from '$lib/types/transcript';
let waveformPlayer: WaveformPlayer;
let audioUrl = $state('');
let isTranscribing = $state(false);
let transcriptionProgress = $state(0);
let transcriptionStage = $state('');
let transcriptionMessage = $state('');
function handleWordClick(timeMs: number) {
waveformPlayer?.seekTo(timeMs);
}
async function handleFileImport() {
// For now, use a simple prompt — will be replaced with Tauri file dialog
const filePath = prompt('Enter path to audio/video file:');
if (!filePath) return;
// Convert file path to URL for wavesurfer
// In Tauri, we can use convertFileSrc or asset protocol
audioUrl = `asset://localhost/${encodeURIComponent(filePath)}`;
waveformPlayer?.loadAudio(audioUrl);
// Start transcription
isTranscribing = true;
transcriptionProgress = 0;
transcriptionStage = 'Starting...';
try {
const result = await invoke<{
segments: Array<{
text: string;
start_ms: number;
end_ms: number;
words: Array<{
word: string;
start_ms: number;
end_ms: number;
confidence: number;
}>;
}>;
language: string;
duration_ms: number;
}>('transcribe_file', { filePath });
// Convert result to our store format
const newSegments: Segment[] = result.segments.map((seg, idx) => ({
id: `seg-${idx}`,
project_id: '',
media_file_id: '',
speaker_id: null,
start_ms: seg.start_ms,
end_ms: seg.end_ms,
text: seg.text,
original_text: null,
confidence: null,
is_edited: false,
edited_at: null,
segment_index: idx,
words: seg.words.map((w, widx) => ({
id: `word-${idx}-${widx}`,
segment_id: `seg-${idx}`,
word: w.word,
start_ms: w.start_ms,
end_ms: w.end_ms,
confidence: w.confidence,
word_index: widx,
})),
}));
segments.set(newSegments);
} catch (err) {
console.error('Transcription failed:', err);
alert(`Transcription failed: ${err}`);
} finally {
isTranscribing = false;
}
}
</script> </script>
<div class="app-header">
<h1>Voice to Notes</h1>
<div class="header-actions">
<button class="import-btn" onclick={handleFileImport}>
Import Audio/Video
</button>
</div>
</div>
<div class="workspace"> <div class="workspace">
<div class="main-content"> <div class="main-content">
<WaveformPlayer /> <WaveformPlayer bind:this={waveformPlayer} {audioUrl} />
<TranscriptEditor /> <TranscriptEditor onWordClick={handleWordClick} />
</div> </div>
<div class="sidebar-right"> <div class="sidebar-right">
<SpeakerManager /> <SpeakerManager />
@@ -16,23 +106,58 @@
</div> </div>
</div> </div>
<ProgressOverlay
visible={isTranscribing}
percent={transcriptionProgress}
stage={transcriptionStage}
message={transcriptionMessage}
/>
<style> <style>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: #0f3460;
color: #e0e0e0;
}
h1 {
font-size: 1.25rem;
margin: 0;
}
.import-btn {
background: #e94560;
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.import-btn:hover {
background: #d63851;
}
.workspace { .workspace {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
height: calc(100vh - 3rem); height: calc(100vh - 3.5rem);
background: #0a0a23;
} }
.main-content { .main-content {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
min-width: 0;
} }
.sidebar-right { .sidebar-right {
width: 300px; width: 300px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
flex-shrink: 0;
} }
</style> </style>