Load the app as one bundled classic script: module fetches are not authenticated in a private Space's iframe
Browse files- app.bundle.js +1687 -0
- index.html +9 -1
app.bundle.js
ADDED
|
@@ -0,0 +1,1687 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* GENERATED by build_bundle.py -- do not edit.
|
| 3 |
+
*
|
| 4 |
+
* The app's ES modules concatenated into one classic script. A private
|
| 5 |
+
* Space's cross-site iframe does not authenticate the module fetches issued
|
| 6 |
+
* while the page is parsing (they 401, and the module map then caches that
|
| 7 |
+
* failure), whereas classic scripts always carry the cookie. Edit the
|
| 8 |
+
* sources listed below and re-run build_bundle.py.
|
| 9 |
+
*
|
| 10 |
+
* Sources, in dependency order: ctc-frontend.js, ctc-tokenizer.js, ctc-engine.js, ctc-streaming.js, app.js
|
| 11 |
+
*/
|
| 12 |
+
(() => {
|
| 13 |
+
'use strict';
|
| 14 |
+
// Stands in for the module namespace: each module destructures what it
|
| 15 |
+
// imports off this and assigns its exports back onto it.
|
| 16 |
+
const __M = {};
|
| 17 |
+
|
| 18 |
+
// ==== ctc-frontend.js ===================================================
|
| 19 |
+
(() => {
|
| 20 |
+
/**
|
| 21 |
+
* Audio front-end for the FastConformer CTC model.
|
| 22 |
+
*
|
| 23 |
+
* A port of the model's `CtcConformerProcessor._frontend` (log-mel + deltas +
|
| 24 |
+
* frame stacking) plus the sidecar's `_agc`. The mel filterbank and the STFT
|
| 25 |
+
* window are NOT recomputed here -- they are loaded from `mel_filters.bin` /
|
| 26 |
+
* `stft_window.bin`, dumped by export_ctc_onnx.py straight out of the torchaudio
|
| 27 |
+
* objects the Python path uses. Reimplementing torchaudio's htk mel scale
|
| 28 |
+
* (norm=None) and torch.stft's window centring in JS is the most likely place for
|
| 29 |
+
* a silent mismatch, and a front-end that is subtly wrong degrades WER without
|
| 30 |
+
* ever raising an error.
|
| 31 |
+
*
|
| 32 |
+
* Verified bit-for-bit against the Python features by test-frontend.mjs.
|
| 33 |
+
*/
|
| 34 |
+
|
| 35 |
+
const EPS = 1e-10;
|
| 36 |
+
|
| 37 |
+
/** In-place iterative radix-2 complex FFT (Cooley-Tukey, decimation in time). */
|
| 38 |
+
class FFT {
|
| 39 |
+
constructor(size) {
|
| 40 |
+
this.size = size;
|
| 41 |
+
this.levels = Math.log2(size) | 0;
|
| 42 |
+
if (2 ** this.levels !== size) throw new Error(`FFT size ${size} is not a power of two`);
|
| 43 |
+
|
| 44 |
+
// Bit-reversal permutation and twiddle tables, precomputed once: the
|
| 45 |
+
// front-end runs this transform ~100 times per second of audio.
|
| 46 |
+
this.rev = new Uint32Array(size);
|
| 47 |
+
for (let i = 0; i < size; i++) {
|
| 48 |
+
let r = 0;
|
| 49 |
+
for (let b = 0; b < this.levels; b++) r |= ((i >> b) & 1) << (this.levels - 1 - b);
|
| 50 |
+
this.rev[i] = r;
|
| 51 |
+
}
|
| 52 |
+
this.cos = new Float32Array(size / 2);
|
| 53 |
+
this.sin = new Float32Array(size / 2);
|
| 54 |
+
for (let i = 0; i < size / 2; i++) {
|
| 55 |
+
this.cos[i] = Math.cos((-2 * Math.PI * i) / size);
|
| 56 |
+
this.sin[i] = Math.sin((-2 * Math.PI * i) / size);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** Transform `re`/`im` in place (both length `size`). */
|
| 61 |
+
run(re, im) {
|
| 62 |
+
const { size, rev, cos, sin } = this;
|
| 63 |
+
for (let i = 0; i < size; i++) {
|
| 64 |
+
const j = rev[i];
|
| 65 |
+
if (j > i) {
|
| 66 |
+
let t = re[i]; re[i] = re[j]; re[j] = t;
|
| 67 |
+
t = im[i]; im[i] = im[j]; im[j] = t;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
for (let len = 2; len <= size; len <<= 1) {
|
| 71 |
+
const half = len >> 1;
|
| 72 |
+
const step = size / len;
|
| 73 |
+
for (let i = 0; i < size; i += len) {
|
| 74 |
+
for (let j = 0, k = 0; j < half; j++, k += step) {
|
| 75 |
+
const c = cos[k], s = sin[k];
|
| 76 |
+
const a = i + j, b = a + half;
|
| 77 |
+
const tre = re[b] * c - im[b] * s;
|
| 78 |
+
const tim = re[b] * s + im[b] * c;
|
| 79 |
+
re[b] = re[a] - tre; im[b] = im[a] - tim;
|
| 80 |
+
re[a] += tre; im[a] += tim;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Centred moving average, cumsum-based (O(n)).
|
| 89 |
+
*
|
| 90 |
+
* Mirrors the sidecar's `_moving_avg`, including its edge handling: the valid
|
| 91 |
+
* region is padded by replicating the first/last valid value, with the extra
|
| 92 |
+
* sample going on the right when the window is even.
|
| 93 |
+
*/
|
| 94 |
+
function movingAvg(v, win) {
|
| 95 |
+
const n = v.length;
|
| 96 |
+
win = Math.max(1, win | 0);
|
| 97 |
+
if (win <= 1 || n <= 1) return v;
|
| 98 |
+
win = Math.min(win, n);
|
| 99 |
+
|
| 100 |
+
// float64 accumulator: a float32 cumsum over ~10 s of samples loses enough
|
| 101 |
+
// precision to shift the gain envelope (the Python side uses float64 too).
|
| 102 |
+
const csum = new Float64Array(n + 1);
|
| 103 |
+
for (let i = 0; i < n; i++) csum[i + 1] = csum[i] + v[i];
|
| 104 |
+
|
| 105 |
+
const valid = n - win + 1;
|
| 106 |
+
const padL = (win - 1) >> 1;
|
| 107 |
+
const out = new Float32Array(n);
|
| 108 |
+
const first = (csum[win] - csum[0]) / win;
|
| 109 |
+
const last = (csum[n] - csum[n - win]) / win;
|
| 110 |
+
for (let i = 0; i < n; i++) {
|
| 111 |
+
const k = i - padL;
|
| 112 |
+
out[i] = k < 0 ? first : k >= valid ? last : (csum[k + win] - csum[k]) / win;
|
| 113 |
+
}
|
| 114 |
+
return out;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Automatic gain control -- port of the sidecar's `_agc`.
|
| 119 |
+
*
|
| 120 |
+
* Not cosmetic: the CTC front-end normalizes log-mel against the clip's *global
|
| 121 |
+
* peak*, so a quiet tail 20-40 dB below a loud head gets floored and silently
|
| 122 |
+
* dropped. A smoothed moving-RMS gain toward `target` equalizes the levels.
|
| 123 |
+
*/
|
| 124 |
+
function agc(x, { sampleRate = 16000, target = 0.12, winMs = 150, maxGain = 20 } = {}) {
|
| 125 |
+
const n = x.length;
|
| 126 |
+
if (n === 0) return x;
|
| 127 |
+
const win = Math.max(1, Math.round((sampleRate * winMs) / 1000));
|
| 128 |
+
|
| 129 |
+
const sq = new Float32Array(n);
|
| 130 |
+
for (let i = 0; i < n; i++) sq[i] = x[i] * x[i];
|
| 131 |
+
const ms = movingAvg(sq, win);
|
| 132 |
+
|
| 133 |
+
const g = new Float32Array(n);
|
| 134 |
+
for (let i = 0; i < n; i++) {
|
| 135 |
+
const env = Math.sqrt(ms[i] + 1e-9);
|
| 136 |
+
g[i] = Math.min(Math.max(target / Math.max(env, 1e-4), 0), maxGain);
|
| 137 |
+
}
|
| 138 |
+
const gs = movingAvg(g, win); // smooth the gain to avoid pumping
|
| 139 |
+
|
| 140 |
+
const y = new Float32Array(n);
|
| 141 |
+
let peak = 0;
|
| 142 |
+
for (let i = 0; i < n; i++) {
|
| 143 |
+
y[i] = x[i] * gs[i];
|
| 144 |
+
const a = Math.abs(y[i]);
|
| 145 |
+
if (a > peak) peak = a;
|
| 146 |
+
}
|
| 147 |
+
if (peak > 0.97) {
|
| 148 |
+
const k = 0.97 / peak;
|
| 149 |
+
for (let i = 0; i < n; i++) y[i] *= k;
|
| 150 |
+
}
|
| 151 |
+
return y;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
class CtcFrontend {
|
| 155 |
+
constructor(config, melFilters, window) {
|
| 156 |
+
this.config = config;
|
| 157 |
+
this.melFilters = melFilters; // [n_mels][n_freqs], mel-major
|
| 158 |
+
this.window = window; // n_fft, already centred by the exporter
|
| 159 |
+
this.fft = new FFT(config.n_fft);
|
| 160 |
+
this.nFreqs = config.n_fft / 2 + 1;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
static async load(baseUrl = './onnx-ctc/') {
|
| 164 |
+
const get = async (name, as) => {
|
| 165 |
+
const r = await fetch(baseUrl + name);
|
| 166 |
+
if (!r.ok) throw new Error(`front-end asset ${name}: HTTP ${r.status}`);
|
| 167 |
+
return as === 'json' ? r.json() : new Float32Array(await r.arrayBuffer());
|
| 168 |
+
};
|
| 169 |
+
const [config, melFilters, window] = await Promise.all([
|
| 170 |
+
get('frontend_config.json', 'json'),
|
| 171 |
+
get('mel_filters.bin'),
|
| 172 |
+
get('stft_window.bin'),
|
| 173 |
+
]);
|
| 174 |
+
return new CtcFrontend(config, melFilters, window);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/** Feature frames a clip of `nSamples` produces (before padding to pad_multiple). */
|
| 178 |
+
frameCount(nSamples) {
|
| 179 |
+
const s = this.config.stack_factor;
|
| 180 |
+
const melFrames = Math.floor(nSamples / this.config.hop_length);
|
| 181 |
+
return s * Math.ceil(melFrames / s);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/**
|
| 185 |
+
* waveform (Float32Array, 16 kHz mono) -> stacked log-mel(+delta) features.
|
| 186 |
+
*
|
| 187 |
+
* Returns `{ data, frames, dim }` where `data` is [frames, dim] row-major,
|
| 188 |
+
* ready to become the model's `input_features` tensor.
|
| 189 |
+
*/
|
| 190 |
+
compute(wav) {
|
| 191 |
+
const { hop_length: hop, n_fft: nFft, n_mels: nMels, stack_factor: stack,
|
| 192 |
+
logmel_floor_db: floorDb, deltas: useDeltas } = this.config;
|
| 193 |
+
const nFrames = this.frameCount(wav.length);
|
| 194 |
+
if (nFrames === 0) return { data: new Float32Array(0), frames: 0, dim: this.config.input_dim };
|
| 195 |
+
|
| 196 |
+
// The processor right-pads the waveform so the trailing partial stack
|
| 197 |
+
// group is filled rather than dropped, then slices to exactly nFrames.
|
| 198 |
+
const need = (nFrames - 1) * hop + 1;
|
| 199 |
+
let x = wav;
|
| 200 |
+
if (x.length < need) {
|
| 201 |
+
x = new Float32Array(need);
|
| 202 |
+
x.set(wav);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// torch.stft(center=True, pad_mode='reflect'): reflect-pad by n_fft/2 so
|
| 206 |
+
// frame i is centred on sample i*hop.
|
| 207 |
+
const half = nFft >> 1;
|
| 208 |
+
const padded = new Float32Array(x.length + nFft);
|
| 209 |
+
padded.set(x, half);
|
| 210 |
+
for (let i = 0; i < half; i++) {
|
| 211 |
+
padded[half - 1 - i] = x[Math.min(i + 1, x.length - 1)];
|
| 212 |
+
padded[half + x.length + i] = x[Math.max(x.length - 2 - i, 0)];
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Mel spectrogram, mel-major ([nMels][nFrames]) because deltas run along time.
|
| 216 |
+
const mel = new Float32Array(nMels * nFrames);
|
| 217 |
+
const re = new Float32Array(nFft);
|
| 218 |
+
const im = new Float32Array(nFft);
|
| 219 |
+
const power = new Float32Array(this.nFreqs);
|
| 220 |
+
for (let t = 0; t < nFrames; t++) {
|
| 221 |
+
const off = t * hop;
|
| 222 |
+
for (let i = 0; i < nFft; i++) {
|
| 223 |
+
re[i] = padded[off + i] * this.window[i];
|
| 224 |
+
im[i] = 0;
|
| 225 |
+
}
|
| 226 |
+
this.fft.run(re, im);
|
| 227 |
+
for (let k = 0; k < this.nFreqs; k++) power[k] = re[k] * re[k] + im[k] * im[k];
|
| 228 |
+
for (let m = 0; m < nMels; m++) {
|
| 229 |
+
const fb = m * this.nFreqs;
|
| 230 |
+
let acc = 0;
|
| 231 |
+
for (let k = 0; k < this.nFreqs; k++) acc += this.melFilters[fb + k] * power[k];
|
| 232 |
+
mel[m * nFrames + t] = acc;
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// log10 with a floor relative to the clip's global peak, then the /4 +1
|
| 237 |
+
// scaling the model was trained with.
|
| 238 |
+
let mx = -Infinity;
|
| 239 |
+
for (let i = 0; i < mel.length; i++) {
|
| 240 |
+
const v = Math.log10(Math.max(mel[i], EPS));
|
| 241 |
+
mel[i] = v;
|
| 242 |
+
if (v > mx) mx = v;
|
| 243 |
+
}
|
| 244 |
+
const floor = mx - floorDb;
|
| 245 |
+
for (let i = 0; i < mel.length; i++) mel[i] = Math.max(mel[i], floor) / 4 + 1;
|
| 246 |
+
|
| 247 |
+
// Deltas: torchaudio.functional.compute_deltas(win_length=3) is
|
| 248 |
+
// (x[t+1] - x[t-1]) / 2 with replicate edge padding.
|
| 249 |
+
const nCh = useDeltas ? nMels * 2 : nMels;
|
| 250 |
+
const chans = new Float32Array(nCh * nFrames);
|
| 251 |
+
chans.set(mel);
|
| 252 |
+
if (useDeltas) {
|
| 253 |
+
for (let m = 0; m < nMels; m++) {
|
| 254 |
+
const src = m * nFrames;
|
| 255 |
+
const dst = (nMels + m) * nFrames;
|
| 256 |
+
for (let t = 0; t < nFrames; t++) {
|
| 257 |
+
const prev = mel[src + Math.max(t - 1, 0)];
|
| 258 |
+
const next = mel[src + Math.min(t + 1, nFrames - 1)];
|
| 259 |
+
chans[dst + t] = (next - prev) / 2;
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Stack: (nCh, nFrames) -> (nFrames/stack, stack*nCh), i.e. row j is the
|
| 265 |
+
// channels of frame stack*j followed by those of frame stack*j+1.
|
| 266 |
+
const outFrames = nFrames / stack;
|
| 267 |
+
const dim = stack * nCh;
|
| 268 |
+
const data = new Float32Array(outFrames * dim);
|
| 269 |
+
for (let j = 0; j < outFrames; j++) {
|
| 270 |
+
for (let k = 0; k < stack; k++) {
|
| 271 |
+
const t = j * stack + k;
|
| 272 |
+
const base = j * dim + k * nCh;
|
| 273 |
+
for (let c = 0; c < nCh; c++) data[base + c] = chans[c * nFrames + t];
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
return { data, frames: outFrames, dim };
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
Object.assign(__M, { agc, CtcFrontend });
|
| 281 |
+
})();
|
| 282 |
+
|
| 283 |
+
// ==== ctc-tokenizer.js ==================================================
|
| 284 |
+
(() => {
|
| 285 |
+
/**
|
| 286 |
+
* Decoder for the model's 16384-entry ByteLevel BPE vocabulary.
|
| 287 |
+
*
|
| 288 |
+
* Only decoding is needed (the model never encodes text), so export_ctc_onnx.py
|
| 289 |
+
* dumps the id->piece slice that can reach the decoder as vocab.json (~170 KB)
|
| 290 |
+
* instead of shipping the whole tokenizer.json.
|
| 291 |
+
*
|
| 292 |
+
* granite-speech-4.2-470m-turboctc uses a GPT-2 style **ByteLevel** tokenizer:
|
| 293 |
+
* every byte 0-255 is represented by one printable codepoint (space is `Ġ`), so a
|
| 294 |
+
* piece is a string of byte-stand-ins rather than text, and one UTF-8 character
|
| 295 |
+
* may be split across several pieces ("é" arrives as "Ã" + "©"). Decoding is
|
| 296 |
+
* therefore: map each character back to its byte, concatenate, then UTF-8 decode
|
| 297 |
+
* once at the end -- never per piece, or multi-byte characters break.
|
| 298 |
+
*
|
| 299 |
+
* (The earlier C-T26.18 build used a SentencePiece tokenizer whose decoder was
|
| 300 |
+
* Replace(U+2581 -> space) / ByteFallback / Fuse / Strip. That is a different
|
| 301 |
+
* algorithm and would silently produce mojibake here, so the exporter asserts the
|
| 302 |
+
* tokenizer's decoder type matches this implementation.)
|
| 303 |
+
*/
|
| 304 |
+
|
| 305 |
+
/**
|
| 306 |
+
* GPT-2's byte <-> codepoint table, inverted to codepoint -> byte.
|
| 307 |
+
*
|
| 308 |
+
* The 188 bytes that are already printable map to themselves; the remaining 68
|
| 309 |
+
* are moved up to U+0100 and beyond so no piece ever contains a control
|
| 310 |
+
* character or a bare space.
|
| 311 |
+
*/
|
| 312 |
+
function byteLevelCharToByte() {
|
| 313 |
+
const bytes = [];
|
| 314 |
+
for (let b = 0x21; b <= 0x7e; b++) bytes.push(b); // !..~
|
| 315 |
+
for (let b = 0xa1; b <= 0xac; b++) bytes.push(b); // ¡..¬
|
| 316 |
+
for (let b = 0xae; b <= 0xff; b++) bytes.push(b); // ®..ÿ
|
| 317 |
+
const codepoints = bytes.slice();
|
| 318 |
+
const printable = new Set(bytes);
|
| 319 |
+
let next = 0;
|
| 320 |
+
for (let b = 0; b < 256; b++) {
|
| 321 |
+
if (!printable.has(b)) {
|
| 322 |
+
bytes.push(b);
|
| 323 |
+
codepoints.push(256 + next);
|
| 324 |
+
next++;
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
const map = new Map();
|
| 328 |
+
for (let i = 0; i < bytes.length; i++) map.set(codepoints[i], bytes[i]);
|
| 329 |
+
return map;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
class CtcDecoder {
|
| 333 |
+
constructor(pieces, numSpecialTokens) {
|
| 334 |
+
this.numSpecialTokens = numSpecialTokens;
|
| 335 |
+
const charToByte = byteLevelCharToByte();
|
| 336 |
+
// Pre-resolve every piece to its bytes, so decoding a frame of ids is a
|
| 337 |
+
// few array copies rather than per-call string work.
|
| 338 |
+
this.encoded = new Array(pieces.length);
|
| 339 |
+
for (let i = 0; i < pieces.length; i++) {
|
| 340 |
+
const piece = pieces[i];
|
| 341 |
+
const bytes = new Uint8Array(piece.length);
|
| 342 |
+
let n = 0;
|
| 343 |
+
for (const ch of piece) {
|
| 344 |
+
const b = charToByte.get(ch.codePointAt(0));
|
| 345 |
+
// A codepoint outside the table means this vocab was not produced
|
| 346 |
+
// by a ByteLevel tokenizer; skip rather than emit a wrong byte.
|
| 347 |
+
if (b !== undefined) bytes[n++] = b;
|
| 348 |
+
}
|
| 349 |
+
this.encoded[i] = bytes.subarray(0, n);
|
| 350 |
+
}
|
| 351 |
+
this.utf8Decoder = new TextDecoder('utf-8');
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
static async load(baseUrl = './onnx-ctc/') {
|
| 355 |
+
const [vocab, config] = await Promise.all([
|
| 356 |
+
fetch(baseUrl + 'vocab.json').then((r) => {
|
| 357 |
+
if (!r.ok) throw new Error(`vocab.json: HTTP ${r.status}`);
|
| 358 |
+
return r.json();
|
| 359 |
+
}),
|
| 360 |
+
fetch(baseUrl + 'frontend_config.json').then((r) => {
|
| 361 |
+
if (!r.ok) throw new Error(`frontend_config.json: HTTP ${r.status}`);
|
| 362 |
+
return r.json();
|
| 363 |
+
}),
|
| 364 |
+
]);
|
| 365 |
+
return new CtcDecoder(vocab, config.num_special_tokens);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/** Decode content ids (all >= numSpecialTokens) to text. */
|
| 369 |
+
decode(ids) {
|
| 370 |
+
let total = 0;
|
| 371 |
+
for (const id of ids) {
|
| 372 |
+
const e = this.encoded[id - this.numSpecialTokens];
|
| 373 |
+
if (e) total += e.length;
|
| 374 |
+
}
|
| 375 |
+
const buf = new Uint8Array(total);
|
| 376 |
+
let at = 0;
|
| 377 |
+
for (const id of ids) {
|
| 378 |
+
const e = this.encoded[id - this.numSpecialTokens];
|
| 379 |
+
if (!e) continue; // out-of-range id: skip rather than poison the string
|
| 380 |
+
buf.set(e, at);
|
| 381 |
+
at += e.length;
|
| 382 |
+
}
|
| 383 |
+
return this.utf8Decoder.decode(buf);
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/**
|
| 388 |
+
* Greedy CTC collapse: drop repeats, then the blank, then any remaining
|
| 389 |
+
* below-content ids.
|
| 390 |
+
*
|
| 391 |
+
* Order matters and matches the Python path: repeats are collapsed against the
|
| 392 |
+
* *raw* previous id (blanks included), which is what lets a blank between two
|
| 393 |
+
* identical tokens preserve both. For this model `numSpecialTokens` is 1 -- the
|
| 394 |
+
* CTC blank at id 0 is the only non-text id, and everything above it is a
|
| 395 |
+
* tokenizer id as-is.
|
| 396 |
+
*/
|
| 397 |
+
function ctcCollapse(ids, { blankId = 0, numSpecialTokens = 1 } = {}) {
|
| 398 |
+
const out = [];
|
| 399 |
+
let prev = -1;
|
| 400 |
+
for (let i = 0; i < ids.length; i++) {
|
| 401 |
+
const id = ids[i];
|
| 402 |
+
if (id !== prev && id !== blankId && id >= numSpecialTokens) out.push(id);
|
| 403 |
+
prev = id;
|
| 404 |
+
}
|
| 405 |
+
return out;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
Object.assign(__M, { CtcDecoder, ctcCollapse });
|
| 409 |
+
})();
|
| 410 |
+
|
| 411 |
+
// ==== ctc-engine.js =====================================================
|
| 412 |
+
(() => {
|
| 413 |
+
const { CtcFrontend, agc, CtcDecoder, ctcCollapse } = __M;
|
| 414 |
+
/**
|
| 415 |
+
* FastConformer CTC inference engine (onnxruntime-web, WebGPU).
|
| 416 |
+
*
|
| 417 |
+
* The model is a custom `ctc_conformer` architecture with no transformers.js
|
| 418 |
+
* support, so this drives an ONNX session directly: features -> one forward pass
|
| 419 |
+
* -> greedy CTC collapse. There is no autoregressive loop, no KV cache and no
|
| 420 |
+
* chat template; a window is one `session.run`.
|
| 421 |
+
*
|
| 422 |
+
* Two contracts come from export_ctc_onnx.py and must hold here:
|
| 423 |
+
*
|
| 424 |
+
* - **Frames are padded to `pad_multiple`.** The encoder chunks attention into
|
| 425 |
+
* context_size (128) blocks at every layer and subsamples time by 4, so a
|
| 426 |
+
* frame count with no partial block anywhere is a multiple of 512 (10.24 s).
|
| 427 |
+
* The exported graph therefore has no tail-block path, and short windows are
|
| 428 |
+
* padded up with `padding_mask` marking the pad. Measured cost of the masked
|
| 429 |
+
* padding on granite-speech-4.2-470m-turboctc: none -- all 14 fixture clips
|
| 430 |
+
* decode identically to the unpadded path, with zero per-frame argmax flips.
|
| 431 |
+
* (Its encoder masks inside the conv module specifically so a padded frame
|
| 432 |
+
* cannot leak through the stride-2 kernel into the last valid frames.)
|
| 433 |
+
*
|
| 434 |
+
* - **The graph returns ids, not logits.** `[1, T/4, 16384]` float32 logits
|
| 435 |
+
* would be an ~8 MB device-to-host copy per window; argmax and the
|
| 436 |
+
* log-softmax max happen inside the graph instead, so a window costs ~1 KB of
|
| 437 |
+
* output. `top_logprob` is the confidence signal the streaming gate uses.
|
| 438 |
+
*/
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
const CACHE_NAME = 'granite-ctc-models-v1';
|
| 443 |
+
|
| 444 |
+
/**
|
| 445 |
+
* Cache-first fetch, so a reload doesn't re-download hundreds of MB.
|
| 446 |
+
*
|
| 447 |
+
* The body is streamed to completion first and only then handed to the cache.
|
| 448 |
+
* Doing it the other way round -- `await cache.put(response.clone())` before
|
| 449 |
+
* reading -- makes the tee buffer the entire 312 MB while the cache write drains
|
| 450 |
+
* it, so the progress callback fires nothing for minutes and the UI looks hung.
|
| 451 |
+
* A failed cache write (quota, private mode) must not fail the load either: the
|
| 452 |
+
* bytes are already in hand, and the only cost is re-downloading next time.
|
| 453 |
+
*/
|
| 454 |
+
async function cachedFetch(url, onProgress) {
|
| 455 |
+
const cache = await caches.open(CACHE_NAME).catch(() => null);
|
| 456 |
+
const hit = await cache?.match(url).catch(() => null);
|
| 457 |
+
if (hit) {
|
| 458 |
+
onProgress?.({ url, loaded: 1, total: 1, cached: true });
|
| 459 |
+
return hit.arrayBuffer();
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
const response = await fetch(url);
|
| 463 |
+
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
| 464 |
+
|
| 465 |
+
const total = Number(response.headers.get('content-length')) || 0;
|
| 466 |
+
let buffer;
|
| 467 |
+
if (!response.body) {
|
| 468 |
+
buffer = await response.arrayBuffer();
|
| 469 |
+
} else {
|
| 470 |
+
const reader = response.body.getReader();
|
| 471 |
+
const chunks = [];
|
| 472 |
+
let loaded = 0;
|
| 473 |
+
for (;;) {
|
| 474 |
+
const { done, value } = await reader.read();
|
| 475 |
+
if (done) break;
|
| 476 |
+
chunks.push(value);
|
| 477 |
+
loaded += value.length;
|
| 478 |
+
onProgress?.({ url, loaded, total });
|
| 479 |
+
}
|
| 480 |
+
const bytes = new Uint8Array(loaded);
|
| 481 |
+
let at = 0;
|
| 482 |
+
for (const c of chunks) { bytes.set(c, at); at += c.length; }
|
| 483 |
+
buffer = bytes.buffer;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
if (cache) {
|
| 487 |
+
try {
|
| 488 |
+
await cache.put(url, new Response(buffer.slice(0), {
|
| 489 |
+
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.byteLength) },
|
| 490 |
+
}));
|
| 491 |
+
} catch (e) {
|
| 492 |
+
console.warn(`could not cache ${url} (will re-download next time):`, e);
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
return buffer;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
class CtcEngine {
|
| 499 |
+
constructor({ session, frontend, decoder, config, device }) {
|
| 500 |
+
this.session = session;
|
| 501 |
+
this.frontend = frontend;
|
| 502 |
+
this.decoder = decoder;
|
| 503 |
+
this.config = config;
|
| 504 |
+
this.device = device;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
/**
|
| 508 |
+
* Create the session and load the front-end assets.
|
| 509 |
+
*
|
| 510 |
+
* `device: 'webgpu'` falls back to wasm if the adapter or a kernel is
|
| 511 |
+
* missing -- a CTC pass is still usable on wasm, just slower.
|
| 512 |
+
*/
|
| 513 |
+
static async load({
|
| 514 |
+
baseUrl = './onnx-ctc/',
|
| 515 |
+
modelFile = 'ctc_conformer_q4f16-b16.onnx',
|
| 516 |
+
device = 'webgpu',
|
| 517 |
+
onProgress = null,
|
| 518 |
+
allowFallback = true,
|
| 519 |
+
} = {}) {
|
| 520 |
+
const [frontend, decoder] = await Promise.all([
|
| 521 |
+
CtcFrontend.load(baseUrl),
|
| 522 |
+
CtcDecoder.load(baseUrl),
|
| 523 |
+
]);
|
| 524 |
+
|
| 525 |
+
const modelUrl = baseUrl + modelFile;
|
| 526 |
+
// The exporter writes weights to `<model>.onnx.data` and records that
|
| 527 |
+
// basename inside the graph, so the session's externalData path must
|
| 528 |
+
// match it exactly.
|
| 529 |
+
const dataName = `${modelFile}.data`;
|
| 530 |
+
const [modelBuffer, dataBuffer] = await Promise.all([
|
| 531 |
+
cachedFetch(modelUrl, onProgress),
|
| 532 |
+
cachedFetch(baseUrl + dataName, onProgress),
|
| 533 |
+
]);
|
| 534 |
+
|
| 535 |
+
const options = {
|
| 536 |
+
executionProviders: [device],
|
| 537 |
+
externalData: [{ path: dataName, data: dataBuffer }],
|
| 538 |
+
graphOptimizationLevel: 'all',
|
| 539 |
+
};
|
| 540 |
+
let session;
|
| 541 |
+
let used = device;
|
| 542 |
+
let fallbackError = null;
|
| 543 |
+
try {
|
| 544 |
+
session = await ort.InferenceSession.create(modelBuffer, options);
|
| 545 |
+
} catch (e) {
|
| 546 |
+
if (device !== 'webgpu' || !allowFallback) throw e;
|
| 547 |
+
console.warn('WebGPU session failed, falling back to wasm:', e);
|
| 548 |
+
fallbackError = String(e?.message || e);
|
| 549 |
+
used = 'wasm';
|
| 550 |
+
session = await ort.InferenceSession.create(modelBuffer, {
|
| 551 |
+
...options,
|
| 552 |
+
executionProviders: ['wasm'],
|
| 553 |
+
});
|
| 554 |
+
}
|
| 555 |
+
const engine = new CtcEngine({ session, frontend, decoder, config: frontend.config, device: used });
|
| 556 |
+
// A WebGPU kernel can also fail on its *first run* rather than at session
|
| 557 |
+
// build (ORT-web reports unsupported MatMulNBits shapes that way), so the
|
| 558 |
+
// warmup pass is where a fallback is really decided.
|
| 559 |
+
const warmupError = await engine.warmup();
|
| 560 |
+
if (warmupError && used === 'webgpu') {
|
| 561 |
+
if (!allowFallback) throw new Error(`WebGPU warmup failed: ${warmupError}`);
|
| 562 |
+
console.warn('WebGPU warmup failed, falling back to wasm:', warmupError);
|
| 563 |
+
fallbackError = warmupError;
|
| 564 |
+
engine.device = 'wasm';
|
| 565 |
+
engine.session = await ort.InferenceSession.create(modelBuffer, {
|
| 566 |
+
...options,
|
| 567 |
+
executionProviders: ['wasm'],
|
| 568 |
+
});
|
| 569 |
+
await engine.warmup();
|
| 570 |
+
}
|
| 571 |
+
engine.fallbackError = fallbackError;
|
| 572 |
+
return engine;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
/**
|
| 576 |
+
* Run one throwaway pass so shader compilation doesn't land on the user's
|
| 577 |
+
* first spoken window (measured on WebGPU: 1271 ms first call vs 295 ms
|
| 578 |
+
* steady state). Every window is padded to the same frame count, so a single
|
| 579 |
+
* warmup covers the shapes the streaming loop will use.
|
| 580 |
+
*/
|
| 581 |
+
async warmup() {
|
| 582 |
+
try {
|
| 583 |
+
const frames = this.config.pad_multiple;
|
| 584 |
+
await this.session.run({
|
| 585 |
+
input_features: new ort.Tensor('float32', new Float32Array(frames * this.config.input_dim), [1, frames, this.config.input_dim]),
|
| 586 |
+
padding_mask: new ort.Tensor('float32', new Float32Array(frames), [1, frames]),
|
| 587 |
+
});
|
| 588 |
+
return null;
|
| 589 |
+
} catch (e) {
|
| 590 |
+
return String(e?.message || e);
|
| 591 |
+
}
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
/** Encoder output frames covering `frames` of real audio (both subsample blocks floor-halve). */
|
| 595 |
+
static realOutFrames(frames) {
|
| 596 |
+
return Math.floor(Math.floor(frames / 2) / 2);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
/**
|
| 600 |
+
* Transcribe a waveform (Float32Array, 16 kHz mono).
|
| 601 |
+
*
|
| 602 |
+
* `applyAgc` mirrors the sidecar: the log-mel front-end normalizes against
|
| 603 |
+
* the clip's global peak, so without it a quiet tail after a loud passage is
|
| 604 |
+
* floored away and its words are silently dropped.
|
| 605 |
+
*/
|
| 606 |
+
async transcribe(wav, { applyAgc = true } = {}) {
|
| 607 |
+
const t0 = performance.now();
|
| 608 |
+
const audio = applyAgc ? agc(wav, { sampleRate: this.config.sample_rate }) : wav;
|
| 609 |
+
const { data, frames, dim } = this.frontend.compute(audio);
|
| 610 |
+
if (frames === 0) return { text: '', words: [], avgLogprob: null, frames: 0, ms: 0 };
|
| 611 |
+
|
| 612 |
+
const multiple = this.config.pad_multiple;
|
| 613 |
+
const padded = Math.ceil(frames / multiple) * multiple;
|
| 614 |
+
const features = new Float32Array(padded * dim);
|
| 615 |
+
features.set(data);
|
| 616 |
+
const mask = new Float32Array(padded).fill(1);
|
| 617 |
+
mask.fill(0, 0, frames);
|
| 618 |
+
|
| 619 |
+
const outputs = await this.session.run({
|
| 620 |
+
input_features: new ort.Tensor('float32', features, [1, padded, dim]),
|
| 621 |
+
padding_mask: new ort.Tensor('float32', mask, [1, padded]),
|
| 622 |
+
});
|
| 623 |
+
|
| 624 |
+
// The graph emits padded/4 frames; only those covering real audio may
|
| 625 |
+
// contribute tokens, so trim before collapsing.
|
| 626 |
+
const nOut = CtcEngine.realOutFrames(frames);
|
| 627 |
+
const ids = outputs.ids.data.subarray(0, nOut);
|
| 628 |
+
const logp = outputs.top_logprob.data.subarray(0, nOut);
|
| 629 |
+
|
| 630 |
+
const content = ctcCollapse(ids, {
|
| 631 |
+
blankId: this.config.blank_id,
|
| 632 |
+
numSpecialTokens: this.config.num_special_tokens,
|
| 633 |
+
});
|
| 634 |
+
const text = this.decoder.decode(content).trim();
|
| 635 |
+
|
| 636 |
+
// Confidence: mean top log-probability over non-blank frames. The CTC
|
| 637 |
+
// analogue of a mean per-token logprob -- clean speech sits near -0.03,
|
| 638 |
+
// silence/noise confabulation near -1.85 and below.
|
| 639 |
+
let sum = 0, n = 0;
|
| 640 |
+
for (let i = 0; i < nOut; i++) {
|
| 641 |
+
if (ids[i] !== this.config.blank_id) { sum += logp[i]; n++; }
|
| 642 |
+
}
|
| 643 |
+
return {
|
| 644 |
+
text,
|
| 645 |
+
words: text.length ? text.split(/\s+/) : [],
|
| 646 |
+
avgLogprob: n ? sum / n : null,
|
| 647 |
+
frames,
|
| 648 |
+
paddedFrames: padded,
|
| 649 |
+
ms: performance.now() - t0,
|
| 650 |
+
};
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
Object.assign(__M, { CtcEngine });
|
| 655 |
+
})();
|
| 656 |
+
|
| 657 |
+
// ==== ctc-streaming.js ==================================================
|
| 658 |
+
(() => {
|
| 659 |
+
/**
|
| 660 |
+
* Live dictation: growing window + LocalAgreement-2, ported from the macOS
|
| 661 |
+
* GraniteLiveDictation app (SpeechGate / Reconciler / StreamingCoordinator).
|
| 662 |
+
*
|
| 663 |
+
* continuous 16 kHz capture -> rolling window + energy SpeechGate
|
| 664 |
+
* every `step` while speech is in the window:
|
| 665 |
+
* window = audio[segment start .. now] (grows; overlaps the last)
|
| 666 |
+
* hypo = words(CTC transcription of window)
|
| 667 |
+
* a word COMMITS once two consecutive windows agree on it (longest
|
| 668 |
+
* common prefix); everything past that is TENTATIVE (shown grey)
|
| 669 |
+
* on a pause: one clean full-window pass commits the segment and re-anchors
|
| 670 |
+
*
|
| 671 |
+
* The window *grows* rather than sliding because the model returns text with no
|
| 672 |
+
* word timestamps -- there is no safe place to trim a fixed window without
|
| 673 |
+
* risking a mid-word cut. Anchoring each window at the start of the current
|
| 674 |
+
* speech segment makes reconciliation a plain prefix comparison and keeps the
|
| 675 |
+
* window bounded (a phrase between pauses).
|
| 676 |
+
*
|
| 677 |
+
* The tuning constants are the ones the macOS app converged on against real
|
| 678 |
+
* far-field recordings, not defaults -- see the comments on each.
|
| 679 |
+
*/
|
| 680 |
+
|
| 681 |
+
const SAMPLE_RATE = 16000;
|
| 682 |
+
const INT16_SCALE = 32768; // gate thresholds are in Int16 RMS units (as tuned)
|
| 683 |
+
|
| 684 |
+
/**
|
| 685 |
+
* Energy speech/silence gate with an adaptive noise floor.
|
| 686 |
+
*
|
| 687 |
+
* Not a neural VAD -- an RMS gate whose enter/exit thresholds float with the
|
| 688 |
+
* measured background level. That matters for its one job: finding a *pause* so
|
| 689 |
+
* the growing window can flush. With fixed thresholds, a room whose ambient RMS
|
| 690 |
+
* sits above the silence cutoff never produces a silence frame, the endpoint
|
| 691 |
+
* timer never fires, and every window runs to the maxWindow cap and cuts
|
| 692 |
+
* mid-sentence.
|
| 693 |
+
*/
|
| 694 |
+
class SpeechGate {
|
| 695 |
+
constructor(config = {}) {
|
| 696 |
+
this.config = {
|
| 697 |
+
endpointSilenceMs: 600, // contiguous below-exit audio that ends a segment
|
| 698 |
+
frameMs: 30,
|
| 699 |
+
// Biased toward inclusion: this CTC model doesn't confabulate on
|
| 700 |
+
// silence the way an autoregressive decoder does (it decodes to empty
|
| 701 |
+
// / low confidence, and the confidence gate backstops it), so
|
| 702 |
+
// admitting soft audio costs nothing -- while excluding it dropped
|
| 703 |
+
// real words (soft onsets never triggered, soft tails got endpointed
|
| 704 |
+
// mid-phrase).
|
| 705 |
+
onRatio: 1.5,
|
| 706 |
+
minEnter: 80,
|
| 707 |
+
// Exit stays near the original: dropping it toward ambient stops
|
| 708 |
+
// pause detection altogether and every segment runs to maxWindow,
|
| 709 |
+
// which wrecks paragraphing.
|
| 710 |
+
offRatio: 1.4,
|
| 711 |
+
minExit: 60,
|
| 712 |
+
onFrames: 2, // debounce transient clicks
|
| 713 |
+
// Seeded from the *mean* RMS over the startup window, not the min: the
|
| 714 |
+
// min tracks the quietest valley of fluctuating noise and seeds the
|
| 715 |
+
// floor far below the ambient body, which then lets noise read as
|
| 716 |
+
// speech.
|
| 717 |
+
calibrationMs: 360,
|
| 718 |
+
floorAdapt: 0.05, // per-frame EMA; at 30 ms frames ~0.6 s to settle
|
| 719 |
+
initialNoiseFloor: 60,
|
| 720 |
+
maxNoiseFloor: 4000,
|
| 721 |
+
...config,
|
| 722 |
+
};
|
| 723 |
+
this.frameLen = Math.max(1, (SAMPLE_RATE * this.config.frameMs) / 1000);
|
| 724 |
+
this.calibrationFrames = Math.floor(this.config.calibrationMs / this.config.frameMs);
|
| 725 |
+
this.onEvent = null;
|
| 726 |
+
this.isSpeaking = false;
|
| 727 |
+
this.noiseFloor = this.config.initialNoiseFloor;
|
| 728 |
+
this.silenceMs = 0;
|
| 729 |
+
this.onCount = 0;
|
| 730 |
+
this.pending = [];
|
| 731 |
+
this.pendingLen = 0;
|
| 732 |
+
this.calibLeft = this.calibrationFrames;
|
| 733 |
+
this.calibSum = 0;
|
| 734 |
+
this.calibCount = 0;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
get enterThreshold() { return Math.max(this.config.minEnter, this.noiseFloor * this.config.onRatio); }
|
| 738 |
+
get exitThreshold() { return Math.max(this.config.minExit, this.noiseFloor * this.config.offRatio); }
|
| 739 |
+
|
| 740 |
+
/**
|
| 741 |
+
* Clear per-utterance state. The noise floor and calibration are deliberately
|
| 742 |
+
* preserved: they describe the *room*, not the utterance, and this is called
|
| 743 |
+
* on every commit (including maxWindow cuts taken mid-speech), where
|
| 744 |
+
* re-seeding would calibrate the floor from speech energy and blind the gate.
|
| 745 |
+
*/
|
| 746 |
+
reset() {
|
| 747 |
+
this.isSpeaking = false;
|
| 748 |
+
this.silenceMs = 0;
|
| 749 |
+
this.onCount = 0;
|
| 750 |
+
this.pending = [];
|
| 751 |
+
this.pendingLen = 0;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
/** Re-arm calibration -- for a fresh capture session / new environment. */
|
| 755 |
+
recalibrate() {
|
| 756 |
+
this.noiseFloor = this.config.initialNoiseFloor;
|
| 757 |
+
this.calibLeft = this.calibrationFrames;
|
| 758 |
+
this.calibSum = 0;
|
| 759 |
+
this.calibCount = 0;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
/** Feed a chunk of float samples (any length); re-framed into fixed blocks. */
|
| 763 |
+
feed(samples) {
|
| 764 |
+
if (!samples.length) return;
|
| 765 |
+
this.pending.push(samples);
|
| 766 |
+
this.pendingLen += samples.length;
|
| 767 |
+
while (this.pendingLen >= this.frameLen) {
|
| 768 |
+
const frame = this.takeFrame();
|
| 769 |
+
// process() emits events synchronously and a handler may call reset()
|
| 770 |
+
// re-entrantly (which drops `pending`), so the frame is removed first.
|
| 771 |
+
this.process(frame);
|
| 772 |
+
}
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
takeFrame() {
|
| 776 |
+
const frame = new Float32Array(this.frameLen);
|
| 777 |
+
let at = 0;
|
| 778 |
+
while (at < this.frameLen) {
|
| 779 |
+
const head = this.pending[0];
|
| 780 |
+
const take = Math.min(head.length, this.frameLen - at);
|
| 781 |
+
frame.set(take === head.length ? head : head.subarray(0, take), at);
|
| 782 |
+
at += take;
|
| 783 |
+
if (take === head.length) this.pending.shift();
|
| 784 |
+
else this.pending[0] = head.subarray(take);
|
| 785 |
+
}
|
| 786 |
+
this.pendingLen -= this.frameLen;
|
| 787 |
+
return frame;
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
process(frame) {
|
| 791 |
+
const rms = rmsInt16(frame);
|
| 792 |
+
|
| 793 |
+
if (this.calibLeft > 0) {
|
| 794 |
+
this.calibLeft--;
|
| 795 |
+
this.calibSum += rms;
|
| 796 |
+
this.calibCount++;
|
| 797 |
+
this.noiseFloor = Math.min(Math.max(this.calibSum / this.calibCount, 1), this.config.maxNoiseFloor);
|
| 798 |
+
return;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
this.adaptNoise(rms);
|
| 802 |
+
if (this.isSpeaking) {
|
| 803 |
+
if (rms < this.exitThreshold) {
|
| 804 |
+
this.silenceMs += this.config.frameMs;
|
| 805 |
+
if (this.silenceMs >= this.config.endpointSilenceMs) {
|
| 806 |
+
this.isSpeaking = false;
|
| 807 |
+
this.silenceMs = 0;
|
| 808 |
+
this.onCount = 0;
|
| 809 |
+
this.onEvent?.('speechEnded');
|
| 810 |
+
}
|
| 811 |
+
} else {
|
| 812 |
+
this.silenceMs = 0; // speech resumed; reset the endpoint timer
|
| 813 |
+
}
|
| 814 |
+
} else if (rms >= this.enterThreshold) {
|
| 815 |
+
this.onCount++;
|
| 816 |
+
if (this.onCount >= this.config.onFrames) {
|
| 817 |
+
this.isSpeaking = true;
|
| 818 |
+
this.silenceMs = 0;
|
| 819 |
+
this.onCount = 0;
|
| 820 |
+
this.onEvent?.('speechStarted');
|
| 821 |
+
}
|
| 822 |
+
} else {
|
| 823 |
+
this.onCount = 0;
|
| 824 |
+
}
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
/**
|
| 828 |
+
* Learn the floor from ambient frames only, tracking the *body* (mean) of the
|
| 829 |
+
* ambient level. Updating solely when not in a segment AND below the enter
|
| 830 |
+
* threshold keeps sustained speech (or a speech attack) from dragging the
|
| 831 |
+
* floor up to speech level.
|
| 832 |
+
*/
|
| 833 |
+
adaptNoise(rms) {
|
| 834 |
+
if (this.isSpeaking || rms >= this.enterThreshold) return;
|
| 835 |
+
const a = this.config.floorAdapt;
|
| 836 |
+
this.noiseFloor = Math.min(Math.max((1 - a) * this.noiseFloor + a * rms, 1), this.config.maxNoiseFloor);
|
| 837 |
+
}
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
function rmsInt16(samples) {
|
| 841 |
+
if (!samples.length) return 0;
|
| 842 |
+
let acc = 0;
|
| 843 |
+
for (let i = 0; i < samples.length; i++) acc += samples[i] * samples[i];
|
| 844 |
+
return Math.sqrt(acc / samples.length) * INT16_SCALE;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
/**
|
| 848 |
+
* LocalAgreement-2 reconciliation.
|
| 849 |
+
*
|
| 850 |
+
* Each step transcribes the growing window and yields a fresh hypothesis. Because
|
| 851 |
+
* the window overlaps the previous one, the two should agree on the stable part
|
| 852 |
+
* and differ only at the unfinished tail. A word is committed once two
|
| 853 |
+
* *consecutive* hypotheses agree on it (their longest common prefix, compared
|
| 854 |
+
* case-insensitively and ignoring edge punctuation); everything past that is
|
| 855 |
+
* tentative and may still change.
|
| 856 |
+
*/
|
| 857 |
+
class Reconciler {
|
| 858 |
+
constructor() {
|
| 859 |
+
this.confirmed = [];
|
| 860 |
+
this.prevHypo = [];
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
update(hypothesis) {
|
| 864 |
+
const agreed = commonPrefixCount(this.prevHypo, hypothesis);
|
| 865 |
+
if (agreed > this.confirmed.length) {
|
| 866 |
+
this.confirmed.push(...hypothesis.slice(this.confirmed.length, agreed));
|
| 867 |
+
}
|
| 868 |
+
this.prevHypo = hypothesis;
|
| 869 |
+
const tentative = hypothesis.length > this.confirmed.length
|
| 870 |
+
? hypothesis.slice(this.confirmed.length)
|
| 871 |
+
: [];
|
| 872 |
+
return { confirmed: this.confirmed, tentative };
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
/** End of segment: a clean full-window hypothesis is authoritative. */
|
| 876 |
+
finalize(words) {
|
| 877 |
+
this.confirmed = words;
|
| 878 |
+
this.prevHypo = words;
|
| 879 |
+
return this.confirmed;
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
reset() {
|
| 883 |
+
this.confirmed = [];
|
| 884 |
+
this.prevHypo = [];
|
| 885 |
+
}
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
/** Lowercase and strip edge punctuation so "Cat," and "cat" match. */
|
| 889 |
+
function normalizeWord(word) {
|
| 890 |
+
return word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').toLowerCase();
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
function commonPrefixCount(a, b) {
|
| 894 |
+
const n = Math.min(a.length, b.length);
|
| 895 |
+
let i = 0;
|
| 896 |
+
while (i < n && normalizeWord(a[i]) === normalizeWord(b[i])) i++;
|
| 897 |
+
return i;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
/**
|
| 901 |
+
* Stock phrases the model confidently emits on ambient/silence -- the classic ASR
|
| 902 |
+
* hallucination. Confidence can't catch these (they score *higher* than genuine
|
| 903 |
+
* soft speech), so they're matched against the whole commit and dropped only when
|
| 904 |
+
* word density is low: a silence-padded hallucination is long and slow, a real
|
| 905 |
+
* crisp "thank you" is short and fast.
|
| 906 |
+
*/
|
| 907 |
+
const CANNED_HALLUCINATIONS = new Set([
|
| 908 |
+
'thank you', 'thank you very much', 'thanks for watching',
|
| 909 |
+
'thank you for watching', 'thanks for watching everyone',
|
| 910 |
+
]);
|
| 911 |
+
|
| 912 |
+
class StreamingCoordinator {
|
| 913 |
+
/**
|
| 914 |
+
* @param {object} opts
|
| 915 |
+
* @param {import('./ctc-engine.js').CtcEngine} opts.engine
|
| 916 |
+
* @param {(state: {committed: string, tentative: string}) => void} opts.onTranscript
|
| 917 |
+
* @param {(level: number) => void} [opts.onLevel] 0..1 input level
|
| 918 |
+
* @param {(info: object) => void} [opts.onStep] per-window diagnostics
|
| 919 |
+
* @param {(text: string) => Promise<string>} [opts.punctuate] committed-text post-pass
|
| 920 |
+
*/
|
| 921 |
+
constructor({ engine, onTranscript, onLevel = null, onStep = null, punctuate = null, tuning = {} }) {
|
| 922 |
+
this.engine = engine;
|
| 923 |
+
this.onTranscript = onTranscript;
|
| 924 |
+
this.onLevel = onLevel;
|
| 925 |
+
this.onStep = onStep;
|
| 926 |
+
this.punctuate = punctuate;
|
| 927 |
+
|
| 928 |
+
this.tuning = {
|
| 929 |
+
stepMs: 400, // re-transcribe cadence. A window costs one padded
|
| 930 |
+
// forward pass (>= 10.24 s of frames, see
|
| 931 |
+
// ctc-engine), so this is coarser than the macOS
|
| 932 |
+
// app's 100 ms; an in-flight tick is dropped, so
|
| 933 |
+
// it self-limits either way.
|
| 934 |
+
minWindowSec: 0.4, // don't transcribe windows shorter than this
|
| 935 |
+
maxWindowSec: 10.0, // force-commit a window that grows this long with no pause
|
| 936 |
+
newlineGapMs: 1200, // a pause >= this starts a new paragraph
|
| 937 |
+
hardPauseMs: 1500,
|
| 938 |
+
prerollSec: 0.5, // audio kept before speech onset, so the first word isn't clipped
|
| 939 |
+
// Calibrated on real far-field logs: clean speech ~ -0.03, but genuine
|
| 940 |
+
// soft utterances scored -0.98..-1.34 and were wrongly rejected at a
|
| 941 |
+
// -0.6 gate, dropping whole clips; silence confabulation sits at
|
| 942 |
+
// -1.85 and below, so -1.5 lands in the gap.
|
| 943 |
+
minCommitAvgLogprob: -1.5,
|
| 944 |
+
cannedHallucinationMaxWps: 2.0,
|
| 945 |
+
...tuning,
|
| 946 |
+
};
|
| 947 |
+
|
| 948 |
+
this.gate = new SpeechGate();
|
| 949 |
+
this.gate.onEvent = (e) => this.handleGate(e);
|
| 950 |
+
this.reconciler = new Reconciler();
|
| 951 |
+
|
| 952 |
+
this.window = []; // Float32Array chunks since the last commit
|
| 953 |
+
this.windowLen = 0;
|
| 954 |
+
this.windowHasSpeech = false;
|
| 955 |
+
this.transcribing = false;
|
| 956 |
+
this.windowGen = 0;
|
| 957 |
+
this.paragraphs = []; // committed text, one entry per paragraph
|
| 958 |
+
this.lastVoiceAt = 0;
|
| 959 |
+
this.segmentGapMs = Infinity;
|
| 960 |
+
this.active = false;
|
| 961 |
+
this.audioContext = null;
|
| 962 |
+
this.mediaStream = null;
|
| 963 |
+
this.stepTimer = null;
|
| 964 |
+
// One ONNX session can't have two concurrent run() calls, and a commit
|
| 965 |
+
// fired by the gate can land while a step transcription is still in
|
| 966 |
+
// flight, so every engine call goes through this chain.
|
| 967 |
+
this.engineLock = Promise.resolve();
|
| 968 |
+
// Commits are started from the gate callback (inside onSamples), which
|
| 969 |
+
// can't await them; stop() drains these so the last words aren't lost.
|
| 970 |
+
this.pending = new Set();
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
/** Serialize engine access; returns the callback's result. */
|
| 974 |
+
async withEngine(fn) {
|
| 975 |
+
const prior = this.engineLock;
|
| 976 |
+
let release;
|
| 977 |
+
this.engineLock = new Promise((r) => { release = r; });
|
| 978 |
+
try {
|
| 979 |
+
await prior;
|
| 980 |
+
return await fn();
|
| 981 |
+
} finally {
|
| 982 |
+
release();
|
| 983 |
+
}
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
/** Track a fire-and-forget async task so stop() can wait for it. */
|
| 987 |
+
track(promise) {
|
| 988 |
+
this.pending.add(promise);
|
| 989 |
+
promise.catch(() => {}).finally(() => this.pending.delete(promise));
|
| 990 |
+
return promise;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
/** Resolve once no commit or step is outstanding. */
|
| 994 |
+
async idle() {
|
| 995 |
+
while (this.pending.size) await Promise.allSettled([...this.pending]);
|
| 996 |
+
await this.engineLock;
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
async start() {
|
| 1000 |
+
if (this.active) return;
|
| 1001 |
+
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
| 1002 |
+
audio: {
|
| 1003 |
+
channelCount: 1,
|
| 1004 |
+
echoCancellation: true,
|
| 1005 |
+
noiseSuppression: false, // the gate wants the true ambient level
|
| 1006 |
+
autoGainControl: false, // the engine's own AGC handles levels
|
| 1007 |
+
},
|
| 1008 |
+
});
|
| 1009 |
+
this.audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
| 1010 |
+
// Resolve against this module, not the document: a bare './' would break
|
| 1011 |
+
// for any page not sitting in the same directory as these scripts.
|
| 1012 |
+
await this.audioContext.audioWorklet.addModule(new URL('./ctc-capture-worklet.js', document.baseURI));
|
| 1013 |
+
const source = this.audioContext.createMediaStreamSource(this.mediaStream);
|
| 1014 |
+
const node = new AudioWorkletNode(this.audioContext, 'ctc-capture');
|
| 1015 |
+
node.port.onmessage = (e) => this.onSamples(e.data);
|
| 1016 |
+
source.connect(node);
|
| 1017 |
+
// Keep the graph pulling without routing mic audio to the speakers.
|
| 1018 |
+
const sink = this.audioContext.createGain();
|
| 1019 |
+
sink.gain.value = 0;
|
| 1020 |
+
node.connect(sink).connect(this.audioContext.destination);
|
| 1021 |
+
|
| 1022 |
+
this.resetWindow();
|
| 1023 |
+
this.gate.recalibrate();
|
| 1024 |
+
this.paragraphs = [];
|
| 1025 |
+
this.lastVoiceAt = 0;
|
| 1026 |
+
this.segmentGapMs = Infinity;
|
| 1027 |
+
this.active = true;
|
| 1028 |
+
this.publish([], []);
|
| 1029 |
+
this.stepTimer = setInterval(() => this.tick(), this.tuning.stepMs);
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
async stop() {
|
| 1033 |
+
if (!this.active) return;
|
| 1034 |
+
this.active = false;
|
| 1035 |
+
clearInterval(this.stepTimer);
|
| 1036 |
+
this.stepTimer = null;
|
| 1037 |
+
this.mediaStream?.getTracks().forEach((t) => t.stop());
|
| 1038 |
+
await this.audioContext?.close();
|
| 1039 |
+
this.audioContext = null;
|
| 1040 |
+
this.onLevel?.(0);
|
| 1041 |
+
// Let any commit the gate started finish before deciding whether the
|
| 1042 |
+
// window still holds uncommitted speech.
|
| 1043 |
+
await this.idle();
|
| 1044 |
+
if (this.windowHasSpeech) await this.commit('stop');
|
| 1045 |
+
this.resetWindow();
|
| 1046 |
+
this.publish([], []);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
// ---------------------------------------------------------------- capture
|
| 1050 |
+
|
| 1051 |
+
onSamples(chunk) {
|
| 1052 |
+
this.window.push(chunk);
|
| 1053 |
+
this.windowLen += chunk.length;
|
| 1054 |
+
|
| 1055 |
+
// Before speech is detected, keep only a short pre-roll. The window is
|
| 1056 |
+
// otherwise cleared only on commit, so a long ambient stretch would
|
| 1057 |
+
// accumulate unbounded and the first real utterance would commit an
|
| 1058 |
+
// enormous mostly-noise window.
|
| 1059 |
+
if (!this.windowHasSpeech) {
|
| 1060 |
+
const maxPreroll = Math.floor(SAMPLE_RATE * this.tuning.prerollSec);
|
| 1061 |
+
while (this.windowLen > maxPreroll && this.window.length > 1) {
|
| 1062 |
+
this.windowLen -= this.window.shift().length;
|
| 1063 |
+
}
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
const now = performance.now();
|
| 1067 |
+
this.onLevel?.(meterLevel(rmsInt16(chunk)));
|
| 1068 |
+
this.gate.feed(chunk); // may fire speechEnded -> commit
|
| 1069 |
+
|
| 1070 |
+
// Drive the window's speech state from the gate's debounced,
|
| 1071 |
+
// calibration-aware decision rather than a parallel per-chunk RMS test:
|
| 1072 |
+
// such a test has no debounce and runs during calibration, so one noise
|
| 1073 |
+
// chunk latches the window open and the model transcribes ambient.
|
| 1074 |
+
if (this.gate.isSpeaking) {
|
| 1075 |
+
if (!this.windowHasSpeech) {
|
| 1076 |
+
this.segmentGapMs = this.lastVoiceAt ? now - this.lastVoiceAt : Infinity;
|
| 1077 |
+
this.windowHasSpeech = true;
|
| 1078 |
+
}
|
| 1079 |
+
this.lastVoiceAt = now;
|
| 1080 |
+
}
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
handleGate(event) {
|
| 1084 |
+
if (event !== 'speechEnded') return; // capture is continuous; onset needs nothing
|
| 1085 |
+
// Called synchronously from onSamples, so the commit can only be tracked,
|
| 1086 |
+
// not awaited (see idle()).
|
| 1087 |
+
if (this.windowHasSpeech) this.track(this.commit('pause'));
|
| 1088 |
+
else this.resetWindow(); // pure-silence window: drop it
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
// -------------------------------------------------------------- step loop
|
| 1092 |
+
|
| 1093 |
+
async tick() {
|
| 1094 |
+
if (!this.active || !this.windowHasSpeech || this.transcribing) return;
|
| 1095 |
+
if (this.windowLen / SAMPLE_RATE >= this.tuning.maxWindowSec) {
|
| 1096 |
+
await this.commit('maxWindow');
|
| 1097 |
+
return;
|
| 1098 |
+
}
|
| 1099 |
+
const speech = this.trimmedSpeech();
|
| 1100 |
+
if (speech.length / SAMPLE_RATE < this.tuning.minWindowSec) return;
|
| 1101 |
+
|
| 1102 |
+
const gen = this.windowGen;
|
| 1103 |
+
this.transcribing = true;
|
| 1104 |
+
try {
|
| 1105 |
+
const result = await this.withEngine(() => this.engine.transcribe(speech));
|
| 1106 |
+
if (gen !== this.windowGen) return; // window committed meanwhile; discard
|
| 1107 |
+
const { confirmed, tentative } = this.reconciler.update(result.words);
|
| 1108 |
+
this.publish(confirmed, tentative);
|
| 1109 |
+
this.onStep?.({ kind: 'step', windowSec: speech.length / SAMPLE_RATE, ...result });
|
| 1110 |
+
} catch (e) {
|
| 1111 |
+
console.error('step transcription failed:', e);
|
| 1112 |
+
} finally {
|
| 1113 |
+
this.transcribing = false;
|
| 1114 |
+
}
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
/**
|
| 1118 |
+
* Commit the current window: snapshot it, immediately clear and re-anchor (so
|
| 1119 |
+
* capture continues uninterrupted and any in-flight partial is discarded),
|
| 1120 |
+
* then transcribe the snapshot once cleanly and append it.
|
| 1121 |
+
*/
|
| 1122 |
+
async commit(reason) {
|
| 1123 |
+
const speech = this.trimmedSpeech();
|
| 1124 |
+
const windowSec = speech.length / SAMPLE_RATE;
|
| 1125 |
+
if (windowSec < this.tuning.minWindowSec) {
|
| 1126 |
+
this.resetWindow();
|
| 1127 |
+
return;
|
| 1128 |
+
}
|
| 1129 |
+
const gapMs = this.segmentGapMs;
|
| 1130 |
+
this.resetWindow();
|
| 1131 |
+
this.publish([], []);
|
| 1132 |
+
|
| 1133 |
+
let result;
|
| 1134 |
+
try {
|
| 1135 |
+
result = await this.withEngine(() => this.engine.transcribe(speech));
|
| 1136 |
+
} catch (e) {
|
| 1137 |
+
console.error('commit transcription failed:', e);
|
| 1138 |
+
return;
|
| 1139 |
+
}
|
| 1140 |
+
const words = result.words;
|
| 1141 |
+
const wps = windowSec > 0 ? words.length / windowSec : 0;
|
| 1142 |
+
|
| 1143 |
+
// Confidence gate: a low mean logprob means the model wasn't sure of its
|
| 1144 |
+
// own tokens -- the signature of confabulation on noise/silence. A null
|
| 1145 |
+
// confidence fails open.
|
| 1146 |
+
if (result.avgLogprob !== null && result.avgLogprob < this.tuning.minCommitAvgLogprob) {
|
| 1147 |
+
this.onStep?.({ kind: 'reject', why: 'confidence', reason, windowSec, wps, ...result });
|
| 1148 |
+
this.publish([], []);
|
| 1149 |
+
return;
|
| 1150 |
+
}
|
| 1151 |
+
const normalized = words.join(' ').toLowerCase();
|
| 1152 |
+
if (CANNED_HALLUCINATIONS.has(normalized) && wps < this.tuning.cannedHallucinationMaxWps) {
|
| 1153 |
+
this.onStep?.({ kind: 'reject', why: 'canned', reason, windowSec, wps, ...result });
|
| 1154 |
+
this.publish([], []);
|
| 1155 |
+
return;
|
| 1156 |
+
}
|
| 1157 |
+
if (!words.length) {
|
| 1158 |
+
this.publish([], []);
|
| 1159 |
+
return;
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
await this.appendSegment(words.join(' '), gapMs, reason);
|
| 1163 |
+
this.onStep?.({ kind: 'commit', reason, windowSec, wps, gapMs, ...result });
|
| 1164 |
+
this.publish([], []);
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
/**
|
| 1168 |
+
* Append a committed segment, opening a new paragraph after a real pause.
|
| 1169 |
+
*
|
| 1170 |
+
* Unlike the macOS app, sentence punctuation and capitalization are NOT
|
| 1171 |
+
* synthesized here -- the model emits raw lowercase text and the punctuator
|
| 1172 |
+
* restores both. A maxWindow cut is mid-utterance, so it extends the current
|
| 1173 |
+
* paragraph and is re-punctuated together with what follows.
|
| 1174 |
+
*/
|
| 1175 |
+
async appendSegment(text, gapMs, reason) {
|
| 1176 |
+
const startsParagraph = this.paragraphs.length === 0
|
| 1177 |
+
|| gapMs >= this.tuning.hardPauseMs
|
| 1178 |
+
|| (gapMs >= this.tuning.newlineGapMs && reason !== 'maxWindow');
|
| 1179 |
+
|
| 1180 |
+
if (startsParagraph) this.paragraphs.push(text);
|
| 1181 |
+
else this.paragraphs[this.paragraphs.length - 1] += ` ${text}`;
|
| 1182 |
+
|
| 1183 |
+
if (this.punctuate) {
|
| 1184 |
+
const i = this.paragraphs.length - 1;
|
| 1185 |
+
const raw = this.paragraphs[i];
|
| 1186 |
+
try {
|
| 1187 |
+
this.paragraphs[i] = await this.punctuate(raw);
|
| 1188 |
+
} catch (e) {
|
| 1189 |
+
console.warn('punctuation failed; keeping raw text:', e);
|
| 1190 |
+
}
|
| 1191 |
+
}
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
// ---------------------------------------------------------------- helpers
|
| 1195 |
+
|
| 1196 |
+
/**
|
| 1197 |
+
* The speech-bearing span of the window, with leading/trailing silence
|
| 1198 |
+
* removed. Trailing trim matters: the front-end normalizes against the clip's
|
| 1199 |
+
* global peak, so an untrimmed quiet tail gets amplified and decoded into
|
| 1200 |
+
* spurious words.
|
| 1201 |
+
*/
|
| 1202 |
+
trimmedSpeech() {
|
| 1203 |
+
const audio = this.flattenWindow();
|
| 1204 |
+
const frame = 480; // 30 ms
|
| 1205 |
+
const floor = this.gate.exitThreshold;
|
| 1206 |
+
let first = -1, last = -1;
|
| 1207 |
+
for (let i = 0; i < audio.length; i += frame) {
|
| 1208 |
+
const end = Math.min(i + frame, audio.length);
|
| 1209 |
+
if (rmsInt16(audio.subarray(i, end)) >= floor) {
|
| 1210 |
+
if (first < 0) first = i;
|
| 1211 |
+
last = end;
|
| 1212 |
+
}
|
| 1213 |
+
}
|
| 1214 |
+
if (first < 0) return new Float32Array(0);
|
| 1215 |
+
const margin = frame * 3; // ~90 ms of context each side
|
| 1216 |
+
return audio.subarray(Math.max(0, first - margin), Math.min(audio.length, last + margin));
|
| 1217 |
+
}
|
| 1218 |
+
|
| 1219 |
+
flattenWindow() {
|
| 1220 |
+
if (this.window.length === 1) return this.window[0];
|
| 1221 |
+
const out = new Float32Array(this.windowLen);
|
| 1222 |
+
let at = 0;
|
| 1223 |
+
for (const c of this.window) { out.set(c, at); at += c.length; }
|
| 1224 |
+
this.window = [out]; // collapse so repeated ticks don't re-copy
|
| 1225 |
+
return out;
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
resetWindow() {
|
| 1229 |
+
this.window = [];
|
| 1230 |
+
this.windowLen = 0;
|
| 1231 |
+
this.windowHasSpeech = false;
|
| 1232 |
+
this.reconciler.reset();
|
| 1233 |
+
this.gate.reset();
|
| 1234 |
+
this.windowGen++;
|
| 1235 |
+
}
|
| 1236 |
+
|
| 1237 |
+
publish(confirmed, tentative) {
|
| 1238 |
+
const live = confirmed.join(' ');
|
| 1239 |
+
const committed = live
|
| 1240 |
+
? [...this.paragraphs, live].join('\n\n')
|
| 1241 |
+
: this.paragraphs.join('\n\n');
|
| 1242 |
+
this.onTranscript({ committed, tentative: tentative.join(' ') });
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
/** Plain text of everything committed so far. */
|
| 1246 |
+
get transcript() {
|
| 1247 |
+
return this.paragraphs.join('\n\n');
|
| 1248 |
+
}
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
/** Int16 RMS -> 0..1 meter level on a dBFS scale (-60 dBFS -> 0, 0 dBFS -> 1). */
|
| 1252 |
+
function meterLevel(rms) {
|
| 1253 |
+
if (rms <= 0) return 0;
|
| 1254 |
+
const dbfs = 20 * Math.log10(rms / 32767);
|
| 1255 |
+
return Math.max(0, Math.min(1, (dbfs + 60) / 60));
|
| 1256 |
+
}
|
| 1257 |
+
|
| 1258 |
+
Object.assign(__M, { SpeechGate, Reconciler, StreamingCoordinator });
|
| 1259 |
+
})();
|
| 1260 |
+
|
| 1261 |
+
// ==== app.js ============================================================
|
| 1262 |
+
(() => {
|
| 1263 |
+
const { CtcEngine, SpeechGate, StreamingCoordinator } = __M;
|
| 1264 |
+
/**
|
| 1265 |
+
* Granite Live Dictation (WebGPU)
|
| 1266 |
+
*
|
| 1267 |
+
* Real-time in-browser dictation with ibm-granite/granite-speech-4.2-470m-turboctc,
|
| 1268 |
+
* a 473M-parameter FastConformer CTC model. Unlike the previous
|
| 1269 |
+
* granite-speech-4.1-2b build, this is not a transformers.js pipeline:
|
| 1270 |
+
* `ctc_conformer` is a custom architecture, so the ONNX session is driven directly
|
| 1271 |
+
* (see ctc-engine.js) -- one forward pass and a greedy collapse per window, no
|
| 1272 |
+
* autoregressive decode. That is what makes it fast enough to re-transcribe a
|
| 1273 |
+
* growing window several times a second.
|
| 1274 |
+
*
|
| 1275 |
+
* The model emits raw lowercase, unpunctuated English. Punctuation and
|
| 1276 |
+
* capitalization are restored by the separate punctuator model (punctuator.js),
|
| 1277 |
+
* which is loaded on demand because it is a 209 MB download of its own.
|
| 1278 |
+
*
|
| 1279 |
+
* `ort` is the global from the onnxruntime-web script tag in index.html, shared
|
| 1280 |
+
* with punctuator.js so both sessions use one runtime.
|
| 1281 |
+
*/
|
| 1282 |
+
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
const MODEL_DIR = './onnx-ctc/';
|
| 1286 |
+
// 4-bit weights, block 32, on an fp16 graph (312 MB from a 1.9 GB fp32 export).
|
| 1287 |
+
// This is the only shape WebGPU can actually run: ORT-web's WebGPU EP enforces
|
| 1288 |
+
// `nbits == 4 || nbits == 2` on MatMulNBits, so an 8-bit build silently falls
|
| 1289 |
+
// back to wasm (1320 ms per window against 305 ms) and buys nothing -- on 8
|
| 1290 |
+
// minutes of LibriSpeech the 4-bit weights cost 0.09% WER against the fp32
|
| 1291 |
+
// export, and what residue remains in the browser comes from fp16 activations,
|
| 1292 |
+
// which more weight bits would not fix.
|
| 1293 |
+
const MODEL_FILE = 'ctc_conformer_q4f16.onnx';
|
| 1294 |
+
const SAMPLE_RATE = 16000;
|
| 1295 |
+
// One padded forward pass covers 10.24 s of frames, so file segments are capped
|
| 1296 |
+
// near that: longer segments cost proportionally more and buy nothing.
|
| 1297 |
+
const MAX_SEGMENT_SEC = 20;
|
| 1298 |
+
|
| 1299 |
+
let engine = null;
|
| 1300 |
+
let coordinator = null;
|
| 1301 |
+
let punctuatorReady = false;
|
| 1302 |
+
let punctuatorLoad = null;
|
| 1303 |
+
let isLoading = false;
|
| 1304 |
+
let currentAudioData = null;
|
| 1305 |
+
|
| 1306 |
+
const statusDot = document.getElementById('statusDot');
|
| 1307 |
+
const statusText = document.getElementById('statusText');
|
| 1308 |
+
const dictateBtn = document.getElementById('dictateBtn');
|
| 1309 |
+
const levelFill = document.getElementById('levelFill');
|
| 1310 |
+
const audioFile = document.getElementById('audioFile');
|
| 1311 |
+
const fileTile = document.querySelector('.file-label');
|
| 1312 |
+
const inputCard = document.querySelector('.input-card');
|
| 1313 |
+
const punctCheckbox = document.getElementById('punctCheckbox');
|
| 1314 |
+
const transcriptCard = document.getElementById('transcriptCard');
|
| 1315 |
+
const committedEl = document.getElementById('committedText');
|
| 1316 |
+
const tentativeEl = document.getElementById('tentativeText');
|
| 1317 |
+
const copyBtn = document.getElementById('copyBtn');
|
| 1318 |
+
const downloadBtn = document.getElementById('downloadBtn');
|
| 1319 |
+
const clearBtn = document.getElementById('clearBtn');
|
| 1320 |
+
const progressFill = document.getElementById('progressFill');
|
| 1321 |
+
const progressSection = document.getElementById('progressSection');
|
| 1322 |
+
const progressText = document.getElementById('progressText');
|
| 1323 |
+
const gpuInfo = document.getElementById('gpuInfo');
|
| 1324 |
+
const statsEl = document.getElementById('stats');
|
| 1325 |
+
|
| 1326 |
+
function setStatus(state, message) {
|
| 1327 |
+
statusDot.className = `status-dot ${state}`;
|
| 1328 |
+
statusText.textContent = message;
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
function showProgress(show, text = '') {
|
| 1332 |
+
progressSection.style.display = show ? 'block' : 'none';
|
| 1333 |
+
if (text) progressText.textContent = text;
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
// ---------------------------------------------------------------- model load
|
| 1337 |
+
|
| 1338 |
+
async function initEngine() {
|
| 1339 |
+
if (engine || isLoading) return engine;
|
| 1340 |
+
isLoading = true;
|
| 1341 |
+
try {
|
| 1342 |
+
// The runtime is a blocking classic script in index.html, so reaching here
|
| 1343 |
+
// without it means it was blocked (Edge Tracking Prevention, an extension,
|
| 1344 |
+
// a CSP). Say that plainly instead of throwing a bare ReferenceError.
|
| 1345 |
+
if (typeof ort === 'undefined') {
|
| 1346 |
+
throw new Error('the ONNX runtime failed to load (ort/ort.all.min.js) — '
|
| 1347 |
+
+ 'check for a blocked request in the network tab');
|
| 1348 |
+
}
|
| 1349 |
+
if (!navigator.gpu) {
|
| 1350 |
+
gpuInfo.textContent = 'WebGPU not available — running on WASM (slower)';
|
| 1351 |
+
}
|
| 1352 |
+
// Absolute, not './ort/': ORT resolves a relative wasmPaths against the
|
| 1353 |
+
// location of ort.all.min.js (already in ort/), which would look for
|
| 1354 |
+
// ort/ort/ort-wasm-simd-threaded.jsep.mjs and fail to find a backend.
|
| 1355 |
+
ort.env.wasm.wasmPaths = new URL('./ort/', document.baseURI).href;
|
| 1356 |
+
// Threaded wasm needs cross-origin isolation (COOP *and* COEP). Hugging
|
| 1357 |
+
// Face Spaces send COOP but not COEP, so asking for threads there only
|
| 1358 |
+
// produces a console warning and a wasted init before ORT falls back.
|
| 1359 |
+
ort.env.wasm.numThreads = self.crossOriginIsolated ? (navigator.hardwareConcurrency || 4) : 1;
|
| 1360 |
+
|
| 1361 |
+
setStatus('loading', 'Downloading model...');
|
| 1362 |
+
showProgress(true, 'Downloading model...');
|
| 1363 |
+
const progress = {};
|
| 1364 |
+
engine = await CtcEngine.load({
|
| 1365 |
+
baseUrl: MODEL_DIR,
|
| 1366 |
+
modelFile: MODEL_FILE,
|
| 1367 |
+
device: navigator.gpu ? 'webgpu' : 'wasm',
|
| 1368 |
+
onProgress: ({ url, loaded, total, cached }) => {
|
| 1369 |
+
progress[url] = { loaded, total, cached };
|
| 1370 |
+
let l = 0, t = 0, allCached = true;
|
| 1371 |
+
for (const p of Object.values(progress)) {
|
| 1372 |
+
l += p.loaded;
|
| 1373 |
+
t += p.total;
|
| 1374 |
+
if (!p.cached) allCached = false;
|
| 1375 |
+
}
|
| 1376 |
+
if (allCached) {
|
| 1377 |
+
showProgress(true, 'Loading model from cache...');
|
| 1378 |
+
return;
|
| 1379 |
+
}
|
| 1380 |
+
const pct = t > 0 ? (l / t) * 100 : 0;
|
| 1381 |
+
progressFill.style.width = `${pct}%`;
|
| 1382 |
+
showProgress(true, `Downloading model... ${(l / 1e6).toFixed(0)} / ${(t / 1e6).toFixed(0)} MB`);
|
| 1383 |
+
},
|
| 1384 |
+
});
|
| 1385 |
+
progressFill.style.width = '0%';
|
| 1386 |
+
showProgress(false);
|
| 1387 |
+
// A wasm fallback is ~4x slower per window (1325 ms vs 305 ms for a 10 s
|
| 1388 |
+
// window), which is the difference between live and not, so say so
|
| 1389 |
+
// rather than letting it look like the model is just slow.
|
| 1390 |
+
gpuInfo.textContent = engine.device === 'webgpu'
|
| 1391 |
+
? 'Backend: WebGPU'
|
| 1392 |
+
: 'Backend: WASM — WebGPU unavailable, so dictation will lag well behind speech';
|
| 1393 |
+
setStatus('ready', 'Ready — start dictation or upload audio');
|
| 1394 |
+
dictateBtn.disabled = false;
|
| 1395 |
+
audioFile.disabled = false;
|
| 1396 |
+
if (punctCheckbox.checked) startPunctuatorLoad();
|
| 1397 |
+
return engine;
|
| 1398 |
+
} catch (e) {
|
| 1399 |
+
console.error('Model loading failed:', e);
|
| 1400 |
+
setStatus('error', `Error: ${e.message || e}`);
|
| 1401 |
+
showProgress(false);
|
| 1402 |
+
throw e;
|
| 1403 |
+
} finally {
|
| 1404 |
+
isLoading = false;
|
| 1405 |
+
}
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
/**
|
| 1409 |
+
* Load the punctuator (209 MB of its own).
|
| 1410 |
+
*
|
| 1411 |
+
* Kicked off in the background as soon as the CTC engine is ready, so it is
|
| 1412 |
+
* usually done before the user presses record -- loading it on the click would
|
| 1413 |
+
* put a ~30 s download between "start dictation" and hearing anything. Calls
|
| 1414 |
+
* share one promise, and only a caller that actually has to wait shows progress.
|
| 1415 |
+
*/
|
| 1416 |
+
function startPunctuatorLoad() {
|
| 1417 |
+
if (!punctuatorLoad && typeof window.loadPunctuator === 'function') {
|
| 1418 |
+
punctuatorLoad = window.loadPunctuator()
|
| 1419 |
+
.then(() => { punctuatorReady = true; })
|
| 1420 |
+
.catch((e) => {
|
| 1421 |
+
console.warn('punctuator failed to load; transcript stays lowercase:', e);
|
| 1422 |
+
punctuatorReady = false;
|
| 1423 |
+
});
|
| 1424 |
+
}
|
| 1425 |
+
return punctuatorLoad;
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
async function ensurePunctuator() {
|
| 1429 |
+
if (punctuatorReady || !punctCheckbox.checked) return punctuatorReady;
|
| 1430 |
+
const load = startPunctuatorLoad();
|
| 1431 |
+
if (!load) return false;
|
| 1432 |
+
const stillLoading = !punctuatorReady;
|
| 1433 |
+
if (stillLoading) showProgress(true, 'Loading punctuation model...');
|
| 1434 |
+
await load;
|
| 1435 |
+
if (stillLoading) showProgress(false);
|
| 1436 |
+
return punctuatorReady;
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
/** Punctuate committed text, or pass it through if the model is off/unavailable. */
|
| 1440 |
+
async function punctuate(text) {
|
| 1441 |
+
if (!punctCheckbox.checked || !punctuatorReady) return text;
|
| 1442 |
+
return window.applyPunctuation(text);
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
// ------------------------------------------------------------ live dictation
|
| 1446 |
+
|
| 1447 |
+
function renderTranscript({ committed, tentative }) {
|
| 1448 |
+
transcriptCard.style.display = 'block';
|
| 1449 |
+
committedEl.textContent = committed;
|
| 1450 |
+
tentativeEl.textContent = tentative ? (committed ? ` ${tentative}` : tentative) : '';
|
| 1451 |
+
const out = committedEl.parentElement;
|
| 1452 |
+
out.scrollTop = out.scrollHeight;
|
| 1453 |
+
const hasText = Boolean(committed || tentative);
|
| 1454 |
+
copyBtn.disabled = !hasText;
|
| 1455 |
+
downloadBtn.disabled = !hasText;
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
async function toggleDictation() {
|
| 1459 |
+
if (coordinator) {
|
| 1460 |
+
dictateBtn.disabled = true;
|
| 1461 |
+
setStatus('processing', 'Finishing…');
|
| 1462 |
+
try {
|
| 1463 |
+
await coordinator.stop();
|
| 1464 |
+
} finally {
|
| 1465 |
+
coordinator = null;
|
| 1466 |
+
dictateBtn.classList.remove('recording');
|
| 1467 |
+
dictateBtn.querySelector('span').textContent = 'Start dictation';
|
| 1468 |
+
dictateBtn.disabled = false;
|
| 1469 |
+
levelFill.style.width = '0%';
|
| 1470 |
+
setStatus('ready', 'Ready — start dictation or upload audio');
|
| 1471 |
+
}
|
| 1472 |
+
return;
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
await initEngine();
|
| 1476 |
+
await ensurePunctuator();
|
| 1477 |
+
|
| 1478 |
+
coordinator = new StreamingCoordinator({
|
| 1479 |
+
engine,
|
| 1480 |
+
onTranscript: renderTranscript,
|
| 1481 |
+
onLevel: (level) => { levelFill.style.width = `${(level * 100).toFixed(0)}%`; },
|
| 1482 |
+
onStep: (info) => {
|
| 1483 |
+
if (info.kind === 'step') {
|
| 1484 |
+
statsEl.textContent = `${info.windowSec.toFixed(1)}s window · ${info.ms.toFixed(0)} ms · conf ${info.avgLogprob?.toFixed(2) ?? 'n/a'}`;
|
| 1485 |
+
} else if (info.kind === 'reject') {
|
| 1486 |
+
console.log(`[reject:${info.why}] "${info.text}" conf=${info.avgLogprob?.toFixed(2)} wps=${info.wps.toFixed(2)}`);
|
| 1487 |
+
} else {
|
| 1488 |
+
console.log(`[commit:${info.reason}] ${info.windowSec.toFixed(1)}s ${info.ms.toFixed(0)}ms conf=${info.avgLogprob?.toFixed(2)} "${info.text}"`);
|
| 1489 |
+
}
|
| 1490 |
+
},
|
| 1491 |
+
punctuate,
|
| 1492 |
+
});
|
| 1493 |
+
|
| 1494 |
+
try {
|
| 1495 |
+
await coordinator.start();
|
| 1496 |
+
dictateBtn.classList.add('recording');
|
| 1497 |
+
dictateBtn.querySelector('span').textContent = 'Stop dictation';
|
| 1498 |
+
setStatus('recording', 'Listening — speak naturally, pause between sentences');
|
| 1499 |
+
} catch (e) {
|
| 1500 |
+
console.error('dictation failed to start:', e);
|
| 1501 |
+
coordinator = null;
|
| 1502 |
+
setStatus('error', e.name === 'NotAllowedError' ? 'Microphone access denied' : `Error: ${e.message || e}`);
|
| 1503 |
+
}
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
// --------------------------------------------------------- file transcription
|
| 1507 |
+
|
| 1508 |
+
/**
|
| 1509 |
+
* Split a file into speech segments with the same energy gate the live path uses.
|
| 1510 |
+
*
|
| 1511 |
+
* This replaces the old Silero VAD download: the gate is already here, it is what
|
| 1512 |
+
* the streaming path is tuned against, and long audio has to be split anyway --
|
| 1513 |
+
* a single pass over a 5-minute file would allocate a ~250 MB logits tensor
|
| 1514 |
+
* inside the graph.
|
| 1515 |
+
*/
|
| 1516 |
+
function segmentAudio(audio) {
|
| 1517 |
+
const gate = new SpeechGate();
|
| 1518 |
+
const frame = 480;
|
| 1519 |
+
const segments = [];
|
| 1520 |
+
let start = null;
|
| 1521 |
+
let consumed = 0;
|
| 1522 |
+
|
| 1523 |
+
gate.onEvent = (event) => {
|
| 1524 |
+
const t = consumed / SAMPLE_RATE;
|
| 1525 |
+
if (event === 'speechStarted') start = t;
|
| 1526 |
+
else if (start !== null) {
|
| 1527 |
+
segments.push({ start, end: t });
|
| 1528 |
+
start = null;
|
| 1529 |
+
}
|
| 1530 |
+
};
|
| 1531 |
+
for (let i = 0; i < audio.length; i += frame) {
|
| 1532 |
+
const chunk = audio.subarray(i, Math.min(i + frame, audio.length));
|
| 1533 |
+
consumed = i + chunk.length;
|
| 1534 |
+
gate.feed(chunk);
|
| 1535 |
+
}
|
| 1536 |
+
if (start !== null) segments.push({ start, end: audio.length / SAMPLE_RATE });
|
| 1537 |
+
if (!segments.length) return [{ start: 0, end: audio.length / SAMPLE_RATE }];
|
| 1538 |
+
|
| 1539 |
+
// Pad each segment slightly (the gate's onset debounce trims the first
|
| 1540 |
+
// phoneme) and cap the length.
|
| 1541 |
+
const out = [];
|
| 1542 |
+
for (const seg of segments) {
|
| 1543 |
+
let from = Math.max(0, seg.start - 0.2);
|
| 1544 |
+
const to = Math.min(audio.length / SAMPLE_RATE, seg.end + 0.2);
|
| 1545 |
+
while (to - from > MAX_SEGMENT_SEC) {
|
| 1546 |
+
out.push({ start: from, end: from + MAX_SEGMENT_SEC });
|
| 1547 |
+
from += MAX_SEGMENT_SEC;
|
| 1548 |
+
}
|
| 1549 |
+
out.push({ start: from, end: to });
|
| 1550 |
+
}
|
| 1551 |
+
return out;
|
| 1552 |
+
}
|
| 1553 |
+
|
| 1554 |
+
async function transcribeFile() {
|
| 1555 |
+
if (!currentAudioData) return;
|
| 1556 |
+
await initEngine();
|
| 1557 |
+
await ensurePunctuator();
|
| 1558 |
+
|
| 1559 |
+
setStatus('processing', 'Transcribing…');
|
| 1560 |
+
transcriptCard.style.display = 'block';
|
| 1561 |
+
const segments = segmentAudio(currentAudioData);
|
| 1562 |
+
const parts = [];
|
| 1563 |
+
let totalMs = 0;
|
| 1564 |
+
|
| 1565 |
+
for (let i = 0; i < segments.length; i++) {
|
| 1566 |
+
const seg = segments[i];
|
| 1567 |
+
showProgress(true, `Segment ${i + 1} / ${segments.length}`);
|
| 1568 |
+
progressFill.style.width = `${((i + 1) / segments.length) * 100}%`;
|
| 1569 |
+
const slice = currentAudioData.subarray(
|
| 1570 |
+
Math.floor(seg.start * SAMPLE_RATE),
|
| 1571 |
+
Math.floor(seg.end * SAMPLE_RATE),
|
| 1572 |
+
);
|
| 1573 |
+
const result = await engine.transcribe(slice);
|
| 1574 |
+
totalMs += result.ms;
|
| 1575 |
+
if (result.text) {
|
| 1576 |
+
parts.push(await punctuate(result.text));
|
| 1577 |
+
renderTranscript({ committed: parts.join('\n\n'), tentative: '' });
|
| 1578 |
+
}
|
| 1579 |
+
}
|
| 1580 |
+
|
| 1581 |
+
showProgress(false);
|
| 1582 |
+
progressFill.style.width = '0%';
|
| 1583 |
+
const audioSec = currentAudioData.length / SAMPLE_RATE;
|
| 1584 |
+
statsEl.textContent = `${audioSec.toFixed(1)}s audio · ${segments.length} segment(s) · ${totalMs.toFixed(0)} ms · ${(audioSec / (totalMs / 1000)).toFixed(0)}x realtime`;
|
| 1585 |
+
if (!parts.length) renderTranscript({ committed: '(no speech detected)', tentative: '' });
|
| 1586 |
+
setStatus('ready', 'Transcription complete');
|
| 1587 |
+
}
|
| 1588 |
+
|
| 1589 |
+
/** Decode any audio file to 16 kHz mono float32. */
|
| 1590 |
+
async function loadAudioFile(file) {
|
| 1591 |
+
setStatus('processing', 'Decoding audio…');
|
| 1592 |
+
try {
|
| 1593 |
+
const audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
| 1594 |
+
const buffer = await audioCtx.decodeAudioData(await file.arrayBuffer());
|
| 1595 |
+
let audio = buffer.getChannelData(0);
|
| 1596 |
+
if (buffer.numberOfChannels > 1) {
|
| 1597 |
+
const mixed = new Float32Array(audio.length);
|
| 1598 |
+
for (let c = 0; c < buffer.numberOfChannels; c++) {
|
| 1599 |
+
const ch = buffer.getChannelData(c);
|
| 1600 |
+
for (let i = 0; i < mixed.length; i++) mixed[i] += ch[i] / buffer.numberOfChannels;
|
| 1601 |
+
}
|
| 1602 |
+
audio = mixed;
|
| 1603 |
+
}
|
| 1604 |
+
// decodeAudioData already resampled to the context rate.
|
| 1605 |
+
await audioCtx.close();
|
| 1606 |
+
currentAudioData = audio;
|
| 1607 |
+
await transcribeFile();
|
| 1608 |
+
} catch (e) {
|
| 1609 |
+
console.error('audio decode failed:', e);
|
| 1610 |
+
setStatus('error', `Could not read that file: ${e.message || e}`);
|
| 1611 |
+
}
|
| 1612 |
+
}
|
| 1613 |
+
|
| 1614 |
+
// -------------------------------------------------------------- transcript IO
|
| 1615 |
+
|
| 1616 |
+
function transcriptText() {
|
| 1617 |
+
return committedEl.textContent + (tentativeEl.textContent || '');
|
| 1618 |
+
}
|
| 1619 |
+
|
| 1620 |
+
async function copyTranscript() {
|
| 1621 |
+
await navigator.clipboard.writeText(transcriptText());
|
| 1622 |
+
const original = copyBtn.title;
|
| 1623 |
+
copyBtn.title = 'Copied';
|
| 1624 |
+
setTimeout(() => { copyBtn.title = original; }, 1200);
|
| 1625 |
+
}
|
| 1626 |
+
|
| 1627 |
+
function downloadTranscript() {
|
| 1628 |
+
const blob = new Blob([transcriptText()], { type: 'text/plain' });
|
| 1629 |
+
const a = document.createElement('a');
|
| 1630 |
+
a.href = URL.createObjectURL(blob);
|
| 1631 |
+
a.download = `transcript-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`;
|
| 1632 |
+
a.click();
|
| 1633 |
+
URL.revokeObjectURL(a.href);
|
| 1634 |
+
}
|
| 1635 |
+
|
| 1636 |
+
function clearTranscript() {
|
| 1637 |
+
renderTranscript({ committed: '', tentative: '' });
|
| 1638 |
+
transcriptCard.style.display = 'none';
|
| 1639 |
+
statsEl.textContent = '';
|
| 1640 |
+
if (coordinator) coordinator.paragraphs = [];
|
| 1641 |
+
}
|
| 1642 |
+
|
| 1643 |
+
// ---------------------------------------------------------------------- wiring
|
| 1644 |
+
|
| 1645 |
+
dictateBtn.addEventListener('click', toggleDictation);
|
| 1646 |
+
audioFile.addEventListener('change', (e) => {
|
| 1647 |
+
const file = e.target.files[0];
|
| 1648 |
+
if (file) loadAudioFile(file);
|
| 1649 |
+
e.target.value = '';
|
| 1650 |
+
});
|
| 1651 |
+
copyBtn.addEventListener('click', copyTranscript);
|
| 1652 |
+
downloadBtn.addEventListener('click', downloadTranscript);
|
| 1653 |
+
clearBtn.addEventListener('click', clearTranscript);
|
| 1654 |
+
|
| 1655 |
+
// Drag and drop onto the input card
|
| 1656 |
+
for (const [event, handler] of [
|
| 1657 |
+
['dragover', (e) => { e.preventDefault(); inputCard.classList.add('drag-over'); }],
|
| 1658 |
+
['dragleave', () => inputCard.classList.remove('drag-over')],
|
| 1659 |
+
['drop', (e) => {
|
| 1660 |
+
e.preventDefault();
|
| 1661 |
+
inputCard.classList.remove('drag-over');
|
| 1662 |
+
const file = e.dataTransfer.files[0];
|
| 1663 |
+
if (file?.type.startsWith('audio/')) loadAudioFile(file);
|
| 1664 |
+
else setStatus('error', 'Please drop an audio file');
|
| 1665 |
+
}],
|
| 1666 |
+
]) {
|
| 1667 |
+
inputCard.addEventListener(event, handler);
|
| 1668 |
+
fileTile.addEventListener(event, (e) => e.stopPropagation(), { capture: false });
|
| 1669 |
+
}
|
| 1670 |
+
|
| 1671 |
+
punctCheckbox.addEventListener('change', () => {
|
| 1672 |
+
if (punctCheckbox.checked) ensurePunctuator();
|
| 1673 |
+
});
|
| 1674 |
+
|
| 1675 |
+
// Space bar toggles dictation, unless a control has focus.
|
| 1676 |
+
document.addEventListener('keydown', (e) => {
|
| 1677 |
+
if (e.code !== 'Space' || e.repeat) return;
|
| 1678 |
+
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(document.activeElement?.tagName)) return;
|
| 1679 |
+
e.preventDefault();
|
| 1680 |
+
toggleDictation();
|
| 1681 |
+
});
|
| 1682 |
+
|
| 1683 |
+
setStatus('loading', 'Loading…');
|
| 1684 |
+
initEngine().catch(() => {});
|
| 1685 |
+
})();
|
| 1686 |
+
|
| 1687 |
+
})();
|
index.html
CHANGED
|
@@ -283,6 +283,14 @@
|
|
| 283 |
interferes with the jsdelivr request. See ort/README.md. -->
|
| 284 |
<script src="./ort/ort.all.min.js"></script>
|
| 285 |
<script src="punctuator.js?v=2"></script>
|
| 286 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
</body>
|
| 288 |
</html>
|
|
|
|
| 283 |
interferes with the jsdelivr request. See ort/README.md. -->
|
| 284 |
<script src="./ort/ort.all.min.js"></script>
|
| 285 |
<script src="punctuator.js?v=2"></script>
|
| 286 |
+
<!-- app.bundle.js, not `type="module" src="app.js"`, and this is not a
|
| 287 |
+
packaging preference. On a private Space the module fetches issued while
|
| 288 |
+
this page is parsing are not authenticated (they 401, and the module map
|
| 289 |
+
then caches that failure for the document's lifetime), so app.js was
|
| 290 |
+
fetched but could never execute and the page sat on "Loading..." forever.
|
| 291 |
+
Classic scripts carry the cookie every time -- punctuator.js and
|
| 292 |
+
ort.all.min.js always loaded. Generated by build_bundle.py from app.js and
|
| 293 |
+
the four ctc-*.js modules; edit those, then re-run it. -->
|
| 294 |
+
<script src="app.bundle.js?v=1"></script>
|
| 295 |
</body>
|
| 296 |
</html>
|