commit e4b4fa5cdb2bc42279fd87098fef0d27a90e23d5 Author: HenryDu8133 <813367384@qq.com> Date: Fri Jul 31 18:23:24 2026 +0800 Remove CTM and inbound prediction diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9dc65c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.trae/ +音频制作/ +list.txt +CloudServer/node_modules/ +CloudServer/tmp/ +CloudServer/server-data.json \ No newline at end of file diff --git a/Audio/Station/ch-01-01.wav b/Audio/Station/ch-01-01.wav new file mode 100644 index 0000000..0ca1342 Binary files /dev/null and b/Audio/Station/ch-01-01.wav differ diff --git a/Audio/Station/ch-01-02.wav b/Audio/Station/ch-01-02.wav new file mode 100644 index 0000000..2053384 Binary files /dev/null and b/Audio/Station/ch-01-02.wav differ diff --git a/Audio/Station/ch-01-03.wav b/Audio/Station/ch-01-03.wav new file mode 100644 index 0000000..893d3c6 Binary files /dev/null and b/Audio/Station/ch-01-03.wav differ diff --git a/Audio/Station/en-01-01.wav b/Audio/Station/en-01-01.wav new file mode 100644 index 0000000..c5dd8f5 Binary files /dev/null and b/Audio/Station/en-01-01.wav differ diff --git a/Audio/Station/en-01-02.wav b/Audio/Station/en-01-02.wav new file mode 100644 index 0000000..9b0612a Binary files /dev/null and b/Audio/Station/en-01-02.wav differ diff --git a/Audio/Station/en-01-03.wav b/Audio/Station/en-01-03.wav new file mode 100644 index 0000000..5761ab3 Binary files /dev/null and b/Audio/Station/en-01-03.wav differ diff --git a/Audio/ch-1.wav b/Audio/ch-1.wav new file mode 100644 index 0000000..2451f7c Binary files /dev/null and b/Audio/ch-1.wav differ diff --git a/Audio/ch-10.wav b/Audio/ch-10.wav new file mode 100644 index 0000000..610a821 Binary files /dev/null and b/Audio/ch-10.wav differ diff --git a/Audio/ch-2.wav b/Audio/ch-2.wav new file mode 100644 index 0000000..aa1529f Binary files /dev/null and b/Audio/ch-2.wav differ diff --git a/Audio/ch-3.wav b/Audio/ch-3.wav new file mode 100644 index 0000000..20ec396 Binary files /dev/null and b/Audio/ch-3.wav differ diff --git a/Audio/ch-4.wav b/Audio/ch-4.wav new file mode 100644 index 0000000..d66b768 Binary files /dev/null and b/Audio/ch-4.wav differ diff --git a/Audio/ch-5.wav b/Audio/ch-5.wav new file mode 100644 index 0000000..ed56889 Binary files /dev/null and b/Audio/ch-5.wav differ diff --git a/Audio/ch-6.wav b/Audio/ch-6.wav new file mode 100644 index 0000000..f2dc3fe Binary files /dev/null and b/Audio/ch-6.wav differ diff --git a/Audio/ch-7.wav b/Audio/ch-7.wav new file mode 100644 index 0000000..b62227c Binary files /dev/null and b/Audio/ch-7.wav differ diff --git a/Audio/ch-8.wav b/Audio/ch-8.wav new file mode 100644 index 0000000..4d264f1 Binary files /dev/null and b/Audio/ch-8.wav differ diff --git a/Audio/ch-9.wav b/Audio/ch-9.wav new file mode 100644 index 0000000..5dd2360 Binary files /dev/null and b/Audio/ch-9.wav differ diff --git a/Audio/ch-fc1.wav b/Audio/ch-fc1.wav new file mode 100644 index 0000000..3b5c9f2 Binary files /dev/null and b/Audio/ch-fc1.wav differ diff --git a/Audio/ch-fc2.wav b/Audio/ch-fc2.wav new file mode 100644 index 0000000..c39f357 Binary files /dev/null and b/Audio/ch-fc2.wav differ diff --git a/Audio/ch-fc3.wav b/Audio/ch-fc3.wav new file mode 100644 index 0000000..bfe945b Binary files /dev/null and b/Audio/ch-fc3.wav differ diff --git a/Audio/ch-jz1.wav b/Audio/ch-jz1.wav new file mode 100644 index 0000000..31a9685 Binary files /dev/null and b/Audio/ch-jz1.wav differ diff --git a/Audio/ch-jz2.wav b/Audio/ch-jz2.wav new file mode 100644 index 0000000..f01c00e Binary files /dev/null and b/Audio/ch-jz2.wav differ diff --git a/Audio/ch-jz3.wav b/Audio/ch-jz3.wav new file mode 100644 index 0000000..e836e5b Binary files /dev/null and b/Audio/ch-jz3.wav differ diff --git a/Audio/ch-jz4.wav b/Audio/ch-jz4.wav new file mode 100644 index 0000000..526b0dc Binary files /dev/null and b/Audio/ch-jz4.wav differ diff --git a/Audio/ch-pt.wav b/Audio/ch-pt.wav new file mode 100644 index 0000000..7a80797 Binary files /dev/null and b/Audio/ch-pt.wav differ diff --git a/Audio/ch-tj.wav b/Audio/ch-tj.wav new file mode 100644 index 0000000..4e5867a Binary files /dev/null and b/Audio/ch-tj.wav differ diff --git a/Audio/ch-zt.wav b/Audio/ch-zt.wav new file mode 100644 index 0000000..69d99b6 Binary files /dev/null and b/Audio/ch-zt.wav differ diff --git a/Audio/en-1.wav b/Audio/en-1.wav new file mode 100644 index 0000000..9e86eb0 Binary files /dev/null and b/Audio/en-1.wav differ diff --git a/Audio/en-10.wav b/Audio/en-10.wav new file mode 100644 index 0000000..f58ffc5 Binary files /dev/null and b/Audio/en-10.wav differ diff --git a/Audio/en-11.wav b/Audio/en-11.wav new file mode 100644 index 0000000..b51b757 Binary files /dev/null and b/Audio/en-11.wav differ diff --git a/Audio/en-12.wav b/Audio/en-12.wav new file mode 100644 index 0000000..5b2b568 Binary files /dev/null and b/Audio/en-12.wav differ diff --git a/Audio/en-13.wav b/Audio/en-13.wav new file mode 100644 index 0000000..2be648e Binary files /dev/null and b/Audio/en-13.wav differ diff --git a/Audio/en-14.wav b/Audio/en-14.wav new file mode 100644 index 0000000..76a0e9b Binary files /dev/null and b/Audio/en-14.wav differ diff --git a/Audio/en-15.wav b/Audio/en-15.wav new file mode 100644 index 0000000..23f0a92 Binary files /dev/null and b/Audio/en-15.wav differ diff --git a/Audio/en-16.wav b/Audio/en-16.wav new file mode 100644 index 0000000..2dec113 Binary files /dev/null and b/Audio/en-16.wav differ diff --git a/Audio/en-17.wav b/Audio/en-17.wav new file mode 100644 index 0000000..ec889af Binary files /dev/null and b/Audio/en-17.wav differ diff --git a/Audio/en-18.wav b/Audio/en-18.wav new file mode 100644 index 0000000..375f031 Binary files /dev/null and b/Audio/en-18.wav differ diff --git a/Audio/en-19.wav b/Audio/en-19.wav new file mode 100644 index 0000000..a60df78 Binary files /dev/null and b/Audio/en-19.wav differ diff --git a/Audio/en-2.wav b/Audio/en-2.wav new file mode 100644 index 0000000..c53e123 Binary files /dev/null and b/Audio/en-2.wav differ diff --git a/Audio/en-20.wav b/Audio/en-20.wav new file mode 100644 index 0000000..aae07d0 Binary files /dev/null and b/Audio/en-20.wav differ diff --git a/Audio/en-21.wav b/Audio/en-21.wav new file mode 100644 index 0000000..7df73bd Binary files /dev/null and b/Audio/en-21.wav differ diff --git a/Audio/en-22.wav b/Audio/en-22.wav new file mode 100644 index 0000000..e4665d5 Binary files /dev/null and b/Audio/en-22.wav differ diff --git a/Audio/en-23.wav b/Audio/en-23.wav new file mode 100644 index 0000000..3162452 Binary files /dev/null and b/Audio/en-23.wav differ diff --git a/Audio/en-24.wav b/Audio/en-24.wav new file mode 100644 index 0000000..413b725 Binary files /dev/null and b/Audio/en-24.wav differ diff --git a/Audio/en-25.wav b/Audio/en-25.wav new file mode 100644 index 0000000..b2d57cf Binary files /dev/null and b/Audio/en-25.wav differ diff --git a/Audio/en-26.wav b/Audio/en-26.wav new file mode 100644 index 0000000..46d49ca Binary files /dev/null and b/Audio/en-26.wav differ diff --git a/Audio/en-27.wav b/Audio/en-27.wav new file mode 100644 index 0000000..c84a901 Binary files /dev/null and b/Audio/en-27.wav differ diff --git a/Audio/en-28.wav b/Audio/en-28.wav new file mode 100644 index 0000000..b86f4f7 Binary files /dev/null and b/Audio/en-28.wav differ diff --git a/Audio/en-29.wav b/Audio/en-29.wav new file mode 100644 index 0000000..18b8610 Binary files /dev/null and b/Audio/en-29.wav differ diff --git a/Audio/en-3.wav b/Audio/en-3.wav new file mode 100644 index 0000000..78a0176 Binary files /dev/null and b/Audio/en-3.wav differ diff --git a/Audio/en-30.wav b/Audio/en-30.wav new file mode 100644 index 0000000..c073d56 Binary files /dev/null and b/Audio/en-30.wav differ diff --git a/Audio/en-31.wav b/Audio/en-31.wav new file mode 100644 index 0000000..bcfc3f8 Binary files /dev/null and b/Audio/en-31.wav differ diff --git a/Audio/en-32.wav b/Audio/en-32.wav new file mode 100644 index 0000000..c253d06 Binary files /dev/null and b/Audio/en-32.wav differ diff --git a/Audio/en-4.wav b/Audio/en-4.wav new file mode 100644 index 0000000..29d210d Binary files /dev/null and b/Audio/en-4.wav differ diff --git a/Audio/en-5.wav b/Audio/en-5.wav new file mode 100644 index 0000000..f3aa451 Binary files /dev/null and b/Audio/en-5.wav differ diff --git a/Audio/en-6.wav b/Audio/en-6.wav new file mode 100644 index 0000000..171b6bf Binary files /dev/null and b/Audio/en-6.wav differ diff --git a/Audio/en-7.wav b/Audio/en-7.wav new file mode 100644 index 0000000..cf2c50a Binary files /dev/null and b/Audio/en-7.wav differ diff --git a/Audio/en-8.wav b/Audio/en-8.wav new file mode 100644 index 0000000..6176509 Binary files /dev/null and b/Audio/en-8.wav differ diff --git a/Audio/en-9.wav b/Audio/en-9.wav new file mode 100644 index 0000000..bd0354d Binary files /dev/null and b/Audio/en-9.wav differ diff --git a/Audio/en-fc1.wav b/Audio/en-fc1.wav new file mode 100644 index 0000000..3d8f0af Binary files /dev/null and b/Audio/en-fc1.wav differ diff --git a/Audio/en-fc2.wav b/Audio/en-fc2.wav new file mode 100644 index 0000000..de1520c Binary files /dev/null and b/Audio/en-fc2.wav differ diff --git a/Audio/en-fc3.wav b/Audio/en-fc3.wav new file mode 100644 index 0000000..877cea2 Binary files /dev/null and b/Audio/en-fc3.wav differ diff --git a/Audio/en-fc4.wav b/Audio/en-fc4.wav new file mode 100644 index 0000000..8f48389 Binary files /dev/null and b/Audio/en-fc4.wav differ diff --git a/Audio/en-jz1.wav b/Audio/en-jz1.wav new file mode 100644 index 0000000..162058c Binary files /dev/null and b/Audio/en-jz1.wav differ diff --git a/Audio/en-jz2.wav b/Audio/en-jz2.wav new file mode 100644 index 0000000..2dc747c Binary files /dev/null and b/Audio/en-jz2.wav differ diff --git a/Audio/en-jz3.wav b/Audio/en-jz3.wav new file mode 100644 index 0000000..c097a5e Binary files /dev/null and b/Audio/en-jz3.wav differ diff --git a/Audio/en-jz4.wav b/Audio/en-jz4.wav new file mode 100644 index 0000000..d040618 Binary files /dev/null and b/Audio/en-jz4.wav differ diff --git a/Audio/en-pt.wav b/Audio/en-pt.wav new file mode 100644 index 0000000..315f82d Binary files /dev/null and b/Audio/en-pt.wav differ diff --git a/Audio/en-tj.wav b/Audio/en-tj.wav new file mode 100644 index 0000000..2276af5 Binary files /dev/null and b/Audio/en-tj.wav differ diff --git a/Audio/en-zt.wav b/Audio/en-zt.wav new file mode 100644 index 0000000..50b09f5 Binary files /dev/null and b/Audio/en-zt.wav differ diff --git a/Audio/fcts.pkf b/Audio/fcts.pkf new file mode 100644 index 0000000..d3789cc Binary files /dev/null and b/Audio/fcts.pkf differ diff --git a/Audio/fcts.wav b/Audio/fcts.wav new file mode 100644 index 0000000..b1e78c1 Binary files /dev/null and b/Audio/fcts.wav differ diff --git a/Audio/ksts.wav b/Audio/ksts.wav new file mode 100644 index 0000000..c5b3a95 Binary files /dev/null and b/Audio/ksts.wav differ diff --git a/CloudServer/ecosystem.config.js b/CloudServer/ecosystem.config.js new file mode 100644 index 0000000..58c77c8 --- /dev/null +++ b/CloudServer/ecosystem.config.js @@ -0,0 +1,18 @@ +/* + * @Author: HenryDu8133 813367384@qq.com + * @Date: 2026-02-24 14:42:13 + * @LastEditors: HenryDu8133 813367384@qq.com + * @LastEditTime: 2026-06-07 02:05:41 + * @FilePath: \StationBroadcastDisplayCN\CloudServer\ecosystem.config.js + * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE + */ + +module.exports = { + apps : [{ + name : "station-cloud-server", + script : "./server.js", + env: { + NODE_ENV: "production" + } + }] +} diff --git a/CloudServer/package-lock.json b/CloudServer/package-lock.json new file mode 100644 index 0000000..9bd1e4f --- /dev/null +++ b/CloudServer/package-lock.json @@ -0,0 +1,1350 @@ +{ + "name": "cloudserver", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudserver", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "body-parser": "^2.2.2", + "dfpwm": "^1.2.2", + "ejs": "^4.0.1", + "express": "^5.2.1", + "https-proxy-agent": "^5.0.1", + "multer": "^2.0.2", + "node-fetch": "^3.3.2", + "socks-proxy-agent": "^9.0.0", + "undici": "^7.24.6", + "wavefile": "^11.0.0", + "ws": "^8.19.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-8.0.0.tgz", + "integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dfpwm": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/dfpwm/-/dfpwm-1.2.2.tgz", + "integrity": "sha512-u+zKZauRY+rnnXWdqSOmJ2Uw6Scy2NMafOpot9DKzcgdU/XFMktqZCo8yz7Mv4Zu7ZHKyrye0iBPJ/s6Sufycw==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/ejs/-/ejs-4.0.1.tgz", + "integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.9.1" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/filelist": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.5.tgz", + "integrity": "sha512-ct/ckWBV/9Dg3MlvCXsLcSUyoWwv9mCKqlhLNB2DAuXR/NZolSXlQqP5dyy6guWlPXBhodZyZ5lGPQcbQDxrEQ==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmmirror.com/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz", + "integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==", + "license": "MIT", + "dependencies": { + "agent-base": "8.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wavefile": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/wavefile/-/wavefile-11.0.0.tgz", + "integrity": "sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng==", + "license": "MIT", + "bin": { + "wavefile": "bin/wavefile.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/CloudServer/package.json b/CloudServer/package.json new file mode 100644 index 0000000..76240bd --- /dev/null +++ b/CloudServer/package.json @@ -0,0 +1,26 @@ +{ + "name": "cloudserver", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "body-parser": "^2.2.2", + "dfpwm": "^1.2.2", + "ejs": "^4.0.1", + "express": "^5.2.1", + "https-proxy-agent": "^5.0.1", + "multer": "^2.0.2", + "node-fetch": "^3.3.2", + "socks-proxy-agent": "^9.0.0", + "undici": "^7.24.6", + "wavefile": "^11.0.0", + "ws": "^8.19.0" + } +} diff --git a/CloudServer/public/generator.html b/CloudServer/public/generator.html new file mode 100644 index 0000000..fc17c79 --- /dev/null +++ b/CloudServer/public/generator.html @@ -0,0 +1,1448 @@ + + + + + + Station Broadcast Generator & Manager + + + + + +
+
+

Broadcast Generator & Manager

+ + + Back to Console + +
+ +
+ +
+
+ + Generate Audio +
+
Use GPT-SoVITS to generate station announcements.
+ + +
+ + +
+ +
+ Notice: Using server-side proxy to generate audio. Make sure your API balance is sufficient. +
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + + +
+ + +
+
+ + Manage list.txt +
+ +
+
Manage station audio sequences visually. (Supports multiple IDs separated by /)
+
+ + +
+
+ +
+
Loading...
+
+ +
+ Note: After generating audio and saving the list, don't forget to go back to the main console and Publish a New Version to sync changes! +
+
+
+
+ + + + +
+ + + + diff --git a/CloudServer/public/index.html b/CloudServer/public/index.html new file mode 100644 index 0000000..13c4cc5 --- /dev/null +++ b/CloudServer/public/index.html @@ -0,0 +1,1603 @@ + + + + + + + Station Broadcast Console + + + + + + + +
+ + + + + +
+ + + + + +
+
+ +
+ + + Full Info Board + Broadcast Generator + Temp Broadcast + Music + list.txt Manager + + + +
+
+ +
+
+ +
+
Name
+
Type
+
Action
+
+ +
+
+
+
+ + +
+ + +
+
+
+ System Output + Disconnected +
+ +
+
+
+
+ + + + + + + + + + +
+ + + + + diff --git a/CloudServer/public/infoboard.html b/CloudServer/public/infoboard.html new file mode 100644 index 0000000..03fb413 --- /dev/null +++ b/CloudServer/public/infoboard.html @@ -0,0 +1,598 @@ + + + + + + + 车站广播系统 - 实时信息板 + + + + + +
+

实时信息板 - 全局概览

+
+
00:00:00
+
+
+ 未连接 +
+
+
+ +
+ +
+
+
活动车站
+
0
+
+
+
+
正在加载车站信息...
+
+
+
+ + +
+
+
活跃列车
+
0
+
+
+
+
正在加载列车信息...
+
+
+
+ + +
+
+
最新消息
+
+
+
+
暂无消息数据
+
+
+
+
+ + + + + diff --git a/CloudServer/public/listmanager.html b/CloudServer/public/listmanager.html new file mode 100644 index 0000000..b983f72 --- /dev/null +++ b/CloudServer/public/listmanager.html @@ -0,0 +1,742 @@ + + + + + + list.txt Manager + + + + +
+
+

list.txt 管理

+ +
+ +
+
对 uploads/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)
+
+ +
+ + +
+
+
+
Loading...
+
+
+
+ + + +
+ + + + diff --git a/CloudServer/public/music.html b/CloudServer/public/music.html new file mode 100644 index 0000000..560c072 --- /dev/null +++ b/CloudServer/public/music.html @@ -0,0 +1,436 @@ + + + + + + 音乐播放 + + + + +
+
音乐播放
+
+ 返回控制台 +
+
+ +
+
+
+

控制

+ 未选择 +
+ + +
+ + + + +
+ +
+ +
+ +
+ + +
+ +
就绪
+
+ +
+
+

目标站台

+ 已选 0 +
+
+ + +
+
+ + +
+
+
+
+ + + + + diff --git a/CloudServer/public/stablist.html b/CloudServer/public/stablist.html new file mode 100644 index 0000000..2218ed0 --- /dev/null +++ b/CloudServer/public/stablist.html @@ -0,0 +1,260 @@ + + + + + + Station Broadcast Streams + + + +
+

Station Broadcast Streams

+
+ + + +
+
+
+
+ + + 控制台 +
+ + + + + + + + + + + + + +
流名车站站号播放地址状态最近时间错误
+
+ + + + diff --git a/CloudServer/public/streammanager.html b/CloudServer/public/streammanager.html new file mode 100644 index 0000000..d5251ef --- /dev/null +++ b/CloudServer/public/streammanager.html @@ -0,0 +1,396 @@ + + + + + + Stream Manager + + + +
+

Stream Manager

+
+ + +
+
+
+
+ + + + 控制台 +
+ + + + + + + + + + + + +
流名车站站号播放地址(m3u8)操作
+
+ + + + + + diff --git a/CloudServer/public/tempbroadcast.html b/CloudServer/public/tempbroadcast.html new file mode 100644 index 0000000..c9d7155 --- /dev/null +++ b/CloudServer/public/tempbroadcast.html @@ -0,0 +1,388 @@ + + + + + + 临时广播 + + + + +
+
临时广播
+
+ 返回控制台 +
+
+ +
+
+
+

内容

+ 未合成 +
+ + + + + + + +
+ + +
+ +
+ 需要服务端已存在 lsts.wav(建议放在 uploads/audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。 +
+ + + +
就绪
+
+ +
+
+

目标站台

+ 已选 0 +
+
+ + +
+
+ + +
+
+
+
+ + + + diff --git a/CloudServer/server.js b/CloudServer/server.js new file mode 100644 index 0000000..9fa6cc5 --- /dev/null +++ b/CloudServer/server.js @@ -0,0 +1,2420 @@ +const express = require('express'); +const http = require('http'); +const WebSocket = require('ws'); +const os = require('os'); +const path = require('path'); +const bodyParser = require('body-parser'); +const multer = require('multer'); +const fs = require('fs'); +const { execSync, spawn } = require('child_process'); +const dfpwm = require('dfpwm'); +const { fetch } = require('undici'); + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocket.Server({ server }); + +const PORT = Number(process.env.PORT || '8999'); +const DEFAULT_ZH_MODEL = process.env.ZH_MODEL || '7b64d954530d490a8c40827e618e55c6'; + +const HLS_ENABLE = process.env.HLS_ENABLE !== '0'; +const HLS_STREAM_MODE = (process.env.HLS_STREAM_MODE || 'event').toLowerCase(); +const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase(); +const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live'; +const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group'; +const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT; +const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8'; +const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0'); +const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0'; + +const tmpDir = path.join(__dirname, 'tmp'); +if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); +const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav'); + +const localHlsDir = path.join(__dirname, 'public', 'hls'); +if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true }); + +// Ensure upload directories exist +const uploadDir = path.join(__dirname, 'public', 'uploads'); +const audioGeneralDir = path.join(uploadDir, 'audio', 'general'); +const audioStationDir = path.join(uploadDir, 'audio', 'station'); +const tempBroadcastDir = path.join(uploadDir, 'temp-broadcast'); + +if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); +if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true }); +if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true }); +if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true }); + +// Multer storage configuration +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + if (file.fieldname === 'listFile') { + cb(null, uploadDir); + } else if (file.fieldname === 'generalAudioFiles') { + cb(null, audioGeneralDir); + } else if (file.fieldname === 'stationAudioFiles') { + cb(null, audioStationDir); + } else { + cb(null, uploadDir); + } + }, + filename: function (req, file, cb) { + if (file.fieldname === 'listFile') { + cb(null, 'list.txt'); + } else { + // Use original name, handle encoding if needed (multer handles basic utf8) + file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8'); + cb(null, file.originalname); + } + } +}); + +const upload = multer({ storage: storage }); + +// Middleware +app.use(bodyParser.json()); +app.use((req, res, next) => { + if (req.path.endsWith('.m3u8')) res.type('application/vnd.apple.mpegurl'); + else if (req.path.endsWith('.ts')) res.type('video/mp2t'); + next(); +}); +app.use(express.static(path.join(__dirname, 'public'))); +app.set('view engine', 'ejs'); + +// Data Store (In-memory) +let stations = {}; +const stationRouteIndex = new Map(); +let activeTrains = []; +let stationLogs = []; +let audioVersion = String(Date.now()); +let versions = []; + +const DATA_FILE = path.join(__dirname, 'server-data.json'); + +const stationAudioQueues = new Map(); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getInterruptModeForPriority(priority) { + if (priority >= 2) return 'all'; + if (priority >= 1) return 'ifLower'; + return 'none'; +} + +function insertTaskByPriority(queue, task) { + const p = Number(task?.priority || 0); + let idx = 0; + while (idx < queue.length) { + const q = queue[idx]; + const qp = Number(q?.priority || 0); + if (p > qp) break; + idx++; + } + queue.splice(idx, 0, task); +} + +function getStationData(stationName) { + const cleanName = String(stationName || '').trim(); + if (Object.prototype.hasOwnProperty.call(stations, cleanName)) return stations[cleanName]; + + // Try matching by code and platform if no direct name match + const targetCode = getStationCodeFromName(cleanName); // Already lowercase and normalized + const targetPlatform = getPlatformNumberFromName(cleanName); + + if (targetCode) { + for (const k in stations) { + if (!Object.prototype.hasOwnProperty.call(stations, k)) continue; + const st = stations[k]; + // Normalize st.code similarly to how targetCode was generated + const stCodeRaw = st.code || getStationCodeFromName(st.name) || ''; + const stCode = stCodeRaw.replace(/_/g, '-').toLowerCase(); + + const stPlatform = getPlatformNumberFromName(st.name); + if (stCode === targetCode && stPlatform === targetPlatform) { + return st; + } + } + } + return null; +} + +function getStationWs(stationName, fallbackWs) { + const st = getStationData(stationName); + if (st && st.ws && st.ws.readyState === WebSocket.OPEN) return st.ws; + if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) return fallbackWs; + return null; +} + +function getMergedStationList() { + const list = []; + for (const k in stations) { + if (!Object.prototype.hasOwnProperty.call(stations, k)) continue; + const st = stations[k]; + const stCodeRaw = st.code || getStationCodeFromName(st.name) || ''; + const code = stCodeRaw.replace(/_/g, '-').toLowerCase(); + list.push({ + name: st.name, + code: code || '', + platform: st.platform || getPlatformNumberFromName(st.name), + lastSeen: st.lastSeen, + source: 'cc', + tags: ['CC'] + }); + } + return list; +} + +function sendAudioControl(stationName, msg, fallbackWs) { + const ws = getStationWs(stationName, fallbackWs); + if (!ws) return false; + try { + ws.send(JSON.stringify(msg)); + return true; + } catch { + return false; + } +} + +async function sendDfpwmBufferToStation(stationName, fallbackWs, task) { + const targetWs = getStationWs(stationName, fallbackWs); + if (!targetWs) throw new Error(`Station "${stationName}" not connected`); + + const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`); + const buf = task.buffer; + const durationSec = buf.length / 6000; + + const startMsg = { + type: 'audio_start', + stationName, + audioId, + byteLength: buf.length, + sampleRate: 48000, + channels: 1, + priority: Number(task.priority || 0), + interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)), + source: task.source || 'unknown' + }; + + targetWs.send(JSON.stringify(startMsg)); + + const token = task.cancelToken; + const SINGLE_SEND_LIMIT = 32 * 1024; + const CHUNK_SIZE = 16 * 1024; + + if (buf.length <= SINGLE_SEND_LIMIT) { + if (token && token.cancelled) return { audioId, cancelled: true, durationSec }; + targetWs.send(buf); + } else { + for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) { + if (token && token.cancelled) return { audioId, cancelled: true, durationSec }; + const end = Math.min(offset + CHUNK_SIZE, buf.length); + targetWs.send(buf.slice(offset, end)); + // For streaming, we can send slightly faster initially then steady + const isInitial = offset < CHUNK_SIZE * 4; + await delay(isInitial ? 20 : 60); + } + } + + if (token && token.cancelled) return { audioId, cancelled: true, durationSec }; + + targetWs.send(JSON.stringify({ + type: 'audio_end', + stationName, + audioId, + durationSec + })); + + return { audioId, cancelled: false, durationSec }; +} + +const stationMusicStates = new Map(); + +function normalizeBaseName(name) { + const s = String(name || '').trim(); + if (!s) return null; + const base = s.replace(/\.(wav|dfpwm)$/i, '').trim(); + return base || null; +} + +function listMusicTracks() { + const out = new Set(); + if (!fs.existsSync(audioGeneralDir)) return []; + + for (const file of fs.readdirSync(audioGeneralDir)) { + if (!file || file.startsWith('.')) continue; + const full = path.join(audioGeneralDir, file); + if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue; + if (!file.toLowerCase().endsWith('.wav') && !file.toLowerCase().endsWith('.dfpwm')) continue; + const base = normalizeBaseName(file); + if (base) out.add(base); + } + + return Array.from(out).sort((a, b) => a.localeCompare(b)); +} + +function getDfpwmBufferByBaseName(baseName) { + const base = normalizeBaseName(baseName); + if (!base) throw new Error('Invalid track name'); + + const dfCandidates = [ + path.join(audioGeneralDir, `${base}.dfpwm`), + path.join(audioStationDir, `${base}.dfpwm`) + ]; + for (const p of dfCandidates) { + if (fs.existsSync(p)) return fs.readFileSync(p); + } + + const wavCandidates = [ + path.join(audioGeneralDir, `${base}.wav`), + path.join(audioStationDir, `${base}.wav`) + ]; + const wavPath = wavCandidates.find(p => fs.existsSync(p)) || null; + if (!wavPath) throw new Error(`Audio not found: ${base}`); + + const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm'); + if (!fs.existsSync(dfpwmPath)) convertToDfpwm(wavPath); + if (!fs.existsSync(dfpwmPath)) throw new Error(`DFPWM build failed: ${base}`); + return fs.readFileSync(dfpwmPath); +} + +function stopStationMusic(stationName, fallbackWs) { + const cleanStation = String(stationName || '').trim(); + const q = getStationAudioQueue(cleanStation); + + q.queue = q.queue.filter(t => t?.source !== 'music'); + + if (q.current && q.current.source === 'music') { + if (q.current.cancelToken) q.current.cancelToken.cancelled = true; + if (q.current.endTimer) { + try { clearTimeout(q.current.endTimer); } catch {} + q.current.endTimer = null; + } + } + sendAudioControl(cleanStation, { type: 'audio_stop', scope: 'music', mode: 'current', reason: 'music_stop' }, fallbackWs); +} + +function getStationAudioQueue(stationName) { + const st = getStationData(stationName); + const key = st ? st.name : String(stationName || '').trim(); + + if (!stationAudioQueues.has(key)) { + stationAudioQueues.set(key, { + processing: false, + current: null, + queue: [] + }); + } + return stationAudioQueues.get(key); +} + +function enqueueStationAudioTask(stationName, fallbackWs, task) { + const cleanStation = String(stationName || '').trim(); + const q = getStationAudioQueue(cleanStation); + const newP = Number(task.priority || 0); + const cur = q.current; + + if (task.interruptMode === 'all' || newP >= 2) { + if (cur && cur.cancelToken) cur.cancelToken.cancelled = true; + if (cur && cur.endTimer) { + try { clearTimeout(cur.endTimer); } catch {} + cur.endTimer = null; + } + q.queue.length = 0; + sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'all', reason: 'emergency' }, fallbackWs); + } else if (cur) { + const curP = Number(cur.priority || 0); + if (newP > curP) { + if (cur.cancelToken) cur.cancelToken.cancelled = true; + if (cur.endTimer) { + try { clearTimeout(cur.endTimer); } catch {} + cur.endTimer = null; + } + sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'current', flushBelow: newP, reason: 'preempt' }, fallbackWs); + if (cur.requeueOnPreempt) { + const replay = { ...cur, cancelToken: null }; + replay.audioId = `${String(replay.audioId || '')}-replay-${Date.now()}`; + insertTaskByPriority(q.queue, replay); + } + } + } + + insertTaskByPriority(q.queue, task); + processStationAudioQueue(cleanStation, fallbackWs).catch(() => {}); +} + +async function processStationAudioQueue(stationName, fallbackWs) { + const cleanStation = String(stationName || '').trim(); + const q = getStationAudioQueue(cleanStation); + if (q.processing) return; + q.processing = true; + try { + while (q.queue.length > 0) { + const task = q.queue.shift(); + task.cancelToken = { cancelled: false }; + q.current = task; + + try { + if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart); + const result = await sendDfpwmBufferToStation(cleanStation, fallbackWs, task); + if (task.consoleStreamEnd) { + if (result.cancelled) { + broadcastToConsole(task.consoleStreamEnd); + } else { + task.endTimer = setTimeout(() => { + try { broadcastToConsole(task.consoleStreamEnd); } catch {} + }, Math.max(0, Number(result.durationSec || 0) * 1000)); + } + } + if (task.onDone) task.onDone({ ...result, stationName: cleanStation }); + } catch (e) { + if (task.consoleStreamEnd) broadcastToConsole(task.consoleStreamEnd); + if (task.onDone) task.onDone({ cancelled: true, error: e?.message || String(e), stationName: cleanStation }); + } finally { + q.current = null; + } + } + } finally { + q.processing = false; + } +} + +function loadData() { + if (fs.existsSync(DATA_FILE)) { + try { + const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); + if (data.stations) stations = data.stations; + if (data.activeTrains) activeTrains = data.activeTrains; + if (data.audioVersion) audioVersion = String(data.audioVersion); + if (Array.isArray(data.versions)) versions = data.versions; + refreshStationIndex(); + log('INFO', 'Data loaded from disk'); + } catch (e) { + log('ERROR', `Failed to load data: ${e.message}`); + } + } +} + +function saveData() { + try { + // Create a copy of stations without ws objects + const stationsToSave = {}; + for (const key in stations) { + const { ws, ...rest } = stations[key]; + stationsToSave[key] = rest; + } + + const data = { + stations: stationsToSave, + activeTrains, + audioVersion, + versions + }; + fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + log('ERROR', `Failed to save data: ${e.message}`); + } +} + +function normalizeVersionString(input) { + if (input === undefined || input === null) return ''; + if (typeof input !== 'string') input = String(input); + input = input.trim(); + if (input.length > 64) input = input.slice(0, 64); + return input; +} + +function getManagedFiles() { + const files = []; + + const configPath = path.join(uploadDir, 'list.txt'); + if (fs.existsSync(configPath)) { + const stats = fs.statSync(configPath); + files.push({ + name: 'list.txt', + path: 'uploads/list.txt', + type: 'config', + size: stats.size, + mtime: stats.mtimeMs + }); + } + + if (fs.existsSync(audioGeneralDir)) { + fs.readdirSync(audioGeneralDir).forEach(file => { + if (file.endsWith('.dfpwm')) { + const wavName = file.replace(/\.dfpwm$/, '.wav'); + if (fs.existsSync(path.join(audioGeneralDir, wavName))) return; + } + const filePath = path.join(audioGeneralDir, file); + const stats = fs.statSync(filePath); + files.push({ + name: file, + path: `uploads/audio/general/${file}`, + type: 'general', + size: stats.size, + mtime: stats.mtimeMs + }); + }); + } + + if (fs.existsSync(audioStationDir)) { + fs.readdirSync(audioStationDir).forEach(file => { + if (file.endsWith('.dfpwm')) { + const wavName = file.replace(/\.dfpwm$/, '.wav'); + if (fs.existsSync(path.join(audioStationDir, wavName))) return; + } + const filePath = path.join(audioStationDir, file); + const stats = fs.statSync(filePath); + files.push({ + name: file, + path: `uploads/audio/station/${file}`, + type: 'station', + size: stats.size, + mtime: stats.mtimeMs + }); + }); + } + + return files; +} + +function snapshotFiles() { + const snapshot = {}; + const files = getManagedFiles(); + for (const f of files) { + snapshot[f.path] = { mtime: f.mtime, size: f.size, type: f.type, name: f.name }; + } + return snapshot; +} + +function diffSnapshots(prevSnap, nextSnap) { + const prev = prevSnap || {}; + const next = nextSnap || {}; + const added = []; + const modified = []; + const deleted = []; + + for (const p of Object.keys(next)) { + if (!prev[p]) added.push(p); + else if (prev[p].mtime !== next[p].mtime || prev[p].size !== next[p].size) modified.push(p); + } + for (const p of Object.keys(prev)) { + if (!next[p]) deleted.push(p); + } + + added.sort(); + modified.sort(); + deleted.sort(); + + return { added, modified, deleted }; +} + +function log(type, message) { + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] [${type}] ${message}`; + console.log(logEntry); + stationLogs.unshift(logEntry); + if (stationLogs.length > 100) stationLogs.pop(); + + // Broadcast logs to web console + broadcastToConsole({ type: 'log', data: logEntry }); +} + +const trainEventCooldown = new Map(); // trainId|stationName|type -> timestamp +function normalizeName(value) { + return String(value || '').trim(); +} + +function getStationCodeFromName(name) { + if (!name) return null; + + // 1. Try to find ID in parentheses or brackets (e.g. "Station (01-01/01-02)") + const parenMatch = name.match(/[\(\[]([a-zA-Z0-9\-\/]+)[\)\]]/); + if (parenMatch) return parenMatch[1].split('/')[0].replace('_', '-').trim().toLowerCase(); + + // 2. Try extraction from anywhere in string (e.g. "01-01" or "01_01") + const m = name.match(/([a-zA-Z0-9]+[-_][a-zA-Z0-9]+)/); + if (m) return m[1].replace('_', '-').toLowerCase(); + + // 3. Fallback to first part of name if it looks like a code or a name + const parts = name.split(/[\s?]+/); + const firstPart = parts[0]; + + // Ignore common generic parts + if (firstPart === 'Station' || firstPart === 'Platform' || firstPart === 'Track') { + if (parts[1]) return parts[1].toLowerCase(); + } + + if (firstPart && firstPart.length > 0) { + const code = firstPart.replace('_', '-').toLowerCase(); + // log('DEBUG', `Extracted code "${code}" from "${name}"`); + return code; + } + + return null; +} + +function getPlatformNumberFromName(name) { + if (!name) return "1"; + // 1. Try "Platform X" or "Track X" (case insensitive) + const m1 = name.match(/(?:platform|track)\s*([0-9]+[a-zA-Z]?)/i); + if (m1) return m1[1]; + + // 2. Fallback to digits at the end + const m2 = name.match(/([0-9]+[a-zA-Z]?)$/); + return m2 ? m2[1] : "1"; +} + +function normalizeStationCode(code) { + const value = String(code || '').trim().replace(/_/g, '-').toLowerCase(); + return value || null; +} + +function normalizeStationPlatform(platform) { + const value = String(platform || '').trim().toLowerCase(); + return value || '1'; +} + +function getStationRouteKeys(name, code, platform) { + const keys = []; + const normalizedCode = normalizeStationCode(code || getStationCodeFromName(name)); + const normalizedPlatform = normalizeStationPlatform(platform || getPlatformNumberFromName(name)); + const baseName = getBaseName(name); + + if (normalizedCode) { + keys.push(`code:${normalizedCode}|platform:${normalizedPlatform}`); + keys.push(`code:${normalizedCode}`); + } + if (baseName) { + keys.push(`base:${baseName}|platform:${normalizedPlatform}`); + keys.push(`base:${baseName}`); + } + return [...new Set(keys)]; +} + +function unindexStation(station) { + if (!station) return; + const keys = Array.isArray(station.routeKeys) ? station.routeKeys : []; + for (const key of keys) { + const current = stationRouteIndex.get(key); + if (current && current.name === station.name) { + stationRouteIndex.delete(key); + } + } + station.routeKeys = []; +} + +function indexStation(station) { + if (!station || !station.name) return; + unindexStation(station); + station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name)); + station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || ''; + station.routeKeys = getStationRouteKeys(station.name, station.code, station.platform); + for (const key of station.routeKeys) { + stationRouteIndex.set(key, station); + } +} + +function refreshStationIndex() { + stationRouteIndex.clear(); + for (const station of Object.values(stations)) { + indexStation(station); + } +} + +function getBaseTrainId(trainId) { + if (!trainId) return "unknown"; + + // Ignore split off trains which are temporary and cause false announcements + if (trainId.includes("Split off from")) return null; + + // Remove car-specific info like "01 3501F" from "CK 1 01 3501F" + // Also remove brackets like "[Local] Train 1" -> "Train 1" + let clean = trainId.replace(/^\[.*?\]\s*/, ''); + + // Look for patterns like "CK 1 01" and keep only "CK 1" + const parts = clean.split(/\s+/); + if (parts.length >= 3) { + // If it looks like "NAME NUM CAR ...", keep "NAME NUM" + if (parts[0].length <= 8 && !isNaN(parts[1])) { + return `${parts[0]} ${parts[1]}`; + } + } + + // Handle dash patterns like "123-01" -> "123" + const dashMatch = parts[0].match(/^([\w\d]+)\-[\w\d]+$/); + if (dashMatch) return dashMatch[1]; + + return parts[0] + (parts[1] ? ' ' + parts[1] : ''); +} + +// Radio Listeners +const radioListeners = {}; +const hlsStreams = {}; + +function sanitizeStreamName(name) { + return String(name || '') + .trim() + .replace(/\s+/g, '_') + .replace(/[^a-zA-Z0-9_\-]/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80) || 'UNK'; +} + +function getPrimaryStationCode(code) { + if (!code) return null; + const first = String(code).split(/[,\s|/]+/).map(s => s.trim()).filter(Boolean)[0]; + return first || null; +} + +function getStationStreamName(stationName) { + const st = getStationData(stationName); + const primaryCode = getPrimaryStationCode(st?.code); + return sanitizeStreamName(primaryCode || stationName); +} + +function getStationHlsUrl(streamName) { + const base = String(STABROADCAST_PUBLIC_BASE || '').replace(/\/+$/g, ''); + return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`; +} + +function guessSrsHttpBase(req) { + const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim(); + const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim(); + if (!hostRaw) return null; + + const m = hostRaw.match(/^(.+?)(?::(\d+))?$/); + const host = m?.[1] || hostRaw; + const port = m?.[2]; + + const withPort = port ? `${host}:8080` : `${host}:8080`; + return `${proto}://${withPort}/live`; +} + +function getStationHlsUrlForRequest(streamName, req) { + const base = String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT).replace(/\/+$/g, ''); + return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`; +} + +function getLocalHlsUrlForRequest(streamName, req) { + const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim(); + const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim(); + const base = hostRaw ? `${proto}://${hostRaw}` : ''; + return `${base}/hls/${encodeURIComponent(streamName)}/index.m3u8`; +} + +function ensureSilenceWav() { + if (fs.existsSync(silence1sWavPath)) return; + execSync(`ffmpeg -y -v quiet -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a pcm_s16le "${silence1sWavPath}"`); +} + +function getOrCreateHlsStreamState(streamName) { + if (!hlsStreams[streamName]) { + hlsStreams[streamName] = { + streamName, + stationName: null, + stationCode: null, + hlsUrl: getStationHlsUrl(streamName), + status: 'idle', + lastStartAt: null, + lastEndAt: null, + lastSeq: null, + lastError: null, + lastExitCode: null, + queue: Promise.resolve(), + currentProcess: null + }; + } + return hlsStreams[streamName]; +} + +function buildConcatListFile(seq) { + ensureSilenceWav(); + const files = []; + + for (const item of seq) { + if (item === '__DELAY_1__') { + files.push(silence1sWavPath); + continue; + } + + const candidates = [ + path.join(audioStationDir, `${item}.wav`), + path.join(audioGeneralDir, `${item}.wav`) + ]; + + const found = candidates.find(p => fs.existsSync(p)); + if (found) { + files.push(found); + } else { + log('WARN', `HLS audio missing on server (wav required): ${item}`); + } + } + + if (HLS_TAIL_SILENCE_SEC > 0) { + for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath); + } + + if (files.length === 0) return null; + + const listPath = path.join(tmpDir, `concat-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`); + const content = files + .map(p => `file '${p.replace(/\\/g, '/').replace(/'/g, "\\'")}'`) + .join('\n'); + fs.writeFileSync(listPath, content, 'utf8'); + return listPath; +} + +function runFfmpegRtmpPush(streamName, seq) { + const state = getOrCreateHlsStreamState(streamName); + const listPath = buildConcatListFile(seq); + if (!listPath) { + state.status = 'idle'; + state.lastError = 'No playable wav files in sequence'; + return Promise.resolve(); + } + + const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`; + + state.lastSeq = seq; + state.lastError = null; + state.lastExitCode = null; + + if (HLS_PREBUILD) { + const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`); + const buildArgs = [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-f', + 'concat', + '-safe', + '0', + '-i', + listPath, + '-ac', + '1', + '-ar', + '48000', + '-c:a', + 'aac', + '-b:a', + '128k', + '-f', + 'adts', + tmpAacPath + ]; + + state.status = 'building'; + state.lastStartAt = Date.now(); + + const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] }); + let buildStderr = ''; + buildProc.stderr.on('data', (buf) => { + buildStderr += buf.toString('utf8'); + if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000); + }); + + return new Promise((resolve) => { + buildProc.on('close', (buildCode) => { + if (buildCode !== 0) { + state.lastExitCode = buildCode; + state.status = 'idle'; + state.lastEndAt = Date.now(); + state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim(); + log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`); + try { fs.unlinkSync(listPath); } catch {} + try { fs.unlinkSync(tmpAacPath); } catch {} + resolve(); + return; + } + + const pushArgs = [ + '-hide_banner', + '-loglevel', + 'error', + '-re', + '-i', + tmpAacPath, + '-c:a', + 'copy', + '-f', + 'flv', + rtmpUrl + ]; + + state.status = 'pushing'; + const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] }); + state.currentProcess = proc; + + let stderr = ''; + proc.stderr.on('data', (buf) => { + stderr += buf.toString('utf8'); + if (stderr.length > 8000) stderr = stderr.slice(-8000); + }); + + proc.on('close', (code) => { + state.lastExitCode = code; + state.status = 'idle'; + state.lastEndAt = Date.now(); + state.currentProcess = null; + if (code !== 0) { + state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim(); + log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`); + } + try { fs.unlinkSync(listPath); } catch {} + try { fs.unlinkSync(tmpAacPath); } catch {} + resolve(); + }); + }); + }); + } + + const args = [ + '-hide_banner', + '-loglevel', + 'error', + '-re', + '-f', + 'concat', + '-safe', + '0', + '-i', + listPath, + '-ac', + '1', + '-ar', + '48000', + '-c:a', + 'aac', + '-b:a', + '128k', + '-f', + 'flv', + rtmpUrl + ]; + + state.status = 'pushing'; + state.lastStartAt = Date.now(); + + const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }); + state.currentProcess = proc; + + let stderr = ''; + proc.stderr.on('data', (buf) => { + stderr += buf.toString('utf8'); + if (stderr.length > 8000) stderr = stderr.slice(-8000); + }); + + return new Promise((resolve) => { + proc.on('close', (code) => { + state.lastExitCode = code; + state.status = 'idle'; + state.lastEndAt = Date.now(); + state.currentProcess = null; + if (code !== 0) { + state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim(); + log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`); + } + try { fs.unlinkSync(listPath); } catch {} + resolve(); + }); + }); +} + +function clearLocalHlsStreamDir(streamName) { + const dir = path.join(localHlsDir, streamName); + if (!fs.existsSync(dir)) return dir; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { + fs.rmSync(p, { recursive: true, force: true }); + } else { + try { fs.unlinkSync(p); } catch {} + } + } + return dir; +} + +function runFfmpegLocalHlsBuild(streamName, seq) { + const state = getOrCreateHlsStreamState(streamName); + const listPath = buildConcatListFile(seq); + if (!listPath) { + state.status = 'idle'; + state.lastError = 'No playable wav files in sequence'; + return Promise.resolve(); + } + + state.lastSeq = seq; + state.lastError = null; + state.lastExitCode = null; + state.status = 'building'; + state.lastStartAt = Date.now(); + + const outDir = clearLocalHlsStreamDir(streamName); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + + const playlistPath = path.join(outDir, 'index.m3u8'); + const segPattern = path.join(outDir, 'seg-%05d.ts'); + const args = [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-f', + 'concat', + '-safe', + '0', + '-i', + listPath, + '-ac', + '1', + '-ar', + '48000', + '-c:a', + 'aac', + '-b:a', + '128k', + '-f', + 'hls', + '-hls_time', + '2', + '-hls_list_size', + '0', + '-hls_playlist_type', + 'vod', + '-hls_flags', + 'independent_segments', + '-hls_segment_filename', + segPattern, + playlistPath + ]; + + const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }); + state.currentProcess = proc; + + let stderr = ''; + proc.stderr.on('data', (buf) => { + stderr += buf.toString('utf8'); + if (stderr.length > 8000) stderr = stderr.slice(-8000); + }); + + return new Promise((resolve) => { + proc.on('close', (code) => { + state.lastExitCode = code; + state.status = 'idle'; + state.lastEndAt = Date.now(); + state.currentProcess = null; + if (code !== 0) { + state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim(); + log('ERROR', `Local HLS build failed for ${streamName}: ${state.lastError}`); + } + try { fs.unlinkSync(listPath); } catch {} + resolve(); + }); + }); +} + +function enqueueHlsPush(stationName, seq) { + if (!HLS_ENABLE) return; + if (!Array.isArray(seq) || seq.length === 0) return; + + const streamName = getStationStreamName(stationName); + const st = getStationData(stationName); + const state = getOrCreateHlsStreamState(streamName); + state.stationName = stationName; + state.stationCode = getPrimaryStationCode(st?.code); + state.hlsUrl = state.hlsUrl || getStationHlsUrl(streamName); + + if (HLS_STREAM_MODE === 'replace' && state.currentProcess) { + try { state.currentProcess.kill('SIGKILL'); } catch {} + } + + state.queue = state.queue + .then(() => (HLS_OUTPUT_MODE === 'local' ? runFfmpegLocalHlsBuild(streamName, seq) : runFfmpegRtmpPush(streamName, seq))) + .catch((e) => { + state.status = 'idle'; + state.lastError = e?.message || String(e); + log('ERROR', `HLS push error for ${streamName}: ${state.lastError}`); + }); +} + +// WebSocket handling +wss.on('connection', (ws, req) => { + const ip = req.socket.remoteAddress; + + // Check if it's a radio connection + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + if (url.pathname === '/api/radio') { + const station = url.searchParams.get('station'); + if (station) { + const cleanStation = station.trim(); + if (!radioListeners[cleanStation]) radioListeners[cleanStation] = []; + + // To prevent zombie connections, close existing connections from the same IP + // Since CC might reconnect quickly and leave old ones hanging + radioListeners[cleanStation] = radioListeners[cleanStation].filter(existingWs => { + if (existingWs.readyState !== WebSocket.OPEN) return false; + // If it's the same IP connecting again, close the old one to save CC resources + if (existingWs._socket && req.socket && existingWs._socket.remoteAddress === req.socket.remoteAddress) { + existingWs.close(); + return false; + } + return true; + }); + + radioListeners[cleanStation].push(ws); + log('INFO', `Radio listener connected for station: ${cleanStation}`); + + // Send an empty initial frame to let the client know it's connected and ready + // DFPWM silence is 0xAA. 24000 bytes = 4 seconds of silence to pre-fill buffer. + ws.send(Buffer.alloc(24000, 0xAA)); + + ws.on('close', () => { + radioListeners[cleanStation] = radioListeners[cleanStation].filter(l => l !== ws); + log('INFO', `Radio listener disconnected for station: ${cleanStation}`); + }); + + // Ping to keep alive + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.ping(); + } else { + clearInterval(pingInterval); + } + }, 30000); + + ws.on('close', () => clearInterval(pingInterval)); + return; + } + } + + ws.on('message', (message) => { + try { + const data = JSON.parse(message); + handleMessage(ws, data); + } catch (e) { + log('ERROR', `Failed to parse message: ${e.message}`); + } + }); + + ws.on('close', () => { + // Handle disconnection if necessary + }); +}); + +function handleMessage(ws, data) { + // Check if it's a console connection + if (data.type === 'console_connect') { + ws.isConsole = true; + // Send initial state + ws.send(JSON.stringify({ + type: 'init_state', + stations, + activeTrains, + logs: stationLogs + })); + return; + } + + // Handle Station Messages + switch (data.type) { + case 'station_register': + handleStationRegister(ws, data); + break; + case 'train_arrival': + handleTrainArrival(data); + break; + case 'train_departure': + handleTrainDeparture(data); + break; + case 'request_station_time': + handleStationTimeRequest(ws, data); + break; + case 'play_sequence': + handlePlaySequence(ws, data); + break; + case 'request_list_txt': + // TODO: Send list.txt content + break; + case 'heartbeat': + handleHeartbeat(ws, data); + break; + default: + log('WARNING', `Unknown message type: ${data.type}`); + } +} + +function getDestinationFromSchedule(schedule) { + if (!schedule || !schedule.entries) return "Unknown"; + const entries = schedule.entries; + let dest = "Unknown"; + + // Convert entries to array if it's an object with numeric keys + const entryList = Array.isArray(entries) ? entries : Object.values(entries); + + // 1. Look for explicit destination instructions + for (const entry of entryList) { + if (entry.instruction?.id === 'create:destination') { + dest = entry.instruction.data?.text || entry.instruction.text || dest; + } + } + + // 2. If still Unknown, find the very last station instruction + if (dest === "Unknown") { + for (let i = entryList.length - 1; i >= 0; i--) { + if (entryList[i].instruction?.id === 'create:station') { + dest = entryList[i].instruction.data?.text || entryList[i].instruction.text || dest; + break; + } + } + } + return dest; +} + +function getCorrectStationName(name) { + const cleanName = String(name || '').trim(); + const station = findStationByFlexibleName(cleanName); + if (station && station.name) { + return station.name; + } + return cleanName; +} + +function handleHeartbeat(ws, data) { + if (data.stationName) { + const cleanName = data.stationName.trim(); + if (!stations[cleanName]) { + // Auto-register if missing (e.g. server restarted) + stations[cleanName] = { + name: cleanName, + code: data.stationCode || '', + platform: data.stationPlatform || getPlatformNumberFromName(cleanName), + lastSeen: Date.now(), + ws: ws + }; + indexStation(stations[cleanName]); + log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`); + broadcastUpdate(); + } else { + stations[cleanName].lastSeen = Date.now(); + if (data.stationCode) stations[cleanName].code = data.stationCode; + if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform; + if (stations[cleanName].ws !== ws) { + stations[cleanName].ws = ws; // Update WS in case of reconnect + log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`); + broadcastUpdate(); + } + indexStation(stations[cleanName]); + } + } +} + +function handleStationRegister(ws, data) { + const { stationName, stationCode, stationPlatform } = data; + const cleanName = stationName.trim(); + + stations[cleanName] = { + name: cleanName, + code: stationCode, + platform: stationPlatform || getPlatformNumberFromName(cleanName), + lastSeen: Date.now(), + ws: ws // Keep reference if we need to send back + }; + indexStation(stations[cleanName]); + + log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`); + + // Send ack + ws.send(JSON.stringify({ + type: 'station_register_ack', + stationName: cleanName + })); + + saveData(); + broadcastUpdate(); +} + +function handleTrainArrival(data) { + let { trainId, stationName, arrivalTime, destination, source, schedule } = data; + stationName = getCorrectStationName(stationName); + const baseId = getBaseTrainId(trainId); + + const cooldownKey = `${baseId}|${stationName}|arrival`; + const lastEventTime = trainEventCooldown.get(cooldownKey); + if (lastEventTime && Date.now() - lastEventTime < 45000) return; + trainEventCooldown.set(cooldownKey, Date.now()); + + log('INFO', `Train ${trainId} (Consist ${baseId}) arrived at ${stationName}`); + + // Add or update active train using base ID to group cars + const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId); + + // Create history entry + const historyEntry = { + station: stationName, + action: 'Arrival', + time: Date.now() + }; + + let trainData = { + trainId, + currentStation: stationName, + destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"), + status: "At Station", + lastUpdate: Date.now(), + schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null), + tripLogs: existingIndex >= 0 ? (activeTrains[existingIndex].tripLogs || []) : [] + }; + + trainData.tripLogs.push(historyEntry); + + if (existingIndex >= 0) { + activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData }; + } else { + activeTrains.push(trainData); + } + + broadcastUpdate(); +} + +function getBaseName(name) { + if (!name) return ""; + return String(name).toLowerCase() + .replace(/^[\d\w]+[-_][\d\w]+\s+/, '') // Strip leading code "01-01 " + .replace(/\s*platform\s*[\d\w]+/gi, '') + .replace(/\s*track\s*[\d\w]+/gi, '') + .replace(/\s*站台\s*[\d\w]+/gi, '') + .replace(/\s*股道\s*[\d\w]+/gi, '') + .replace(/@/g, ' ') + .replace(/\s*(station|站|tread|track)$/gi, '') + .replace(/[\(\[].*?[\)\]]/g, ' ') + .replace(/\s+\d+$/g, '') + .replace(/[^\w\s\u4e00-\u9fa5]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function isFuzzyMatch(str, pattern) { + if (!str || !pattern) return false; + const s = String(str).toLowerCase().trim(); + const p = String(pattern).toLowerCase().trim(); + + if (s === p) return true; + + // 1. Wildcard match + if (p.includes('*')) { + const regex = new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') + '$', 'i'); + if (regex.test(s)) return true; + } + + // 2. Base name comparison + const b1 = getBaseName(s); + const b2 = getBaseName(p); + if (b1 && b2 && b1 === b2) return true; + + // 3. Substring match of base names (if one is long enough) + if (b1 && b2) { + if (b1.length >= 4 && b2.length >= 4) { + if (b1.includes(b2) || b2.includes(b1)) return true; + } + } + + // 4. Stripped comparison + const s1 = s.replace(/[\s\-\._\(\)\[\]]/g, ''); + const s2 = p.replace(/[\s\-\._\(\)\[\]]/g, ''); + if (s1 && s2 && s1 === s2) return true; + + // 5. Station code comparison + const c1 = getStationCodeFromName(s); + const c2 = getStationCodeFromName(p); + if (c1 && c2 && c1 === c2) { + // Only trust code match if it looks like a real code (contains numbers or is very short) + const isRealCode = (str) => /[\d]/.test(str) || str.length <= 4; + if (isRealCode(c1)) return true; + + // If it's a long name-based code, also require base name similarity + if (b1 && b2 && (b1.includes(b2) || b2.includes(b1))) return true; + } + + return false; +} + +function findStationByFlexibleName(targetName) { + if (!targetName) return null; + + const directKeys = getStationRouteKeys(targetName); + for (const key of directKeys) { + const station = stationRouteIndex.get(key); + if (station) return station; + } + + // 1. Try exact match + if (stations[targetName]) return stations[targetName]; + + // 2. Search through registered stations using fuzzy match + for (const [regName, data] of Object.entries(stations)) { + if (isFuzzyMatch(regName, targetName)) { + // log('DEBUG', `[Station-Search] Matched "${targetName}" to registered "${regName}"`); + return data; + } + } + + // Log available stations for debugging + const available = Object.keys(stations).map(k => `"${k}" (base: "${getBaseName(k)}")`).join(', '); + log('DEBUG', `[Station-Search] Could not find match for "${targetName}" (base: "${getBaseName(targetName)}"). Available: [${available}]`); + return null; +} + +function handleTrainDeparture(data) { + let { trainId, stationName, destination, schedule, source } = data; + stationName = getCorrectStationName(stationName); + const baseId = getBaseTrainId(trainId); + + const cooldownKey = `${baseId}|${stationName}|departure`; + const lastEventTime = trainEventCooldown.get(cooldownKey); + if (lastEventTime && Date.now() - lastEventTime < 45000) return; + trainEventCooldown.set(cooldownKey, Date.now()); + + log('INFO', `Train ${trainId} (Consist ${baseId}) departed from ${stationName}`); + + const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId); + if (existingIndex >= 0) { + const train = activeTrains[existingIndex]; + train.status = "In Transit"; + train.currentStation = "Transit (" + stationName + " -> " + (destination || "?") + ")"; + train.lastUpdate = Date.now(); + train.lastStation = stationName; + train.departureTime = Date.now(); + train.destination = destination || train.destination; + train.schedule = schedule || train.schedule; + + if (!train.tripLogs) train.tripLogs = []; + train.tripLogs.push({ + station: stationName, + action: 'Departure', + time: Date.now() + }); + } + broadcastUpdate(); +} + +function handlePlaySequence(ws, data) { + const { stationName, seq, requestId } = data; + const cleanStation = stationName.trim(); + const correctStation = getCorrectStationName(cleanStation); + const streamName = getStationStreamName(cleanStation); + const hlsUrl = getStationHlsUrl(streamName); + + log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`); + + // Process files sequentially + let combinedBuffer = []; + for (const file of seq) { + if (file === '__DELAY_1__') { + const silence = Buffer.alloc(6000, 0xAA); + combinedBuffer.push(silence); + continue; + } + + let foundPath = null; + + const candidates = [ + path.join(audioStationDir, `${file}.dfpwm`), + path.join(audioGeneralDir, `${file}.dfpwm`), + path.join(audioStationDir, `${file}.wav`), + path.join(audioGeneralDir, `${file}.wav`) + ]; + + for (const p of candidates) { + if (fs.existsSync(p)) { + foundPath = p; + break; + } + } + + if (foundPath) { + let dfpwmPath = foundPath; + if (foundPath.endsWith('.wav')) { + dfpwmPath = foundPath.replace(/\.wav$/, '.dfpwm'); + if (!fs.existsSync(dfpwmPath)) { + convertToDfpwm(foundPath); + } + } + + if (fs.existsSync(dfpwmPath)) { + combinedBuffer.push(fs.readFileSync(dfpwmPath)); + } else { + log('WARN', `Could not get DFPWM for: ${file}`); + } + } else { + log('WARN', `Audio missing on server: ${file}`); + } + } + + if (combinedBuffer.length > 0) { + const finalBuffer = Buffer.concat(combinedBuffer); + const priority = Number.isFinite(Number(data.priority)) + ? Number(data.priority) + : (String(data.category || '').toLowerCase() === 'station' ? 0 : 1); + const interruptMode = data.interruptMode || getInterruptModeForPriority(priority); + const source = data.source || data.category || 'sequence'; + + const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`); + + enqueueStationAudioTask(cleanStation, ws, { + buffer: finalBuffer, + priority, + interruptMode, + source, + audioId, + requeueOnPreempt: priority <= 0, + consoleStreamStart: { type: 'stream_start', station: cleanStation, seq: seq, streamName, hlsUrl }, + consoleStreamEnd: { type: 'stream_end', station: cleanStation } + }); + } +} + +function handleStationTimeRequest(ws, data) { + // Simple logic: default to 60s or lookup schedule + const stationTime = 60; + ws.send(JSON.stringify({ + type: 'station_time_response', + trainId: data.trainId, + stationTime: stationTime + })); +} + +function broadcastToConsole(msg) { + wss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN && client.isConsole) { + client.send(JSON.stringify(msg)); + } + }); +} + +function broadcastUpdate() { + const list = getMergedStationList().sort((a, b) => a.name.localeCompare(b.name)); + + const state = { + type: 'state_update', + stations: list, + activeTrains + }; + broadcastToConsole(state); +} + +app.get('/api/streams', (req, res) => { + const streams = []; + const seen = new Set(); + const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim(); + const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim(); + const localBase = hostRaw ? `${proto}://${hostRaw}/hls` : '/hls'; + const publicBaseResolved = HLS_OUTPUT_MODE === 'local' + ? localBase + : String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT); + + Object.keys(stations).forEach(stationName => { + const st = stations[stationName]; + const streamName = getStationStreamName(stationName); + const state = getOrCreateHlsStreamState(streamName); + state.stationName = stationName; + state.stationCode = getPrimaryStationCode(st?.code); + state.hlsUrl = HLS_OUTPUT_MODE === 'local' + ? getLocalHlsUrlForRequest(streamName, req) + : getStationHlsUrlForRequest(streamName, req); + seen.add(streamName); + + streams.push({ + streamName, + stationName: state.stationName, + stationCode: state.stationCode, + hlsUrl: state.hlsUrl, + status: state.status, + lastStartAt: state.lastStartAt, + lastEndAt: state.lastEndAt, + lastSeq: state.lastSeq, + lastError: state.lastError, + lastExitCode: state.lastExitCode + }); + }); + + Object.values(hlsStreams).forEach(state => { + if (seen.has(state.streamName)) return; + streams.push({ + streamName: state.streamName, + stationName: state.stationName, + stationCode: state.stationCode, + hlsUrl: HLS_OUTPUT_MODE === 'local' + ? getLocalHlsUrlForRequest(state.streamName, req) + : getStationHlsUrlForRequest(state.streamName, req), + status: state.status, + lastStartAt: state.lastStartAt, + lastEndAt: state.lastEndAt, + lastSeq: state.lastSeq, + lastError: state.lastError, + lastExitCode: state.lastExitCode + }); + }); + + streams.sort((a, b) => String(a.streamName).localeCompare(String(b.streamName))); + + res.json({ + ok: true, + hlsEnable: HLS_ENABLE, + streamMode: HLS_STREAM_MODE, + publicBase: publicBaseResolved, + srsRtmpBase: SRS_RTMP_BASE, + streams + }); +}); + +app.get('/stablist', (req, res) => { + res.redirect('/stablist.html'); +}); + +// Routes for direct API access if needed +app.get('/', (req, res) => { + const host = String(req.headers.host || '').toLowerCase(); + if (host.startsWith('stablist.fse-media.group')) { + res.sendFile(path.join(__dirname, 'public', 'stablist.html')); + return; + } + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +function convertToDfpwm(filePath) { + try { + if (!filePath.toLowerCase().endsWith('.wav')) return; + + const rawPath = filePath.replace(/\.wav$/i, '.raw'); + const dfpwmPath = filePath.replace(/\.wav$/i, '.dfpwm'); + + // Convert to raw signed 8-bit 48kHz mono using FFmpeg + // -y: overwrite output + // -v quiet: suppress output + // -f s8: signed 8-bit pcm + // -ac 1: mono + // -ar 48000: 48kHz + execSync(`ffmpeg -y -v quiet -i "${filePath}" -f s8 -acodec pcm_s8 -ac 1 -ar 48000 "${rawPath}"`); + + if (fs.existsSync(rawPath)) { + const buffer = fs.readFileSync(rawPath); + + // dfpwm.Encoder.encode requires a Buffer in Node.js + const encoder = new dfpwm.Encoder(); + + // We need to pass the raw buffer to the encoder. The buffer already contains 8-bit PCM data. + // Some versions of dfpwm throw "Argument #1 must be a Buffer" if it's an Array or TypedArray. + const encoded = encoder.encode(buffer); + + fs.writeFileSync(dfpwmPath, encoded); + fs.unlinkSync(rawPath); // Clean up temp file + + log('SUCCESS', `Converted ${path.basename(filePath)} to DFPWM (FFmpeg)`); + } else { + log('ERROR', `FFmpeg failed to generate raw file for ${path.basename(filePath)}`); + } + + } catch (e) { + log('ERROR', `DFPWM conversion failed for ${path.basename(filePath)}: ${e.message}`); + } +} + +// Routes +app.get('/api/version', (req, res) => { + res.json({ version: audioVersion }); +}); + +app.post('/api/publish-version', (req, res) => { + const version = normalizeVersionString(req.body?.version) || String(Date.now()); + const snap = snapshotFiles(); + const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null; + const changes = diffSnapshots(prev, snap); + const entry = { + id: String(Date.now()), + version, + note: normalizeVersionString(req.body?.note), + createdAt: Date.now(), + snapshot: snap, + changes + }; + versions.push(entry); + audioVersion = version; + saveData(); + log('INFO', `Audio version published: ${version}`); + res.json({ success: true, version }); +}); + +app.get('/api/versions', (req, res) => { + const list = versions + .slice() + .reverse() + .map(v => ({ + id: v.id, + version: v.version, + note: v.note || '', + createdAt: v.createdAt, + changes: v.changes || { added: [], modified: [], deleted: [] } + })); + res.json({ success: true, activeVersion: audioVersion, versions: list }); +}); + +app.post('/api/versions', (req, res) => { + const version = normalizeVersionString(req.body?.version) || String(Date.now()); + const snap = snapshotFiles(); + const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null; + const changes = diffSnapshots(prev, snap); + const entry = { + id: String(Date.now()), + version, + note: normalizeVersionString(req.body?.note), + createdAt: Date.now(), + snapshot: snap, + changes + }; + versions.push(entry); + audioVersion = version; + saveData(); + log('INFO', `Audio version published: ${version}`); + res.json({ success: true, version, id: entry.id }); +}); + +app.put('/api/versions/:id', (req, res) => { + const id = req.params.id; + const idx = versions.findIndex(v => v.id === id); + if (idx < 0) return res.status(404).json({ success: false, message: 'Version not found' }); + + const wasActive = versions[idx].version === audioVersion; + const nextVersion = normalizeVersionString(req.body?.version); + if (nextVersion) versions[idx].version = nextVersion; + const nextNote = normalizeVersionString(req.body?.note); + if (req.body?.note !== undefined) versions[idx].note = nextNote; + + if (wasActive) audioVersion = versions[idx].version; + + saveData(); + res.json({ success: true }); +}); + +app.get('/api/versions/:id/changes', (req, res) => { + const id = req.params.id; + const entry = versions.find(v => v.id === id); + if (!entry) return res.status(404).json({ success: false, message: 'Version not found' }); + res.json({ success: true, changes: entry.changes || { added: [], modified: [], deleted: [] } }); +}); + +app.get('/api/versions/:id/files', (req, res) => { + const id = req.params.id; + const entry = versions.find(v => v.id === id); + if (!entry) return res.status(404).json({ success: false, message: 'Version not found' }); + res.json({ success: true, files: entry.snapshot || {} }); +}); + +app.post('/api/save-data', (req, res) => { + saveData(); + log('INFO', 'Manual data save triggered'); + res.json({ success: true }); +}); + +app.post('/api/upload/list', upload.single('listFile'), (req, res) => { + log('SUCCESS', 'Updated list.txt'); + res.json({ success: true, message: 'list.txt updated' }); +}); + +app.post('/api/upload/general-audio', upload.array('generalAudioFiles'), (req, res) => { + const files = req.files || []; + log('SUCCESS', `Uploaded ${files.length} general audio files`); + res.json({ success: true, message: `${files.length} general audio files uploaded` }); +}); + +app.post('/api/upload/station-audio', upload.array('stationAudioFiles'), (req, res) => { + const files = req.files || []; + log('SUCCESS', `Uploaded ${files.length} station audio files`); + res.json({ success: true, message: `${files.length} station audio files uploaded` }); +}); + +app.get('/api/files', (req, res) => { + res.json({ files: getManagedFiles() }); +}); + +app.delete('/api/files', (req, res) => { + const filePath = req.query.path; + if (!filePath) return res.status(400).json({ success: false, message: 'Path required' }); + + // Security check: prevent directory traversal + const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, ''); + const fullPath = path.join(__dirname, 'public', safePath); + + if (!fullPath.startsWith(uploadDir)) { + return res.status(403).json({ success: false, message: 'Invalid path' }); + } + + if (fs.existsSync(fullPath)) { + try { + fs.unlinkSync(fullPath); + log('INFO', `Deleted file: ${safePath}`); + + // Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa) + if (safePath.endsWith('.wav')) { + const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm'); + if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath); + } else if (safePath.endsWith('.dfpwm')) { + const wavPath = fullPath.replace(/\.dfpwm$/, '.wav'); + if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath); + } + + res.json({ success: true }); + } catch (e) { + res.status(500).json({ success: false, message: e.message }); + } + } else { + res.status(404).json({ success: false, message: 'File not found' }); + } +}); + +app.get('/api/stations', (req, res) => { + const list = getMergedStationList() + .sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || ''))); + res.json({ success: true, stations: list }); +}); + +app.post('/api/stations', express.json(), (req, res) => { + const name = String(req.body?.name || '').trim(); + const code = req.body?.code === undefined ? '' : String(req.body.code).trim(); + if (!name) return res.status(400).json({ success: false, message: 'Station name required' }); + if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' }); + + stations[name] = { + name, + code, + platform: getPlatformNumberFromName(name), + lastSeen: Date.now() + }; + indexStation(stations[name]); + saveData(); + broadcastUpdate(); + res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } }); +}); + +app.put('/api/stations/:name', express.json(), (req, res) => { + const name = req.params.name; + const st = stations[name]; + if (!st) return res.status(404).json({ success: false, message: 'Station not found' }); + + const newName = req.body?.newName === undefined ? '' : String(req.body.newName).trim(); + const nextCode = req.body?.code === undefined ? undefined : String(req.body.code).trim(); + + let targetName = name; + if (newName && newName !== name) { + if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' }); + stations[newName] = { ...st, name: newName }; + unindexStation(stations[name]); + delete stations[name]; + targetName = newName; + } + + if (nextCode !== undefined) stations[targetName].code = nextCode; + stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name); + stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now(); + indexStation(stations[targetName]); + + saveData(); + broadcastUpdate(); + res.json({ success: true, station: { name: stations[targetName].name, code: stations[targetName].code, lastSeen: stations[targetName].lastSeen } }); +}); + +app.delete('/api/stations/:name', (req, res) => { + const name = req.params.name; + if (stations[name]) { + unindexStation(stations[name]); + delete stations[name]; + log('INFO', `Deleted station: ${name}`); + saveData(); + broadcastUpdate(); + res.json({ success: true }); + } else { + res.status(404).json({ success: false, message: 'Station not found' }); + } +}); + +app.delete('/api/streams/:streamName', (req, res) => { + const streamName = String(req.params.streamName || '').trim(); + if (!streamName) return res.status(400).json({ success: false, message: 'Stream name required' }); + const state = hlsStreams[streamName]; + if (!state) return res.status(404).json({ success: false, message: 'Stream not found' }); + if (state.currentProcess) { + try { state.currentProcess.kill('SIGKILL'); } catch {} + } + delete hlsStreams[streamName]; + res.json({ success: true }); +}); + +app.delete('/api/trains/:id', (req, res) => { + const id = req.params.id; + const initialLength = activeTrains.length; + activeTrains = activeTrains.filter(t => t.trainId !== id); + + if (activeTrains.length < initialLength) { + log('INFO', `Deleted train: ${id}`); + saveData(); + broadcastUpdate(); + res.json({ success: true }); + } else { + res.status(404).json({ success: false, message: 'Train not found' }); + } +}); + +app.get('/api/files/content', (req, res) => { + const filePath = req.query.path; + if (!filePath) return res.status(400).json({ success: false, message: 'Path required' }); + + const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, ''); + const fullPath = path.join(__dirname, 'public', safePath); + + if (!fullPath.startsWith(uploadDir)) { + return res.status(403).json({ success: false, message: 'Invalid path' }); + } + + if (fs.existsSync(fullPath)) { + try { + const content = fs.readFileSync(fullPath, 'utf8'); + res.json({ success: true, content }); + } catch (e) { + res.status(500).json({ success: false, message: e.message }); + } + } else { + res.status(404).json({ success: false, message: 'File not found' }); + } +}); + +app.post('/api/files/content', express.json(), (req, res) => { + const { path: filePath, content } = req.body; + if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' }); + + const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, ''); + const fullPath = path.join(__dirname, 'public', safePath); + + if (!fullPath.startsWith(uploadDir)) { + return res.status(403).json({ success: false, message: 'Invalid path' }); + } + + try { + fs.writeFileSync(fullPath, content, 'utf8'); + log('INFO', `Updated file: ${safePath}`); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +// TTS Generation Proxy +const tls = require('tls'); + +function requestViaProxy(urlStr, options, proxyUrlStr) { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const proxyUrl = new URL(proxyUrlStr); + + const proxyReq = http.request({ + host: proxyUrl.hostname, + port: proxyUrl.port, + method: 'CONNECT', + path: url.hostname + ':' + (url.port || 443), + }); + + proxyReq.on('connect', (res, socket, head) => { + if (res.statusCode !== 200) { + return reject(new Error('Proxy connection failed: ' + res.statusCode)); + } + const tlsSocket = tls.connect({ + socket: socket, + servername: url.hostname + }, () => { + const req = require('https').request({ + host: url.hostname, + port: url.port || 443, + path: url.pathname + url.search, + method: options.method || 'GET', + headers: options.headers, + createConnection: () => tlsSocket + }, (res) => { + let body = []; + res.on('data', chunk => body.push(chunk)); + res.on('end', () => resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + buffer: Buffer.concat(body), + text: Buffer.concat(body).toString('utf8') + })); + }); + + req.on('error', reject); + if (options.body) req.write(options.body); + req.end(); + }); + tlsSocket.on('error', reject); + }); + + proxyReq.on('error', reject); + proxyReq.end(); + }); +} + +function requestDirect(urlStr, options) { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const req = require('https').request({ + host: url.hostname, + port: url.port || 443, + path: url.pathname + url.search, + method: options.method || 'GET', + headers: options.headers + }, (res) => { + let body = []; + res.on('data', chunk => body.push(chunk)); + res.on('end', () => resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + buffer: Buffer.concat(body), + text: Buffer.concat(body).toString('utf8') + })); + }); + + req.on('error', reject); + if (options.body) req.write(options.body); + req.end(); + }); +} + +async function generateTtsWavToTemp(text, reference_id) { + const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy; + + const requestBody = JSON.stringify({ + text: text, + reference_id: reference_id, + format: "wav" + }); + + const options = { + method: 'POST', + headers: { + 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody) + }, + body: requestBody + }; + + const response = proxyUrl + ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl) + : await requestDirect('https://api.fish.audio/v1/tts', options); + + if (!response.ok) { + const msg = (response.text && String(response.text).trim().length > 0) + ? response.text + : `Upstream API error (HTTP ${response.status})`; + const err = new Error(msg); + err.status = response.status || 500; + throw err; + } + + const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; + const tempPath = path.join(uploadDir, tempName); + fs.writeFileSync(tempPath, response.buffer); + + return { tempName, tempPath, url: `/uploads/${tempName}` }; +} + +app.post('/api/tts/generate', express.json(), async (req, res) => { + const { text, reference_id } = req.body; + if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' }); + + try { + const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy; + console.log(`[TTS] Sending request for text: "${text}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`); + + const requestBody = JSON.stringify({ + text: text, + reference_id: reference_id, + format: "wav" + }); + + const options = { + method: 'POST', + headers: { + 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody) + }, + body: requestBody + }; + + const response = proxyUrl + ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl) + : await requestDirect('https://api.fish.audio/v1/tts', options); + + if (!response.ok) { + const msg = (response.text && String(response.text).trim().length > 0) + ? response.text + : `Upstream API error (HTTP ${response.status})`; + console.error(`[TTS] API Error Status ${response.status}: ${msg}`); + return res.status(response.status || 500).json({ success: false, message: msg }); + } + + // Save to a temporary file + const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; + const tempPath = path.join(uploadDir, tempName); + fs.writeFileSync(tempPath, response.buffer); + + console.log(`[TTS] Success: saved to ${tempName}`); + res.json({ success: true, url: `/uploads/${tempName}`, tempName }); + } catch (e) { + console.error("[TTS] Generation Error:", e); + const msg = (e && e.message && String(e.message).trim().length > 0) + ? e.message + : (e && e.code ? String(e.code) : 'Unknown error'); + res.status(500).json({ success: false, message: msg + (e && e.cause && e.cause.message ? ` (${e.cause.message})` : '') }); + } +}); + +// Endpoint to receive temp files from client +app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }, { name: 'enAudio', maxCount: 1 }]), (req, res) => { + try { + const zhFile = req.files['zhAudio'] ? req.files['zhAudio'][0] : null; + const enFile = req.files['enAudio'] ? req.files['enAudio'][0] : null; + + if (!zhFile || !enFile) { + return res.status(400).json({ success: false, message: 'Missing audio files' }); + } + + // Rename to secure temp names + const zhTempName = `temp_zh_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; + const enTempName = `temp_en_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; + + const zhTempPath = path.join(uploadDir, zhTempName); + const enTempPath = path.join(uploadDir, enTempName); + + fs.renameSync(zhFile.path, zhTempPath); + fs.renameSync(enFile.path, enTempPath); + + res.json({ + success: true, + zhTempName, + enTempName, + zhUrl: `/uploads/${zhTempName}`, + enUrl: `/uploads/${enTempName}` + }); + } catch (e) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +// Import TTS temp files to station folder +app.post('/api/tts/import', express.json(), (req, res) => { + const { stationCode, zhTemp, enTemp } = req.body; + if (!stationCode || !zhTemp || !enTemp) return res.status(400).json({ success: false, message: 'Missing parameters' }); + + const zhSource = path.join(uploadDir, zhTemp); + const enSource = path.join(uploadDir, enTemp); + const safeStationCode = String(stationCode).replace(/\//g, '-'); + const zhDest = path.join(audioStationDir, `ch-${safeStationCode}.wav`); + const enDest = path.join(audioStationDir, `en-${safeStationCode}.wav`); + + try { + if (fs.existsSync(zhSource)) { + fs.copyFileSync(zhSource, zhDest); + fs.unlinkSync(zhSource); + } + if (fs.existsSync(enSource)) { + fs.copyFileSync(enSource, enDest); + fs.unlinkSync(enSource); + } + log('SUCCESS', `Imported generated audio for station ${stationCode}`); + res.json({ success: true }); + } catch (e) { + log('ERROR', `Failed to import audio: ${e.message}`); + res.status(500).json({ success: false, message: e.message }); + } +}); + +function safeFilename(name) { + const base = path.basename(String(name || '').trim()); + if (!base || base.includes('/') || base.includes('\\')) return null; + return base; +} + +function findWavByBaseName(baseName) { + const safeBase = String(baseName || '').trim().replace(/\.wav$/i, ''); + if (!safeBase) return null; + const candidates = [ + path.join(audioGeneralDir, `${safeBase}.wav`), + path.join(audioStationDir, `${safeBase}.wav`), + path.join(uploadDir, `${safeBase}.wav`) + ]; + return candidates.find(p => fs.existsSync(p)) || null; +} + +function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) { + const broadcastId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const outBase = `temp_${broadcastId}`; + const outWavPath = path.join(tempBroadcastDir, `${outBase}.wav`); + + const filterComplex = [ + '[0:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a0]', + '[1:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a1]', + '[a0][a1]concat=n=2:v=0:a=1[a]' + ].join(';'); + + execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`); + + convertToDfpwm(outWavPath); + + const dfpwmPath = outWavPath.replace(/\.wav$/i, '.dfpwm'); + if (!fs.existsSync(dfpwmPath)) throw new Error('DFPWM build failed'); + const dfpwmBuf = fs.readFileSync(dfpwmPath); + const durationSec = dfpwmBuf.length / 6000; + + return { + broadcastId, + wavPath: outWavPath, + dfpwmPath, + durationSec + }; +} + +app.post('/api/temp-broadcast/build', (req, res) => { + const contentTempName = safeFilename(req.body?.contentTempName); + const prefixName = String(req.body?.prefixName || 'lsts').trim(); + if (!contentTempName) return res.status(400).json({ success: false, message: 'Invalid contentTempName' }); + + const contentPath = path.join(uploadDir, contentTempName); + if (!contentPath.startsWith(uploadDir)) return res.status(403).json({ success: false, message: 'Invalid path' }); + if (!fs.existsSync(contentPath)) return res.status(404).json({ success: false, message: 'Content temp wav not found' }); + + const prefixWavPath = findWavByBaseName(prefixName); + if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` }); + + try { + const built = buildTempBroadcastFromWav(prefixWavPath, contentPath); + res.json({ + success: true, + broadcastId: built.broadcastId, + wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`, + durationSec: built.durationSec + }); + } catch (e) { + res.status(500).json({ success: false, message: e?.message || String(e) }); + } +}); + +app.post('/api/temp-broadcast/compose', express.json(), async (req, res) => { + const text = String(req.body?.text || '').trim(); + const reference_id = String(req.body?.reference_id || DEFAULT_ZH_MODEL).trim(); + const prefixName = String(req.body?.prefixName || 'lsts').trim(); + if (!text) return res.status(400).json({ success: false, message: 'text required' }); + if (!reference_id) return res.status(400).json({ success: false, message: 'reference_id required' }); + + const prefixWavPath = findWavByBaseName(prefixName); + if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` }); + + let ttsTempPath = null; + try { + const tts = await generateTtsWavToTemp(text, reference_id); + ttsTempPath = tts.tempPath; + const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath); + try { fs.unlinkSync(ttsTempPath); } catch {} + ttsTempPath = null; + + res.json({ + success: true, + broadcastId: built.broadcastId, + wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`, + durationSec: built.durationSec + }); + } catch (e) { + if (ttsTempPath) { + try { fs.unlinkSync(ttsTempPath); } catch {} + } + res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) }); + } +}); + +app.post('/api/temp-broadcast/publish', (req, res) => { + const broadcastId = String(req.body?.broadcastId || '').trim(); + const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase(); + const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : []; + + if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' }); + if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' }); + if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' }); + + const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`); + const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm'); + if (!fs.existsSync(dfpwmPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (dfpwm)' }); + + const dfpwmBuf = fs.readFileSync(dfpwmPath); + const priority = urgency === 'emergency' ? 2 : 0; + const interruptMode = urgency === 'emergency' ? 'all' : 'none'; + const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal'; + const requeueOnPreempt = urgency !== 'emergency'; + + let sent = 0; + let failed = 0; + const results = []; + + for (const rawName of stationsReq) { + const name = String(rawName || '').trim(); + if (!name) continue; + const ws = getStationWs(name); + if (!ws || ws.readyState !== WebSocket.OPEN) { + failed++; + results.push({ stationName: name, ok: false, reason: 'offline' }); + continue; + } + enqueueStationAudioTask(name, ws, { + buffer: dfpwmBuf, + priority, + interruptMode, + source, + audioId: `temp-${broadcastId}-${name}-${Date.now()}`, + requeueOnPreempt, + consoleStreamStart: { type: 'stream_start', station: name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) }, + consoleStreamEnd: { type: 'stream_end', station: name } + }); + sent++; + results.push({ stationName: name, ok: true }); + } + + res.json({ success: true, sent, failed, results }); +}); + +app.get('/api/music/tracks', (req, res) => { + res.json({ success: true, tracks: listMusicTracks() }); +}); + +app.post('/api/music/start', express.json(), (req, res) => { + const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : []; + const track = normalizeBaseName(req.body?.track); + const loop = Boolean(req.body?.loop); + + if (!track) return res.status(400).json({ success: false, message: 'track required' }); + if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' }); + + let dfpwmBuf; + try { + dfpwmBuf = getDfpwmBufferByBaseName(track); + } catch (e) { + return res.status(404).json({ success: false, message: e?.message || String(e) }); + } + + let sent = 0; + let failed = 0; + const results = []; + + for (const rawName of stationsReq) { + const name = String(rawName || '').trim(); + if (!name) continue; + const ws = getStationWs(name); + if (!ws || ws.readyState !== WebSocket.OPEN) { + failed++; + results.push({ stationName: name, ok: false, reason: 'offline' }); + continue; + } + + stopStationMusic(name, ws); + stationMusicStates.set(name, { enabled: true, track, loop }); + + const enqueueOnce = () => { + const currentWs = getStationWs(name); + if (!currentWs || currentWs.readyState !== WebSocket.OPEN) { + // If offline, retry later instead of giving up + setTimeout(enqueueOnce, 5000); + return; + } + + enqueueStationAudioTask(name, currentWs, { + buffer: dfpwmBuf, + priority: -1, + interruptMode: 'none', + source: 'music', + audioId: `music-${track}-${name}-${Date.now()}`, + requeueOnPreempt: true, + consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) }, + consoleStreamEnd: { type: 'stream_end', station: name }, + onDone: (info) => { + const st = stationMusicStates.get(name); + if (!st || !st.enabled) return; + if (st.track !== track) return; + if (!st.loop) return; + // Only stop if cancelled by something else (like stopMusic or higher priority) + if (info && info.cancelled) { + // If it was just preempted, enqueueStationAudioTask handles the re-insertion + // If it was cancelled by stopStationMusic, st.enabled will be false + return; + } + enqueueOnce(); + } + }); + }; + + enqueueOnce(); + sent++; + results.push({ stationName: name, ok: true }); + } + + res.json({ success: true, sent, failed, results }); +}); + +app.post('/api/music/stop', express.json(), (req, res) => { + const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : []; + if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' }); + + let stopped = 0; + const results = []; + + for (const rawName of stationsReq) { + const name = String(rawName || '').trim(); + if (!name) continue; + const ws = getStationWs(name); + stationMusicStates.set(name, { enabled: false, track: null, loop: false }); + stopStationMusic(name, ws); + stopped++; + results.push({ stationName: name, ok: true }); + } + + res.json({ success: true, stopped, results }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + log('ERROR', `Unhandled error: ${err.message}`); + res.status(err.status || 500).json({ + success: false, + message: err.message || 'Internal Server Error' + }); +}); + +server.listen(PORT, () => { + loadData(); + startCtmClient(); + if (!audioVersion) audioVersion = String(Date.now()); + if (!Array.isArray(versions) || versions.length === 0) { + const snap = snapshotFiles(); + const changes = diffSnapshots(null, snap); + versions = [{ + id: String(Date.now()), + version: audioVersion, + note: '', + createdAt: Date.now(), + snapshot: snap, + changes + }]; + saveData(); + } + console.log(`Cloud Server running on port ${PORT}`); +}); diff --git a/aukit.lua b/aukit.lua new file mode 100644 index 0000000..439c2b4 --- /dev/null +++ b/aukit.lua @@ -0,0 +1,3620 @@ +--- AUKit: aukit.Audio decoding and processing framework for ComputerCraft +--- @file +--- +--- AUKit is a framework designed to simplify the process of loading, modifying, +--- and playing audio files in various formats. It includes support for loading +--- audio from many sources, including PCM, DFPWM, G.711, and ADPCM codecs, as +--- well as WAV, AIFF, AU, and FLAC files. It can also generate audio on-the-fly +--- as tones, noise, or silence. +--- +--- AUKit uses a structure called aukit.Audio to store information about each audio +--- chunk. An audio object holds the sample rate of the audio, as well as the +--- data for each channel stored as floating-point numbers. aukit.Audio objects can +--- hold any number of channels at any sample rate with any duration. +--- +--- To obtain an audio object, you can use any of the main functions in the aukit +--- module. These allow loading from various raw codecs or file formats, with +--- data sources as strings, or tables if using a raw codec loader. +--- +--- Once the audio is loaded, various basic operations are available. A subset of +--- the string library is available to simplify operations on the audio, and a +--- number of operators (+, *, .., #) are overridden as well. There's also built- +--- in functions for resampling the audio, with nearest-neighbor, linear, cubic, +--- and sinc interpolation available; as well as mixing channels (including down to +--- mono) and combining/splitting channels. Finally, audio objects can be exported +--- back to PCM, DFPWM, or WAV data, allowing changes to be easily stored on disk. +--- The stream function also automatically chunks data for use with a speaker. +--- All of these functions return a new audio object, leaving the original intact. +--- +--- There are also a number of effects available for audio. These are contained +--- in the aukit.effects table, and modify the audio passed to them (as well as +--- returning the audio for streamlining). The effects are intended to speed up +--- common operations on audio. More effects may be added in future versions. +--- +--- For simple audio playback tasks, the aukit.stream table provides a number of +--- functions that can quickly decode audio for real-time playback. Each function +--- returns an iterator function that can be called multiple times to obtain fully +--- decoded chunks of audio in 8-bit PCM, ready for playback to one or more +--- speakers. The functions decode the data, resample it to 48 kHz (using the +--- default resampling method), apply a low-pass filter to decrease interpolation +--- error, mix to mono if desired, and then return a list of tables with samples +--- in the range [-128, 127], plus the current position of the audio. The +--- iterators can be passed directly to the aukit.play function, which complements +--- the aukit.stream suite by playing the decoded audio on speakers while decoding +--- it in real-time, handling synchronization of speakers as best as possible. +--- +--- If you're really lazy, you can also call `aukit` as a function, which takes +--- the path to a file, and plays this on all available speakers. +--- +--- Be aware that processing large amounts of audio (especially loading FLAC or +--- resampling with higher quality) is *very* slow. It's recommended to use audio +--- files with lower data size (8-bit mono PCM/WAV/AIFF is ideal), and potentially +--- a lower sample rate, to reduce the load on the system - especially as all +--- data gets converted to 8-bit DFPWM data on playback anyway. The code yields +--- internally when things take a long time to avoid abort timeouts. +--- +--- For an example of how to use AUKit, see the accompanying auplay.lua file. +--- +--- @author JackMacWindows +--- +--- @copyright +--- +--- MIT License +--- +--- Copyright (c) 2021-2024 JackMacWindows +--- +--- Permission is hereby granted, free of charge, to any person obtaining a copy +--- of this software and associated documentation files (the "Software"), to deal +--- in the Software without restriction, including without limitation the rights +--- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--- copies of the Software, and to permit persons to whom the Software is +--- furnished to do so, subject to the following conditions: +--- +--- The above copyright notice and this permission notice shall be included in all +--- copies or substantial portions of the Software. +--- +--- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--- SOFTWARE. + +local expect = require "cc.expect" +local dfpwm = require "cc.audio.dfpwm" + +local bit32_band, bit32_bxor, bit32_lshift, bit32_rshift, bit32_arshift, bit32_btest, bit32_extract = bit32.band, bit32.bxor, bit32.lshift, bit32.rshift, bit32.arshift, bit32.btest, bit32.extract +local math_floor, math_ceil, math_sin, math_abs, math_fmod, math_min, math_max, math_pi = math.floor, math.ceil, math.sin, math.abs, math.fmod, math.min, math.max, math.pi +local os_epoch, os_queueEvent, os_pullEvent = os.epoch, os.queueEvent, os.pullEvent +local str_pack, str_unpack, str_sub, str_byte, str_rep = string.pack, string.unpack, string.sub, string.byte, string.rep +local table_pack, table_unpack, table_insert, table_remove = table.pack, table.unpack, table.insert, table.remove + +--- The main AUKit module. +---@class aukit +---@field _VERSION string The version of AUKit that is loaded. This follows [SemVer](https://semver.org) format. +---@field defaultInterpolation "none"|"linear"|"cubic"|"sinc" Default interpolation mode for `Audio.resample` and other functions that need to resample. +local aukit = setmetatable({ + _VERSION = "1.10.0", + defaultInterpolation = "linear" +}, {__call = function(aukit, path) + expect(1, path, "string") + local file = assert(fs.open(path, "rb")) + local type = aukit.detect(file.read(64)) or "dfpwm" + file.seek("set", 0) + aukit.play(aukit.stream[type](function() return file.read(48000) end), peripheral.find("speaker")) + file.close() +end}) +--- Effects that can modify audio chunks. +---@class aukit.effects +aukit.effects = {} +--- Loader functions for streaming audio from a remote resource. These are usually used with `aukit.play`. +---@class aukit.stream +aukit.stream = {} + + +--- The aukit.Audio class represents a chunk of audio with variable channels and sample rate. +---@class aukit.Audio +---@field data number[][] The samples in each channel. +---@field sampleRate number The sample rate of the audio. +---@field metadata table Stores any metadata read from the file if present. +---@field info Metadata Stores any decoder-specific information, including `bitDepth` and `dataType`. +local Audio = {} +local Audio_mt + +local dfpwmUUID = "3ac1fa38-811d-4361-a40d-ce53ca607cd1" -- UUID for DFPWM in WAV files + +local function uuidBytes(uuid) return uuid:gsub("-", ""):gsub("%x%x", function(c) return string.char(tonumber(c, 16)) end) end + +local sincWindowSize = jit and 30 or 10 + +local wavExtensible = { + dfpwm = uuidBytes(dfpwmUUID), + pcm = uuidBytes "01000000-0000-1000-8000-00aa00389b71", + msadpcm = uuidBytes "02000000-0000-1000-8000-00aa00389b71", + alaw = uuidBytes "06000000-0000-1000-8000-00aa00389b71", + ulaw = uuidBytes "07000000-0000-1000-8000-00aa00389b71", + adpcm = uuidBytes "11000000-0000-1000-8000-00aa00389b71", + pcm_float = uuidBytes "03000000-0000-1000-8000-00aa00389b71" +} + +local wavExtensibleChannels = { + 0x04, + 0x03, + 0x07, + 0x33, + 0x37, + 0x3F, + 0x637, + 0x63F, + 0x50F7, + 0x50FF, + 0x56F7, + 0x56FF +} + +local ima_index_table = { + [0] = -1, -1, -1, -1, 2, 4, 6, 8, + -1, -1, -1, -1, 2, 4, 6, 8 +} + +local ima_step_table = { + [0] = 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, + 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, + 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, + 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, + 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, + 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, + 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, + 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, + 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767 +} + +local msadpcm_adaption_table = { + [0] = 230, 230, 230, 230, 307, 409, 512, 614, + [-8] = 768, [-7] = 614, [-6] = 512, [-5] = 409, [-4] = 307, [-3] = 230, [-2] = 230, [-1] = 230 +} + +local flacMetadata = { + tracknumber = "trackNumber", + ["encoded-by"] = "encodedBy", + sourcemedia = "sourceMedia", + labelno = "labelNumber", + discnumber = "discNumber", + partnumber = "partNumber", + productnumber = "productNumber", + catalognumber = "catalogNumber", + ["release date"] = "releaseDate", + ["source medium"] = "sourceMedium", + ["source artist"] = "sourceArtist", + ["guest artist"] = "guestArtist", + ["source work"] = "sourceWork", + disctotal = "discCount", + tracktotal = "trackCount", + parttotal = "partCount", + tcm = "composer" +} + +local wavMetadata = { + IPRD = "album", + INAM = "title", + IART = "artist", + IWRI = "author", + IMUS = "composer", + IPRO = "producer", + IPRT = "trackNumber", + ITRK = "trackNumber", + IFRM = "trackCount", + PRT1 = "partNumber", + PRT2 = "partCount", + TLEN = "length", + IRTD = "rating", + ICRD = "date", + ITCH = "encodedBy", + ISFT = "encoder", + ISRF = "media", + IGNR = "genre", + ICMT = "comment", + ICOP = "copyright", + ILNG = "language" +} + +local function utf8decode(str, pos) + local codes = {utf8.codepoint(str, 1, -1)} + for i, v in ipairs(codes) do if v > 0xFF then codes[i] = 0x3F end end + return string.char(table_unpack(codes)), pos +end + +local function clamp(n, min, max) + if n < min then return min + elseif n > max then return max + else return n end +end + +local function expectAudio(n, var) + if type(var) == "table" and getmetatable(var) == Audio_mt then return var end + expect(n, var, "Audio") -- always fails +end + +local function copy(tab) + local t = {} + for k, v in pairs(tab) do t[k] = v end + return t +end + +local function intunpack(str, pos, sz, signed, be) + local n = 0 + if be then for i = 0, sz - 1 do n = n * 256 + str_byte(str, pos+i) end + else for i = 0, sz - 1 do n = n + str_byte(str, pos+i) * 2^(8*i) end end + if signed and n >= 2^(sz*8-1) then n = n - 2^(sz*8) end + return n, pos + sz +end + +local interpolate = { + none = function(data, x) + return data[math_floor(x)] + end, + linear = function(data, x) + local ffx = math_floor(x) + return data[ffx] + ((data[ffx+1] or data[ffx]) - data[ffx]) * (x - ffx) + end, + cubic = function(data, x) + local ffx = math_floor(x) + local p0, p1, p2, p3, fx = data[ffx-1], data[ffx], data[ffx+1], data[ffx+2], x - ffx + p0, p2, p3 = p0 or p1, p2 or p1, p3 or p2 or p1 + return (-0.5*p0 + 1.5*p1 - 1.5*p2 + 0.5*p3)*fx^3 + (p0 - 2.5*p1 + 2*p2 - 0.5*p3)*fx^2 + (-0.5*p0 + 0.5*p2)*fx + p1 + end, + sinc = function(data, x) + local ffx = math_floor(x) + local fx = x - ffx + local sum = 0 + for n = -sincWindowSize, sincWindowSize do + local idx = ffx+n + local d = data[idx] + if d then + local px = math_pi * (fx - n) + if px == 0 then sum = sum + d + else sum = sum + d * math_sin(px) / px end + end + end + return sum + end +} +local interpolation_start = {none = 1, linear = 1, cubic = 0, sinc = 0} +local interpolation_end = {none = 1, linear = 2, cubic = 3, sinc = 0} + +local wavegen = { + sine = function(x, freq, amplitude) + return math_sin(2 * x * math_pi * freq) * amplitude + end, + triangle = function(x, freq, amplitude) + return 2.0 * math_abs(amplitude * math_fmod(2.0 * x * freq + 1.5, 2.0) - amplitude) - amplitude + end, + square = function(x, freq, amplitude, duty) + if (x * freq) % 1 >= duty then return -amplitude else return amplitude end + end, + sawtooth = function(x, freq, amplitude) + return amplitude * math_fmod(2.0 * x * freq + 1.0, 2.0) - amplitude + end +} + +--[[ +.########.##..........###.....######. +.##.......##.........##.##...##....## +.##.......##........##...##..##...... +.######...##.......##.....##.##...... +.##.......##.......#########.##...... +.##.......##.......##.....##.##....## +.##.......########.##.....##..######. +]] + +local decodeFLAC do + + -- Simple FLAC decoder (Java) + -- + -- Copyright (c) 2017 Project Nayuki. (MIT License) + -- https://www.nayuki.io/page/simple-flac-implementation + -- + -- Permission is hereby granted, free of charge, to any person obtaining a copy of + -- this software and associated documentation files (the "Software"), to deal in + -- the Software without restriction, including without limitation the rights to + -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + -- the Software, and to permit persons to whom the Software is furnished to do so, + -- subject to the following conditions: + -- - The above copyright notice and this permission notice shall be included in + -- all copies or substantial portions of the Software. + -- - The Software is provided "as is", without warranty of any kind, express or + -- implied, including but not limited to the warranties of merchantability, + -- fitness for a particular purpose and noninfringement. In no event shall the + -- authors or copyright holders be liable for any claim, damages or other + -- liability, whether in an action of contract, tort or otherwise, arising from, + -- out of or in connection with the Software or the use or other dealings in the + -- Software. + + local FIXED_PREDICTION_COEFFICIENTS = { + {}, + {1}, + {2, -1}, + {3, -3, 1}, + {4, -6, 4, -1}, + }; + + local function BitInputStream(data, pos) + local obj = {} + local bitBuffer, bitBufferLen = 0, 0 + function obj.alignToByte() + bitBufferLen = bitBufferLen - bitBufferLen % 8 + end + function obj.readByte() + return obj.readUint(8) + end + function obj.readUint(n) + if n == 0 then return 0 end + while bitBufferLen < n do + local temp = str_byte(data, pos) + pos = pos + 1 + if temp == nil then return nil end + bitBuffer = (bitBuffer * 256 + temp) % 0x100000000000 + bitBufferLen = bitBufferLen + 8 + end + bitBufferLen = bitBufferLen - n + local result = math_floor(bitBuffer / 2^bitBufferLen) + if n < 32 then result = result % 2^n end + return result + end + function obj.readSignedInt(n) + local v = obj.readUint(n) + if v >= 2^(n-1) then v = v - 2^n end + return v + end + function obj.readRiceSignedInt(param) + local val = 0 + while (obj.readUint(1) == 0) do val = val + 1 end + val = val * 2^param + obj.readUint(param) + if bit32_btest(val, 1) then return -math_floor(val / 2) - 1 + else return math_floor(val / 2) end + end + return obj + end + + local function decodeResiduals(inp, warmup, blockSize, result) + local method = inp.readUint(2); + if (method >= 2) then error("Reserved residual coding method " .. method) end + local paramBits = method == 0 and 4 or 5; + local escapeParam = method == 0 and 0xF or 0x1F; + + local partitionOrder = inp.readUint(4); + local numPartitions = 2^partitionOrder; + if (blockSize % numPartitions ~= 0) then + error("Block size not divisible by number of Rice partitions") + end + local partitionSize = math_floor(blockSize / numPartitions); + + for i = 0, numPartitions-1 do + local start = i * partitionSize + (i == 0 and warmup or 0); + local endd = (i + 1) * partitionSize; + + local param = inp.readUint(paramBits); + if (param < escapeParam) then + for j = start, endd - 1 do + result[j+1] = inp.readRiceSignedInt(param) + end + else + local numBits = inp.readUint(5); + for j = start, endd - 1 do + result[j+1] = inp.readSignedInt(numBits) + end + end + end + end + + local function restoreLinearPrediction(result, coefs, shift, blockSize) + for i = #coefs, blockSize - 1 do + local sum = 0 + for j = 0, #coefs - 1 do + sum = sum + result[i - j] * coefs[j + 1] + end + result[i + 1] = result[i + 1] + math_floor(sum / 2^shift) + end + end + + local function decodeFixedPredictionSubframe(inp, predOrder, sampleDepth, blockSize, result) + for i = 1, predOrder do + result[i] = inp.readSignedInt(sampleDepth); + end + decodeResiduals(inp, predOrder, blockSize, result); + restoreLinearPrediction(result, FIXED_PREDICTION_COEFFICIENTS[predOrder+1], 0, blockSize); + end + + local function decodeLinearPredictiveCodingSubframe(inp, lpcOrder, sampleDepth, blockSize, result) + for i = 1, lpcOrder do + result[i] = inp.readSignedInt(sampleDepth); + end + local precision = inp.readUint(4) + 1; + local shift = inp.readSignedInt(5); + local coefs = {}; + for i = 1, lpcOrder do + coefs[i] = inp.readSignedInt(precision); + end + decodeResiduals(inp, lpcOrder, blockSize, result); + restoreLinearPrediction(result, coefs, shift, blockSize); + end + + local function decodeSubframe(inp, sampleDepth, blockSize, result) + inp.readUint(1); + local type = inp.readUint(6); + local shift = inp.readUint(1); + if (shift == 1) then + while (inp.readUint(1) == 0) do shift = shift + 1 end + end + sampleDepth = sampleDepth - shift + + if (type == 0) then -- Constant coding + local c = inp.readSignedInt(sampleDepth) + for i = 1, blockSize do result[i] = c end + elseif (type == 1) then -- Verbatim coding + for i = 1, blockSize do + result[i] = inp.readSignedInt(sampleDepth); + end + elseif (8 <= type and type <= 12) then + decodeFixedPredictionSubframe(inp, type - 8, sampleDepth, blockSize, result) + elseif (32 <= type and type <= 63) then + decodeLinearPredictiveCodingSubframe(inp, type - 31, sampleDepth, blockSize, result) + else + error("Reserved subframe type") + end + + for i = 1, blockSize do + result[i] = result[i] * 2^shift + end + end + + local function decodeSubframes(inp, sampleDepth, chanAsgn, blockSize, result) + local subframes = {} + for i = 1, #result do subframes[i] = {} end + if (0 <= chanAsgn and chanAsgn <= 7) then + for ch = 1, #result do + decodeSubframe(inp, sampleDepth, blockSize, subframes[ch]) + end + elseif (8 <= chanAsgn and chanAsgn <= 10) then + decodeSubframe(inp, sampleDepth + (chanAsgn == 9 and 1 or 0), blockSize, subframes[1]) + decodeSubframe(inp, sampleDepth + (chanAsgn == 9 and 0 or 1), blockSize, subframes[2]) + if (chanAsgn == 8) then + for i = 1, blockSize do + subframes[2][i] = subframes[1][i] - subframes[2][i] + end + elseif (chanAsgn == 9) then + for i = 1, blockSize do + subframes[1][i] = subframes[1][i] + subframes[2][i] + end + elseif (chanAsgn == 10) then + for i = 1, blockSize do + local side = subframes[2][i] + local right = subframes[1][i] - math_floor(side / 2) + subframes[2][i] = right + subframes[1][i] = right + side + end + end + else + error("Reserved channel assignment"); + end + for ch = 1, #result do + for i = 1, blockSize do + local s = subframes[ch][i] + if s >= 2^(sampleDepth-1) then s = s - 2^sampleDepth end + result[ch][i] = s / 2^sampleDepth + end + end + end + + local function decodeFrame(inp, numChannels, sampleDepth, out2, callback) + local out = {} + for i = 1, numChannels do out[i] = {} end + -- Read a ton of header fields, and ignore most of them + local temp = inp.readByte() + if temp == nil then + return false + end + local sync = temp * 64 + inp.readUint(6); + if sync ~= 0x3FFE then error("Sync code expected") end + + inp.readUint(2); + local blockSizeCode = inp.readUint(4); + local sampleRateCode = inp.readUint(4); + local chanAsgn = inp.readUint(4); + inp.readUint(4); + + temp = inp.readUint(8); + local t2 = -1 + for i = 7, 0, -1 do if not bit32_btest(temp, 2^i) then break end t2 = t2 + 1 end + for i = 1, t2 do inp.readUint(8) end + + local blockSize + if (blockSizeCode == 1) then + blockSize = 192 + elseif (2 <= blockSizeCode and blockSizeCode <= 5) then + blockSize = 576 * 2^(blockSizeCode - 2) + elseif (blockSizeCode == 6) then + blockSize = inp.readUint(8) + 1 + elseif (blockSizeCode == 7) then + blockSize = inp.readUint(16) + 1 + elseif (8 <= blockSizeCode and blockSizeCode <= 15) then + blockSize = 256 * 2^(blockSizeCode - 8) + else + error("Reserved block size") + end + + if (sampleRateCode == 12) then + inp.readUint(8) + elseif (sampleRateCode == 13 or sampleRateCode == 14) then + inp.readUint(16) + end + + inp.readUint(8) + + decodeSubframes(inp, sampleDepth, chanAsgn, blockSize, out) + inp.alignToByte() + inp.readUint(16) + + if callback then callback(out) else + for c = 1, numChannels do + local n = #out2[c] + for i = 1, blockSize do out2[c][n+i] = out[c][i] end + end + end + + return true + end + + function decodeFLAC(inp, callback, head) + local out = {} + local pos = 1 + -- Handle FLAC header and metadata blocks + local temp temp, pos = intunpack(inp, pos, 4, false, true) + if temp ~= 0x664C6143 then error("Invalid magic string") end + local sampleRate, numChannels, sampleDepth, numSamples + local last = false + local meta = {} + while not last do + temp, pos = str_byte(inp, pos), pos + 1 + last = bit32_btest(temp, 0x80) + local type = bit32_band(temp, 0x7F); + local length length, pos = intunpack(inp, pos, 3, false, true) + if type == 0 then -- Stream info block + pos = pos + 10 + sampleRate, pos = intunpack(inp, pos, 2, false, true) + sampleRate = sampleRate * 16 + bit32_rshift(str_byte(inp, pos), 4) + numChannels = bit32_band(bit32_rshift(str_byte(inp, pos), 1), 7) + 1; + sampleDepth = bit32_band(str_byte(inp, pos), 1) * 16 + bit32_rshift(str_byte(inp, pos+1), 4) + 1; + numSamples, pos = intunpack(inp, pos + 2, 4, false, true) + numSamples = numSamples + bit32_band(str_byte(inp, pos-5), 15) * 2^32 + pos = pos + 16 + elseif type == 4 then + local ncomments + meta.vendor, ncomments, pos = str_unpack(" 3000 then start = os_epoch "utc" sleep(0) end + local x = (i - 1) / ratio + 1 + if x % 1 == 0 then line[i] = c[x] + else line[i] = clamp(interp(c, x), -1, 1) end + end + new.data[y] = line + end + return new +end + +--- Mixes down all channels to a new mono-channel audio object. +---@return aukit.Audio _ A new audio object with the audio mixed to mono +function Audio:mono() + local new = setmetatable({sampleRate = self.sampleRate, data = {{}}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + local ndata = new.data[1] + local cn = #self.data + local start = os_epoch "utc" + for i = 1, #self.data[1] do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + local s = 0 + for c = 1, cn do s = s + self.data[c][i] end + ndata[i] = s / cn + end + return new +end + +--- Concatenates this audio object with another, adding the contents of each +--- new channel to the end of each old channel, resampling the new channels to match +--- this one (if necessary), and inserting silence in any missing channels. +---@param ... aukit.Audio The audio objects to concatenate +---@return aukit.Audio _ The new concatenated audio object +function Audio:concat(...) + local audios = {self, ...} + local l = {#self.data[1]} + local cn = #self.data + for i = 2, #audios do + expectAudio(i-1, audios[i]) + if audios[i].sampleRate ~= self.sampleRate then audios[i] = audios[i]:resample(self.sampleRate) end + l[i] = #audios[i].data[1] + cn = math_max(cn, #audios[i].data) + end + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + for c = 1, cn do + local ch = {} + local pos = 0 + for a = 1, #audios do + local sch = audios[a].data[c] + if sch then for i = 1, l[a] do ch[pos+i] = sch[i] end + else for i = 1, l[a] do ch[pos+i] = 0 end end + pos = pos + l[a] + end + obj.data[c] = ch + end + return obj +end + +--- Takes a subregion of the audio and returns a new audio object with its contents. +--- This takes the same arguments as `string.sub`, but positions start at 0. +---@param start? number The start position of the audio in seconds +---@param last? number The end position of the audio in seconds (0 means end of file) +---@return aukit.Audio _ The new split audio object +function Audio:sub(start, last) + start = math_floor(expect(1, start, "number", "nil") or 0) + last = math_floor(expect(2, last, "number", "nil") or 0) + local len = #self.data[1] / self.sampleRate + if start < 0 then start = len + start end + if last <= 0 then last = len + last end + expect.range(start, 0, len) + expect.range(last, 0, len) + start, last = start * self.sampleRate + 1, last * self.sampleRate + 1 + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + for c = 1, #self.data do + local ch = {} + local sch = self.data[c] + for i = start, last do ch[i-start+1] = sch[i] end + obj.data[c] = ch + end + return obj +end + +--- Combines the channels of this audio object with another, adding the new +--- channels on the end of the new object, resampling the new channels to match +--- this one (if necessary), and extending any channels that are shorter than the +--- longest channel with zeroes. +---@param ... aukit.Audio The audio objects to combine with +---@return aukit.Audio _ The new combined audio object +function Audio:combine(...) + local audios = {self, ...} + local len = #self.data[1] + for i = 2, #audios do + expectAudio(i-1, audios[i]) + if audios[i].sampleRate ~= self.sampleRate then audios[i] = audios[i]:resample(self.sampleRate) end + len = math_max(len, #audios[i].data[1]) + end + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + local pos = 0 + for a = 1, #audios do + for c = 1, #audios[a].data do + local sch, ch = audios[a].data[c], {} + for i = 1, len do ch[i] = sch[i] or 0 end + obj.data[pos+c] = ch + end + pos = pos + #audios[a].data + end + return obj +end + +--- Splits this audio object into one or more objects with the specified channels. +--- Passing a channel that doesn't exist will throw an error. +---@param ... number[] The lists of channels in each new object +---@return aukit.Audio ... The new audio objects created from the channels in each list +---@usage Split a stereo track into independent mono objects +-- +--- local left, right = stereo:split({1}, {2}) +function Audio:split(...) + local retval = {} + for n, cl in ipairs{...} do + expect(n, cl, "table") + if #cl == 0 then error("bad argument #" .. n .. " (cannot use empty table)") end + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + for cd, cs in ipairs(cl) do + local sch, ch = self.data[expect(cd, cs, "number")], {} + if not sch then error("channel " .. cs .. " (in argument " .. n .. ") out of range", 2) end + for i = 1, #sch do ch[i] = sch[i] end + obj.data[cd] = ch + end + retval[#retval+1] = obj + end + return table_unpack(retval) +end + +--- Mixes two or more audio objects into a single object, amplifying each sample +--- with a multiplier (before clipping) if desired, and clipping any values +--- outside the audio range ([-1, 1]). Channels that are shorter are padded with +--- zeroes at the end, and non-existent channels are replaced with all zeroes. +--- Any audio objects with a different sample rate are resampled to match this one. +---@param amplifier number|Audio The multiplier to apply, or the first audio object +---@param ... aukit.Audio The objects to mix with this one +---@return aukit.Audio _ The new mixed audio object +function Audio:mix(amplifier, ...) + local audios = {self, ...} + local len = #self.data[1] + local cn = #self.data + for i = 2, #audios do + expectAudio(i, audios[i]) + if audios[i].sampleRate ~= self.sampleRate then audios[i] = audios[i]:resample(self.sampleRate) end + len = math_max(len, #audios[i].data[1]) + cn = math_max(cn, #audios[i].data) + end + if type(amplifier) ~= "number" then + expectAudio(1, amplifier) + if amplifier.sampleRate ~= self.sampleRate then amplifier = amplifier:resample(self.sampleRate) end + len = math_max(len, #amplifier.data[1]) + cn = math_max(cn, #amplifier.data) + table_insert(audios, 2, amplifier) + amplifier = 1 + end + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + for c = 1, cn do + local ch = {} + local sch = {} + for a = 1, #audios do sch[a] = audios[a].data[c] end + for i = 1, len do + local s = 0 + for a = 1, #audios do if sch[a] then s = s + (sch[a][i] or 0) end end + ch[i] = clamp(s * amplifier, -1, 1) + end + obj.data[c] = ch + end + return obj +end + +--- Returns a new audio object that repeats this audio a number of times. +---@param count number The number of times to play the audio +---@return aukit.Audio _ The repeated audio +function Audio:rep(count) + if type(self) ~= "table" and type(count) == "table" then self, count = count, self end + expect(1, count, "number") + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + for c = 1, #self.data do + local sch, ch = self.data[c], {} + for n = 0, count - 1 do + local pos = n * #sch + for i = 1, #sch do ch[pos+i] = sch[i] end + end + obj.data[c] = ch + end + return obj +end + +--- Returns a reversed version of this audio. +---@return aukit.Audio _ The reversed audio +function Audio:reverse() + local obj = setmetatable({sampleRate = self.sampleRate, data = {}, metadata = copy(self.metadata), info = copy(self.info)}, Audio_mt) + for c = 1, #self.data do + local sch, ch = self.data[c], {} + local len = #sch + for i = 1, len do ch[len-i+1] = sch[i] end + obj.data[c] = ch + end + return obj +end + +local function encodePCM(info, pos) + local maxValue = 2^(info.bitDepth-1) + local add = info.dataType == "unsigned" and maxValue or 0 + local source = info.audio.data + local encode + if info.dataType == "float" then encode = function(d) return d end + else encode = function(d) return d * (d < 0 and maxValue or maxValue-1) + add end end + local data = {} + local nc = #source + local len = #source[1] + if pos > len then return nil end + local start = os_epoch "utc" + if info.interleaved then for n = pos, pos + info.len - 1 do if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end for c = 1, nc do data[(n-1)*nc+c] = encode(source[c][n]) end end + elseif info.multiple then + for c = 1, nc do + data[c] = {} + for n = pos, pos + info.len - 1 do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + local s = source[c][n] + if not s then break end + data[c][n-pos+1] = encode(s) + end + end + return pos + info.len, table_unpack(data) + else for c = 1, nc do for n = pos, pos + info.len - 1 do if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end data[(c-1)*len+n] = encode(source[c][n]) end end end + return data +end + +--- Converts the audio data to raw PCM samples. +---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32) +---@param dataType? "signed"|"unsigned"|"float" The type of each sample +---@param interleaved? boolean Whether to interleave each channel +---@return number[]|nil ... The resulting audio data +function Audio:pcm(bitDepth, dataType, interleaved) + bitDepth = expect(1, bitDepth, "number", "nil") or 8 + dataType = expect(2, dataType, "string", "nil") or "signed" + expect(3, interleaved, "boolean", "nil") + if interleaved == nil then interleaved = true end + if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end + if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end + if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end + return encodePCM({audio = self, bitDepth = bitDepth, dataType = dataType, interleaved = interleaved, len = #self.data[1]}, 1) +end + +--- Returns a function that can be called to encode PCM samples in chunks. +--- This is useful as a for iterator, and can be used with `aukit.play`. +---@param chunkSize? number The size of each chunk +---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32) +---@param dataType? "signed"|"unsigned"|"float" The type of each sample +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number _ The total length of the audio in seconds +function Audio:stream(chunkSize, bitDepth, dataType) + chunkSize = expect(1, chunkSize, "number", "nil") or 131072 + bitDepth = expect(2, bitDepth, "number", "nil") or 8 + dataType = expect(3, dataType, "string", "nil") or "signed" + if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end + if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end + if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end + local info, pos = {audio = self, bitDepth = bitDepth, dataType = dataType, interleaved = false, multiple = true, len = chunkSize}, 1 + return function() + if info == nil then return nil end + local p = pos / self.sampleRate + local v = {encodePCM(info, pos)} + if v[1] == nil then info = nil return nil end + pos = table_remove(v, 1) + return v, p + end, #self.data[1] / self.sampleRate +end + +--- Coverts the audio data to a WAV file. +---@param bitDepth? number The bit depth of the audio (1 = DFPWM, 8, 16, 24, 32) +---@return string _ The resulting WAV file data +function Audio:wav(bitDepth) + -- TODO: Support float data + bitDepth = expect(1, bitDepth, "number", "nil") or 16 + if bitDepth == 1 then + local str = self:dfpwm(true) + if self.metadata and next(self.metadata) then + local info = {} + for k, v in pairs(self.metadata) do + for l, w in pairs(wavMetadata) do + if w == k then + info[#info+1] = l + info[#info+1] = tostring(v) + break + end + end + end + local list = str_pack("!2" or "<" + local sformat = dataType == "float" and "f" or ((dataType == "signed" and "i" or "I") .. byteDepth) + local format = bitDir .. str_rep(sformat, csize) + local maxValue = 2^(bitDepth-1) + local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {bitDepth = bitDepth, dataType = dataType}}, Audio_mt) + for i = 1, channels do obj.data[i] = {} end + local pos, spos = 1, 1 + local tmp = {} + local read + if type(data) == "table" then + if dataType == "signed" then + function read() + local s = data[pos] + pos = pos + 1 + return s / (s < 0 and maxValue or maxValue-1) + end + elseif dataType == "unsigned" then + function read() + local s = data[pos] + pos = pos + 1 + return (s - 128) / (s < 128 and maxValue or maxValue-1) + end + else + function read() + local s = data[pos] + pos = pos + 1 + return s + end + end + elseif dataType == "float" then + function read() + if pos > #tmp then + if spos + csizeb > #data then + local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth) + tmp = {str_unpack(f, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + else + tmp = {str_unpack(format, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + end + pos = 1 + end + local s = tmp[pos] + pos = pos + 1 + return s + end + elseif dataType == "signed" then + function read() + if pos > #tmp then + if spos + csizeb > #data then + local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth) + tmp = {str_unpack(f, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + else + tmp = {str_unpack(format, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + end + pos = 1 + end + local s = tmp[pos] + pos = pos + 1 + return s / (s < 0 and maxValue or maxValue-1) + end + else -- unsigned + function read() + if pos > #tmp then + if spos + csizeb > #data then + local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth) + tmp = {str_unpack(f, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + else + tmp = {str_unpack(format, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + end + pos = 1 + end + local s = tmp[pos] + pos = pos + 1 + return (s - 128) / (s < 128 and maxValue or maxValue-1) + end + end + local start = os_epoch "utc" + if interleaved and channels > 1 then + local d = obj.data + for i = 1, len do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + for j = 1, channels do d[j][i] = read() end + end + else for j = 1, channels do + local line = {} + obj.data[j] = line + for i = 1, len do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + line[i] = read() + end + end end + return obj +end + +--- Creates a new audio object from IMA ADPCM data. +---@param data string|table The audio data, either as a raw string, or a table of nibbles +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@param topFirst? boolean Whether the top nibble is the first nibble +--- (true) or last (false); ignored if `data` is a table +---@param interleaved? boolean Whether each channel is interleaved or separate +---@param predictor? number|table The initial predictor value(s) +---@param step_index? number|table The initial step index(es) +---@return aukit.Audio _ A new audio object containing the decoded data +function aukit.adpcm(data, channels, sampleRate, topFirst, interleaved, predictor, step_index) + expect(1, data, "string", "table") + channels = expect(2, channels, "number", "nil") or 1 + sampleRate = expect(3, sampleRate, "number", "nil") or 48000 + expect(4, topFirst, "boolean", "nil") + if topFirst == nil then topFirst = true end + expect(5, interleaved, "boolean", "nil") + if interleaved == nil then interleaved = true end + predictor = expect(6, predictor, "number", "table", "nil") + step_index = expect(7, step_index, "number", "table", "nil") + expect.range(channels, 1) + expect.range(sampleRate, 1) + if predictor == nil then + predictor = {} + for i = 1, channels do predictor[i] = 0 end + elseif type(predictor) == "number" then + if channels ~= 1 then error("bad argument #6 (table too short)", 2) end + predictor = {expect.range(predictor, -32768, 32767)} + else + if channels > #predictor then error("bad argument #6 (table too short)", 2) end + for i = 1, channels do expect.range(predictor[i], -32768, 32767) end + end + if step_index == nil then + step_index = {} + for i = 1, channels do step_index[i] = 0 end + elseif type(step_index) == "number" then + if channels ~= 1 then error("bad argument #7 (table too short)", 2) end + step_index = {expect.range(step_index, 0, 88)} + else + if channels > #step_index then error("bad argument #7 (table too short)", 2) end + for i = 1, channels do expect.range(step_index[i], 0, 88) end + end + local pos = 1 + local read, tmp, len + if type(data) == "string" then + function read() + if tmp then + local v = tmp + tmp = nil + return v + else + local b = str_byte(data, pos) + pos = pos + 1 + if topFirst then tmp, b = bit32_band(b, 0x0F), bit32_rshift(b, 4) + else tmp, b = bit32_rshift(b, 4), bit32_band(b, 0x0F) end + return b + end + end + len = math_floor(#data * 2 / channels) + else + function read() + local v = data[pos] + pos = pos + 1 + return v + end + len = #data / channels + end + local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {bitDepth = 16, dataType = "signed"}}, Audio_mt) + local step = {} + local start = os_epoch "utc" + if interleaved then + local d = obj.data + for j = 1, channels do d[j] = {} end + for i = 1, len do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + for j = 1, channels do + local nibble = read() + step[j] = ima_step_table[step_index[j]] + step_index[j] = clamp(step_index[j] + ima_index_table[nibble], 0, 88) + local diff = bit32_rshift((nibble % 8) * step[j], 2) + bit32_rshift(step[j], 3) + if nibble >= 8 then predictor[j] = clamp(predictor[j] - diff, -32768, 32767) + else predictor[j] = clamp(predictor[j] + diff, -32768, 32767) end + d[j][i] = predictor[j] / (predictor[j] < 0 and 32768 or 32767) + end + end + else for j = 1, channels do + local line = {} + local predictor, step_index, step = predictor[j], step_index[j], nil + for i = 1, len do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + local nibble = read() + step = ima_step_table[step_index] + step_index = clamp(step_index + ima_index_table[nibble], 0, 88) + local diff = bit32_rshift((nibble % 8) * step, 2) + bit32_rshift(step, 3) + if nibble >= 8 then predictor = clamp(predictor - diff, -32768, 32767) + else predictor = clamp(predictor + diff, -32768, 32767) end + line[i] = predictor / (predictor < 0 and 32768 or 32767) + end + obj.data[j] = line + end end + return obj +end + +--- Creates a new audio object from Microsoft ADPCM data. +---@param data string The audio data as a raw string +---@param blockAlign number The number of bytes in each block +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@param coefficients? table Two lists of coefficients to use +---@return aukit.Audio _ A new audio object containing the decoded data +function aukit.msadpcm(data, blockAlign, channels, sampleRate, coefficients) + expect(1, data, "string") + expect(2, blockAlign, "number") + channels = expect(3, channels, "number", "nil") or 1 + sampleRate = expect(4, sampleRate, "number", "nil") or 48000 + expect(5, coefficients, "table", "nil") + expect.range(sampleRate, 1) + local coeff1, coeff2 + if coefficients then + if type(coefficients[1]) ~= "table" then error("bad argument #5 (first entry is not a table)", 2) end + if type(coefficients[2]) ~= "table" then error("bad argument #5 (second entry is not a table)", 2) end + --if #coefficients[1] ~= #coefficients[2] then error("bad argument #5 (lists are not the same length)", 2) end + --coeff1, coeff2 = {}, {} + for i, v in ipairs(coefficients[1]) do + if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 1 (expected number, got " .. type(v) .. ")", 2) end + coeff1[i-1] = v + end + for i, v in ipairs(coefficients[2]) do + if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 2 (expected number, got " .. type(v) .. ")", 2) end + coeff2[i-1] = v + end + else coeff1, coeff2 = {[0] = 256, 512, 0, 192, 240, 460, 392}, {[0] = 0, -256, 0, 64, 0, -208, -232} end + local obj = setmetatable({sampleRate = sampleRate, data = {{}, channels == 2 and {} or nil}, metadata = {}, info = {bitDepth = 16, dataType = "signed"}}, Audio_mt) + local left, right = obj.data[1], obj.data[2] + local start = os_epoch "utc" + for n = 1, #data, blockAlign do + if channels == 2 then + local predictorIndexL, predictorIndexR, deltaL, deltaR, sample1L, sample1R, sample2L, sample2R = str_unpack("= 8 then hi = hi - 16 end + if lo >= 8 then lo = lo - 16 end + local predictor = clamp(math_floor((sample1L * c1L + sample2L * c2L) / 256) + hi * deltaL, -32768, 32767) + left[#left+1] = predictor / (predictor < 0 and 32768 or 32767) + sample2L, sample1L = sample1L, predictor + deltaL = math_max(math_floor(msadpcm_adaption_table[hi] * deltaL / 256), 16) + predictor = clamp(math_floor((sample1R * c1R + sample2R * c2R) / 256) + lo * deltaR, -32768, 32767) + right[#right+1] = predictor / (predictor < 0 and 32768 or 32767) + sample2R, sample1R = sample1R, predictor + deltaR = math_max(math_floor(msadpcm_adaption_table[lo] * deltaR / 256), 16) + end + elseif channels == 1 then + local predictorIndex, delta, sample1, sample2 = str_unpack("= 8 then hi = hi - 16 end + if lo >= 8 then lo = lo - 16 end + local predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + hi * delta, -32768, 32767) + left[#left+1] = predictor / (predictor < 0 and 32768 or 32767) + sample2, sample1 = sample1, predictor + delta = math_max(math_floor(msadpcm_adaption_table[hi] * delta / 256), 16) + predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + lo * delta, -32768, 32767) + left[#left+1] = predictor / (predictor < 0 and 32768 or 32767) + sample2, sample1 = sample1, predictor + delta = math_max(math_floor(msadpcm_adaption_table[lo] * delta / 256), 16) + end + else error("Unsupported number of channels: " .. channels) end + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + end + return obj +end + +--- Creates a new audio object from G.711 u-law/A-law data. +---@param data string The audio data as a raw string +---@param ulaw boolean Whether the audio uses u-law (true) or A-law (false). +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@return aukit.Audio _ A new audio object containing the decoded data +function aukit.g711(data, ulaw, channels, sampleRate) + expect(1, data, "string") + expect(2, ulaw, "boolean") + channels = expect(3, channels, "number", "nil") or 1 + sampleRate = expect(4, sampleRate, "number", "nil") or 8000 + local retval = {} + local csize = jit and 7680 or 32768 + local xor = ulaw and 0xFF or 0x55 + for i = 1, channels do retval[i] = {} end + local start = os_epoch "utc" + for i = 1, #data, csize do + local bytes = {str_byte(data, i, i + csize - 1)} + for j = 1, #bytes do + local b = bit32_bxor(bytes[j], xor) + local m, e = bit32_band(b, 0x0F), bit32_extract(b, 4, 3) + if not ulaw and e == 0 then m = m * 4 + 2 + else m = bit32_lshift(m * 2 + 33, e) end + if ulaw then m = m - 33 end + retval[(i+j-2) % channels + 1][math_floor((i+j-2) / channels + 1)] = m / (bit32_btest(b, 0x80) == ulaw and -0x2000 or 0x2000) + end + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + end + return setmetatable({sampleRate = sampleRate, data = retval, metadata = {bitDepth = ulaw and 14 or 13, dataType = "signed"}, info = {}}, Audio_mt) +end + +--- Creates a new audio object from DFPWM1a data. All channels are expected to +--- share the same decoder, and are stored interleaved in a single stream. +---@param data string The audio data as a raw string +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@return aukit.Audio _ A new audio object containing the decoded data +function aukit.dfpwm(data, channels, sampleRate) + expect(1, data, "string") + channels = expect(2, channels, "number", "nil") or 1 + sampleRate = expect(3, sampleRate, "number", "nil") or 48000 + expect.range(channels, 1) + expect.range(sampleRate, 1) + local audio = {} + local decoder = dfpwm.make_decoder() + local pos = 1 + local last = 0 + local start = os_epoch "utc" + while pos <= #data do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + local temp = decoder(str_sub(data, pos, pos + 6000)) + if temp == nil or #temp == 0 then break end + for i=1,#temp do + audio[last+i] = temp[i] + end + last = last + #temp + pos = pos + 6000 + end + return aukit.pcm(audio, 8, "signed", channels, sampleRate, true, false) +end + +--- Creates a new audio object from MDFPWMv3 data. +---@param data string The audio data as a raw string +---@param head boolean Whether to only load metadata (header data) - this will not decode the audio +---@return aukit.Audio _ A new audio object containing the decoded data +function aukit.mdfpwm(data, head) + expect(1, data, "string") + if data:sub(1, 7) ~= "MDFPWM\3" then error("bad argument #1 (not a MDFPWM file)", 2) end + local length, artist, title, album, pos = (" 3000 then start = os_epoch "utc" sleep(0) end + local tempL = decoderL(str_sub(data, pos, pos + 5999)) + if tempL == nil or #tempL == 0 then break end + for i = 1, #tempL do audio[last+i*2-1] = tempL[i] end + local tempR = decoderR(str_sub(data, pos + 6000, pos + 11999 )) + if tempR == nil or #tempR == 0 then break end + for i = 1, #tempR do audio[last+i*2] = tempR[i] end + last = last + #tempL + #tempR + pos = pos + 12000 + end + for i = length * 8 + 1, #audio do audio[i] = nil end + local obj = aukit.pcm(audio, 8, "signed", 2, 48000, true, false) + obj.metadata = {artist = artist, title = title, album = album} + return obj +end + +--- Creates a new audio object from a WAV file. This accepts PCM files up to 32 +--- bits, including float data, as well as DFPWM files [as specified here](https://gist.github.com/MCJack123/90c24b64c8e626c7f130b57e9800962c), +--- plus IMA and Microsoft ADPCM formats and G.711 u-law/A-law. +---@param data string The WAV data to load +---@param head boolean Whether to only load metadata (header data) - this will not decode the audio +---@return aukit.Audio _ A new audio object with the contents of the WAV file +function aukit.wav(data, head) + expect(1, data, "string") + local channels, sampleRate, bitDepth, length, dataType, blockAlign, coefficients + local temp, pos = str_unpack("c4", data) + if temp ~= "RIFF" then error("bad argument #1 (not a WAV file)", 2) end + pos = pos + 4 + temp, pos = str_unpack("c4", data, pos) + if temp ~= "WAVE" then error("bad argument #1 (not a WAV file)", 2) end + local meta = {} + local obj + while pos <= #data do + local size + temp, size, pos = str_unpack(" 0 then + coefficients = {{}, {}} + for i = 1, numcoeff do + coefficients[1][i], coefficients[2][i] = str_unpack("c4I", data, pos) + if temp == "COMM" then + local e, m + channels, length, bitDepth, e, m, pos = str_unpack(">hIhHI7x", data, pos) + if isAIFC then + local s + compression, s, pos = str_unpack(">c4s1", data, pos) + if #s % 2 == 0 then pos = pos + 1 end + end + length = length * channels * math_floor(bitDepth / 8) + local s = bit32_btest(e, 0x8000) + e = ((bit32_band(e, 0x7FFF) - 0x3FFE) % 0x800) + sampleRate = math.ldexp(m * (s and -1 or 1) / 0x100000000000000, e) + elseif temp == "SSND" then + offset, blockAlign, pos = str_unpack(">II", data, pos) + local data = str_sub(data, pos + offset, pos + offset + length - 1) + --if #data < length then error("invalid AIFF file", 2) end + local obj + if head then obj = aukit.new(0, channels, sampleRate) + elseif compression == nil or compression == "NONE" then obj = aukit.pcm(data, bitDepth, "signed", channels, sampleRate, true, true) + elseif compression == "sowt" then obj = aukit.pcm(data, bitDepth, "signed", channels, sampleRate, true, false) + elseif compression == "fl32" or compression == "FL32" then obj = aukit.pcm(data, 32, "float", channels, sampleRate, true, true) + elseif compression == "alaw" or compression == "ulaw" or compression == "ALAW" or compression == "ULAW" then obj = aukit.g711(data, compression == "ulaw" or compression == "ULAW", channels, sampleRate) + else error("Unsupported compression scheme " .. compression, 2) end + obj.metadata = meta + return obj + elseif temp == "NAME" then + meta.title = str_sub(data, pos, pos + size - 1) + pos = pos + size + elseif temp == "AUTH" then + meta.artist = str_sub(data, pos, pos + size - 1) + pos = pos + size + elseif temp == "(c) " then + meta.copyright = str_sub(data, pos, pos + size - 1) + pos = pos + size + elseif temp == "ANNO" then + meta.comment = str_sub(data, pos, pos + size - 1) + pos = pos + size + else pos = pos + size end + end + error("invalid AIFF file", 2) +end + +--- Creates a new audio object from an AU file. +---@param data string The AU data to load +---@return aukit.Audio _ A new audio object with the contents of the AU file +function aukit.au(data) + expect(1, data, "string") + local magic, offset, size, encoding, sampleRate, channels = str_unpack(">c4IIIII", data) + if magic ~= ".snd" then error("invalid AU file", 2) end + if encoding == 1 then return aukit.g711(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), true, channels, sampleRate) + elseif encoding == 2 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 8, "signed", channels, sampleRate, true, true) + elseif encoding == 3 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 16, "signed", channels, sampleRate, true, true) + elseif encoding == 4 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 24, "signed", channels, sampleRate, true, true) + elseif encoding == 5 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 32, "signed", channels, sampleRate, true, true) + elseif encoding == 6 then return aukit.pcm(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), 32, "float", channels, sampleRate, true, true) + elseif encoding == 27 then return aukit.g711(str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), false, channels, sampleRate) + else error("unsupported encoding type " .. encoding, 2) end +end + +--- Creates a new audio object from a FLAC file. +---@param data string The FLAC data to load +---@param head boolean Whether to only load metadata (header data) - this will not decode the audio +---@return aukit.Audio _ A new audio object with the contents of the FLAC file +function aukit.flac(data, head) + expect(1, data, "string") + return setmetatable(decodeFLAC(data, nil, head), Audio_mt) +end + +local qoa_dequant_tab = { + [0] = {[0] = 1, -1, 3, -3, 5, -5, 7, -7}, + {[0] = 5, -5, 18, -18, 32, -32, 49, -49}, + {[0] = 16, -16, 53, -53, 95, -95, 147, -147}, + {[0] = 34, -34, 113, -113, 203, -203, 315, -315}, + {[0] = 63, -63, 210, -210, 378, -378, 588, -588}, + {[0] = 104, -104, 345, -345, 621, -621, 966, -966}, + {[0] = 158, -158, 528, -528, 950, -950, 1477, -1477}, + {[0] = 228, -228, 760, -760, 1368, -1368, 2128, -2128}, + {[0] = 316, -316, 1053, -1053, 1895, -1895, 2947, -2947}, + {[0] = 422, -422, 1405, -1405, 2529, -2529, 3934, -3934}, + {[0] = 548, -548, 1828, -1828, 3290, -3290, 5117, -5117}, + {[0] = 696, -696, 2320, -2320, 4176, -4176, 6496, -6496}, + {[0] = 868, -868, 2893, -2893, 5207, -5207, 8099, -8099}, + {[0] = 1064, -1064, 3548, -3548, 6386, -6386, 9933, -9933}, + {[0] = 1286, -1286, 4288, -4288, 7718, -7718, 12005, -12005}, + {[0] = 1536, -1536, 5120, -5120, 9216, -9216, 14336, -14336}, +} + +local function signed_rshift(a, b) + local n = bit32_arshift(a, b) + if n >= 0x80000000 then return n - 0x100000000 else return n end +end + +local function qoa_lms_predict(lms) + local w, h = lms.weights, lms.history + return signed_rshift(w[1] * h[1] + w[2] * h[2] + w[3] * h[3] + w[4] * h[4], 13) +end + +local function qoa_lms_update(lms, sample, residual) + local delta = signed_rshift(residual, 4) + local w, h = lms.weights, lms.history + lms.weights = { + w[1] + (h[1] < 0 and -delta or delta), + w[2] + (h[2] < 0 and -delta or delta), + w[3] + (h[3] < 0 and -delta or delta), + w[4] + (h[4] < 0 and -delta or delta) + } + lms.history = {h[2], h[3], h[4], sample} +end + +--- Creates a new audio object from a QOA file. +---@param data string The QOA data to load +---@return aukit.Audio _ A new audio object with the contents of the QOA file +function aukit.qoa(data) + expect(1, data, "string") + local magic, file_samples, pos = (">c4I4"):unpack(data) + if magic ~= "qoaf" then error("Not a QOA file", 2) end + local file_channels, file_sampleRate = (">BI3"):unpack(data, pos) + local retval = setmetatable({sampleRate = file_sampleRate, data = {}, metadata = {}, info = {bitDepth = 16, dataType = "signed"}}, Audio_mt) ---@type aukit.Audio + local lms = {} + for i = 1, file_channels do + retval.data[i] = {} + lms[i] = {history = {}, weights = {}} + end + + local start = os_epoch "utc" + local sample_pos = 0 + while pos + 16 * file_channels + 8 <= #data and sample_pos < file_samples do + -- from https://github.com/phoboslab/qoa/blob/master/qoa.h + -- MIT license - Copyright (c) 2023 Dominic Szablewski + + -- Read and verify the frame header + local channels, samplerate, samples, frame_size + channels, samplerate, samples, frame_size, pos = (">BI3I2I2"):unpack(data, pos) + + local data_size = frame_size - 8 - 4 * 4 * channels + local num_slices = math_floor(data_size / 8) + local max_total_samples = num_slices * 20 + + if + channels ~= file_channels or + samplerate ~= file_sampleRate or + frame_size > #data - pos + 1 or + samples * channels > max_total_samples + then + --error("Bad frame data", 2) + break + end + + -- Read the LMS state: 4 x 2 bytes history, 4 x 2 bytes weights per channel + for c = 1, channels do + lms[c].history = {(">i2i2i2i2"):unpack(data, pos)} + pos = table.remove(lms[c].history) + lms[c].weights = {(">i2i2i2i2"):unpack(data, pos)} + pos = table.remove(lms[c].weights) + end + + -- Decode all slices for all channels in this frame + for sample_index = 1, samples, 20 do + for c = 1, channels do + local sliceH, sliceL + sliceH, sliceL, pos = (">I4I4"):unpack(data, pos) + + local scalefactor = bit32_extract(sliceH, 28, 4) + + for si = sample_index, sample_index + 19 do + local predicted = qoa_lms_predict(lms[c]) + local quantized = bit32_extract(sliceH, 25, 3) + local dequantized = qoa_dequant_tab[scalefactor][quantized] + --print(predicted, dequantized) + local reconstructed = math_min(math_max(predicted + dequantized, -32768), 32767) + + retval.data[c][sample_pos + si] = reconstructed / (reconstructed < 0 and 32768 or 32767) + sliceH = bit32_lshift(sliceH, 3) + bit32_extract(sliceL, 29, 3) + sliceL = bit32_lshift(sliceL, 3) + + qoa_lms_update(lms[c], reconstructed, dequantized) + end + end + end + sample_pos = sample_pos + samples + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + end + return retval +end + +--- Creates a new empty audio object with the specified duration. +---@param duration number The length of the audio in seconds +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@return aukit.Audio _ The new empty audio object +function aukit.new(duration, channels, sampleRate) + expect(1, duration, "number") + channels = expect(2, channels, "number", "nil") or 1 + sampleRate = expect(3, sampleRate, "number", "nil") or 48000 + expect.range(channels, 1) + expect.range(sampleRate, 1) + local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {}}, Audio_mt) + for c = 1, channels do + local l = {} + for i = 1, duration * sampleRate do l[i] = 0 end + obj.data[c] = l + end + return obj +end + +--- Creates a new audio object with a tone of the specified frequency and duration. +---@param frequency number The frequency of the tone in Hertz +---@param duration number The length of the audio in seconds +---@param amplitude? number The amplitude of the audio from 0.0 to 1.0 +---@param waveType? "sine"|"triangle"|"sawtooth"|"square" The type of wave to generate +---@param duty? number The duty cycle of the square wave if selected; ignored otherwise +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@return aukit.Audio _ A new audio object with the tone +function aukit.tone(frequency, duration, amplitude, waveType, duty, channels, sampleRate) + expect(1, frequency, "number") + expect(2, duration, "number") + amplitude = expect(3, amplitude, "number", "nil") or 1 + waveType = expect(4, waveType, "string", "nil") or "sine" + duty = expect(5, duty, "number", "nil") or 0.5 + channels = expect(6, channels, "number", "nil") or 1 + sampleRate = expect(7, sampleRate, "number", "nil") or 48000 + expect.range(amplitude, 0, 1) + local f = wavegen[waveType] + if not f then error("bad argument #4 (invalid wave type)", 2) end + expect.range(duty, 0, 1) + expect.range(channels, 1) + expect.range(sampleRate, 1) + local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {}}, Audio_mt) + for c = 1, channels do + local l = {} + for i = 1, duration * sampleRate do l[i] = f(i / sampleRate, frequency, amplitude, duty) end + obj.data[c] = l + end + return obj +end + +--- Creates a new audio object with white noise for the specified duration. +---@param duration number The length of the audio in seconds +---@param amplitude? number The amplitude of the audio from 0.0 to 1.0 +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@return aukit.Audio _ A new audio object with noise +function aukit.noise(duration, amplitude, channels, sampleRate) + expect(1, duration, "number") + amplitude = expect(2, amplitude, "number", "nil") or 1 + channels = expect(3, channels, "number", "nil") or 1 + sampleRate = expect(4, sampleRate, "number", "nil") or 48000 + expect.range(amplitude, 0, 1) + expect.range(channels, 1) + expect.range(sampleRate, 1) + local obj = setmetatable({sampleRate = sampleRate, data = {}, metadata = {}, info = {}}, Audio_mt) + local random = math.random + for c = 1, channels do + local l = {} + for i = 1, duration * sampleRate do l[i] = (random() * 2 - 1) * amplitude end + obj.data[c] = l + end + return obj +end + +--- Packs a table with PCM data into a string using the specified data type. +---@param data number[] The PCM data to pack +---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32); if `dataType` is "float" then this must be 32 +---@param dataType? "signed"|"unsigned"|"float" The type of each sample +---@param bigEndian? boolean Whether the data should be big-endian or little-endian +---@return string _ The packed PCM data +function aukit.pack(data, bitDepth, dataType, bigEndian) + expect(1, data, "string", "table") + bitDepth = expect(2, bitDepth, "number", "nil") or 8 + dataType = expect(3, dataType, "string", "nil") or "signed" + expect(4, bigEndian, "boolean", "nil") + if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end + if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end + if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end + local byteDepth = bitDepth / 8 + local format = (bigEndian and ">" or "<") .. (dataType == "float" and "f" or ((dataType == "signed" and "i" or "I") .. byteDepth)) + local formatChunk = str_sub(format, 1, 1) .. str_sub(format, 2):rep(512) + local retval = "" + for i = 1, #data, 512 do + if #data < i + 512 then retval = retval .. str_pack(str_rep(format, #data % 512), table_unpack(data, i, #data)) + else retval = retval .. str_pack(formatChunk, table_unpack(data, i, i+511)) end + end + return retval +end + +---@alias speaker {playAudio: fun(samples: number[], volume?: number)} + +--- Plays back stream functions created by one of the `aukit.stream` functions +--- or `Audio.stream`. +---@param callback fun():number[][]|nil The iterator function that returns each chunk +---@param progress? fun(pos:number) A callback to report progress to +--- the caller; if omitted then this argument is the first speaker +---@param volume? number The volume to play the audio at; if omitted then +--- this argument is the second speaker (if provided) +---@param ... speaker The speakers to play on +function aukit.play(callback, progress, volume, ...) + expect(1, callback, "function") + expect(2, progress, "function", "table") + expect(3, volume, "number", "table", "nil") + local speakers = {...} + if type(volume) == "table" then + table_insert(speakers, 1, volume) + volume = nil + end + if type(progress) == "table" then + table_insert(speakers, 1, progress) + progress = nil + end + if #speakers == 0 then error("bad argument #2 (expected speakers, got nil)", 2) end + local chunks = {} + local complete = false + local a, b = coroutine.create(function() + for chunk, pos in callback do chunks[#chunks+1] = {chunk, pos} coroutine.yield(speakers) end + complete = true + end), coroutine.create(function() + while not complete or #chunks > 0 do + while not chunks[1] do if complete then return end coroutine.yield(speakers) end + local pchunk = table_remove(chunks, 1) + local fn = {} + if progress then progress(pchunk[2]) end + pchunk = pchunk[1] + local chunklist = {} + if #pchunk[1] < 96000 then chunklist = {pchunk} + else + for i = 0, #pchunk[1] - 1, 48000 do + local chunk = {} + chunklist[#chunklist+1] = chunk + for j = 1, #pchunk do + local s, c = pchunk[j], {} + chunk[j] = c + for k = 1, 48000 do c[k] = s[k+i] end + end + end + end + for _, chunk in ipairs(chunklist) do + for i, v in ipairs(speakers) do fn[i] = function() + local name = peripheral.getName(v) + if _HOST:find("CraftOS-PC v2.6.4") and config and not config.get("standardsMode") then + v.playAudio(chunk[i] or chunk[1], volume) + repeat until select(2, os_pullEvent("speaker_audio_empty")) == name + else while not v.playAudio(chunk[i] or chunk[1], volume) do + repeat until select(2, os_pullEvent("speaker_audio_empty")) == name + end end + end end + parallel.waitForAll(table_unpack(fn)) + end + end + end) + local ok, af, bf + local aq, bq = {{}}, {{}} + repeat + if #aq > 0 then + local event = table_remove(aq, 1) + if af == speakers then + af = nil + table_insert(aq, 1, event) + end + if af == nil or event[1] == af then + ok, af = coroutine.resume(a, table_unpack(event, 1, event.n)) + if not ok then error(af, 2) end + end + end + if #bq > 0 then + local event = table_remove(bq, 1) + if bf == speakers then + bf = nil + table_insert(bq, 1, event) + end + if bf == nil or event[1] == bf then + ok, bf = coroutine.resume(b, table_unpack(event, 1, event.n)) + if not ok then error(bf, 2) end + end + end + if coroutine.status(b) == "suspended" and (#aq == 0 or #bq == 0) then + if af ~= nil and bf ~= nil then + local event = table_pack(os_pullEvent()) + aq[#aq+1] = event + bq[#bq+1] = event + else + os_queueEvent("__queue_end") + while true do + local event = table_pack(os_pullEvent()) + if event[1] == "__queue_end" then break end + aq[#aq+1] = event + bq[#bq+1] = event + end + end + end + until coroutine.status(b) == "dead" or complete + while coroutine.status(b) == "suspended" and #bq > 0 do + local event = table_remove(bq, 1) + if bf == nil or event[1] == bf then + ok, bf = coroutine.resume(b, table_unpack(event, 1, event.n)) + if not ok then error(bf, 2) end + end + end + while coroutine.status(b) == "suspended" do + ok, bf = coroutine.resume(b, os_pullEvent()) + if not ok then error(bf, 2) end + end +end + +--- An asynchronous player created by `aukit.player`. +---@class aukit.Player +---@field isPaused boolean Whether the player is paused. +---@field position number The current position of the audio, in seconds +---@field volume number The volume of the audio +---@field loaderTask Task The task that is loading the audio +---@field playerTask Task The task that is playing the audio +---@field speakers speaker[] The speakers the audio is playing on +---@field lastPlayback {pos:number,time:number} Internal playback positions for pausing +---@field invalidate boolean Internal flag to invalidate current chunk +local Player = {} +local Player_mt = {__index = Player, __name = "aukit.player"} + +--- Pauses the player if it's playing. +function Player:pause() + if self.isPaused then return end + if not self.playerTask then error("Player is stopped", 2) end + local time = os_epoch "utc" + self.isPaused = true + self.invalidate = true + for _, v in ipairs(self.speakers) do v.stop() end + self.position = (self.lastPlayback.pos + ((time - self.lastPlayback.time) * 48) - 1) / 48000 +end + +--- Plays the audio if it's paused. +function Player:play() + if not self.isPaused then return end + if not self.playerTask then error("Player is stopped", 2) end + self.isPaused = false +end + +--- Returns the estimated current position of audio playback. +---@return number pos The position of the audio, in seconds +function Player:livePosition() + if not self.playerTask then error("Player is stopped", 2) end + if not self.lastPlayback then return 0 end + if self.isPaused then return self.position end + return (self.lastPlayback.pos + ((os_epoch "utc" - self.lastPlayback.time) * 48) - 1) / 48000 +end + +--- Seeks the current audio playback position. If the requested position isn't +--- loaded yet, the player will hang until the audio is loaded. (If the position +--- is out of bounds, the player will not continue.) +---@param pos number The position to seek to, in seconds +function Player:seek(pos) + expect.range(pos, 0) + if not self.playerTask then error("Player is stopped", 2) end + self.position = pos + self.invalidate = true + for _, v in ipairs(self.speakers) do v.stop() end +end + +--- Stops the audio playback. This kills all tasks and invalidates the player. +function Player:stop() + if not self.playerTask then error("Player is stopped", 2) end + self.playerTask:remove() + if self.loaderTask then self.loaderTask:remove() end + self.playerTask = nil + self.loaderTask = nil +end + +--- Creates a player object that runs asynchronously. This requires the +--- [Taskmaster](https://gist.github.com/MCJack123/1678fb2c240052f1480b07e9053d4537) +--- library to function. +---@param loop Taskmaster The Taskmaster loop to start the player on +---@param callback fun():number[][]|nil The iterator function that returns each chunk +---@param volume? number The volume to play the audio at; if omitted then +--- this argument is the second speaker (if provided) +---@param ... speaker The speakers to play on +---@return aukit.Player player The player object to control playback with +function aukit.player(loop, callback, volume, ...) + expect(1, loop, "table") + expect(2, callback, "function") + expect(3, volume, "number", "table") + local speakers = {...} + if type(volume) == "table" then + table_insert(speakers, 1, volume) + volume = nil + end + if #speakers == 0 then error("bad argument #3 (expected speakers, got nil)", 2) end + local player = setmetatable({ + isPaused = false, + position = 0, + volume = volume, + speakers = speakers + }, Player_mt) + local decoded = {} + local id = tostring(player) + player.loaderTask = loop:addTask(function() + local n = 0 + for chunk, pos in callback do + for j = 1, #chunk do + local c, d = chunk[j], decoded[j] + if not d then d = {} decoded[j] = d end + for i = 1, #c do + d[n+i] = c[i] + end + end + n = n + #chunk[1] + os_queueEvent("aukit.loader_next", id) + repeat local _, param = os_pullEvent("aukit.loader_next") + until param == id + end + player.loaderTask = nil + end) + player.playerTask = loop:addTask(function() + while true do + local spos = math_floor(player.position * 48000) + 1 + if not player.isPaused and decoded[1] and decoded[1][spos] then + local chunk = {} + for j = 1, #decoded do + chunk[j] = {table_unpack(decoded[j], spos, math_min(spos + 47999, #decoded[j]))} + end + player.position = player.position + #chunk[1] / 48000 + local fn = {} + for i, v in ipairs(speakers) do fn[i] = function() + local name = peripheral.getName(v) + if _HOST:find("CraftOS-PC v2.6.4") and config and not config.get("standardsMode") then + v.playAudio(chunk[i] or chunk[1], volume) + repeat until select(2, os_pullEvent("speaker_audio_empty")) == name + else while not v.playAudio(chunk[i] or chunk[1], volume) do + repeat until select(2, os_pullEvent("speaker_audio_empty")) == name + if player.invalidate then chunk = nil return end + end end + player.lastPlayback = {time = os.epoch "utc", pos = spos} + end end + parallel.waitForAll(table_unpack(fn)) + player.invalidate = false + elseif not player.isPaused and player.loaderTask == nil and spos == #decoded[1] + 1 then + player.playerTask = nil + return + else + os_pullEvent() + end + end + end) + return player +end + +local datafmts = { + {"bbbbbbbb", 8, "signed"}, + {"BBBBBBBB", 8, "unsigned"}, + {"hhhhhhhh", 16, "signed"}, + {"iiiiiiii", 32, "signed"}, + {"ffffffff", 32, "float"}, + {"i3i3i3i3i3i3i3i3", 24, "signed"}, + {"IIIIIIII", 32, "unsigned"}, + {"I3I3I3I3I3I3I3I3", 24, "unsigned"}, + {"HHHHHHHH", 16, "unsigned"}, +} + +--- Detect the type of audio file from the specified data. This uses heuristic +--- detection methods to attempt to find the correct data type for files without +--- headers. It is not recommended to rely on the data type/bit depth reported +--- for PCM files - they are merely a suggestion. +---@param data string The audio file to check +---@return string|nil type The type of audio file detected, or `nil` if none could be found ("pcm"|"dfpwm"|"mdfpwm"|"wav"|"aiff"|"au"|"flac") +---@return number|nil bitDepth The bit depth for PCM data, if the type is "pcm" and the bit depth can be detected +---@return string|nil dataType The data type for PCM data, if the type is "pcm" and the type can be detected ("signed"|"unsigned"|"float") +function aukit.detect(data) + expect(1, data, "string") + if data:match "^RIFF....WAVE" then return "wav" + elseif data:match "^FORM....AIF[FC]" then return "aiff" + elseif data:match "^%.snd" then return "au" + elseif data:match "^fLaC" then return "flac" + elseif data:match "^MDFPWM\3" then return "mdfpwm" + elseif data:match "^qoaf" then return "qoa" + else + -- Detect data type otherwise + -- This expects the start or end of the audio to be (near) silence + for _, bits in pairs(datafmts) do + local mid, gap = bits[3] == "unsigned" and 2^(bits[2]-1) or 0, bits[3] == "float" and 0.001 or 8 * 2^(bits[2]-8) + local nums = {pcall(str_unpack, bits[1], data)} + nums[#nums] = nil + if table_remove(nums, 1) then + local allzero, ok = true, true + for _, v in ipairs(nums) do + if v ~= mid then allzero = false end + if v < mid - gap or v > mid + gap then ok = false break end + end + ---@diagnostic disable-next-line return-type-mismatch + if ok and not allzero then return "pcm", table_unpack(bits, 2) end + end + nums = {pcall(str_unpack, bits[1], data, #data - bits[2])} + nums[#nums] = nil + if table_remove(nums, 1) then + local allzero, ok = true, true + for _, v in ipairs(nums) do + if v ~= mid then allzero = false end + if v < mid - gap or v > mid + gap then ok = false break end + end + ---@diagnostic disable-next-line return-type-mismatch + if ok and not allzero then return "pcm", table_unpack(bits, 2) end + end + end + if data:match(("\x55"):rep(12)) or data:match(("\xAA"):rep(12)) then return "dfpwm" end + end + return nil +end + +--[[ +..######..########.########..########....###....##.....## +.##....##....##....##.....##.##.........##.##...###...### +.##..........##....##.....##.##........##...##..####.#### +..######.....##....########..######...##.....##.##.###.## +.......##....##....##...##...##.......#########.##.....## +.##....##....##....##....##..##.......##.....##.##.....## +..######.....##....##.....##.########.##.....##.##.....## +]] + +--- aukit.stream +---@section aukit.stream + +--- Returns an iterator to stream raw PCM audio in CC format. aukit.Audio will +--- automatically be resampled to 48 kHz, and optionally mixed down to mono. Data +--- *must* be interleaved - this will not work with planar audio. +---@param data string|table|function The audio data, either as a raw string, a +--- table of values (in the format specified by `bitDepth` and `dataType`), or a +--- function that returns either of those types. Functions will be called at +--- least once before returning to get the type of data to use. +---@param bitDepth? number The bit depth of the audio (8, 16, 24, 32); if `dataType` is "float" then this must be 32 +---@param dataType? "signed"|"unsigned"|"float" The type of each sample +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@param bigEndian? boolean Whether the audio is big-endian or little-endian; ignored if data is a table +---@param mono? boolean Whether to mix the audio down to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number _ The total length of the audio in seconds, or the length of +--- the first chunk if using a function +function aukit.stream.pcm(data, bitDepth, dataType, channels, sampleRate, bigEndian, mono) + local fn, complete + if type(data) == "function" then fn, data = data, data() end + expect(1, data, "string", "table") + bitDepth = expect(2, bitDepth, "number", "nil") or 8 + dataType = expect(3, dataType, "string", "nil") or "signed" + channels = expect(4, channels, "number", "nil") or 1 + sampleRate = expect(5, sampleRate, "number", "nil") or 48000 + expect(6, bigEndian, "boolean", "nil") + expect(7, mono, "boolean", "nil") + if bitDepth ~= 8 and bitDepth ~= 16 and bitDepth ~= 24 and bitDepth ~= 32 then error("bad argument #2 (invalid bit depth)", 2) end + if dataType ~= "signed" and dataType ~= "unsigned" and dataType ~= "float" then error("bad argument #3 (invalid data type)", 2) end + if dataType == "float" and bitDepth ~= 32 then error("bad argument #2 (float audio must have 32-bit depth)", 2) end + expect.range(channels, 1) + expect.range(sampleRate, 1) + if channels == 1 then mono = false end + local byteDepth = bitDepth / 8 + local len = (#data / (type(data) == "table" and 1 or byteDepth)) / channels + local csize = jit and 7680 or 32768 + local csizeb = csize * byteDepth + local bitDir = bigEndian and ">" or "<" + local sformat = dataType == "float" and "f" or ((dataType == "signed" and "i" or "I") .. byteDepth) + local format = bitDir .. str_rep(sformat, csize) + local maxValue = 2^(bitDepth-1) + local pos, spos = 1, 1 + local tmp = {} + local read + if type(data) == "table" then + if dataType == "signed" then + function read() + if complete then return nil end + if fn and pos > #data then + data, pos = fn(), 1 + if not data then complete = true return nil end + end + local s = data[pos] + pos = pos + 1 + return s / (s < 0 and maxValue or maxValue-1) + end + elseif dataType == "unsigned" then + function read() + if complete then return nil end + if fn and pos > #data then + data, pos = fn(), 1 + if not data then complete = true return nil end + end + local s = data[pos] + pos = pos + 1 + return (s - 128) / (s < 128 and maxValue or maxValue-1) + end + else + function read() + if complete then return nil end + if fn and pos > #data then + data, pos = fn(), 1 + if not data then complete = true return nil end + end + local s = data[pos] + pos = pos + 1 + return s + end + end + elseif dataType == "float" then + function read() + if complete then return nil end + if pos > #tmp then + if fn and spos > #data then + data, spos = fn(), 1 + if not data then complete = true return nil end + end + if spos + csizeb > #data then + local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth) + tmp = {str_unpack(f, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + else + tmp = {str_unpack(format, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + end + pos = 1 + end + local s = tmp[pos] + pos = pos + 1 + return s + end + elseif dataType == "signed" then + function read() + if complete then return nil end + if pos > #tmp then + if fn and spos > #data then + data, spos = fn(), 1 + if not data then complete = true return nil end + end + if spos + csizeb > #data then + local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth) + tmp = {str_unpack(f, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + else + tmp = {str_unpack(format, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + end + pos = 1 + end + local s = tmp[pos] + pos = pos + 1 + return s / (s < 0 and maxValue or maxValue-1) + end + else -- unsigned + function read() + if complete then return nil end + if pos > #tmp then + if fn and spos > #data then + data, spos = fn(), 1 + if not data then complete = true return nil end + end + if spos + csizeb > #data then + local f = bitDir .. str_rep(sformat, (#data - spos + 1) / byteDepth) + tmp = {str_unpack(f, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + else + tmp = {str_unpack(format, data, spos)} + spos = tmp[#tmp] + tmp[#tmp] = nil + end + pos = 1 + end + local s = tmp[pos] + pos = pos + 1 + return (s - 128) / (s < 128 and maxValue or maxValue-1) + end + end + local d = {} + local ratio = 48000 / sampleRate + local lp_alpha = 1 - math.exp(-(sampleRate / 96000) * 2 * math_pi) + local interp = interpolate[aukit.defaultInterpolation] + for j = 1, (mono and 1 or channels) do d[j] = setmetatable({}, {__index = function(self, i) + if mono then for _ = 1, channels do self[i] = (rawget(self, i) or 0) + read() end self[i] = self[i] / channels + else self[i] = read() end + return rawget(self, i) + end}) end + local n = 0 + local ok = true + return function() + if not ok or complete then return nil end + for i = (n == 0 and interpolation_start[aukit.defaultInterpolation] or 1), interpolation_end[aukit.defaultInterpolation] do + if mono then + local s = 0 + for j = 1, channels do + local c = read() + if not c then return nil end + s = s + c + end + d[1][i] = s / channels + else for j = 1, channels do d[j][i] = read() if not d[j][i] then return nil end end end + end + local chunk = {} + for j = 1, #d do chunk[j] = {} end + ok = pcall(function() + local ls = {} + for y = 1, #d do + local s = chunk[y][0] or 0 + ls[y] = s / (s < 0 and 128 or 127) + end + for i = 1, 48000 do + for y = 1, #d do + local x = ((i - 1) / ratio) + 1 + local s + if x % 1 == 0 then s = d[y][x] + else s = interp(d[y], x) end + local ns = ls[y] + lp_alpha * (s - ls[y]) + chunk[y][i] = clamp(ns * (ns < 0 and 128 or 127), -128, 127) + ls[y] = s + end + end + end) + if #chunk[1] == 0 then return nil end + n = n + #chunk[1] + for y = 1, #d do + if aukit.defaultInterpolation == "sinc" then + local t, l = {}, #d[y] + for i = -sincWindowSize, 0 do + t[i] = d[y][l + i] + end + d[y] = setmetatable(t, getmetatable(d[y])) + else + local l2, l1 = d[y][#d[y]-1], d[y][#d[y]] + d[y] = setmetatable({}, getmetatable(d[y])) + d[y][-1], d[y][0] = l2, l1 + end + end + return chunk, (n - #chunk[1]) / 48000 + end, len / sampleRate +end + +--- Returns an iterator to stream audio from DFPWM data. aukit.Audio will automatically +--- be resampled to 48 kHz. Multiple channels are expected to be interleaved in +--- the encoded DFPWM data. +---@param data string|fun():string The DFPWM data to decode, or a function +--- returning chunks to decode +---@param sampleRate? number The sample rate of the audio in Hertz +---@param channels? number The number of channels present in the audio +---@param mono? boolean Whether to mix the audio down to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number|nil _ The total length of the audio in seconds, or nil if data +--- is a function +function aukit.stream.dfpwm(data, sampleRate, channels, mono) + expect(1, data, "string", "function") + sampleRate = expect(2, sampleRate, "number", "nil") or 48000 + channels = expect(3, channels, "number", "nil") or 1 + expect.range(sampleRate, 1) + expect.range(channels, 1) + if channels == 1 then mono = false end + local decoder = dfpwm.make_decoder() + local pos = 1 + local last = 0 + local isstr = type(data) == "string" + local buf = "" + return function() + local d + if isstr then + if pos > #data then return nil end + d = str_sub(data, pos, pos + 6000 * channels) + else + while #buf < sampleRate / 8 * channels do + local chunk = data() + if not chunk then + if #buf == 0 then return nil + else break end + end + buf = buf .. chunk + end + d = str_sub(buf, 1, 6000 * channels) + buf = str_sub(buf, 6000 * channels + 1) + end + local audio = decoder(d) + if audio == nil or #audio == 0 then return nil end + audio[0], last = last, audio[#audio] + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + local ratio = 48000 / sampleRate + local newlen = #audio * ratio + local interp = interpolate[aukit.defaultInterpolation] + local lines = {{}} + if not mono then for j = 1, channels do lines[j] = {} end end + for i = 1, newlen, channels do + local n = 0 + for j = 1, channels do + local x = (i - 1) / ratio + 1 + local s + if x % 1 == 0 then s = audio[x] + else s = clamp(interp(audio, x), -128, 127) end + if mono then n = n + s + else lines[j][math_ceil(i / channels)] = s end + end + if mono then lines[1][math_ceil(i / channels)] = n / channels end + end + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + local p = pos + pos = pos + 6000 * channels + return lines, p * 8 / sampleRate / channels + end, isstr and #data * 8 / sampleRate / channels or nil +end + +--- Returns an iterator to stream audio from MDFPWMv3 data. +---@param data string|fun():string The MDFPWM data to decode, or a function +--- returning chunks to decode +---@param mono? boolean Whether to mix the audio down to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number|nil _ The total length of the audio in seconds, or nil if data +--- is a function +function aukit.stream.mdfpwm(data, mono) + expect(1, data, "string", "function") + local decoderL, decoderR = dfpwm.make_decoder(), dfpwm.make_decoder() + local isstr = type(data) == "string" + local pos = 1 + local headerSize = 0 + local length + local buf = "" + local _ + if isstr then + if data:sub(1, 7) ~= "MDFPWM\3" then error("bad argument #1 (invalid MDFPWM data)", 2) end + length, _, _, _, pos = str_unpack(" #data then return nil end + dL = str_sub(data, pos, pos + 5999) + dR = str_sub(data, pos + 6000, pos + 11999) + else + while #buf < 12000 do + local chunk = data() + if not chunk then + if #buf == 0 then return nil + else break end + end + buf = buf .. chunk + end + dL = str_sub(buf, 1, 6000) + dR = str_sub(buf, 6001, 12000) + buf = str_sub(buf, 12001) + end + local audioL = decoderL(dL) + if audioL == nil or #audioL == 0 then return nil end + local audioR = decoderR(dR) + if audioR == nil or #audioR == 0 then return nil end + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + if pos - headerSize + 12000 > length then + for i = (length / 2) % 6000 + 1, 6000 do + audioL[i], audioR[i] = nil + end + end + local lines + if mono then + local l = {} + lines = {l} + for i = 1, 48000 do + l[i] = clamp(math_floor(audioL[i] + audioR[i] / 2), -128, 127) + end + else lines = {audioL, audioR} end + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + local p = pos - headerSize + pos = pos + #dL + #dR + return lines, p / 12000 + end, length / 12000 +end + +--- Returns an iterator to stream audio from Microsoft ADPCM data. aukit.Audio will +--- automatically be resampled to 48 kHz. +---@param input string|fun():string The audio data as a raw string or +--- reader function +---@param blockAlign number The number of bytes in each block +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@param mono? boolean Whether to mix the audio down to mono +---@param coefficients? table Two lists of coefficients to use +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number|nil _ The total length of the audio in seconds, or nil if data +--- is a function +function aukit.stream.msadpcm(input, blockAlign, channels, sampleRate, mono, coefficients) + expect(1, input, "string", "function") + expect(2, blockAlign, "number") + channels = expect(3, channels, "number", "nil") or 1 + sampleRate = expect(4, sampleRate, "number", "nil") or 48000 + expect(5, mono, "boolean", "nil") + expect(6, coefficients, "table", "nil") + expect.range(sampleRate, 1) + local isfunc = type(input) == "function" + local coeff1, coeff2 + if coefficients then + if type(coefficients[1]) ~= "table" then error("bad argument #5 (first entry is not a table)", 2) end + if type(coefficients[2]) ~= "table" then error("bad argument #5 (second entry is not a table)", 2) end + if #coefficients[1] ~= #coefficients[2] then error("bad argument #5 (lists are not the same length)", 2) end + coeff1, coeff2 = {}, {} + for i, v in ipairs(coefficients[1]) do + if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 1 (expected number, got " .. type(v) .. ")", 2) end + coeff1[i-1] = v + end + for i, v in ipairs(coefficients[2]) do + if type(v) ~= "number" then error("bad entry #" .. i .. " in coefficient list 2 (expected number, got " .. type(v) .. ")", 2) end + coeff2[i-1] = v + end + else coeff1, coeff2 = {[0] = 256, 512, 0, 192, 240, 460, 392}, {[0] = 0, -256, 0, 64, 0, -208, -232} end + local ratio = 48000 / sampleRate + local interp = interpolate[aukit.defaultInterpolation] + local n, pos = 1, 0 + local data = isfunc and input() or input + if channels == 2 then + local samplesPerBlock = blockAlign - 14 + local iterPerSecond = math_ceil(sampleRate / samplesPerBlock) + local bytesPerSecond = blockAlign * iterPerSecond + local newlen = math_floor(samplesPerBlock * ratio) + local lastL, lastR + return function() + if data == nil then return nil end + local target = n + bytesPerSecond + local retval = {{}, not mono and {} or nil} + local rp = 0 + local start = os_epoch "utc" + while n < target do + if os_epoch "utc" - start > 3000 then + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + start = os_epoch "utc" + end + if isfunc and n > #data then + pos = pos + #data + n = n - #data + data = input() + if data == nil then return nil end + end + if n > #data then break end + local left, right = {}, {} + if lastL then for i = 1, #lastL do + left[i-#lastL-1] = lastL[i] + right[i-#lastR-1] = lastR[i] + end end + local predictorIndexL, predictorIndexR, deltaL, deltaR, sample1L, sample1R, sample2L, sample2R = str_unpack("= 8 then hi = hi - 16 end + if lo >= 8 then lo = lo - 16 end + local predictor = clamp(math_floor((sample1L * c1L + sample2L * c2L) / 256) + hi * deltaL, -32768, 32767) + left[#left+1] = math_floor(predictor / (predictor < 0 and 128 or 127)) + sample2L, sample1L = sample1L, predictor + deltaL = math_max(math_floor(msadpcm_adaption_table[hi] * deltaL / 256), 16) + predictor = clamp(math_floor((sample1R * c1R + sample2R * c2R) / 256) + lo * deltaR, -32768, 32767) + right[#right+1] = math_floor(predictor / (predictor < 0 and 128 or 127)) + sample2R, sample1R = sample1R, predictor + deltaR = math_max(math_floor(msadpcm_adaption_table[lo] * deltaR / 256), 16) + end + lastL, lastR = left, right + for i = 1, newlen do + local x = (i - 1) / ratio + 1 + local l, r + if x % 1 == 0 then l, r = left[x], right[x] + else l, r = interp(left, x), interp(right, x) end + if mono then retval[1][rp+i] = clamp(math_floor(l + r / 2), -128, 127) + else retval[1][rp+i], retval[2][rp+i] = clamp(math_floor(l), -128, 127), clamp(math_floor(r), -128, 127) end + end + rp = rp + newlen + n = n + blockAlign + end + if #retval[1] == 0 then return nil end + return retval, (n + pos) / bytesPerSecond + end, not isfunc and #data / blockAlign * samplesPerBlock / sampleRate or nil + elseif channels == 1 then + local samplesPerBlock = (blockAlign - 7) * 2 + local iterPerSecond = math_ceil(sampleRate / samplesPerBlock) + local bytesPerSecond = blockAlign * iterPerSecond + local newlen = math_floor(samplesPerBlock * ratio) + return function() + if data == nil then return nil end + local target = n + bytesPerSecond + local retval = {{}} + local rp = 0 + local start = os_epoch "utc" + while n < target do + if os_epoch "utc" - start > 3000 then + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + start = os_epoch "utc" + end + if isfunc and n > #data then + pos = pos + #data + n = n - #data + data = input() + if data == nil then return nil end + end + if n > #data then break end + local left = {} + local predictorIndex, delta, sample1, sample2 = str_unpack("= 8 then hi = hi - 16 end + if lo >= 8 then lo = lo - 16 end + local predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + hi * delta, -32768, 32767) + left[#left+1] = predictor / (predictor < 0 and 128 or 127) + sample2, sample1 = sample1, predictor + delta = math_max(math_floor(msadpcm_adaption_table[hi] * delta / 256), 16) + predictor = clamp(math_floor((sample1 * c1 + sample2 * c2) / 256) + lo * delta, -32768, 32767) + left[#left+1] = predictor / (predictor < 0 and 128 or 127) + sample2, sample1 = sample1, predictor + delta = math_max(math_floor(msadpcm_adaption_table[lo] * delta / 256), 16) + end + for i = 1, newlen do + local x = (i - 1) / ratio + 1 + if x % 1 == 0 then retval[1][rp+i] = clamp(math_floor(left[x]), -128, 127) + else retval[1][rp+i] = clamp(math_floor(interp(left, x)), -128, 127) end + end + rp = rp + newlen + n = n + blockAlign + end + if #retval[1] == 0 then return nil end + return retval, (n + pos) / bytesPerSecond + end, not isfunc and #data / blockAlign * samplesPerBlock / sampleRate or nil + else error("Unsupported number of channels: " .. channels) end +end + +--- Returns an iterator to stream data from IMA ADPCM data. aukit.Audio will +--- automatically be resampled to 48 kHz, and mixed to mono if desired. Data +--- *must* be in the interleaving format used in WAV files (i.e. periodic blocks +--- with 4/8-byte headers, channels alternating every 4 bytes, lower nibble first). +---@param input string|fun():string The audio data as a raw string or +--- reader function +---@param blockAlign number The number of bytes in each block +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@param mono? boolean Whether to mix the audio down to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number|nil _ The total length of the audio in seconds, or nil if data +--- is a function +function aukit.stream.adpcm(input, blockAlign, channels, sampleRate, mono) + expect(1, input, "string", "function") + expect(2, blockAlign, "number") + channels = expect(3, channels, "number", "nil") or 1 + sampleRate = expect(4, sampleRate, "number", "nil") or 48000 + expect(5, mono, "boolean", "nil") + expect.range(sampleRate, 1) + local isfunc = type(input) == "function" + local ratio = 48000 / sampleRate + local interp = interpolate[aukit.defaultInterpolation] + local n, pos = 1, 0 + local data = isfunc and input() or input + local samplesPerBlock = (blockAlign - 4 * channels) * 2 / channels + local iterPerSecond = math_ceil(sampleRate / samplesPerBlock) + local bytesPerSecond = blockAlign * iterPerSecond + local newlen = math_floor(samplesPerBlock * ratio) + local last + return function() + if data == nil then return nil end + local target = n + bytesPerSecond + local retval = {{}} + if not mono then for i = 2, channels do retval[i] = {} end end + local rp = 0 + if isfunc and target > #data then + pos = pos + n - 1 + target = target - n + 1 + data = str_sub(data, n) + n = 1 + while #data < target do + local d = input() + if not d then break end + data = data .. d + end + end + local start = os_epoch "utc" + while n < target do + if os_epoch "utc" - start > 3000 then + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + start = os_epoch "utc" + end + if n + channels * 4 > #data then break end + local d = {} + for i = 1, channels do d[i] = {} end + if last then for i = 1, channels do for j = 1, #last[i] do d[j-#last[i]-1] = last[i][j] end end end + local predictor, step_index, step = {}, {}, {} + for i = 1, channels do predictor[i], step_index[i] = str_unpack("= 8 then predictor[j] = clamp(predictor[j] - diff, -32768, 32767) + else predictor[j] = clamp(predictor[j] + diff, -32768, 32767) end + d[j][p+k] = predictor[j] / (predictor[j] < 0 and 128 or 127) + end + end + end + last = d + if #d[1] < samplesPerBlock then newlen = math_floor(#d[1] * ratio) end + for i = 1, newlen do + local x = (i - 1) / ratio + 1 + local c = {} + if x % 1 == 0 then for j = 1, channels do c[j] = d[j][x] end + else for j = 1, channels do c[j] = interp(d[j], x) end end + if mono then + local n = 0 + for j = 1, channels do n = n + c[j] end + retval[1][rp+i] = clamp(math_floor(n / channels), -128, 127) + else for j = 1, channels do retval[j][rp+i] = clamp(math_floor(c[j]), -128, 127) end end + end + rp = rp + newlen + n = n + blockAlign + end + if #retval[1] == 0 then return nil end + return retval, (n + pos) / bytesPerSecond + end, not isfunc and #data / blockAlign * samplesPerBlock / sampleRate or nil +end + +--- Returns an iterator to stream data from u-law/A-law G.711 data. aukit.Audio will +--- automatically be resampled to 48 kHz, and mixed to mono if desired. +---@param input string|fun():string The audio data as a raw string or +--- reader function +---@param ulaw boolean Whether the audio uses u-law (true) or A-law (false). +---@param channels? number The number of channels present in the audio +---@param sampleRate? number The sample rate of the audio in Hertz +---@param mono? boolean Whether to mix the audio down to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number|nil _ The total length of the audio in seconds, or nil if data +--- is a function +function aukit.stream.g711(input, ulaw, channels, sampleRate, mono) + expect(1, input, "string", "function") + expect(2, ulaw, "boolean") + channels = expect(3, channels, "number", "nil") or 1 + sampleRate = expect(4, sampleRate, "number", "nil") or 8000 + expect(5, mono, "boolean", "nil") + local csize = jit and 7680 or 32768 + local xor = ulaw and 0xFF or 0x55 + local isfunc = type(input) == "function" + local buf, pos = "", 1 + local ratio = 48000 / sampleRate + local interp = interpolate[aukit.defaultInterpolation] + local last + return function() + local lp = pos + local retval = {} + for i = 1, channels do retval[i] = {} end + if last then for i = 1, channels do for j = 1, #last[i] do retval[j-#last[i]-1] = last[i][j] end end end + local data + if isfunc then + while #buf < sampleRate * channels do + local d = input() + if not input then + if #buf == 0 then return nil + else break end + end + buf = buf .. d + end + data = str_sub(buf, 1, sampleRate * channels) + buf = str_sub(buf, sampleRate * channels + 1) + else data = str_sub(input, pos, pos + sampleRate * channels - 1) end + pos = pos + sampleRate * channels + local start = os_epoch "utc" + for i = 1, #data, csize do + local bytes = {str_byte(data, i, i + csize - 1)} + for j = 1, #bytes do + local b = bit32_bxor(bytes[j], xor) + local m, e = bit32_band(b, 0x0F), bit32_extract(b, 4, 3) + if not ulaw and e == 0 then m = m * 4 + 2 + else m = bit32_lshift(m * 2 + 33, e) end + if ulaw then m = m - 33 end + retval[(i+j-2) % channels + 1][math_floor((i+j-2) / channels + 1)] = m / (bit32_btest(b, 0x80) == ulaw and -0x40 or 0x40) + end + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + end + last = {} + for j = 1, channels do last[j] = {} for i = 1, sincWindowSize do last[j][i] = retval[j][#retval[j]-30+i] end end + local newlen = math_floor(#retval[1] * ratio) + local resamp = {} + for j = 1, channels do resamp[j] = {} end + for i = 1, newlen do + local x = (i - 1) / ratio + 1 + local c = {} + if x % 1 == 0 then for j = 1, channels do c[j] = retval[j][x] end + else for j = 1, channels do c[j] = interp(retval[j], x) end end + if mono then + local n = 0 + for j = 1, channels do n = n + c[j] end + resamp[1][i] = clamp(math_floor(n / channels), -128, 127) + else for j = 1, channels do resamp[j][i] = clamp(math_floor(c[j]), -128, 127) end end + end + return resamp, (lp - 1) / sampleRate / channels + end, not isfunc and #input / sampleRate / channels or nil +end + +--- Returns an iterator to stream audio from a WAV file. aukit.Audio will automatically +--- be resampled to 48 kHz, and optionally mixed down to mono. This accepts PCM +--- files up to 32 bits, including float data, as well as DFPWM files [as specified here](https://gist.github.com/MCJack123/90c24b64c8e626c7f130b57e9800962c). +---@param data string|fun():string The WAV file to decode, or a function +--- returning chunks to decode (the first chunk MUST contain the ENTIRE header) +---@param mono? boolean Whether to mix the audio to mono +---@param ignoreHeader? boolean Whether to ignore additional headers +--- if they appear later in the audio stream +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number|nil _ The total length of the audio in seconds +function aukit.stream.wav(data, mono, ignoreHeader) + local fn + if type(data) == "function" then fn, data = data, data() end + expect(1, data, "string") + local channels, sampleRate, bitDepth, length, dataType, blockAlign, coefficients + local temp, pos = str_unpack("c4", data) + if temp ~= "RIFF" then error("bad argument #1 (not a WAV file)", 2) end + pos = pos + 4 + temp, pos = str_unpack("c4", data, pos) + if temp ~= "WAVE" then error("bad argument #1 (not a WAV file)", 2) end + while pos <= #data do + local size + temp, pos = str_unpack("c4", data, pos) + size, pos = str_unpack(" 0 then + coefficients = {{}, {}} + for i = 1, numcoeff do + coefficients[1][i], coefficients[2][i] = str_unpack("I", data, pos) + if temp == "COMM" then + local e, m + channels, length, bitDepth, e, m, pos = str_unpack(">hIhHI7x", data, pos) + if isAIFC then + local s + compression, s, pos = str_unpack(">c4s1", data, pos) + if #s % 2 == 0 then pos = pos + 1 end + end + length = length * channels * math_floor(bitDepth / 8) + local s = bit32_btest(e, 0x8000) + e = ((bit32_band(e, 0x7FFF) - 0x3FFE) % 0x800) + sampleRate = math.ldexp(m * (s and -1 or 1) / 0x100000000000000, e) + elseif temp == "SSND" then + offset, blockAlign, pos = str_unpack(">II", data, pos) + local data = str_sub(data, pos + offset, pos + offset + length - 1) + if not fn and #data < length then error("invalid AIFF file", 2) end + if fn then + local first, f = data + data = function() + if first then f, first = first return f + elseif ignoreHeader then + local d = fn() + if not d then return nil end + if d:match "^FORM....AIF[FC]" then + local n, p = d:match("^FORM....AIF[FC].-SSND(....)....()") + offset = str_unpack(">I", n) + return str_sub(d, p + offset) + else return d end + else return fn() end + end + end + if compression == nil or compression == "NONE" then return aukit.stream.pcm(data, bitDepth, "signed", channels, sampleRate, true, mono), length / channels / (bitDepth / 8) / sampleRate + elseif compression == "sowt" then return aukit.stream.pcm(data, bitDepth, "signed", channels, sampleRate, true, mono), length / channels / (bitDepth / 8) / sampleRate + elseif compression == "fl32" or compression == "FL32" then return aukit.stream.pcm(data, 32, "float", channels, sampleRate, true, mono), length / channels / 4 / sampleRate + elseif compression == "alaw" or compression == "ulaw" or compression == "ALAW" or compression == "ULAW" then return aukit.stream.g711(data, compression == "ulaw" or compression == "ULAW", channels, sampleRate, mono), length / channels / sampleRate + else error("Unsupported compression scheme " .. compression, 2) end + return aukit.stream.pcm(data, bitDepth, "signed", channels, sampleRate, true, mono), length / channels / (bitDepth / 8) / sampleRate + else pos = pos + size end + end + error("invalid AIFF file", 2) +end + +--- Returns an iterator to stream data from an AU file. aukit.Audio will automatically +--- be resampled to 48 kHz, and optionally mixed down to mono. +---@param data string|fun():string The AU file to decode, or a function +--- returning chunks to decode (the first chunk MUST contain the ENTIRE header) +---@param mono? boolean Whether to mix the audio to mono +---@param ignoreHeader? boolean Whether to ignore additional headers +--- if they appear later in the audio stream +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number _ The total length of the audio in seconds +function aukit.stream.au(data, mono, ignoreHeader) + local fn + if type(data) == "function" then fn, data = data, data() end + expect(1, data, "string") + expect(2, mono, "boolean", "nil") + local magic, offset, size, encoding, sampleRate, channels = str_unpack(">c4IIIII", data) + if magic ~= ".snd" then error("invalid AU file", 2) end + if fn then + local first, f = str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil), nil + data = function() + if first then f, first = first return f + elseif ignoreHeader then + local d = fn() + if not d then return nil end + if d:match "^.snd" then return str_sub(d, str_unpack(">I", str_sub(d, 5, 8)), nil) + else return d end + else return fn() end + end + else data = str_sub(data, offset, size ~= 0xFFFFFFFF and offset + size - 1 or nil) end + if encoding == 1 then return aukit.stream.g711(data, true, channels, sampleRate, mono), size / channels / sampleRate + elseif encoding == 2 then return aukit.stream.pcm(data, 8, "signed", channels, sampleRate, true, mono), size / channels / sampleRate + elseif encoding == 3 then return aukit.stream.pcm(data, 16, "signed", channels, sampleRate, true, mono), size / channels / 2 / sampleRate + elseif encoding == 4 then return aukit.stream.pcm(data, 24, "signed", channels, sampleRate, true, mono), size / channels / 3 / sampleRate + elseif encoding == 5 then return aukit.stream.pcm(data, 32, "signed", channels, sampleRate, true, mono), size / channels / 4 / sampleRate + elseif encoding == 6 then return aukit.stream.pcm(data, 32, "float", channels, sampleRate, true, mono), size / channels / 4 / sampleRate + elseif encoding == 27 then return aukit.stream.g711(data, false, channels, sampleRate, mono), size / channels / sampleRate + else error("unsupported encoding type " .. encoding, 2) end +end + +--- Returns an iterator to stream data from a FLAC file. aukit.Audio will automatically +--- be resampled to 48 kHz, and optionally mixed down to mono. +---@param data string|fun():string The FLAC file to decode, or a function +--- returning chunks to decode +---@param mono? boolean Whether to mix the audio to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number _ The total length of the audio in seconds +function aukit.stream.flac(data, mono) + expect(1, data, "string", "function") + expect(2, mono, "boolean", "nil") + local infn = false + if type(data) == "function" then data = setmetatable({str = "", fn = data, final = false, byte = function(self, start, e) + while start > #self.str do + infn = true + local d = self.fn() + infn = false + if not d then self.final = true return nil end + self.str = self.str .. d + end + while e and e > #self.str do + infn = true + local d = self.fn() + infn = false + if not d then self.final = true return nil end + self.str = self.str .. d + end + return str_byte(self.str, start, e) + end}, {__len = function(self) return self.final and #self.str or math.huge end}) end + local function saferesume(coro, ...) + local res = table_pack(coroutine.resume(coro, ...)) + while res[1] and infn do res = table_pack(coroutine.resume(coro, coroutine.yield(table_unpack(res, 2, res.n)))) end + return table_unpack(res, 1, res.n) + end + local coro = coroutine.create(decodeFLAC) + local ok, sampleRate, len = saferesume(coro, data, coroutine.yield) + if not ok then error(sampleRate, 2) end + local pos = 0 + local ratio = 48000 / sampleRate + local lp_alpha = 1 - math.exp(-(sampleRate / 96000) * 2 * math_pi) + local interp = interpolate[aukit.defaultInterpolation] + local last = {0, 0} + return function() + if coroutine.status(coro) == "dead" then return nil end + local chunk = {{}} + while #chunk[1] < sampleRate do + local ok, res = saferesume(coro) + if not ok or res == nil or res.sampleRate then break end + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + for c = 1, #res do + chunk[c] = chunk[c] or {} + local src, dest = res[c], chunk[c] + local start = #dest + src[0] = last[2] + src[-1] = last[1] + local ls = last[2] / (last[2] < 0 and 128 or 127) + for i = 1, math_floor(#src * ratio) do + local d = start+i + local x = ((i - 1) / ratio) + 1 + local s + if x % 1 == 0 then s = src[x] + else s = interp(src, x) end + s = ls + lp_alpha * (s - ls) + ls = s + dest[d] = clamp(s * (s < 0 and 128 or 127), -128, 127) + end + last = {src[#src-1], src[#src]} + end + os_queueEvent("nosleep") + repeat until "nosleep" == os_pullEvent() + end + pos = pos + #chunk[1] / 48000 + return chunk, pos + end, len / sampleRate +end + +--- Returns an iterator to stream data from a QOA file. aukit.Audio will automatically +--- be resampled to 48 kHz, and optionally mixed down to mono. +---@param data string|fun():string The QOA file to decode, or a function +--- returning chunks to decode +---@param mono? boolean Whether to mix the audio to mono +---@return fun():number[][]|nil,number|nil _ An iterator function that returns +--- chunks of each channel's data as arrays of signed 8-bit 48kHz PCM, as well as +--- the current position of the audio in seconds +---@return number _ The total length of the audio in seconds +function aukit.stream.qoa(data, mono) + expect(1, data, "string", "function") + expect(2, mono, "boolean", "nil") + local read, peek + if type(data) == "string" then + local pos = 1 + function read(n) + if pos > #data then return nil end + local d = data:sub(pos, pos + n - 1) + pos = pos + n + return d + end + function peek(n) + if pos > #data then return nil end + return data:sub(pos, pos + n - 1) + end + else + local buf = "" + function read(n) + while #buf < n do + local d = data() + if not d then return nil end + buf = buf .. d + end + local d = buf:sub(1, n) + buf = buf:sub(n + 1) + return d + end + function peek(n) + while #buf < n do + local d = data() + if not d then return nil end + buf = buf .. d + end + return buf:sub(1, n) + end + end + local magic, file_samples = (">c4I4"):unpack(assert(read(8), "Not a QOA file"), nil) + if magic ~= "qoaf" then error("Not a QOA file", 2) end + local file_channels, file_sampleRate = (">BI3"):unpack(assert(peek(4), "Not a QOA file"), nil) + local lms = {} + local last = {} + for i = 1, file_channels do + lms[i] = {history = {}, weights = {}} + last[i] = {0, 0} + end + + local file_pos = 0 + local ratio = 48000 / file_sampleRate + local lp_alpha = 1 - math.exp(-(file_sampleRate / 96000) * 2 * math_pi) + local interp = interpolate[aukit.defaultInterpolation] + return function() + local chunk = {} + for i = 1, file_channels do chunk[i] = {[-1] = last[i][1], [0] = last[i][2]} end + local sample_pos = 0 + while sample_pos < file_sampleRate do + -- from https://github.com/phoboslab/qoa/blob/master/qoa.h + -- MIT license - Copyright (c) 2023 Dominic Szablewski + + -- Read and verify the frame header + local d = read(8) + if not d then break end + local channels, samplerate, samples, frame_size = (">BI3I2I2"):unpack(d) + + local data_size = frame_size - 8 - 4 * 4 * channels + local num_slices = math_floor(data_size / 8) + local max_total_samples = num_slices * 20 + + if + channels ~= file_channels or + samplerate ~= file_sampleRate or + samples * channels > max_total_samples + then + --error("Bad frame data", 2) + break + end + + -- Read the LMS state: 4 x 2 bytes history, 4 x 2 bytes weights per channel + for c = 1, channels do + lms[c].history = {(">i2i2i2i2"):unpack(assert(read(8), "Invalid QOA data"), nil)} + lms[c].weights = {(">i2i2i2i2"):unpack(assert(read(8), "Invalid QOA data"), nil)} + end + + -- Decode all slices for all channels in this frame + for sample_index = 1, samples, 20 do + for c = 1, channels do + local sliceH, sliceL = (">I4I4"):unpack(assert(read(8), "Invalid QOA data"), nil) + + local scalefactor = bit32_extract(sliceH, 28, 4) + + for si = sample_index, sample_index + 19 do + local predicted = qoa_lms_predict(lms[c]) + local quantized = bit32_extract(sliceH, 25, 3) + local dequantized = qoa_dequant_tab[scalefactor][quantized] + --print(predicted, dequantized) + local reconstructed = math_min(math_max(predicted + dequantized, -32768), 32767) + + chunk[c][sample_pos + si] = math_floor(reconstructed / 256) + sliceH = bit32_lshift(sliceH, 3) + bit32_extract(sliceL, 29, 3) + sliceL = bit32_lshift(sliceL, 3) + + qoa_lms_update(lms[c], reconstructed, dequantized) + end + end + end + sample_pos = sample_pos + samples + end + + if #chunk[1] == 0 then return nil end + + local newlen = #chunk[1] * ratio + local lines = {{}} + if not mono then for j = 2, file_channels do lines[j] = {} end end + local ls = {} + for j = 1, file_channels do ls[j] = last[j][2] end + for i = 1, newlen do + local n = 0 + for j = 1, file_channels do + local x = (i - 1) / ratio + 1 + local s + if x % 1 == 0 then s = chunk[j][x] + else s = clamp(interp(chunk[j], x), -128, 127) end + s = ls[j] + lp_alpha * (s - ls[j]) + ls[j] = s + if mono then n = n + s + else lines[j][i] = s end + end + if mono then lines[1][i] = n / file_channels end + end + + local pos = file_pos / file_sampleRate + file_pos = file_pos + sample_pos + for i = 1, file_channels do last[i] = {chunk[i][#chunk[i]-1], chunk[i][#chunk[i]]} end + return lines, pos + end, file_samples / file_sampleRate +end + +--[[ +.########.########.########.########..######..########..######. +.##.......##.......##.......##.......##....##....##....##....## +.##.......##.......##.......##.......##..........##....##...... +.######...######...######...######...##..........##.....######. +.##.......##.......##.......##.......##..........##..........## +.##.......##.......##.......##.......##....##....##....##....## +.########.##.......##.......########..######.....##.....######. +]] + +--- aukit.effects +---@section aukit.effects + +--- Amplifies the audio by the multiplier specified. +---@param audio aukit.Audio The audio to modify +---@param multiplier number The multiplier to apply +---@return aukit.Audio _ The audio modified +function aukit.effects.amplify(audio, multiplier) + expectAudio(1, audio) + expect(2, multiplier, "number") + if multiplier == 1 then return audio end + local start = os_epoch "utc" + for c = 1, #audio.data do + local ch = audio.data[c] + for i = 1, #ch do + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + ch[i] = clamp(ch[i] * multiplier, -1, 1) + end + end + return audio +end + +--- Changes the speed and pitch of audio by a multiplier, resampling to keep the +--- same sample rate. +---@param audio aukit.Audio The audio to modify +---@param multiplier number The multiplier to apply +---@return aukit.Audio _ The audio modified +function aukit.effects.speed(audio, multiplier) + expectAudio(1, audio) + expect(2, multiplier, "number") + if multiplier == 1 then return audio end + local rate = audio.sampleRate + audio.sampleRate = audio.sampleRate * multiplier + local new = audio:resample(rate) + audio.sampleRate, audio.data = rate, new.data + return audio +end + +--- Fades a period of music from one amplitude to another. +---@param audio aukit.Audio The audio to modify +---@param startTime number The start time of the fade, in seconds +---@param startAmplitude number The amplitude of the beginning of the fade +---@param endTime number The end time of the fade, in seconds +---@param endAmplitude number The amplitude of the end of the fade +---@return aukit.Audio _ The audio modified +function aukit.effects.fade(audio, startTime, startAmplitude, endTime, endAmplitude) + expectAudio(1, audio) + expect(2, startTime, "number") + expect(3, startAmplitude, "number") + expect(4, endTime, "number") + expect(5, endAmplitude, "number") + if startAmplitude == 1 and endAmplitude == 1 then return audio end + local startt = os_epoch "utc" + for c = 1, #audio.data do + local ch = audio.data[c] + local start = startTime * audio.sampleRate + local m = (endAmplitude - startAmplitude) / ((endTime - startTime) * audio.sampleRate) + for i = start, endTime * audio.sampleRate do + if os_epoch "utc" - startt > 5000 then startt = os_epoch "utc" sleep(0) end + ch[i] = clamp(ch[i] * (m * (i - start) + startAmplitude), -1, 1) + end + end + return audio +end + +--- Inverts all channels in the specified audio. +---@param audio aukit.Audio The audio to modify +---@return aukit.Audio _ The audio modified +function aukit.effects.invert(audio) + expectAudio(1, audio) + for c = 1, #audio.data do + local ch = audio.data[c] + for i = 1, #ch do ch[i] = -ch[i] end + end + return audio +end + +--- Normalizes audio to the specified peak amplitude. +---@param audio aukit.Audio The audio to modify +---@param peakAmplitude? number The maximum amplitude +---@param independent? boolean Whether to normalize each channel independently +---@return aukit.Audio _ The audio modified +function aukit.effects.normalize(audio, peakAmplitude, independent) + expectAudio(1, audio) + peakAmplitude = expect(2, peakAmplitude, "number", "nil") or 1 + expect(3, independent, "boolean", "nil") + local mult + local start, sampleRate = os_epoch "utc", audio.sampleRate + if not independent then + local max = 0 + for c = 1, #audio.data do + local ch = audio.data[c] + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + for i = 1, #ch do max = math_max(max, math_abs(ch[i])) end + end + mult = peakAmplitude / max + end + for c = 1, #audio.data do + local ch = audio.data[c] + if independent then + local max = 0 + for i = 1, #ch do max = math_max(max, math_abs(ch[i])) end + mult = peakAmplitude / max + end + if os_epoch "utc" - start > 3000 then start = os_epoch "utc" sleep(0) end + for i = 1, #ch do + ch[i] = clamp(ch[i] * mult, -1, 1) + end + end + return audio +end + +--- Centers the DC offset of each channel. +---@param audio aukit.Audio The audio to modify +---@return aukit.Audio _ The audio modified +function aukit.effects.center(audio) + expectAudio(1, audio) + for c = 1, #audio.data do + local ch = audio.data[c] + for i = 0, #ch - 1, audio.sampleRate do + local avg = 0 + local l = math_min(#ch - i, audio.sampleRate) + for j = 1, l do avg = avg + ch[i+j] end + avg = avg / l + for j = 1, l do ch[i+j] = clamp(ch[i+j] - avg, -1, 1) end + end + end + return audio +end + +--- Trims any extra silence on either end of the specified audio. +---@param audio aukit.Audio The audio to modify +---@param threshold? number The maximum value to register as silence +---@return aukit.Audio _ The audio modified +function aukit.effects.trim(audio, threshold) + expectAudio(1, audio) + threshold = expect(2, threshold, "number", "nil") or (1/65536) + local s, e + for i = 1, #audio.data[1] do + for c = 1, #audio.data do if math_abs(audio.data[c][i]) > threshold then s = i break end end + if s then break end + end + for i = #audio.data[1], 1, -1 do + for c = 1, #audio.data do if math_abs(audio.data[c][i]) > threshold then e = i break end end + if e then break end + end + local new = str_sub(audio, s / audio.sampleRate, e / audio.sampleRate) + audio.data = new.data + return audio +end + +--- Adds a delay to the specified audio. +---@param audio aukit.Audio The audio to modify +---@param delay number The amount of time to delay for, in seconds +---@param multiplier? number The multiplier to apply to the delayed audio +---@return aukit.Audio _ The audio modified +function aukit.effects.delay(audio, delay, multiplier) + expectAudio(1, audio) + expect(2, delay, "number") + multiplier = expect(3, multiplier, "number", "nil") or 0.5 + local samples = math_floor(delay * audio.sampleRate) + for c = 1, #audio.data do + local original = {} + local o = audio.data[c] + for i = 1, #o do original[i] = o[i] end + for i = samples + 1, #o do o[i] = clamp(o[i] + original[i - samples] * multiplier, -1, 1) end + end + return audio +end + +--- Adds an echo to the specified audio. +---@param audio aukit.Audio The audio to modify +---@param delay? number The amount of time to echo after, in seconds +---@param multiplier? number The decay multiplier to apply to the echoed audio +---@return aukit.Audio _ The audio modified +function aukit.effects.echo(audio, delay, multiplier) + expectAudio(1, audio) + delay = expect(2, delay, "number", "nil") or 1 + multiplier = expect(3, multiplier, "number", "nil") or 0.5 + local samples = math_floor(delay * audio.sampleRate) + for c = 1, #audio.data do + local o = audio.data[c] + for i = samples + 1, #o do o[i] = clamp(o[i] + o[i - samples] * multiplier, -1, 1) end + end + return audio +end + +local combDelayShift = {0, -11.73, 19.31, -7.97} +local combDecayShift = {0, 0.1313, 0.2743, 0.31} + +--- Adds reverb to the specified audio. +---@param audio aukit.Audio The audio to modify +---@param delay? number The amount of time to reverb after, in **milliseconds** +---@param decay? number The decay factor to use +---@param wetMultiplier? number The wet (reverbed) mix amount +---@param dryMultiplier? number The dry (original) mix amount +---@return aukit.Audio _ The audio modified +function aukit.effects.reverb(audio, delay, decay, wetMultiplier, dryMultiplier) + expectAudio(1, audio) + delay = expect(2, delay, "number", "nil") or 100 + decay = expect(3, decay, "number", "nil") or 0.3 + wetMultiplier = expect(4, wetMultiplier, "number", "nil") or 1 + dryMultiplier = expect(5, dryMultiplier, "number", "nil") or 0 + for c = 1, #audio.data do + -- four comb filters + local sum = {} + local o = audio.data[c] + for n = 1, 4 do + local comb = {} + local samples = math_floor((delay + combDelayShift[n]) / 1000 * audio.sampleRate) + local multiplier = decay - combDecayShift[n] + for i = 1, math_min(samples, #o) do + comb[i] = o[i] + sum[i] = (sum[i] or 0) + o[i] + end + for i = samples + 1, #o do + local s = o[i] + comb[i - samples] * multiplier + comb[i] = s + sum[i] = (sum[i] or 0) + s + end + end + -- mix wet/dry + for i = 1, #sum do sum[i] = sum[i] * wetMultiplier + o[i] * dryMultiplier end + -- two all pass filters + local samples = math_floor(0.08927 * audio.sampleRate) + sum[samples+1] = sum[samples+1] - 0.131 * sum[1] + for i = samples + 2, #sum do sum[i] = sum[i] - 0.131 * sum[i - samples] + 0.131 * sum[i + 20 - samples] end + o[samples+1] = clamp(sum[samples+1] - 0.131 * sum[1], -1, 1) + for i = samples + 2, #sum do o[i] = clamp(sum[i] - 0.131 * sum[i - samples] + 0.131 * sum[i + 20 - samples], -1, 1) end + end + return audio +end + +--- Applies a low-pass filter to the specified audio. +---@param audio aukit.Audio The audio to modify +---@param frequency number The cutoff frequency for the filter +---@return aukit.Audio _ The audio modified +function aukit.effects.lowpass(audio, frequency) + expectAudio(1, audio) + expect(2, frequency, "number") + local a = 1 - math.exp(-(frequency / audio.sampleRate) * 2 * math_pi) + for c = 1, #audio.data do + local d = audio.data[c] + for i = 2, #d do + local l = d[i-1] + d[i] = l + a * (d[i] - l) + end + end + return audio +end + +--- Applies a high-pass filter to the specified audio. +---@param audio aukit.Audio The audio to modify +---@param frequency number The cutoff frequency for the filter +---@return aukit.Audio _ The audio modified +function aukit.effects.highpass(audio, frequency) + expectAudio(1, audio) + expect(2, frequency, "number") + local a = 1 / (2 * math_pi * (frequency / audio.sampleRate) + 1) + for c = 1, #audio.data do + local d = audio.data[c] + local lx = d[1] + for i = 2, #d do + local llx = d[i] + d[i] = a * (d[i-1] + llx - lx) + lx = llx + end + end + return audio +end + +return aukit \ No newline at end of file diff --git a/austream.lua b/austream.lua new file mode 100644 index 0000000..dde7103 --- /dev/null +++ b/austream.lua @@ -0,0 +1,255 @@ + +local aukit = require "aukit" + +local speakers = {peripheral.find("speaker")} +if #speakers == 0 then error("No speaker attached") end +if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end + +local path = ... +if not path then error("Usage: austream [arguments for PCM/DFPWM]") end +local params = {...} +table.remove(params, 1) -- Remove path +local v = {} +if #params > 0 then + -- Try to parse as Lua table string first (original behavior) + local paramStr = table.concat(params, " ") + -- Remove outer braces if they exist to match original austream format + paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "") + + local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end})) + if fn then + local ok, parsed = pcall(fn) + if ok and type(parsed) == "table" then + for k, val in pairs(parsed) do v[k] = val end + end + end + + -- If that fails, try manual string matching + if not next(v) then + local matchStr = table.concat(params, ", ") + if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then + v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") + end + if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end + if matchStr:match("streamData%s*=%s*true") then v.streamData = true end + if matchStr:match("silent%s*=%s*true") then v.silent = true end + end +end +if v.volume == nil then v.volume = 3 end +if type(v.volume) == "number" then + if v.volume < 0 then v.volume = 0 end + if v.volume > 3 then v.volume = 3 end +end +local mono = v.mono or #speakers == 1 +aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation +local data +if path:match("^https?://") then + local handle, err = http.get(path, nil, true) + if not handle then error("Could not connect to " .. path .. ": " .. err) end + local code = handle.getResponseCode() + if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end + if v.streamData then + local closed = false + function data() + if closed then return nil end + local d = handle.read(48000) + if not d then handle.close() closed = true return nil end + return d + end + else + data = handle.readAll() + handle.close() + end +elseif path:match("^wss?://") then + local ws, err = http.websocket(path) + if not ws then error("Failed to connect to websocket: " .. tostring(err)) end + + -- Buffer completely locally to avoid any network stutter + local fullBuffer = "" + local isDone = false + + local function cleanup() + if not isDone then + isDone = true + if ws then ws.close() end + end + end + + -- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect) + local startWait = os.startTimer(1.0) + while true do + local event, p1, p2 = os.pullEvent() + if event == "websocket_message" and p1 == path then + if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end + -- Once we get the first message, we can break and start playing + break + elseif event == "websocket_closed" and p1 == path then + cleanup() + break + elseif event == "timer" and p1 == startWait then + break + end + end + + -- Since WebSocket connection needs to run alongside aukit stream reading, + -- we will just use a non-blocking read approach in the data function itself. + + function data(n) + n = n or 6000 + + if isDone then + if #fullBuffer == 0 then return nil end + else + -- First, read any available messages without blocking + -- ws.receive(0) returns immediately. If nil, no message is ready. + while true do + local msg = ws.receive(0) + if msg == nil then break end + if type(msg) == "string" then + fullBuffer = fullBuffer .. msg + end + end + + -- If we still don't have enough data, wait for more + -- but only if we know more might be coming (not isDone) + if #fullBuffer < n then + -- We only wait briefly so we don't block the audio thread too long + -- If the server isn't sending data, it means silence or end of stream + local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events + while #fullBuffer < n do + local event, p1, p2 = os.pullEvent() + if event == "websocket_message" and p1 == path then + if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end + elseif event == "websocket_closed" and p1 == path then + cleanup() + break + elseif event == "timer" and p1 == timeout then + -- We hit timeout, we must return whatever we have to keep playing + break + end + end + end + end + + if #fullBuffer == 0 and isDone then return nil end + -- Return DFPWM silence (0xAA) if buffer is empty but stream is alive + -- This keeps the stream alive without crashing or making noise + if #fullBuffer == 0 then return string.rep("\xAA", n) end + + local ret + if #fullBuffer >= n then + ret = fullBuffer:sub(1, n) + fullBuffer = fullBuffer:sub(n + 1) + else + ret = fullBuffer + -- Pad with silence if we don't have enough data to fulfill the request + ret = ret .. string.rep("\xAA", n - #ret) + fullBuffer = "" + end + return ret + end + -- Note: aukit.stream handles the reading internally + -- We can use a table with a read function for custom streaming + local obj = { + read = function(n) + local d = data(n) + if not d and isDone then cleanup() end + return d + end, + close = cleanup + } + return obj +elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then + peripheral.find("modem", rednet.open) + local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$") + id = tonumber(id) + if proto == "" then proto = nil end + if path ~= "" and id >= 0 then rednet.send(id, name, proto) end + function data() + local i, msg + repeat i, msg = rednet.receive(proto) until id == -1 or i == id + return msg + end +else + path = shell.resolve(...) + local file, err = fs.open(path, "rb") + if not file then error("Could not open " .. path .. ": " .. err) end + if v.streamData then + local closed = false + function data() + if closed then return nil end + local d = file.read(48000) + if not d then file.close() closed = true return nil end + return d + end + else + data = file.readAll() + file.close() + end +end + + +local iter, length +if type(v.type) == "string" then v.type = v.type:lower() end + + local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://") + if isDfpwm then + if type(data) == "table" and data.read then + iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono) + else + iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono) + end + elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono) +elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader) +elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader) +elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader) +elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono) +elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono) +elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono) +else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end +if length == nil then length = 0 end + +if not v.silent then + print("Streaming...") + local w = term.getSize() + local y = select(2, term.getCursorPos()) + local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor()) + term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60)) + aukit.play(iter, function(pos) + pos = math.min(pos, 5999) + local p = pos / length + term.setCursorPos(1, y) + + -- 计算基本时间字符串 + local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60) + local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60) + + if p > 1 then + -- 确保空格数量正确 + local spaces = (" "):rep(w - 12) + local text = timeStr .. " " .. spaces .. "--:--" + local fgColor = fg:rep(#text) + local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6) + -- 确保所有参数长度相等 + if #text == #fgColor and #text == #bgColor then + term.blit(text, fgColor, bgColor) + end + else + local barWidth = w - 12 + local progress = math.floor(barWidth * p) + progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内 + + local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress) + local text = timeStr .. " " .. progressBar .. " " .. endTimeStr + local fgColor = fg:rep(#text) + local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6) + -- 确保所有参数长度相等 + if #text == #fgColor and #text == #bgColor then + term.blit(text, fgColor, bgColor) + end + end + end, v.volume, table.unpack(speakers)) + print() +else + aukit.play(iter, function() end, v.volume, table.unpack(speakers)) +end diff --git a/install_station_broadcast.lua b/install_station_broadcast.lua new file mode 100644 index 0000000..9678391 --- /dev/null +++ b/install_station_broadcast.lua @@ -0,0 +1,83 @@ +local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0" +local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0" +local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0" + +local function fetch(url) + if not http then + return nil, "HTTP API unavailable" + end + + if http.checkURL then + local ok, err = http.checkURL(url) + if not ok then + return nil, err or "URL blocked" + end + end + + local res, err = http.get(url) + if not res then + return nil, err or "request failed" + end + + local body = res.readAll() + res.close() + + if type(body) ~= "string" or #body == 0 then + return nil, "empty response" + end + + return body +end + +local function writeFile(path, content) + local dir = fs.getDir(path) + if dir and dir ~= "" and not fs.exists(dir) then + fs.makeDir(dir) + end + + local f = fs.open(path, "w") + if not f then + return nil, "cannot open " .. path + end + + f.write(content) + f.close() + return true +end + +local function downloadTo(url, path) + write(("Downloading %s... "):format(path)) + local body, err = fetch(url) + if not body then + print("FAILED") + print(err) + return false + end + + local ok, werr = writeFile(path, body) + if not ok then + print("FAILED") + print(werr) + return false + end + + print("OK") + return true +end + +local targets = { + { url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }, + { url = AUKIT_URL, path = shell.resolve("/aukit.lua") }, + { url = AUSTREAM_URL, path = shell.resolve("/austream.lua") } +} + +for _, t in ipairs(targets) do + local ok = downloadTo(t.url, t.path) + if not ok then + print("Install aborted.") + return + end +end + +print("Install complete.") +print("Run 'reboot' to start StationBroadcast on boot.") diff --git a/station_broadcast-warning.lua b/station_broadcast-warning.lua new file mode 100644 index 0000000..22729fa --- /dev/null +++ b/station_broadcast-warning.lua @@ -0,0 +1,52 @@ +-- Random Audio Player +-- Simplified version that only plays random audio files + +local COLORS = { + INFO = colors.white, + WARN = colors.yellow, + WARNING = colors.yellow, + ERROR = colors.red, + DEBUG = colors.cyan, + SUCCESS = colors.green +} + +-- Debug output function +local function debugPrint(level, message) + local timestamp = os.date("%H:%M:%S") + local logMessage = string.format("[%s][%s] %s", timestamp, level, message) + local color = COLORS[level] or colors.white + local oldColor = term.getTextColor() + term.setTextColor(color) + print(logMessage) + term.setTextColor(oldColor) +end + +-- Add random audio playback function +local function playRandomAudio() + local audioFiles = {"aqtx", "jzxy"} + local randomFile = audioFiles[math.random(#audioFiles)] + local path = "/Audio/"..randomFile..".dfpwm" + + if fs.exists(path) then + debugPrint("INFO", "Attempting to play: "..path) + shell.run("austream "..path.." volume=3.0") + debugPrint("INFO", "Finished playing: "..randomFile) + else + debugPrint("ERROR", "Audio file not found: "..path) + end +end + +-- Main loop +local elapsedSeconds = 0 +while true do + if elapsedSeconds >= 240 then + playRandomAudio() + elapsedSeconds = 0 + end + + os.sleep(1) + elapsedSeconds = elapsedSeconds + 1 +end + + + diff --git a/station_broadcast.lua b/station_broadcast.lua new file mode 100644 index 0000000..b53fa6b --- /dev/null +++ b/station_broadcast.lua @@ -0,0 +1,1345 @@ + +-- ============================================================================ +-- 1. Configuration & Constants +-- ============================================================================ +local CONFIG = { + CLOUD_URL = "ws://stabadmin.fse-media.group", + API_URL = "http://stabadmin.fse-media.group", + SYNC_INTERVAL = 30, + AUDIO_BASE = "Audio/", + AUDIO_STATION = "Audio/Station/", + DISK_AUDIO_BASE = "disk/Audio/", + DISK_AUDIO_STATION = "disk/Audio/Station/", + AUDIO_VOLUME = 3, + DEBUG = true +} + +local aukit = require "aukit" + +local COLORS = { + INFO = colors.white, + WARN = colors.yellow, + WARNING = colors.yellow, + ERROR = colors.red, + DEBUG = colors.cyan, + SUCCESS = colors.green +} + +-- ============================================================================ +-- 2. State Management +-- ============================================================================ +local State = { + ws = nil, + stationName = "Unknown", + stationCode = "UNK", + stationPlatform = "1", + registered = false, + stationList = {}, + currentTrain = nil, + logs = {}, + logScrollOffset = 0, + departingPlayed = false, + arrivalTonePlayed = false, + shortStopMode = false, + audioRx = nil, + audioQueue = {}, + currentPlaying = nil, + currentPriority = -1, + interruptSeq = 0, + lastArrivalPlayAt = 0 +} + +-- ============================================================================ +-- 3. Peripherals Setup +-- ============================================================================ +local Peripherals = {} + +function Peripherals.init() + -- Station Peripheral + Peripherals.station = peripheral.wrap("back", "train_station") + or error("Train station peripheral required on 'back'") + -- Monitors + local monitors = { peripheral.find("monitor") } + if #monitors == 0 then error("At least one monitor is required") end + -- Assign monitors + Peripherals.primaryMonitor = monitors[1] + Peripherals.primaryMonitor.setTextScale(0.5) + -- Speakers + Peripherals.speakers = { peripheral.find("speaker") } +end + +-- ============================================================================ +-- 4. Utility Functions +-- ============================================================================ +local Utils = {} + +function Utils.log(level, message) + local timestamp = os.date("%H:%M:%S") + local entryText = string.format("[%s][%s] %s", timestamp, level, message) + local entry = { level = level, text = entryText } + table.insert(State.logs, 1, entry) + if #State.logs > 100 then table.remove(State.logs) end + + -- If user is scrolling history, increment offset to stay on same items + if State.logScrollOffset > 0 then + State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1)) + end +end + +function Utils.getStationCode(name) + if not name then return nil end + name = tostring(name) + + local code = nil + local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]") + if codeInParens then + local firstPart = codeInParens:match("[^/]+") + if firstPart then + code = firstPart:match("^%s*(.-)%s*$") + end + end + + if not code then + code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+") + end + + if not code then + local cleanName = name:match("^%s*(.-)%s*$"):lower() + if State.stationList and State.stationList[cleanName] then + code = cleanName + end + end + + if code then + return tostring(code):lower() -- Always return lowercase for consistent comparison + end + return nil +end + +function Utils.getPlatformNumber(name, default) + if not name then return default end + local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)") + if p then return p end + return name:match("([%d]+[a-zA-Z]?)$") or default +end + +function Utils.getBroadcastStationCode(name) + if not name then return "UNK" end + name = name:match("^%s*(.-)%s*$") + name = name:gsub("%s+", "_") + if name == "" then return "UNK" end + return name +end + +function Utils.isSameTrain(id1, id2) + if not id1 or not id2 then return false end + if id1 == id2 then return true end + + -- Consist Car Match: "CK 1 01" vs "CK 1 02" + local function getConsist(id) + local clean = id:gsub("^%[.-%]%s*", "") + local p = {} + for s in clean:gmatch("%S+") do table.insert(p, s) end + if #p >= 2 then + if #p[1] <= 4 and tonumber(p[2]) then + return p[1] .. " " .. p[2] + end + end + return p[1] or clean + end + + local c1 = getConsist(id1) + local c2 = getConsist(id2) + if c1 == c2 then return true end + + -- Fuzzy match: remove type prefix and compare content + local _, name1, num1 = Utils.parseTrainId(id1) + local _, name2, num2 = Utils.parseTrainId(id2) + + -- If both have numbers, compare name and number + if num1 ~= "" and num2 ~= "" then + return name1 == name2 and num1 == num2 + end + + -- Fallback to comparing just the main content/name + return name1 == name2 +end + +function Utils.parseTrainId(trainId) + if not trainId then return "unknown", "" end + + -- Generic match: [Type]Content + local typeStr, content = trainId:match("^%[(.-)%](.+)$") + + if typeStr then + -- Check for Express types + if typeStr == "Ltd.Exp" or typeStr == "Express" then + + local name, num = content:match("^(.+) No%.(%d+)$") + if name then + return "express", name, num + else + return "express", content, "" + end + elseif typeStr == "Local" then + return "regular", "Local", content + else + return "regular", typeStr, content + end + end + + return "unknown", "Unknown", trainId +end + +function Utils.getDestination(schedule) + if not schedule then return "Unknown" end + -- Handle both wrapped and unwrapped schedule structure + local entries = schedule.entries or (type(schedule) == "table" and schedule) + if not entries or type(entries) ~= "table" then return "Unknown" end + + + if #entries == 0 then + local count = 0 + for _ in pairs(entries) do count = count + 1 end + if count > 0 then + + local list = {} + for k, v in pairs(entries) do + local n = tonumber(k) + if n then list[n] = v end + end + entries = list + end + end + + local dest = "Unknown" + local maxIdx = 0 + for k, _ in pairs(entries) do + if type(k) == "number" and k > maxIdx then maxIdx = k end + end + + + for i = 1, maxIdx do + local entry = entries[i] + if entry then + local inst = entry.instruction + if inst then + if inst.id == "create:destination" then + dest = (inst.data and inst.data.text) or inst.text or dest + end + + if inst.id == "create:station" then + local sName = (inst.data and inst.data.text) or inst.text + if sName then + dest = sName + end + end + end + end + end + + return dest +end + +function Utils.getBaseName(name) + if not name then return "" end + name = tostring(name):lower() + name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "") + name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ") + name = name:gsub("%s+%d+$", "") + return name:match("^%s*(.-)%s*$") +end + +function Utils.isFuzzyMatch(str, pattern) + if not str or not pattern then return false end + str = tostring(str):lower():match("^%s*(.-)%s*$") + pattern = tostring(pattern):lower():match("^%s*(.-)%s*$") + + if str == pattern then return true end + + -- 1. Handle Create Mod Wildcards (*) + if pattern:find("*") then + local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1") + p = p:gsub("%%%*", ".*") + local ok, res = pcall(function() return str:match("^" .. p .. "$") end) + if ok and res then return true end + end + + -- 2. Base name comparison + local b1 = Utils.getBaseName(str) + local b2 = Utils.getBaseName(pattern) + if b1 ~= "" and b2 ~= "" then + if b1 == b2 then return true end + -- Substring match for base names + if #b1 >= 4 and #b2 >= 4 then + if b1:find(b2, 1, true) or b2:find(b1, 1, true) then + return true + end + end + end + + -- 3. Alphanumeric only comparison + local a1 = str:gsub("[^%w%d]", "") + local a2 = pattern:gsub("[^%w%d]", "") + if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then + return true + end + + return false +end + +function Utils.getStopTime(schedule, stationName) + if not schedule or not schedule.entries or not stationName then return nil end + + local target = tostring(stationName):lower():match("^%s*(.-)%s*$") + if target == "" then return nil end + + local targetCode = Utils.getStationCode(target) + local targetBase = Utils.getBaseName(target) + local targetAlpha = target:gsub("[^%w%d]", "") + + Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha)) + + local function extractTime(data) + if not data then return nil end + local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s) + or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay) + local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1 + if val then + if unit == 0 then return val / 20 + elseif unit == 1 then return val + elseif unit == 2 then return val * 60 + end + return val + end + local ticks = tonumber(data.ticks) or tonumber(data.tick) + if ticks then return ticks / 20 end + return nil + end + + local function findTimeInConditions(conditions) + if not conditions then return nil end + for i, item in ipairs(conditions) do + if type(item) == "table" then + local cid = tostring(item.id or ""):lower() + if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then + local s = extractTime(item.data) + if s and s > 0 then + Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid)) + return s + end + end + for k, sub in pairs(item) do + if type(sub) == "table" then + local s = findTimeInConditions({sub}) + if s then return s end + end + end + end + end + return nil + end + + local maxIdx = 0 + if schedule.entries then + for k, _ in pairs(schedule.entries) do + if type(k) == "number" and k > maxIdx then maxIdx = k end + end + end + + Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx)) + + local matchFoundIndex = nil + + for i = 1, maxIdx do + local entry = schedule.entries[i] + if entry then + local function scanRecursive(obj, depth) + if depth > 5 or not obj then return false end + for k, v in pairs(obj) do + if type(v) == "string" then + local entryText = v:lower() + local entryCode = Utils.getStationCode(entryText) + local entryAlpha = entryText:gsub("[^%w%d]", "") + local entryBase = Utils.getBaseName(entryText) + + Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, entryText, tostring(entryCode))) + + local isMatch = (target == entryText) + or (targetCode and entryCode and targetCode == entryCode) + or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true))) + or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true))) + or Utils.isFuzzyMatch(target, entryText) + + if isMatch then + Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d (via field '%s')", i, k)) + return true + end + elseif type(v) == "table" then + if scanRecursive(v, depth + 1) then return true end + end + end + return false + end + + if scanRecursive(entry, 0) then + matchFoundIndex = i + local s = findTimeInConditions(entry.conditions) + if s then + Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s)) + return s + end + + if entry.instruction and entry.instruction.data then + local s = extractTime(entry.instruction.data) + if s then + Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s)) + return s + end + end + + break + end + end + end + + if matchFoundIndex then + Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex)) + for i = matchFoundIndex + 1, maxIdx do + local entry = schedule.entries[i] + if entry then + local isOtherStation = false + local function checkIsStation(obj, depth) + if depth > 2 or not obj then return false end + for k, v in pairs(obj) do + if type(v) == "string" then + local lk = tostring(k):lower() + if lk:find("text") or lk:find("station") or lk:find("destination") then return true end + elseif type(v) == "table" then + if checkIsStation(v, depth + 1) then return true end + end + end + return false + end + if checkIsStation(entry, 0) then break end + + local s = findTimeInConditions(entry.conditions) + if s then + Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s)) + return s + end + if entry.instruction and entry.instruction.data then + local s = extractTime(entry.instruction.data) + if s then + Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s)) + return s + end + end + end + end + end + + Utils.log("DEBUG", "--- Scan End: No Time Resolved ---") + return nil +end + +-- ============================================================================ +-- 5. Audio System +-- ============================================================================ +local Audio = {} + +function Audio.createStream() + local chunks = {} + local finished = false + local stream = {} + + stream.push = function(chunk) + if type(chunk) ~= "string" then return end + table.insert(chunks, chunk) + os.queueEvent("audio_chunk") + end + + stream.finish = function() + finished = true + os.queueEvent("audio_chunk") + end + + stream.read = function() + while #chunks == 0 do + if finished then return nil end + os.pullEvent("audio_chunk") + end + return table.remove(chunks, 1) + end + + return stream +end + +function Audio.loadStationList() + State.stationList = {} + if not fs.exists("list.txt") then + Utils.log("WARN", "list.txt not found") + return + end + + local file = fs.open("list.txt", "r") + if file then + local count = 0 + local line = file.readLine() + while line do + line = line:gsub("%s+$", "") -- Trim trailing \r\n and spaces + if line ~= "" then + local codes, files = line:match("^([^=]+)==(.+)$") + if codes and files then + local list = {} + for f in files:gmatch("[^,]+") do + local cleanName = f:match("^%s*(.-)%s*$"):gsub("%.wav$", ""):gsub("%.dfpwm$", "") + if cleanName ~= "" then + table.insert(list, cleanName) + end + end + + -- Map all codes to this list + local allCodes = {} + for code in codes:gmatch("[^/]+") do + local cleanCode = code:match("^%s*(.-)%s*$"):lower() + if cleanCode ~= "" then + table.insert(allCodes, cleanCode) + State.stationList[cleanCode] = list + end + end + + if #allCodes > 1 then + local fullRawCode = codes:match("^%s*(.-)%s*$"):lower() + State.stationList[fullRawCode] = list + local dashCode = table.concat(allCodes, "-"):lower() + State.stationList[dashCode] = list + end + count = count + 1 + end + end + line = file.readLine() + end + file.close() + Utils.log("INFO", string.format("Station list loaded: %d entries", count)) + end +end + +function Audio.enqueueDfpwm(dfpwmData) + if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end + Audio.enqueueItem({ dfpwmData = dfpwmData, priority = 1 }) +end + +function Audio.enqueueItem(item) + if type(item) ~= "table" then return end + if type(item.dfpwmData) ~= "string" and type(item.dfpwmData) ~= "table" then return end + item.priority = tonumber(item.priority) or 0 + local idx = 1 + while idx <= #State.audioQueue do + local p = tonumber(State.audioQueue[idx].priority) or 0 + if item.priority > p then break end + idx = idx + 1 + end + table.insert(State.audioQueue, idx, item) + os.queueEvent("audio_queue") +end + +function Audio.stop(mode, flushBelow) + State.interruptSeq = State.interruptSeq + 1 + if State.audioRx and State.audioRx.stream then + pcall(State.audioRx.stream.finish) + end + State.audioRx = nil + if Peripherals.speakers then + for _, sp in ipairs(Peripherals.speakers) do + pcall(function() + if type(sp.speakStop) == "function" then sp.speakStop() + else sp.stop() end + end) + end + end + + if mode == "all" then + State.audioQueue = {} + elseif flushBelow ~= nil then + local minP = tonumber(flushBelow) or 0 + local kept = {} + for _, it in ipairs(State.audioQueue) do + local p = tonumber(it.priority) or 0 + if p >= minP then table.insert(kept, it) end + end + State.audioQueue = kept + end + + os.queueEvent("audio_interrupt", State.interruptSeq) +end + +local function shouldStop(playSeq) + return State.interruptSeq ~= playSeq +end + +local function playChunkOnSpeakers(samples, volume, playSeq) + if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end + local controller = Peripherals.speakers[1] + if controller and type(controller.playAudioAll) == "function" then + while true do + if shouldStop(playSeq) then return false end + local ok = false + pcall(function() + ok = controller.playAudioAll(samples, volume) + end) + if ok then break end + local ev = { os.pullEvent() } + if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then + return false + end + end + else + local pending = {} + for _, sp in ipairs(Peripherals.speakers) do + local name = peripheral.getName(sp) + if name then pending[name] = sp end + end + + while next(pending) do + if shouldStop(playSeq) then return false end + for name, sp in pairs(pending) do + local ok = false + pcall(function() + ok = sp.playAudio(samples, volume) + end) + if ok then pending[name] = nil end + end + if next(pending) then + local ev = { os.pullEvent() } + if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then + return false + end + end + end + end + + local waiting = {} + for _, sp in ipairs(Peripherals.speakers) do + local name = peripheral.getName(sp) + if name then waiting[name] = true end + end + + while next(waiting) do + if shouldStop(playSeq) then return false end + local ev = { os.pullEvent() } + if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then + return false + end + if ev[1] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then + waiting[ev[2]] = nil + end + end + + return true +end + +function Audio.playDfpwmItem(item) + if not Peripherals.speakers or #Peripherals.speakers == 0 then + Utils.log("WARN", "No speakers found; cannot play audio") + return + end + + local playSeq = State.interruptSeq + local source = item.dfpwmData + + -- Support both static string data and streaming functions + if type(source) == "table" and source.read then + source = source.read + end + + local ok, iter = pcall(aukit.stream.dfpwm, source, 48000, 1, false) + if not ok then + Utils.log("ERROR", "Failed to create audio stream: " .. tostring(iter)) + return + end + + for chunk in iter do + if shouldStop(playSeq) then break end + if not playChunkOnSpeakers(chunk[1], CONFIG.AUDIO_VOLUME, playSeq) then break end + end +end + +function Audio.playerLoop() + while true do + if #State.audioQueue == 0 then + os.pullEvent("audio_queue") + else + local item = table.remove(State.audioQueue, 1) + State.currentPlaying = item + State.currentPriority = tonumber(item.priority) or 0 + + local ok, err = pcall(Audio.playDfpwmItem, item) + if not ok then + Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err)) + end + + State.currentPlaying = nil + State.currentPriority = -1 + end + end +end + +function Utils.getChineseNumberAudio(val) + local num = tonumber(val) + if num then + local seq = {} + if num < 10 then + table.insert(seq, "ch-" .. num) + elseif num == 10 then + table.insert(seq, "ch-10") + elseif num > 10 and num < 20 then + table.insert(seq, "ch-10") + table.insert(seq, "ch-" .. (num % 10)) + elseif num >= 20 and num < 100 then + local ten = math.floor(num / 10) + local unit = num % 10 + table.insert(seq, "ch-" .. ten) + table.insert(seq, "ch-10") + if unit > 0 then + table.insert(seq, "ch-" .. unit) + end + else + table.insert(seq, "ch-" .. num) + end + return seq + else + -- Handle Alphanumeric (e.g., "1A", "2B") + local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$") + if digit and letter then + local seq = Utils.getChineseNumberAudio(digit) + table.insert(seq, letter:upper()) -- e.g. "A", "B" + return seq + end + return {tostring(val)} + end +end + +function Utils.getStationAudio(code, langPrefix) + if not code then return nil end + local files = State.stationList[code] + if files then + local p = langPrefix:lower() + for _, f in ipairs(files) do + if f:lower():find("^" .. p) then + return f + end + end + end + -- Fallback to hardcoded pattern + return langPrefix .. code +end + +function Audio.announceArrival(trainId, trainType, platform, dest, useAltJz3) + local seq = {} + Utils.log("DEBUG", string.format("Announcing Arrival: %s, Type: %s, Plat: %s, Dest: %s", + tostring(trainId), tostring(trainType), tostring(platform), tostring(dest))) + + table.insert(seq, "ksts") -- Chime + table.insert(seq, "__DELAY_1__") + + local destCode = Utils.getStationCode(dest) + if (not destCode or destCode == "unknown") and dest ~= "Unknown" then + -- Fallback to broadcast code if regular code failed + destCode = Utils.getBroadcastStationCode(dest) + Utils.log("DEBUG", "Station code fallback for " .. tostring(dest) .. ": " .. tostring(destCode)) + end + + Utils.log("DEBUG", "Final destination code for " .. tostring(dest) .. ": " .. tostring(destCode)) + + local chJz3 = useAltJz3 and "ch-jz3-1" or "ch-jz3" + local enJz3 = useAltJz3 and "en-jz3-1" or "en-jz3" + + -- Chinese + table.insert(seq, "ch-jz1") + if destCode then + table.insert(seq, Utils.getStationAudio(destCode, "ch-")) + end + table.insert(seq, "ch-jz2") + table.insert(seq, trainType == "express" and "ch-tj" or "ch-pt") + table.insert(seq, chJz3) + + -- Platform Number (Chinese) + local pAudio = Utils.getChineseNumberAudio(platform) + for _, a in ipairs(pAudio) do table.insert(seq, a) end + + table.insert(seq, "ch-zt") + table.insert(seq, "ch-jz4") + + -- English + table.insert(seq, "en-jz1") + table.insert(seq, trainType == "express" and "en-tj" or "en-pt") + table.insert(seq, "en-jz2") + if destCode then + table.insert(seq, Utils.getStationAudio(destCode, "en-")) + end + table.insert(seq, enJz3) + table.insert(seq, "en-zt") + + -- English Platform Number + local pDigit, pLetter = tostring(platform):match("^(%d+)([a-zA-Z])$") + if pDigit and pLetter then + table.insert(seq, "en-" .. pDigit) + table.insert(seq, pLetter:upper()) + else + table.insert(seq, "en-" .. platform) + end + + table.insert(seq, "en-jz4") + + Audio.play(seq) +end + +function Audio.announceDeparture(trainId, trainType, platform, dest, time) + local seq = {} + table.insert(seq, "fcts") + table.insert(seq, "__DELAY_1__") + + local destCode = Utils.getStationCode(dest) + + -- Chinese + -- Platform Number + local pAudio = Utils.getChineseNumberAudio(platform) + for _, a in ipairs(pAudio) do table.insert(seq, a) end + + table.insert(seq, "ch-zt") + table.insert(seq, "ch-fc1") + table.insert(seq, trainType == "express" and "ch-tj" or "ch-pt") + table.insert(seq, "ch-fc2") + if destCode then + table.insert(seq, Utils.getStationAudio(destCode, "ch-")) + end + table.insert(seq, "ch-fc3") + + -- English + table.insert(seq, "en-fc1") + table.insert(seq, trainType == "express" and "en-tj" or "en-pt") + table.insert(seq, "en-fc2") + table.insert(seq, "en-zt") + + -- English Platform Number + local pDigit, pLetter = tostring(platform):match("^(%d+)([a-zA-Z])$") + if pDigit and pLetter then + table.insert(seq, "en-" .. pDigit) + table.insert(seq, pLetter:upper()) + else + table.insert(seq, "en-" .. platform) + end + + table.insert(seq, "en-fc3") + if destCode then + table.insert(seq, Utils.getStationAudio(destCode, "en-")) + end + table.insert(seq, "en-fc4") + + Audio.play(seq) +end + +-- ============================================================================ +-- 6. Network System +-- ============================================================================ +local Network = {} + +function Audio.play(files) + if not State.ws then + Utils.log("WARN", "Cannot play audio: Not connected to server") + return + end + + Utils.log("INFO", "Requesting server to play sequence...") + Network.send({ + type = "play_sequence", + stationName = State.stationName, + seq = files, + category = "train", + priority = 1, + requestId = tostring(os.epoch("utc")) .. "-" .. tostring(math.random(100000, 999999)) + }) +end + +function Network.connect() + if State.ws then return end + Utils.log("INFO", "Connecting to cloud...") + State.ws = http.websocket(CONFIG.CLOUD_URL) + if State.ws then + Utils.log("SUCCESS", "Connected") + Network.register() + else + Utils.log("ERROR", "Connection failed") + end +end + +function Network.send(data) + if State.ws then + local ok, err = pcall(function() + State.ws.send(textutils.serializeJSON(data)) + end) + if not ok then + Utils.log("ERROR", "Failed to send WS message: " .. tostring(err)) + end + end +end + +function Network.register() + State.stationName = (Peripherals.station.getStationName() or "Unknown"):match("^%s*(.-)%s*$") -- Trim + -- Try to get a clean code from name, fallback to broadcast format + State.stationCode = Utils.getStationCode(State.stationName) or Utils.getBroadcastStationCode(State.stationName) + State.stationPlatform = Utils.getPlatformNumber(State.stationName, "1") + + Network.send({ + type = "station_register", + stationName = State.stationName, + stationCode = State.stationCode, + stationPlatform = State.stationPlatform + }) +end + +function Network.syncFiles() + -- Sync only config file (list.txt) periodically + while true do + local resp = http.get(CONFIG.API_URL .. "/uploads/list.txt") + if resp then + local content = resp.readAll() + resp.close() + if content and #content > 0 then + local currentList = "" + if fs.exists("list.txt") then + local f = fs.open("list.txt", "r") + currentList = f.readAll() + f.close() + end + + if currentList ~= content then + local f = fs.open("list.txt", "w") + f.write(content) + f.close() + Audio.loadStationList() + end + end + end + os.sleep(CONFIG.SYNC_INTERVAL) + end +end + +function Network.messageLoop() + while true do + local event, url, msg = os.pullEvent() + if event == "websocket_message" and url == CONFIG.CLOUD_URL then + local ok, data = pcall(textutils.unserializeJSON, msg) + if ok and type(data) == "table" then + if data.type == "audio_start" then + if State.audioRx and State.audioRx.stream then + pcall(State.audioRx.stream.finish) + end + local priority = tonumber(data.priority) or 0 + local interruptMode = tostring(data.interruptMode or "") + if interruptMode == "" then + if priority >= 2 then interruptMode = "all" + elseif priority >= 1 then interruptMode = "ifLower" + else interruptMode = "none" end + end + + if interruptMode == "all" then + Audio.stop("all") + elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then + Audio.stop("current", priority) + end + + local stream = Audio.createStream() + State.audioRx = { + audioId = data.audioId, + expectedBytes = tonumber(data.byteLength) or 0, + receivedBytes = 0, + stream = stream, + priority = priority, + source = data.source or data.category + } + + -- Start playing immediately by enqueuing the stream + Audio.enqueueItem({ + dfpwmData = stream, + priority = priority, + source = State.audioRx.source, + audioId = data.audioId + }) + elseif data.type == "audio_end" then + if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then + State.audioRx.stream.finish() + State.audioRx = nil + end + elseif data.type == "audio_stop" then + local scope = tostring(data.scope or "") + if scope == "music" then + local kept = {} + for _, it in ipairs(State.audioQueue) do + if tostring(it.source or "") ~= "music" then + table.insert(kept, it) + end + end + State.audioQueue = kept + + if State.audioRx and tostring(State.audioRx.source or "") == "music" then + State.audioRx = nil + end + + if State.currentPlaying and tostring(State.currentPlaying.source or "") == "music" then + Audio.stop("current") + end + else + local mode = tostring(data.mode or "current") + local flushBelow = data.flushBelow + Audio.stop(mode, flushBelow) + end + end + else + if State.audioRx then + -- Memory protection: limit to 10MB per audio item + if State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then + Utils.log("ERROR", "Audio item too large, discarding") + State.audioRx.stream.finish() + State.audioRx = nil + else + State.audioRx.stream.push(msg) + State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg + if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then + State.audioRx.stream.finish() + State.audioRx = nil + end + end + end + end + elseif event == "websocket_closed" then + Utils.log("WARN", "WS Closed") + State.ws = nil + State.audioRx = nil -- Clear partial download + os.sleep(5) + end + end +end + +function Network.heartbeat() + while true do + if State.ws then + Network.send({ + type = "heartbeat", + stationName = State.stationName, + stationCode = State.stationCode, + stationPlatform = State.stationPlatform + }) + end + os.sleep(10) + end +end + +-- ============================================================================ +-- 7. Display System +-- ============================================================================ +local Display = {} + +function Display.render() + local mon = Peripherals.primaryMonitor + local w, h = mon.getSize() + mon.clear() + mon.setCursorPos(1,1) + mon.setTextColor(COLORS.INFO) + mon.write("STATION: " .. State.stationName) + mon.setCursorPos(1,2) + mon.write("TIME: " .. os.date("%H:%M:%S")) + + local function classifyAudioSource(source, priority) + local s = tostring(source or ""):lower() + if s == "train" or s == "station" or s == "broadcast" then return "Station Announcement" end + if s ~= "" then return "Special Announcement(" .. tostring(source) .. ")" end + if (tonumber(priority) or 0) >= 2 then return "Special Announcement" end + return "Special Announcement" + end + + local function trunc(str) + str = tostring(str or "") + if #str > w then return str:sub(1, w) end + return str + end + + mon.setCursorPos(1,3) + if State.currentPlaying then + local p = tonumber(State.currentPlaying.priority) or 0 + local kind = classifyAudioSource(State.currentPlaying.source, p) + mon.setTextColor(COLORS.SUCCESS) + mon.write(trunc("AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")")) + elseif State.audioRx then + local p = tonumber(State.audioRx.priority) or 0 + local kind = classifyAudioSource(State.audioRx.source, p) + local rx = tonumber(State.audioRx.receivedBytes) or 0 + local total = tonumber(State.audioRx.expectedBytes) or 0 + local pct = "" + if total > 0 then pct = " " .. tostring(math.floor((rx / total) * 100)) .. "%" end + mon.setTextColor(COLORS.DEBUG) + mon.write(trunc("AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")")) + else + mon.setTextColor(COLORS.INFO) + mon.write(trunc("AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")")) + end + + -- Current Train + if State.currentTrain then + mon.setCursorPos(1,4) + mon.setTextColor(COLORS.SUCCESS) + mon.write("CURRENT TRAIN:") + mon.setTextColor(COLORS.INFO) + mon.setCursorPos(1,5) + mon.write(State.currentTrain.id) + mon.setCursorPos(1,6) + mon.write("Dest: " .. State.currentTrain.dest) + + -- Countdown + if State.currentTrain.departureTime then + local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000) + mon.setCursorPos(1, 8) + if remaining > 0 then + mon.setTextColor(COLORS.WARNING) + mon.write(string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60))) + else + mon.setTextColor(COLORS.ERROR) + mon.write("Departing...") + end + end + else + mon.setCursorPos(1,4) + mon.setTextColor(COLORS.WARNING) + mon.write("NO TRAIN AT PLATFORM") + end + + -- Logs + local y = h - 3 + for i = 1, 3 do + local logIdx = i + State.logScrollOffset + local entry = State.logs[logIdx] + if entry then + mon.setCursorPos(1, y + i - 1) + mon.setTextColor(COLORS[entry.level] or colors.white) + mon.write(entry.text:sub(1, w)) + end + end +end + +function Display.renderTerminal() + local w, h = term.getSize() + + -- Header + term.setCursorPos(1, 1) + term.setBackgroundColor(colors.gray) + term.setTextColor(colors.white) + term.clearLine() + local title = " Station Broadcast Console (" .. State.stationName .. ") " + term.setCursorPos(math.floor((w - #title)/2) + 1, 1) + term.write(title) + term.setBackgroundColor(colors.black) + term.setCursorPos(1, 2) + term.setTextColor(colors.gray) + term.write(string.rep("-", w)) + + -- Logs + local logStart = 3 + local maxLogs = h - logStart + 1 + for i = 1, maxLogs do + local logIdx = i + State.logScrollOffset + term.setCursorPos(1, logStart + i - 1) + term.clearLine() + local entry = State.logs[logIdx] + if entry then + term.setTextColor(COLORS[entry.level] or colors.white) + term.write(entry.text:sub(1, w - 1)) + end + end + + -- Scroll Indicator + if State.logScrollOffset > 0 then + term.setCursorPos(w, logStart) + term.setTextColor(colors.yellow) + term.write("^") + end + if #State.logs > State.logScrollOffset + maxLogs then + term.setCursorPos(w, h) + term.setTextColor(colors.yellow) + term.write("v") + end +end + +-- ============================================================================ +-- 7.5 Input Handling +-- ============================================================================ +local Input = {} + +function Input.listener() + Utils.log("DEBUG", "Input listener started") + local monitorName = peripheral.getName(Peripherals.primaryMonitor) + + while true do + local event, param1, param2, param3 = os.pullEvent() + local _, h = term.getSize() + local maxLogs = h - 5 + 1 + + if event == "mouse_scroll" then + local direction = param1 + if direction == -1 then -- Scroll Up + State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs)) + elseif direction == 1 then -- Scroll Down + State.logScrollOffset = math.max(0, State.logScrollOffset - 1) + end + Display.renderTerminal() + Display.render() + elseif event == "monitor_scroll" and param1 == monitorName then + local direction = param2 + if direction == -1 then + State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3)) + elseif direction == 1 then + State.logScrollOffset = math.max(0, State.logScrollOffset - 1) + end + Display.renderTerminal() + Display.render() + elseif event == "monitor_touch" and param1 == monitorName then + -- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down + local _, monH = Peripherals.primaryMonitor.getSize() + local y = param3 + if y >= monH - 3 then + if y == monH - 3 then -- Top line of log area + State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3)) + elseif y == monH then -- Bottom line of log area + State.logScrollOffset = math.max(0, State.logScrollOffset - 1) + end + Display.renderTerminal() + Display.render() + end + elseif event == "key" then + if param1 == keys.up then + State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs)) + elseif param1 == keys.down then + State.logScrollOffset = math.max(0, State.logScrollOffset - 1) + elseif param1 == keys.pageUp then + State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs)) + elseif param1 == keys.pageDown then + State.logScrollOffset = math.max(0, State.logScrollOffset - 10) + elseif param1 == keys.home then + State.logScrollOffset = 0 + elseif param1 == keys["end"] then + State.logScrollOffset = math.max(0, #State.logs - maxLogs) + end + Display.renderTerminal() + Display.render() + end + end +end + +-- ============================================================================ +-- 8. Main Logic +-- ============================================================================ +local Main = {} + +function Main.loop() + while true do + local hasTrain = Peripherals.station.isTrainPresent() + + if hasTrain then + if not State.currentTrain then + -- Train just arrived + local id = Peripherals.station.getTrainName() + local schedule = Peripherals.station.getSchedule() + local dest = Utils.getDestination(schedule) + local now = os.epoch("utc") / 1000 + local stationTime = Utils.getStopTime(schedule, State.stationName) or 60 + + State.currentTrain = { + id = id, + trainType = Utils.parseTrainId(id), + schedule = schedule, + dest = dest, + arrivalTime = now, + departureTime = now + stationTime, + platform = Utils.getPlatformNumber(State.stationName, "1") + } + + Network.send({ + type = "train_arrival", + trainId = id, + stationName = State.stationName, + destination = dest + }) + + if not State.arrivalTonePlayed then + if stationTime < 50 then + Utils.log("INFO", "Short stop (<50s) and no pre-arrival. Skipping arrival announcement.") + State.shortStopMode = true + else + Utils.log("INFO", "Train arrived. Playing arrival announcement (alt).") + Audio.announceArrival(State.currentTrain.id, State.currentTrain.trainType, State.currentTrain.platform, State.currentTrain.dest, true) + end + State.arrivalTonePlayed = true + end + else + -- Train currently at station + local now = os.epoch("utc") / 1000 + local remaining = State.currentTrain.departureTime - now + + if State.shortStopMode then + -- Play fcts.wav only in the last 10 seconds + if remaining <= 10 and not State.departingPlayed then + Utils.log("INFO", "Short stop mode: playing fcts.wav only.") + Audio.play({"fcts"}) + State.departingPlayed = true + end + else + if remaining <= 50 and not State.departingPlayed then + Audio.announceDeparture(State.currentTrain.id, State.currentTrain.trainType, State.currentTrain.platform, State.currentTrain.dest, 60) + State.departingPlayed = true + end + end + end + else + if State.currentTrain then + -- Train just left + Network.send({ + type = "train_departure", + trainId = State.currentTrain.id, + stationName = State.stationName, + destination = State.currentTrain.dest, + schedule = State.currentTrain.schedule, + stationTime = math.floor((os.epoch("utc") / 1000) - State.currentTrain.arrivalTime) + }) + State.currentTrain = nil + State.arrivalTonePlayed = false + State.departingPlayed = false + State.shortStopMode = false + end + end + + Display.render() + Display.renderTerminal() + os.sleep(0.5) + end +end + +-- ============================================================================ +-- 9. Bootstrap +-- ============================================================================ +Peripherals.init() +Audio.loadStationList() + +parallel.waitForAny( + function() + while true do + if not State.ws then Network.connect() end + os.sleep(5) + end + end, + Audio.playerLoop, + Network.messageLoop, + Network.heartbeat, + Network.syncFiles, + Input.listener, + Main.loop +) diff --git a/update_station_broadcast.lua b/update_station_broadcast.lua new file mode 100644 index 0000000..c2747c8 --- /dev/null +++ b/update_station_broadcast.lua @@ -0,0 +1,82 @@ +local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0" +local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0" +local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0" + +local function fetch(url) + if not http then + return nil, "HTTP API unavailable" + end + + if http.checkURL then + local ok, err = http.checkURL(url) + if not ok then + return nil, err or "URL blocked" + end + end + + local res, err = http.get(url) + if not res then + return nil, err or "request failed" + end + + local body = res.readAll() + res.close() + + if type(body) ~= "string" or #body == 0 then + return nil, "empty response" + end + + return body +end + +local function writeFile(path, content) + local dir = fs.getDir(path) + if dir and dir ~= "" and not fs.exists(dir) then + fs.makeDir(dir) + end + + local f = fs.open(path, "w") + if not f then + return nil, "cannot open " .. path + end + + f.write(content) + f.close() + return true +end + +local function downloadTo(url, path) + write(("Updating %s... "):format(path)) + local body, err = fetch(url) + if not body then + print("FAILED") + print(err) + return false + end + + local ok, werr = writeFile(path, body) + if not ok then + print("FAILED") + print(werr) + return false + end + + print("OK") + return true +end + +local targets = { + { url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }, + { url = AUKIT_URL, path = shell.resolve("/aukit.lua") }, + { url = AUSTREAM_URL, path = shell.resolve("/austream.lua") } +} + +for _, t in ipairs(targets) do + local ok = downloadTo(t.url, t.path) + if not ok then + print("Update aborted.") + return + end +end + +print("Update complete.")