Commit:
614e890Parent:
1c31caeAdd BirdNET TF.js on-device bird identification
- Add @tensorflow/tfjs dependency - AudioWorklet (static/audio-processor.js) captures mic in 2048-sample chunks - Web Worker (birdnet.worker.ts) loads BirdNET model via tf.loadLayersModel(), registers MelSpecLayerSimple custom layer and STFT WebGL kernel (ported from birdnet-team/real-time-pwa, MIT), feeds raw 3s PCM windows, returns top-10 predictions ranked by log-mean-exp pooled confidence - Rewrite +page.svelte: scrollable detection list with confidence bars, fixed mic button above nav bar, worker lifecycle via $effect - Add scripts/download-models.ts and deno task download-models Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.gitignore
+3
-0
diff --git a/.gitignore b/.gitignore
index 41f1bdc..62a21a9 100644
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
test-results
# BirdNET model files (large binaries, download separately)
/static/models/
deno.json
+2
-1
diff --git a/deno.json b/deno.json
index eb190d3..48c602c 100644
@@ -12,6 +12,7 @@
"check": "deno run -A npm:@sveltejs/kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json",
"lint": "deno run -A npm:eslint .",
"test:e2e": "deno run -A npm:playwright install && deno run -A npm:playwright test",
"test": "deno task test:e2e"
"test": "deno task test:e2e",
"download-models": "deno run -A scripts/download-models.ts"
}
}
deno.lock
+353
-0
diff --git a/deno.lock b/deno.lock
index 79d6b94..3c4107b 100644
@@ -7,6 +7,7 @@
"npm:@sveltejs/adapter-auto@^7.0.1": "7.0.1_@sveltejs+kit@2.61.0__@sveltejs+vite-plugin-svelte@7.1.2___svelte@5.55.9___vite@8.0.14____@types+node@24.12.4___@types+node@24.12.4__svelte@5.55.9__typescript@6.0.3__vite@8.0.14___@types+node@24.12.4__@types+node@24.12.4_@sveltejs+vite-plugin-svelte@7.1.2__svelte@5.55.9__vite@8.0.14___@types+node@24.12.4__@types+node@24.12.4_@types+node@24.12.4_svelte@5.55.9_typescript@6.0.3_vite@8.0.14__@types+node@24.12.4",
"npm:@sveltejs/kit@^2.57.0": "2.61.0_@sveltejs+vite-plugin-svelte@7.1.2__svelte@5.55.9__vite@8.0.14___@types+node@24.12.4__@types+node@24.12.4_svelte@5.55.9_typescript@6.0.3_vite@8.0.14__@types+node@24.12.4_@types+node@24.12.4",
"npm:@sveltejs/vite-plugin-svelte@7": "7.1.2_svelte@5.55.9_vite@8.0.14__@types+node@24.12.4_@types+node@24.12.4",
"npm:@tensorflow/tfjs@^4.22.0": "4.22.0_seedrandom@3.0.5",
"npm:@types/node@24": "24.12.4",
"npm:eslint-plugin-svelte@^3.17.0": "3.17.1_eslint@10.4.0_svelte@5.55.9",
"npm:eslint@^10.2.0": "10.4.0",
@@ -300,6 +301,75 @@
"vitefu"
]
},
"@tensorflow/tfjs-backend-cpu@4.22.0_@tensorflow+tfjs-core@4.22.0": {
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"dependencies": [
"@tensorflow/tfjs-core",
"@types/seedrandom",
"seedrandom"
]
},
"@tensorflow/tfjs-backend-webgl@4.22.0_@tensorflow+tfjs-core@4.22.0": {
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"dependencies": [
"@tensorflow/tfjs-backend-cpu",
"@tensorflow/tfjs-core",
"@types/offscreencanvas@2019.3.0",
"@types/seedrandom",
"seedrandom"
]
},
"@tensorflow/tfjs-converter@4.22.0_@tensorflow+tfjs-core@4.22.0": {
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==",
"dependencies": [
"@tensorflow/tfjs-core"
]
},
"@tensorflow/tfjs-core@4.22.0": {
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"dependencies": [
"@types/long",
"@types/offscreencanvas@2019.7.3",
"@types/seedrandom",
"@webgpu/types",
"long",
"node-fetch",
"seedrandom"
]
},
"@tensorflow/tfjs-data@4.22.0_@tensorflow+tfjs-core@4.22.0_seedrandom@3.0.5": {
"integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==",
"dependencies": [
"@tensorflow/tfjs-core",
"@types/node-fetch",
"node-fetch",
"seedrandom",
"string_decoder"
]
},
"@tensorflow/tfjs-layers@4.22.0_@tensorflow+tfjs-core@4.22.0": {
"integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==",
"dependencies": [
"@tensorflow/tfjs-core"
]
},
"@tensorflow/tfjs@4.22.0_seedrandom@3.0.5": {
"integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==",
"dependencies": [
"@tensorflow/tfjs-backend-cpu",
"@tensorflow/tfjs-backend-webgl",
"@tensorflow/tfjs-converter",
"@tensorflow/tfjs-core",
"@tensorflow/tfjs-data",
"@tensorflow/tfjs-layers",
"argparse",
"chalk",
"core-js",
"regenerator-runtime",
"yargs"
],
"bin": true
},
"@tybys/wasm-util@0.10.2": {
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dependencies": [
@@ -318,12 +388,31 @@
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"@types/long@4.0.2": {
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
},
"@types/node-fetch@2.6.13": {
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"dependencies": [
"@types/node",
"form-data"
]
},
"@types/node@24.12.4": {
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dependencies": [
"undici-types"
]
},
"@types/offscreencanvas@2019.3.0": {
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="
},
"@types/offscreencanvas@2019.7.3": {
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="
},
"@types/seedrandom@2.4.34": {
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="
},
"@types/trusted-types@2.0.7": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
@@ -425,6 +514,9 @@
"eslint-visitor-keys@5.0.1"
]
},
"@webgpu/types@0.1.38": {
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="
},
"acorn-jsx@5.3.2_acorn@8.16.0": {
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dependencies": [
@@ -444,9 +536,27 @@
"uri-js"
]
},
"ansi-regex@5.0.1": {
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles@4.3.0": {
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": [
"color-convert"
]
},
"argparse@1.0.10": {
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": [
"sprintf-js"
]
},
"aria-query@5.3.1": {
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="
},
"asynckit@0.4.0": {
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
},
@@ -459,18 +569,59 @@
"balanced-match"
]
},
"call-bind-apply-helpers@1.0.2": {
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": [
"es-errors",
"function-bind"
]
},
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
"ansi-styles",
"supports-color"
]
},
"chokidar@4.0.3": {
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dependencies": [
"readdirp"
]
},
"cliui@7.0.4": {
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"dependencies": [
"string-width",
"strip-ansi",
"wrap-ansi"
]
},
"clsx@2.1.1": {
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
"color-convert@2.0.1": {
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": [
"color-name"
]
},
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"combined-stream@1.0.8": {
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": [
"delayed-stream"
]
},
"cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"core-js@3.29.1": {
"integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==",
"scripts": true
},
"cross-spawn@7.0.6": {
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": [
@@ -495,12 +646,50 @@
"deepmerge@4.3.1": {
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
},
"delayed-stream@1.0.0": {
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
"devalue@5.8.1": {
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
},
"dunder-proto@1.0.1": {
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": [
"call-bind-apply-helpers",
"es-errors",
"gopd"
]
},
"emoji-regex@8.0.0": {
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"es-define-property@1.0.1": {
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
},
"es-errors@1.3.0": {
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"es-object-atoms@1.1.2": {
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dependencies": [
"es-errors"
]
},
"es-set-tostringtag@2.1.0": {
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": [
"es-errors",
"get-intrinsic",
"has-tostringtag",
"hasown"
]
},
"escalade@3.2.0": {
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
},
"escape-string-regexp@4.0.0": {
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
@@ -669,6 +858,16 @@
"flatted@3.4.2": {
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="
},
"form-data@4.0.5": {
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": [
"asynckit",
"combined-stream",
"es-set-tostringtag",
"hasown",
"mime-types"
]
},
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
@@ -679,6 +878,34 @@
"os": ["darwin"],
"scripts": true
},
"function-bind@1.1.2": {
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"get-caller-file@2.0.5": {
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"get-intrinsic@1.3.0": {
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": [
"call-bind-apply-helpers",
"es-define-property",
"es-errors",
"es-object-atoms",
"function-bind",
"get-proto",
"gopd",
"has-symbols",
"hasown",
"math-intrinsics"
]
},
"get-proto@1.0.1": {
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": [
"dunder-proto",
"es-object-atoms"
]
},
"glob-parent@6.0.2": {
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dependencies": [
@@ -691,6 +918,27 @@
"globals@17.6.0": {
"integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="
},
"gopd@1.2.0": {
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
},
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"has-symbols@1.1.0": {
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
},
"has-tostringtag@1.0.2": {
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": [
"has-symbols"
]
},
"hasown@2.0.3": {
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dependencies": [
"function-bind"
]
},
"ignore@5.3.2": {
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
},
@@ -703,6 +951,9 @@
"is-extglob@2.1.1": {
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-fullwidth-code-point@3.0.0": {
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"is-glob@4.0.3": {
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dependencies": [
@@ -832,12 +1083,27 @@
"p-locate"
]
},
"long@4.0.0": {
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"magic-string@0.30.21": {
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
},
"math-intrinsics@1.1.0": {
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
"mime-db@1.52.0": {
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types@2.1.35": {
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": [
"mime-db"
]
},
"minimatch@10.2.5": {
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dependencies": [
@@ -860,6 +1126,12 @@
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
"node-fetch@2.6.13": {
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"dependencies": [
"whatwg-url"
]
},
"obug@2.1.1": {
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="
},
@@ -959,6 +1231,12 @@
"readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
},
"regenerator-runtime@0.13.11": {
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"require-directory@2.1.1": {
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
},
"rolldown@1.0.2": {
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"dependencies": [
@@ -990,6 +1268,12 @@
"mri"
]
},
"safe-buffer@5.2.1": {
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"seedrandom@3.0.5": {
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
},
"semver@7.8.1": {
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"bin": true
@@ -1017,6 +1301,35 @@
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"sprintf-js@1.0.3": {
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"string-width@4.2.3": {
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": [
"emoji-regex",
"is-fullwidth-code-point",
"strip-ansi"
]
},
"string_decoder@1.3.0": {
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": [
"safe-buffer"
]
},
"strip-ansi@6.0.1": {
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": [
"ansi-regex"
]
},
"supports-color@7.2.0": {
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": [
"has-flag"
]
},
"svelte-check@4.4.8_svelte@5.55.9_typescript@6.0.3": {
"integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==",
"dependencies": [
@@ -1077,6 +1390,9 @@
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
"tr46@0.0.3": {
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"ts-api-utils@2.5.0_typescript@6.0.3": {
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dependencies": [
@@ -1146,6 +1462,16 @@
"vite"
]
},
"webidl-conversions@3.0.1": {
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url@5.0.0": {
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": [
"tr46",
"webidl-conversions"
]
},
"which@2.0.2": {
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dependencies": [
@@ -1156,9 +1482,35 @@
"word-wrap@1.2.5": {
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="
},
"wrap-ansi@7.0.0": {
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dependencies": [
"ansi-styles",
"string-width",
"strip-ansi"
]
},
"y18n@5.0.8": {
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
},
"yaml@1.10.3": {
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="
},
"yargs-parser@20.2.9": {
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="
},
"yargs@16.2.0": {
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"dependencies": [
"cliui",
"escalade",
"get-caller-file",
"require-directory",
"string-width",
"y18n",
"yargs-parser"
]
},
"yocto-queue@0.1.0": {
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
@@ -1175,6 +1527,7 @@
"npm:@sveltejs/adapter-auto@^7.0.1",
"npm:@sveltejs/kit@^2.57.0",
"npm:@sveltejs/vite-plugin-svelte@7",
"npm:@tensorflow/tfjs@^4.22.0",
"npm:@types/node@24",
"npm:eslint-plugin-svelte@^3.17.0",
"npm:eslint@^10.2.0",
package.json
+3
-0
diff --git a/package.json b/package.json
index fc26a4f..7c25b0d 100644
@@ -14,6 +14,9 @@
"test:e2e": "playwright install && playwright test",
"test": "npm run test:e2e"
},
"dependencies": {
"@tensorflow/tfjs": "^4.22.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.4",
"@eslint/js": "^10.0.1",
scripts/download-models.ts
+32
-0
diff --git a/scripts/download-models.ts b/scripts/download-models.ts
new file mode 100644
index 0000000..69159e8
@@ -0,0 +1,32 @@
const BASE_URL =
"https://birdnet-team.github.io/real-time-pwa/models/birdnet";
const OUT_DIR = "static/models/birdnet";
async function download(url: string, dest: string) {
console.log(`Downloading ${url}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
await Deno.mkdir(dest.split("/").slice(0, -1).join("/"), { recursive: true });
await Deno.writeFile(dest, new Uint8Array(await res.arrayBuffer()));
}
const modelJson = await fetch(`${BASE_URL}/model.json`).then((r) => r.json());
await Deno.mkdir(OUT_DIR, { recursive: true });
await Deno.writeTextFile(
`${OUT_DIR}/model.json`,
JSON.stringify(modelJson, null, 2),
);
const shards: string[] = modelJson.weightsManifest.flatMap(
(m: { paths: string[] }) => m.paths,
);
for (const shard of shards) {
await download(`${BASE_URL}/${shard}`, `${OUT_DIR}/${shard}`);
}
await download(
`${BASE_URL}/labels/en_us.txt`,
`${OUT_DIR}/labels/en_us.txt`,
);
console.log("Done. Model saved to", OUT_DIR);
src/lib/birdnet.worker.ts
+276
-0
diff --git a/src/lib/birdnet.worker.ts b/src/lib/birdnet.worker.ts
new file mode 100644
index 0000000..7fe1611
@@ -0,0 +1,276 @@
import * as tf from "@tensorflow/tfjs";
import type { Prediction, WorkerInMessage, WorkerOutMessage } from "./types";
const MODEL_PATH = "/models/birdnet/model.json";
const LABELS_PATH = "/models/birdnet/labels/en_us.txt";
const WINDOW_SAMPLES = 144000;
const ALPHA = 5.0;
interface BirdLabel {
scientificName: string;
commonName: string;
}
let model: tf.LayersModel | null = null;
let labels: BirdLabel[] = [];
// ── Custom MelSpecLayerSimple ──────────────────────────────────────────────
// Ported from https://github.com/birdnet-team/real-time-pwa (MIT)
class MelSpecLayerSimple extends tf.layers.Layer {
sampleRate: number;
specShape: number[];
frameStep: number;
frameLength: number;
melFilterbank: tf.Tensor2D;
magScale!: tf.LayerVariable;
constructor(config: Record<string, unknown>) {
super(config as tf.serialization.ConfigDict);
this.sampleRate = config.sampleRate as number;
this.specShape = config.specShape as number[];
this.frameStep = config.frameStep as number;
this.frameLength = config.frameLength as number;
this.melFilterbank = tf.tensor2d(config.melFilterbank as number[][]);
}
build(_inputShape: tf.Shape | tf.Shape[]) {
this.magScale = this.addWeight(
"magnitude_scaling",
[],
"float32",
tf.initializers.constant({ value: 1.23 }),
);
super.build(_inputShape);
}
computeOutputShape(inputShape: tf.Shape): tf.Shape {
return [inputShape[0], this.specShape[0], this.specShape[1], 1];
}
call(inputs: tf.Tensor | tf.Tensor[]): tf.Tensor {
return tf.tidy(() => {
const x = Array.isArray(inputs) ? inputs[0] : inputs;
const frameLength = this.frameLength;
const frameStep = this.frameStep;
return tf.stack(
x.split(x.shape[0]).map((input) => {
let spec = input.squeeze();
spec = tf.sub(spec, tf.min(spec, -1, true));
spec = tf.div(spec, tf.max(spec, -1, true).add(1e-6));
spec = tf.sub(spec, 0.5).mul(2.0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spec = (tf.engine() as any).runKernel("STFT", {
signal: spec,
frameLength,
frameStep,
});
spec = tf.matMul(spec as tf.Tensor2D, this.melFilterbank).pow(2.0);
spec = spec.pow(
tf.div(1.0, tf.add(1.0, tf.exp(this.magScale.read()))),
);
spec = tf.reverse(spec, -1);
spec = tf.transpose(spec).expandDims(-1);
return spec;
}),
);
});
}
static get className() {
return "MelSpecLayerSimple";
}
}
// ── Custom STFT WebGL kernel ───────────────────────────────────────────────
// Ported from https://github.com/birdnet-team/real-time-pwa (MIT)
function registerStftKernel() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const kernelFunc: tf.KernelFunc = (params: any) => {
const { backend, inputs } = params as {
backend: unknown;
inputs: { signal: unknown; frameLength: number; frameStep: number };
};
const b = backend as Record<string, (...args: unknown[]) => unknown>;
const { signal, frameLength, frameStep } = inputs;
const fl = frameLength as number;
const fs = frameStep as number;
const innerDim = fl / 2;
const log2Inner = Math.log2(innerDim);
// Stage 1: windowing + bit-reversal
let cur = b.runWebGLProgram(
{
variableNames: ["x"],
outputShape: [(signal as { size: number }).size],
userCode: `void main(){
ivec2 c=getOutputCoords();
int p=c[1]%${innerDim};
int k=0;
for(int i=0;i<${log2Inner};++i){
if((p & (1<<i))!=0){ k|=(1<<(${log2Inner - 1}-i)); }
}
int i=2*k;
if(c[1]>=${innerDim}){ i=2*(k%${innerDim})+1; }
int q=c[0]*${fl}+i;
float val=getX((q/${fl})*${fs}+ q % ${fl});
float cosArg=${(2.0 * Math.PI) / fl}*float(q);
float mul=0.5-0.5*cos(cosArg);
setOutput(val*mul);
}`,
} as unknown,
[signal],
"float32",
) as unknown;
// Stage 2: FFT butterflies
for (let len = 1; len < innerDim; len *= 2) {
const prev = cur;
cur = b.runWebGLProgram(
{
variableNames: ["x"],
outputShape: [innerDim * 2],
userCode: `void main(){
ivec2 c=getOutputCoords();
int b=c[0];
int i=c[1];
int k=i%${innerDim};
int isHigh=(k%${len * 2})/${len};
int highSign=(1 - isHigh*2);
int baseIndex=k - isHigh*${len};
float t=${Math.PI / len}*float(k%${len});
float a=cos(t);
float bsin=sin(-t);
float oddK_re=getX(b, baseIndex+${len});
float oddK_im=getX(b, baseIndex+${len + innerDim});
if(i<${innerDim}){
float evenK_re=getX(b, baseIndex);
setOutput(evenK_re + (oddK_re*a - oddK_im*bsin)*float(highSign));
} else {
float evenK_im=getX(b, baseIndex+${innerDim});
setOutput(evenK_im + (oddK_re*bsin + oddK_im*a)*float(highSign));
}
}`,
} as unknown,
[prev],
"float32",
) as unknown;
(
b.disposeIntermediateTensorInfo as (t: unknown) => void
)(prev);
}
// Stage 3: real RFFT output
const real = b.runWebGLProgram(
{
variableNames: ["x"],
outputShape: [innerDim + 1],
userCode: `void main(){
ivec2 c=getOutputCoords();
int b=c[0];
int i=c[1];
int zI=i%${innerDim};
int conjI=(${innerDim}-i)%${innerDim};
float Zk0=getX(b,zI);
float Zk1=getX(b,zI+${innerDim});
float Zk_conj0=getX(b,conjI);
float Zk_conj1=-getX(b,conjI+${innerDim});
float t=${-2.0 * Math.PI}*float(i)/float(${innerDim * 2});
float diff0=Zk0 - Zk_conj0;
float diff1=Zk1 - Zk_conj1;
float result=(Zk0+Zk_conj0 + cos(t)*diff1 + sin(t)*diff0)*0.5;
setOutput(result);
}`,
} as unknown,
[cur],
"float32",
) as unknown;
(b.disposeIntermediateTensorInfo as (t: unknown) => void)(cur);
return real as tf.TensorInfo;
};
tf.registerKernel({ kernelName: "STFT", backendName: "webgl", kernelFunc });
}
// ── Init ───────────────────────────────────────────────────────────────────
async function init() {
try {
await tf.setBackend("webgl");
registerStftKernel();
tf.serialization.registerClass(MelSpecLayerSimple);
const labelsText = await fetch(LABELS_PATH).then((r) => r.text());
labels = labelsText
.split("\n")
.filter(Boolean)
.map((line) => {
const idx = line.indexOf("_");
return {
scientificName: idx >= 0 ? line.slice(0, idx) : line,
commonName: idx >= 0 ? line.slice(idx + 1) : line,
};
});
model = await tf.loadLayersModel(MODEL_PATH);
console.log(
"BirdNET model inputs:",
model.inputs.map((t) => t.shape),
"outputs:",
model.outputs.map((t) => t.shape),
);
tf.tidy(() => {
(model as tf.LayersModel).predict(tf.zeros([1, WINDOW_SAMPLES]));
});
const out: WorkerOutMessage = { type: "ready" };
self.postMessage(out);
} catch (err) {
const out: WorkerOutMessage = {
type: "error",
message: String(err),
};
self.postMessage(out);
}
}
// ── Analyze ────────────────────────────────────────────────────────────────
async function analyze(samples: Float32Array) {
if (!model) return;
try {
const audioTensor = tf.tensor2d(samples, [1, WINDOW_SAMPLES]);
const resTensor = model.predict(audioTensor) as tf.Tensor;
const rawPreds = (await resTensor.array()) as number[][];
resTensor.dispose();
audioTensor.dispose();
const frame = rawPreds[0];
const sumsExp = frame.map((p) => Math.exp(ALPHA * p));
const pooled = sumsExp.map((s) => Math.log(s) / ALPHA);
const predictions: Prediction[] = pooled
.map((confidence, i) => ({
confidence,
commonName: labels[i]?.commonName ?? `Species ${i}`,
scientificName: labels[i]?.scientificName ?? "",
}))
.filter((p) => p.confidence > 0.1)
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 10);
const out: WorkerOutMessage = { type: "results", predictions };
self.postMessage(out);
} catch (err) {
const out: WorkerOutMessage = { type: "error", message: String(err) };
self.postMessage(out);
}
}
self.addEventListener("message", (e: MessageEvent<WorkerInMessage>) => {
const msg = e.data;
if (msg.type === "init") {
init();
} else if (msg.type === "analyze") {
analyze(msg.samples);
}
});
src/lib/types.ts
+14
-0
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..346369b
@@ -0,0 +1,14 @@
export interface Prediction {
commonName: string;
scientificName: string;
confidence: number;
}
export type WorkerInMessage =
| { type: "init" }
| { type: "analyze"; samples: Float32Array };
export type WorkerOutMessage =
| { type: "ready" }
| { type: "error"; message: string }
| { type: "results"; predictions: Prediction[] };
src/routes/+page.svelte
+187
-53
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 32b9fe2..4a5c565 100644
@@ -1,72 +1,206 @@
<script lang="ts">
import BirdNetWorker from "$lib/birdnet.worker.ts?worker";
import type { Prediction, WorkerOutMessage } from "$lib/types";
const WINDOW_SAMPLES = 144000;
let listening = $state(false);
let workerReady = $state(false);
let predictions = $state<Prediction[]>([]);
let worker: Worker | null = null;
let audioCtx: AudioContext | null = null;
let workletNode: AudioWorkletNode | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let stream: MediaStream | null = null;
let sampleBuffer: Float32Array = new Float32Array(0);
$effect(() => {
worker = new BirdNetWorker();
worker.addEventListener("message", (e: MessageEvent<WorkerOutMessage>) => {
const msg = e.data;
if (msg.type === "ready") {
workerReady = true;
} else if (msg.type === "results") {
predictions = msg.predictions;
}
});
worker.postMessage({ type: "init" });
return () => {
worker?.terminate();
worker = null;
};
});
async function startListening() {
audioCtx = new AudioContext({ sampleRate: 48000 });
await audioCtx.audioWorklet.addModule("/audio-processor.js");
stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
sourceNode = audioCtx.createMediaStreamSource(stream);
workletNode = new AudioWorkletNode(audioCtx, "audio-processor");
workletNode.port.addEventListener("message", (e: MessageEvent<Float32Array>) => {
const chunk = e.data;
const merged = new Float32Array(sampleBuffer.length + chunk.length);
merged.set(sampleBuffer);
merged.set(chunk, sampleBuffer.length);
sampleBuffer = merged;
if (sampleBuffer.length >= WINDOW_SAMPLES && worker && workerReady) {
const window = sampleBuffer.slice(0, WINDOW_SAMPLES);
sampleBuffer = sampleBuffer.slice(WINDOW_SAMPLES);
worker.postMessage({ type: "analyze", samples: window }, [window.buffer]);
}
});
workletNode.port.start();
sourceNode.connect(workletNode);
workletNode.connect(audioCtx.destination);
}
function stopListening() {
workletNode?.disconnect();
sourceNode?.disconnect();
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close();
workletNode = null;
sourceNode = null;
stream = null;
audioCtx = null;
sampleBuffer = new Float32Array(0);
}
async function toggleListening() {
if (listening) {
stopListening();
listening = false;
} else {
listening = true;
try {
await startListening();
} catch {
listening = false;
stopListening();
}
}
}
</script>
<section>
<button
class:listening
onclick={() => (listening = !listening)}
aria-label={listening ? "Stop listening" : "Start listening"}
aria-pressed={listening}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"
/>
</svg>
</button>
</section>
{#if predictions.length > 0}
<ol>
{#each predictions as p (p.scientificName)}
<li style:--pct={p.confidence * 100}>
<span>{p.commonName}</span>
<span>{(p.confidence * 100).toFixed(1)}%</span>
</li>
{/each}
</ol>
{/if}
<button
class:listening
disabled={!workerReady}
onclick={toggleListening}
aria-label={listening ? "Stop listening" : "Start listening"}
aria-pressed={listening}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"
/>
</svg>
</button>
<style>
section {
height: 100dvh;
ol {
list-style: none;
padding: 1rem;
padding-top: calc(4rem);
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 6rem);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: calc(
var(--nav-height) + env(safe-area-inset-bottom) + 2rem
);
button {
position: relative;
width: 5rem;
height: 5rem;
border-radius: 50%;
border: none;
background: color-mix(in srgb, var(--accent) 20%, var(--bg));
color: var(--text);
cursor: pointer;
gap: 0.5rem;
li {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.1s;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: linear-gradient(
to right,
color-mix(in srgb, var(--accent) 25%, var(--nav-bg)) calc(var(--pct) * 1%),
var(--nav-bg) calc(var(--pct) * 1%)
);
svg {
width: 2rem;
height: 2rem;
span:first-child {
font-weight: 500;
}
&:hover {
background: color-mix(in srgb, var(--accent) 30%, var(--bg));
span:last-child {
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
}
}
&:active {
transform: scale(0.95);
}
button {
position: fixed;
bottom: calc(var(--nav-height) + env(safe-area-inset-bottom) + 2rem);
left: 50%;
transform: translateX(-50%);
width: 5rem;
height: 5rem;
border-radius: 50%;
border: none;
background: color-mix(in srgb, var(--accent) 20%, var(--bg));
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.1s;
svg {
width: 2rem;
height: 2rem;
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 30%, var(--bg));
}
&:active:not(:disabled) {
transform: translateX(-50%) scale(0.95);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
&.listening {
background: var(--accent);
&.listening {
background: var(--accent);
&::after {
content: "";
position: absolute;
inset: -0.5rem;
border-radius: 50%;
border: 2px solid var(--accent);
pointer-events: none;
animation: pulse 1.5s ease-out infinite;
}
&::after {
content: "";
position: absolute;
inset: -0.5rem;
border-radius: 50%;
border: 2px solid var(--accent);
pointer-events: none;
animation: pulse 1.5s ease-out infinite;
}
}
}
static/audio-processor.js
+26
-0
diff --git a/static/audio-processor.js b/static/audio-processor.js
new file mode 100644
index 0000000..09dbded
@@ -0,0 +1,26 @@
class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._buffer = [];
this._chunkSize = 2048;
}
process(inputs) {
const input = inputs[0];
if (!input || !input[0]) return true;
const channel = input[0];
for (let i = 0; i < channel.length; i++) {
this._buffer.push(channel[i]);
}
while (this._buffer.length >= this._chunkSize) {
const chunk = new Float32Array(this._buffer.splice(0, this._chunkSize));
this.port.postMessage(chunk, [chunk.buffer]);
}
return true;
}
}
registerProcessor("audio-processor", AudioProcessor);