Compare commits
91 Commits
i18n
...
1d2daef9a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d2daef9a1 | |||
| 41288532ba | |||
| ceb12ba8b6 | |||
| 190b6143c9 | |||
| 9f92e90237 | |||
| 7909327326 | |||
| 0bb68ae554 | |||
| cf02921078 | |||
| 91a28b1998 | |||
| 29fc505bf1 | |||
| 0245230696 | |||
| 5aa29d13db | |||
| 02da7827af | |||
| d8ce084a8e | |||
| f5cfa3cdb8 | |||
| c4f3435d9f | |||
| f9030f36ad | |||
| 2b9aa80aaa | |||
| 564f6f80e5 | |||
| 15287669b6 | |||
| 3294956e64 | |||
| ac15c174ab | |||
| fc944e1355 | |||
| 6932d586d4 | |||
| a5317c670f | |||
| 0be54adf20 | |||
| 1b4de5634a | |||
| 33cabbcefc | |||
| 8ec2c47746 | |||
| ce0494eb36 | |||
| a6bb5e37fd | |||
| d8b1ec0456 | |||
| 9d0f99c368 | |||
| 07da64f31a | |||
| 6c44a3650c | |||
| 229f1282db | |||
| 8ce2089eef | |||
| ec6583f9dc | |||
| 676d91e2b4 | |||
| 5cc48926c2 | |||
| 866a868116 | |||
| 5e68d87be3 | |||
| 1ad51f36a5 | |||
| deb136e30f | |||
| 539c82b1a9 | |||
| a5596fb4ff | |||
| 51b2335db7 | |||
| 6f7df5361b | |||
| a45316bd16 | |||
| f1ef175700 | |||
| aa8d61a8ac | |||
| 2927243237 | |||
| 2febbea6ec | |||
| 93b674942d | |||
| cb797cbb61 | |||
| 2f4f8f0d48 | |||
| 1de19d82aa | |||
| ffca84bf7b | |||
| cec1e4a968 | |||
| 03d8e2896b | |||
| 44c9b646ea | |||
| 47557883bb | |||
| 656c81b82e | |||
| 53a4d6428e | |||
| c5293f34b3 | |||
| 44d99dc8c1 | |||
| 3bb4ff8e9a | |||
| f6ba9a8897 | |||
| f70fd90032 | |||
| 3afb72538d | |||
| 3d42b72c8b | |||
| d2a1de93ee | |||
| 8ebad5d32b | |||
| 11d20c560c | |||
| a3fbcbffb2 | |||
| fd13846a5b | |||
| 1c2086ac42 | |||
| 7445966a61 | |||
| 33e7db5642 | |||
| b9fcfd41a5 | |||
| 4f76bfc61f | |||
| ca5b68132c | |||
| 957094bc2b | |||
| f3e1c802d7 | |||
| 9947b35829 | |||
| 37b0a9df46 | |||
| 460566c792 | |||
| 3ae05f27da | |||
| fc22edd3d9 | |||
| 36651c0f6a | |||
| 348f6ac699 |
@@ -1,8 +1,17 @@
|
||||
# KaraDash (IPR)
|
||||
# アマオケ / amaoke.app
|
||||
|
||||
Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDash!
|
||||
Practice Japanese Karaoke lyrics reading and typing at the same time with amaoke.app!
|
||||
|
||||
在这里可以同时练习日语卡拉 OK 歌词阅读速度和打字速度!
|
||||
是一个日语卡拉 OK 阅读打字唱歌练习软件!
|
||||
|
||||
<img width="30%" alt="image" src="https://github.com/user-attachments/assets/4f47bc67-56c9-4c4f-98e6-aff4d1886672" />
|
||||
|
||||
|
||||
## 使用教程
|
||||
|
||||
用手机打开 [amaoke.app](https://amaoke.app) 就可以了!
|
||||
|
||||
(电脑上也可以用,虽然没有做视图适配所以可能有点怪)
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -15,21 +24,86 @@ Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDa
|
||||
* [ ] 历史成绩和进步曲线
|
||||
* [x] 唱歌模式
|
||||
* [x] 自动分离人声和伴奏
|
||||
* [ ] 分段处理以加快初始加载速度
|
||||
* [x] 自动预处理下一首歌
|
||||
* [x] 调节人声伴奏比例
|
||||
* [x] 跟随音乐滚动歌词
|
||||
* [ ] 升降调
|
||||
* [ ] 播放/暂停音乐控制
|
||||
* [ ] 变速
|
||||
* [x] 播放/暂停音乐控制
|
||||
* [x] 实时变调变速(速度越快音调就越高)
|
||||
* [ ] 单独控制变调和变速(非实时)
|
||||
* [ ] 音域分析(自动推荐升降调幅度)
|
||||
* [ ] 电视模式
|
||||
* [ ] 和手机配对、用手机点歌
|
||||
* [ ] 从网易云搜索歌曲
|
||||
|
||||
## Technical Tasks
|
||||
|
||||
* [ ] i18n
|
||||
* [ ] 404 page
|
||||
* [ ] Update an existing playlist
|
||||
* [x] i18n
|
||||
* [x] 404 page
|
||||
* [x] Previous song / next song buttons
|
||||
* [x] Update an existing playlist
|
||||
* [ ] Allow users to correct lyric pronunciations through correction feedback
|
||||
* [ ] Correct lyrics timing inconsistencies (i.e. 网易云的歌词因为是业余用户上传的,时间戳不一定准确。但是 waveform 里面可以分析出每句歌词的具体开始结束时间,也许可以自动修正)
|
||||
* [ ] Correct lyrics timing inconsistencies (i.e. 网易云的歌词因为是业余用户上传的,时间戳不一定准确。但是 waveform 里面可以分析出每段人声的具体开始结束时间,也许可以自动修正)
|
||||
* [x] Processing lyrics and audio should be parallel
|
||||
* [x] Add admin password to admin pages
|
||||
* [x] About page
|
||||
* [ ] Intro popup
|
||||
* [ ] Re-encode songs using opus
|
||||
* [x] Meta tags
|
||||
|
||||
## 自搭服务器文档 / Self-hosting Guide
|
||||
|
||||
**运行服务器需要的东西:**
|
||||
|
||||
1. 一个 openrouter.ai 的 API key
|
||||
2. 一个网易云账号
|
||||
3. docker
|
||||
4. 一张 >2GB 显存的显卡(推荐 Nvidia,其他显卡需要改 Dockerfile)
|
||||
5. 小猫
|
||||
|
||||
**自搭教程**
|
||||
|
||||
1. 克隆仓库
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MaigoLabs/amaoke.app
|
||||
cd amaoke.app/deploy
|
||||
```
|
||||
|
||||
2. 创建一个叫 `.env` 的文件,把下面这些写进去
|
||||
|
||||
```.env
|
||||
OPENROUTER_API_KEY="你的 openrouter.ai API key"
|
||||
ADMIN_PASSWORD=一个随机管理密码
|
||||
```
|
||||
|
||||
3. 运行
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. 去登录网易云账号
|
||||
|
||||
```
|
||||
http://127.0.0.1:3000/admin/netease-login?pwd=你的管理密码
|
||||
```
|
||||
|
||||
5. 让小猫喵喵
|
||||
6. 完成了!
|
||||
|
||||
## 开发者文档 / Developer Guide
|
||||
|
||||
欢迎给这个项目贡献代码!想要贡献代码的话请参考 [README_DEVELOPER.md](README_DEVELOPER.md)
|
||||
|
||||
|
||||
## 更新日志 / Changelog
|
||||
|
||||
### v1.0.2
|
||||
|
||||
* 修复了 12 キー(日语九宫格)输入法输入某些正确的濁音(e.g. が)会被判错的问题
|
||||
* 修复了 12 キー(日语九宫格)输入法输入不正确的濁音不会被判错的问题
|
||||
|
||||
### v1.0.1
|
||||
|
||||
* 修复了英文单词之间显示没有空格的问题
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
## Development server
|
||||
|
||||
### Requirements
|
||||
- Bun
|
||||
- Docker
|
||||
|
||||
### 1. Environment setup
|
||||
1. Create your `.env` file by renaming `.env.example` to `.env`
|
||||
2. Add the following variable:
|
||||
OPENROUTER_API_KEY=your_key_here
|
||||
(Request the key from the repository owner)
|
||||
|
||||
### 2. Start the database
|
||||
Run in the project root:
|
||||
docker compose up
|
||||
|
||||
### 3. Install dependencies
|
||||
Install Bun:
|
||||
https://bun.com/get
|
||||
|
||||
Then install project dependencies:
|
||||
bun install
|
||||
|
||||
### 4. Start development server
|
||||
bun run dev
|
||||
@@ -3,47 +3,51 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "karadash",
|
||||
"name": "amaoke",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@iconify-json/material-symbols": "^1.2.46",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@neteasecloudmusicapienhanced/api": "^4.29.17",
|
||||
"@neteasecloudmusicapienhanced/api": "4.29.17",
|
||||
"@openrouter/sdk": "^0.1.17",
|
||||
"@unocss/core": "^66.5.6",
|
||||
"@unocss/extractor-svelte": "^66.5.6",
|
||||
"@unocss/preset-attributify": "^66.5.6",
|
||||
"@unocss/preset-icons": "^66.5.6",
|
||||
"@unocss/reset": "^66.5.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"franc": "^6.2.0",
|
||||
"m3-svelte": "^5.14.1",
|
||||
"mongodb": "^7.0.0",
|
||||
"openai": "^6.9.0",
|
||||
"sass": "^1.94.0",
|
||||
"scope-extensions-js": "^1.1.0",
|
||||
"tone": "^15.3.7",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"wanakana": "^5.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@fontsource/darumadrop-one": "^5.2.8",
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@iconify-json/material-symbols": "^1.2.46",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify-json/solar": "^1.2.5",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@unocss/core": "^66.5.6",
|
||||
"@unocss/extractor-svelte": "^66.5.6",
|
||||
"@unocss/preset-attributify": "^66.5.6",
|
||||
"@unocss/preset-icons": "^66.5.6",
|
||||
"@unocss/preset-wind3": "^66.5.6",
|
||||
"@unocss/reset": "^66.5.6",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^16.4.0",
|
||||
"sass": "^1.94.0",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"unocss": "^66.5.6",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"vite": "^7.1.10",
|
||||
"wanakana": "^5.3.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -72,6 +76,12 @@
|
||||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.0", "", {}, "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
@@ -144,6 +154,8 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@fontsource/darumadrop-one": ["@fontsource/darumadrop-one@5.2.8", "", {}, "sha512-pN0RVFzd3cCpG3cQOeFLDZJ/2W/HMQR9KtCNhwHOMhEndsSiR1Dnk0e1qj/v8xzVTu+Q3/ZyTc0sYvIDj3z36A=="],
|
||||
|
||||
"@fontsource/roboto": ["@fontsource/roboto@5.2.8", "", {}, "sha512-oh9g4Cg3loVMz9MWeKWfDI+ooxxG1aRVetkiKIb2ESS2rrryGecQ/y4pAj4z5A5ebyw450dYRi/c4k/I3UBhHA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
@@ -156,6 +168,10 @@
|
||||
|
||||
"@iconify-json/material-symbols": ["@iconify-json/material-symbols@1.2.46", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-cNWdSAa5Z3f0TlqdCt28rmeYWGKwe68J1ORdyHyqC4D6H7CWiVKBJXV3TDTocOQVDO372bz+cmsFeo4+pbRy+A=="],
|
||||
|
||||
"@iconify-json/mdi": ["@iconify-json/mdi@1.2.3", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg=="],
|
||||
|
||||
"@iconify-json/solar": ["@iconify-json/solar@1.2.5", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-WMAiNwchU8zhfrySww6KQBRIBbsQ6SvgIu2yA+CHGyMima/0KQwT5MXogrZPJGoQF+1Ye3Qj6K+1CiyNn3YkoA=="],
|
||||
|
||||
"@iconify-json/svg-spinners": ["@iconify-json/svg-spinners@1.2.4", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-ayn0pogFPwJA1WFZpDnoq9/hjDxN+keeCMyThaX4d3gSJ3y0mdKUxIA/b1YXWGtY9wVtZmxwcvOIeEieG4+JNg=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
@@ -180,6 +196,8 @@
|
||||
|
||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.2", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@neteasecloudmusicapienhanced/api": ["@neteasecloudmusicapienhanced/api@4.29.17", "", { "dependencies": { "@unblockneteasemusic/server": "^0.28.0", "axios": "^1.13.2", "crypto-js": "^4.2.0", "dotenv": "^17.2.3", "express": "^5.1.0", "express-fileupload": "^1.5.2", "md5": "^2.3.0", "music-metadata": "^11.10.0", "node-forge": "^1.3.1", "pac-proxy-agent": "^7.2.0", "qrcode": "^1.5.4", "safe-decode-uri-component": "^1.2.1", "tunnel": "^0.0.6", "xml2js": "^0.6.2", "yargs": "^18.0.0" }, "bin": { "api": "app.js" } }, "sha512-zKqmA7NoP+H3dK0b4/1K7SkxAYz69z9zwPd6+9wXcQNA42EO4AK/rDZ0ZPC9bonQjigHrvK4beFMaIl01S1iig=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
@@ -190,6 +208,10 @@
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.17", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@tanstack/react-query", "react", "react-dom"] }, "sha512-RFN0sfe83G85MirfpkZSuoX8hLLucemnwqrTr53vlrJmBJZ244CCnuZ33vpVUI8rLg+hP1i/smW6IExzYRDGDg=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.71.0", "", {}, "sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||
|
||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||
@@ -222,6 +244,32 @@
|
||||
|
||||
"@quansync/fs": ["@quansync/fs@0.1.5", "", { "dependencies": { "quansync": "^0.2.11" } }, "sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "x64" }, "sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8BDM939bbMariZupiHp3OmP5N+LXPT4mULA0hZjDaq970PCxv4krZOSMG+HkWUUwmuQROtV+/00xw39EO0P+8g=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm" }, "sha512-sntsPaPgrECpBB/+2xrQzVUt0r493TMPI+4kWRMhvMsmrxOqH1Ep5lM0Wua/ZdbfZNwm1aVa5pcESQfNfM4Fhw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5clBW/I+er9F2uM1OFjJFWX86y7Lcy0M+NqsN4s3o07W+8467Zk8oQa4B45vdaXoNUF/yqIAgKkA/OEdQDxZqA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm64" }, "sha512-wv+rnAfQDk9p/CheX8/Kmqk2o1WaFa4xhWI9gOyDMk/ljvOX0u0ubeM8nI1Qfox7Tnh71eV5AjzSePXUhFOyOg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "x64" }, "sha512-gxD0/xhU4Py47IH3bKZbWtvB99tMkUPGPJFRfSc5UB9Osoje0l0j1PPbxpUtXIELurYCqwLBKXIMTQGifox1BQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "x64" }, "sha512-HotuVe3XUjDwqqEMbm3o3IRkP9gdm8raY/btd/6KE3JGLF/cv4+3ff1l6nOhAZI8wulWDPEXPtE7v+HQEaTXnA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.4" }, "cpu": "none" }, "sha512-8Cx+ucbd8n2dIr21FqBh6rUvTVL0uTgEtKR7l+MUZ5BgY4dFh1e4mPVX8oqmoYwOxBiXrsD2JIOCz4AyKLKxWA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Vhq5vikrVDxAa75fxsyqj0c0Y/uti/TwshXI71Xb8IeUQJOBnmLUsn5dgYf5ljpYYkNa0z9BPAvUDIDMmyDi+w=="],
|
||||
|
||||
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "ia32" }, "sha512-lN7RIg9Iugn08zP2aZN9y/MIdG8iOOCE93M1UrFlrxMTqPf8X+fDzmR/OKhTSd1A2pYNipZHjyTcb5H8kyQSow=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "x64" }, "sha512-7/7cLIn48Y+EpQ4CePvf8reFl63F15yPUlg4ZAhl+RXJIfydkdak1WD8Ir3AwAO+bJBXzrfNL+XQbxm0mcQZmw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5", "", {}, "sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.2", "", { "os": "android", "cpu": "arm" }, "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.2", "", { "os": "android", "cpu": "arm64" }, "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g=="],
|
||||
@@ -278,12 +326,14 @@
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.3.1", "", { "dependencies": { "debug": "^4.4.1", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA=="],
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
@@ -386,6 +436,8 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -464,7 +516,7 @@
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
@@ -594,11 +646,9 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"file-type": ["file-type@21.1.0", "", { "dependencies": { "@tokenizer/inflate": "^0.3.1", "strtok3": "^10.3.1", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA=="],
|
||||
"file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
@@ -614,7 +664,7 @@
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
@@ -654,7 +704,7 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
@@ -756,7 +806,7 @@
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
@@ -772,7 +822,7 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"music-metadata": ["music-metadata@11.10.0", "", { "dependencies": { "@borewit/text-codec": "^0.2.0", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.0.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.5.0" } }, "sha512-alZYPjpqAPFgVZaFQob0PMq/9tSqaR+3m159vavrptxj09P0GcyBkDQI/wuCyn4uz/TDCrS8gN+9SzURlahmdQ=="],
|
||||
"music-metadata": ["music-metadata@11.10.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.0", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.1.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.5.0" } }, "sha512-sGbF+Si+GqYJGO6qQDyfvesfxb1M49m0QjLLkGR5zoRlPaZtHRvo8DDI5R/vySJVtUzTQ6Lwfd7nspYpmmInsA=="],
|
||||
|
||||
"n-gram": ["n-gram@2.0.2", "", {}, "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ=="],
|
||||
|
||||
@@ -884,7 +934,7 @@
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="],
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
@@ -900,6 +950,8 @@
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="],
|
||||
|
||||
"rollup": ["rollup@4.53.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.2", "@rollup/rollup-android-arm64": "4.53.2", "@rollup/rollup-darwin-arm64": "4.53.2", "@rollup/rollup-darwin-x64": "4.53.2", "@rollup/rollup-freebsd-arm64": "4.53.2", "@rollup/rollup-freebsd-x64": "4.53.2", "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", "@rollup/rollup-linux-arm-musleabihf": "4.53.2", "@rollup/rollup-linux-arm64-gnu": "4.53.2", "@rollup/rollup-linux-arm64-musl": "4.53.2", "@rollup/rollup-linux-loong64-gnu": "4.53.2", "@rollup/rollup-linux-ppc64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-musl": "4.53.2", "@rollup/rollup-linux-s390x-gnu": "4.53.2", "@rollup/rollup-linux-x64-gnu": "4.53.2", "@rollup/rollup-linux-x64-musl": "4.53.2", "@rollup/rollup-openharmony-arm64": "4.53.2", "@rollup/rollup-win32-arm64-msvc": "4.53.2", "@rollup/rollup-win32-ia32-msvc": "4.53.2", "@rollup/rollup-win32-x64-gnu": "4.53.2", "@rollup/rollup-win32-x64-msvc": "4.53.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
@@ -986,6 +1038,8 @@
|
||||
|
||||
"svelte": ["svelte@5.43.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-RnyO9VXI85Bmsf4b8AuQFBKFYL3LKUl+ZrifOjvlrQoboAROj5IITVLK1yOXBjwUWUn2BI5cfmurktgCzuZ5QA=="],
|
||||
|
||||
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
|
||||
|
||||
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA=="],
|
||||
@@ -1118,8 +1172,6 @@
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
@@ -1198,8 +1250,6 @@
|
||||
|
||||
"qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"args/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"node-windows/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
:80 {
|
||||
handle_path /audio/* {
|
||||
root * /srv/audio
|
||||
file_server
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy web:3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# ffmpeg is required for audio processing
|
||||
# python3 and pip are required since we are using a base cuda image
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg python3 python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
# We install dependencies globally as this is a container
|
||||
# Install audio-separator with GPU support
|
||||
RUN pip3 install --no-cache-dir fastapi uvicorn[standard] audio-separator[gpu] python-multipart
|
||||
|
||||
# Copy the server script
|
||||
COPY scripts/server.py .
|
||||
|
||||
EXPOSE 24801
|
||||
|
||||
CMD ["python3", "server.py"]
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN bun run build
|
||||
|
||||
################################################################################
|
||||
# Production image
|
||||
FROM oven/bun:1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Note: If using adapter-auto, ensure it resolves to a Node-compatible build.
|
||||
# If you encounter issues, consider installing @sveltejs/adapter-node and updating svelte.config.js.
|
||||
CMD ["bun", "./build/index.js"]
|
||||
@@ -0,0 +1,69 @@
|
||||
services:
|
||||
web:
|
||||
container_name: amaoke-web
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.web
|
||||
# ports:
|
||||
# - "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
- ./data/storage:/app/static
|
||||
environment:
|
||||
- ORIGIN=http://localhost:3000
|
||||
- MONGO_URL=mongodb://cat:meow@db:27017/amaoke?authSource=admin
|
||||
- AUDIO_SEPARATOR_API=http://ai:24801
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- db
|
||||
- ai
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
image: caddy:alpine
|
||||
container_name: amaoke-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- ./data/storage/audio:/srv/audio
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
ai:
|
||||
container_name: amaoke-ai
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.python
|
||||
# ports:
|
||||
# - "24801:24801"
|
||||
env_file: .env
|
||||
volumes:
|
||||
- python_temp:/app/temp_audio
|
||||
- python_cache:/root/.cache
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: mongo:latest
|
||||
restart: unless-stopped
|
||||
container_name: amaoke-db
|
||||
ports:
|
||||
- "127.0.0.1:27017:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: cat
|
||||
MONGO_INITDB_ROOT_PASSWORD: meow
|
||||
env_file: .env
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
python_temp:
|
||||
python_cache:
|
||||
+59
-55
@@ -1,56 +1,60 @@
|
||||
{
|
||||
"name": "karadash",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@unocss/preset-wind3": "^66.5.6",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^16.4.0",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"unocss": "^66.5.6",
|
||||
"vite": "^7.1.10",
|
||||
"wanakana": "^5.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@iconify-json/material-symbols": "^1.2.46",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@neteasecloudmusicapienhanced/api": "^4.29.17",
|
||||
"@openrouter/sdk": "^0.1.17",
|
||||
"@unocss/core": "^66.5.6",
|
||||
"@unocss/extractor-svelte": "^66.5.6",
|
||||
"@unocss/preset-attributify": "^66.5.6",
|
||||
"@unocss/preset-icons": "^66.5.6",
|
||||
"@unocss/reset": "^66.5.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"franc": "^6.2.0",
|
||||
"m3-svelte": "^5.14.1",
|
||||
"mongodb": "^7.0.0",
|
||||
"openai": "^6.9.0",
|
||||
"sass": "^1.94.0",
|
||||
"scope-extensions-js": "^1.1.0",
|
||||
"tone": "^15.3.7",
|
||||
"unocss-preset-animations": "^1.3.0"
|
||||
}
|
||||
}
|
||||
"name": "amaoke",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@fontsource/darumadrop-one": "^5.2.8",
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@iconify-json/material-symbols": "^1.2.46",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify-json/solar": "^1.2.5",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@unocss/core": "^66.5.6",
|
||||
"@unocss/extractor-svelte": "^66.5.6",
|
||||
"@unocss/preset-attributify": "^66.5.6",
|
||||
"@unocss/preset-icons": "^66.5.6",
|
||||
"@unocss/preset-wind3": "^66.5.6",
|
||||
"@unocss/reset": "^66.5.6",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^16.4.0",
|
||||
"sass": "^1.94.0",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"unocss": "^66.5.6",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"vite": "^7.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neteasecloudmusicapienhanced/api": "4.29.17",
|
||||
"@openrouter/sdk": "^0.1.17",
|
||||
"mongodb": "^7.0.0",
|
||||
"openai": "^6.9.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"franc": "^6.2.0",
|
||||
"m3-svelte": "^5.14.1",
|
||||
"scope-extensions-js": "^1.1.0",
|
||||
"tone": "^15.3.7",
|
||||
"wanakana": "^5.3.1"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const API = {
|
||||
netease: {
|
||||
startImport: async (link: string) => await post('/api/import/netease/start', { link }),
|
||||
checkProgress: async (id: string) => await post('/api/import/netease/progress', { id }),
|
||||
checkLogin: async () => await post('/admin/netease-login', {})
|
||||
checkLogin: async (pwd?: string) => await post('/admin/netease-login', { pwd })
|
||||
},
|
||||
|
||||
user: {
|
||||
|
||||
+42
-2
@@ -37,6 +37,7 @@ export default {
|
||||
tip: 'Go to NetEase Music App, find a Japanese playlist you like, click share, copy link, and paste it here to start importing!',
|
||||
inputLabel: 'NetEase Playlist Link / ID',
|
||||
btnStart: 'Start Import',
|
||||
btnUpdate: 'Update Playlist',
|
||||
btnView: 'View Playlist'
|
||||
}
|
||||
},
|
||||
@@ -47,7 +48,8 @@ export default {
|
||||
count: 'Songs: ',
|
||||
startPractice: 'Start Practice',
|
||||
songList: 'Song List',
|
||||
songs: 'songs'
|
||||
songs: 'songs',
|
||||
updateFromNetease: 'Update from NetEase'
|
||||
},
|
||||
list: {
|
||||
mine: 'My Playlists',
|
||||
@@ -86,7 +88,8 @@ export default {
|
||||
song: {
|
||||
mode: {
|
||||
typing: 'Typing Mode',
|
||||
music: 'Music Mode'
|
||||
music: 'Music Mode',
|
||||
karaoke: 'Singing Mode'
|
||||
},
|
||||
karaoke: {
|
||||
noVocals: 'No vocal separation track detected, cannot adjust vocal volume. Please process in song details page first.'
|
||||
@@ -100,6 +103,13 @@ export default {
|
||||
wrong: 'Wrong: ',
|
||||
remaining: 'Remaining: '
|
||||
}
|
||||
},
|
||||
prepare: {
|
||||
lyrics: 'Fetching lyrics from NetEase',
|
||||
ai: 'AI analyzing lyrics',
|
||||
music: 'Fetching music from NetEase',
|
||||
separation: 'AI vocal separation',
|
||||
error: 'Error: '
|
||||
}
|
||||
},
|
||||
user: {
|
||||
@@ -126,5 +136,35 @@ export default {
|
||||
loginWithCode: 'Login with Sync Code',
|
||||
login: 'Login'
|
||||
}
|
||||
},
|
||||
errorPage: {
|
||||
title: 'Oops!',
|
||||
message: 'The page you’re looking for doesn’t exist. It might have been removed, renamed, or never existed.\n\n </br></br> Go back to the <a class="error-page__link" href="/">homepage</a> to continue browsing',
|
||||
return: 'Return home',
|
||||
},
|
||||
player: {
|
||||
menu: {
|
||||
showFuri: 'Show Furigana',
|
||||
hideFuri: 'Hide Furigana',
|
||||
revertHiragana: 'Revert to Hiragana',
|
||||
convertToKatakana: 'Convert all to Katakana',
|
||||
showRomaji: 'Show Romaji',
|
||||
hideRomaji: 'Hide Romaji',
|
||||
showRomajiOnError: 'Show Romaji on Error',
|
||||
hideRomajiOnError: 'Don\'t Show Romaji on Error',
|
||||
musicModeUnavailable: 'Not available in music mode',
|
||||
showRepeated: 'Show Repeated Lines',
|
||||
hideRepeated: 'Hide Repeated Lines',
|
||||
shuffle: 'Current: Shuffle',
|
||||
sequential: 'Current: Sequential',
|
||||
nextSong: 'Next Song'
|
||||
}
|
||||
},
|
||||
dialog: {
|
||||
close: 'Close',
|
||||
error: {
|
||||
title: 'Error',
|
||||
refresh: 'Refresh to Retry'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ export const setLanguage = (lang: Lang) => {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
export const useMsg = () => {
|
||||
const i18n = getI18n()
|
||||
return (key: string) => key.split('.').reduce((o, i) => (o as any)?.[i], i18n) as unknown as string
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
|
||||
+42
-2
@@ -37,6 +37,7 @@ export default {
|
||||
tip: 'NetEase Musicアプリでお気に入りの日本語プレイリストを見つけ、共有をクリックし、リンクをコピーしてここに貼り付けると、インポートを開始できます!',
|
||||
inputLabel: 'NetEaseプレイリストリンク / ID',
|
||||
btnStart: 'インポート開始',
|
||||
btnUpdate: 'プレイリストを更新',
|
||||
btnView: 'プレイリストを表示'
|
||||
}
|
||||
},
|
||||
@@ -47,7 +48,8 @@ export default {
|
||||
count: '曲数: ',
|
||||
startPractice: '練習開始',
|
||||
songList: '曲リスト',
|
||||
songs: '曲'
|
||||
songs: '曲',
|
||||
updateFromNetease: 'NetEaseから更新'
|
||||
},
|
||||
list: {
|
||||
mine: 'マイプレイリスト',
|
||||
@@ -86,7 +88,8 @@ export default {
|
||||
song: {
|
||||
mode: {
|
||||
typing: 'タイピングモード',
|
||||
music: '音楽モード'
|
||||
music: '音楽モード',
|
||||
karaoke: '歌うモード'
|
||||
},
|
||||
karaoke: {
|
||||
noVocals: 'ボーカル分離トラックが検出されないため、ボーカル音量を調整できません。まず曲の詳細ページで処理してください。'
|
||||
@@ -100,6 +103,13 @@ export default {
|
||||
wrong: '不正解:',
|
||||
remaining: '残り:'
|
||||
}
|
||||
},
|
||||
prepare: {
|
||||
lyrics: 'NetEaseから歌詞を取得中',
|
||||
ai: 'AIが歌詞を分析中',
|
||||
music: 'NetEaseから音楽を取得中',
|
||||
separation: 'AIボーカル分離',
|
||||
error: 'エラー: '
|
||||
}
|
||||
},
|
||||
user: {
|
||||
@@ -126,5 +136,35 @@ export default {
|
||||
loginWithCode: '引き継ぎコードでログイン',
|
||||
login: 'ログイン'
|
||||
}
|
||||
},
|
||||
errorPage: {
|
||||
title: 'おっと!',
|
||||
message: 'お探しのページは見つかりませんでした。削除されたか、名前が変更されたか、あるいは最初から存在しなかった可能性があります。\n\n </br></br> <a class="error-page__link" href="/">トップページ</a> に戻って閲覧を続けてください',
|
||||
return: 'トップページに戻る',
|
||||
},
|
||||
player: {
|
||||
menu: {
|
||||
showFuri: 'ふりがなを表示',
|
||||
hideFuri: 'ふりがなを隠す',
|
||||
revertHiragana: 'ひらがなに戻す',
|
||||
convertToKatakana: 'すべてカタカナに変換',
|
||||
showRomaji: 'ローマ字を表示',
|
||||
hideRomaji: 'ローマ字を隠す',
|
||||
showRomajiOnError: 'エラー時にローマ字を表示',
|
||||
hideRomajiOnError: 'エラー時にローマ字を表示しない',
|
||||
musicModeUnavailable: '音楽モードでは利用できません',
|
||||
showRepeated: '重複行を表示',
|
||||
hideRepeated: '重複行を隠す',
|
||||
shuffle: '現在:シャッフル再生',
|
||||
sequential: '現在:順次再生',
|
||||
nextSong: '次の曲'
|
||||
}
|
||||
},
|
||||
dialog: {
|
||||
close: '閉じる',
|
||||
error: {
|
||||
title: 'エラー',
|
||||
refresh: '更新して再試行'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-2
@@ -37,6 +37,7 @@ export default {
|
||||
tip: '去网易云 APP 找一个你喜欢的日本语歌单,点击分享,再点击复制链接,然后把链接粘贴到这里就可以开始导入了!',
|
||||
inputLabel: '网易云歌单链接 / ID',
|
||||
btnStart: '开始导入',
|
||||
btnUpdate: '更新歌单',
|
||||
btnView: '查看歌单'
|
||||
}
|
||||
},
|
||||
@@ -47,7 +48,8 @@ export default {
|
||||
count: '歌曲数: ',
|
||||
startPractice: '开始练习',
|
||||
songList: '歌曲列表',
|
||||
songs: '首歌曲'
|
||||
songs: '首歌曲',
|
||||
updateFromNetease: '从网易云更新歌单'
|
||||
},
|
||||
list: {
|
||||
mine: '我的歌单',
|
||||
@@ -86,7 +88,8 @@ export default {
|
||||
song: {
|
||||
mode: {
|
||||
typing: '打字模式',
|
||||
music: '音乐模式'
|
||||
music: '音乐模式',
|
||||
karaoke: '唱歌模式'
|
||||
},
|
||||
karaoke: {
|
||||
noVocals: '未检测到人声分离音轨,无法调节人声音量。请先在歌曲详情页进行处理。'
|
||||
@@ -100,6 +103,13 @@ export default {
|
||||
wrong: '错误:',
|
||||
remaining: '剩余:'
|
||||
}
|
||||
},
|
||||
prepare: {
|
||||
lyrics: '从网易云获取歌词',
|
||||
ai: 'AI 标注歌词读音',
|
||||
music: '从网易云获取音乐',
|
||||
separation: 'AI 人声分离',
|
||||
error: '错误: '
|
||||
}
|
||||
},
|
||||
user: {
|
||||
@@ -126,5 +136,35 @@ export default {
|
||||
loginWithCode: '用引继码登录',
|
||||
login: '登录'
|
||||
}
|
||||
},
|
||||
errorPage: {
|
||||
title: '页面不存在',
|
||||
message: '很抱歉,您访问的页面不存在。可能已被删除、更名,或链接输入错误。\n\n </br></br> 返回 <a class="error-page__link" href="/">首页</a> 继续浏览',
|
||||
return: '返回首页',
|
||||
},
|
||||
player: {
|
||||
menu: {
|
||||
showFuri: '显示假名标注',
|
||||
hideFuri: '隐藏假名标注',
|
||||
revertHiragana: '恢复平假名',
|
||||
convertToKatakana: '全部转换为片假名',
|
||||
showRomaji: '显示罗马音',
|
||||
hideRomaji: '隐藏罗马音',
|
||||
showRomajiOnError: '错误时显示罗马音',
|
||||
hideRomajiOnError: '不在错误时显示罗马音',
|
||||
musicModeUnavailable: '音乐模式下不可用',
|
||||
showRepeated: '显示重复行',
|
||||
hideRepeated: '隐藏重复行',
|
||||
shuffle: '当前:随机播放',
|
||||
sequential: '当前:顺序播放',
|
||||
nextSong: '下首'
|
||||
}
|
||||
},
|
||||
dialog: {
|
||||
close: '关闭',
|
||||
error: {
|
||||
title: '错误',
|
||||
refresh: '刷新重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { ObjectId } from "mongodb"
|
||||
export async function saveResult(data: Omit<ResultDocument, "_id" | "createdAt">): Promise<string> {
|
||||
const doc = { ...data, createdAt: new Date() }
|
||||
const res = await dbs.results.insertOne(doc)
|
||||
console.log(`Saved result ${res.insertedId} for user ${data.userId}`)
|
||||
return res.insertedId.toString()
|
||||
}
|
||||
|
||||
export async function getResult(id: string): Promise<ResultDocument | null> {
|
||||
try {
|
||||
console.log(`Getting result ${id}`)
|
||||
return await dbs.results.findOne({ _id: new ObjectId(id) })
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -12,7 +12,12 @@ const API_URL = process.env.AUDIO_SEPARATOR_API
|
||||
export async function separateSong(inputPath: string, outputDir: string) {
|
||||
const vocalsPath = path.join(outputDir, 'vocals.opus')
|
||||
const instrumentalPath = path.join(outputDir, 'instrumental.opus')
|
||||
if (await fs.exists(vocalsPath) && await fs.exists(instrumentalPath)) return
|
||||
if (await fs.exists(vocalsPath) && await fs.exists(instrumentalPath)) {
|
||||
console.log(`Separation already done for ${inputPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Starting separation for ${inputPath}`)
|
||||
|
||||
// Read file and create FormData
|
||||
const fileBuffer = await fs.readFile(inputPath)
|
||||
@@ -40,6 +45,7 @@ export async function separateSong(inputPath: string, outputDir: string) {
|
||||
|
||||
await downloadStem('vocals', vocalsPath)
|
||||
await downloadStem('instrumental', instrumentalPath)
|
||||
console.log(`Separation completed for ${inputPath}`)
|
||||
|
||||
// Clean up task on server
|
||||
try {
|
||||
@@ -49,8 +55,14 @@ export async function separateSong(inputPath: string, outputDir: string) {
|
||||
}
|
||||
break
|
||||
}
|
||||
if (status.status === 'error') throw error(500, status.error || '分离失败')
|
||||
if (status.status === 'not_found') throw error(500, '任务丢失')
|
||||
if (status.status === 'error') {
|
||||
console.error(`Separation failed for ${inputPath}: ${status.error}`)
|
||||
throw error(500, status.error || '分离失败')
|
||||
}
|
||||
if (status.status === 'not_found') {
|
||||
console.error(`Separation task lost for ${inputPath}`)
|
||||
throw error(500, '任务丢失')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-18
@@ -50,6 +50,7 @@ function parsePlaylistRef(ref: string): number {
|
||||
|
||||
const getPlaylistRaw = cached(dbs.playlistsRaw,
|
||||
async (id: number) => {
|
||||
console.log(`Fetching playlist raw ${id}`)
|
||||
const pl = ((await ne.playlist_detail({ id })).body as any).playlist
|
||||
for (const track of pl.tracks)
|
||||
await dbs.songsRaw.replaceOne({ _id: track.id }, { _id: track.id, data: track }, { upsert: true })
|
||||
@@ -140,32 +141,33 @@ export const getSongUrl = async (id: number | string) => {
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// API for Song Preparation
|
||||
|
||||
export interface ProgressItem { task: string, progress: number }
|
||||
export interface ProgressItem { id: string, task: string, progress: number }
|
||||
export interface SongProcessState { items: ProgressItem[], status: 'running' | 'done' | 'error' }
|
||||
|
||||
const songProcessingStatus = new Map<number, SongProcessState>()
|
||||
export const getSongStatus = (songId: number) => songProcessingStatus.get(songId) || { items: [], status: 'idle' }
|
||||
export const checkLyricsProcessed = async (songId: number) => !!await dbs.lyricsProcessed.findOne({ _id: songId as any })
|
||||
export const prepareSong = async (songId: number) => {
|
||||
if (songProcessingStatus.has(songId)) return
|
||||
console.log(`Preparing song ${songId}`)
|
||||
if (songProcessingStatus.has(songId)) {
|
||||
console.log(`Song ${songId} is already being processed`)
|
||||
return
|
||||
}
|
||||
|
||||
const state: SongProcessState = { items: [], status: 'running' }
|
||||
songProcessingStatus.set(songId, state)
|
||||
|
||||
const addTask = (task: string) => ({ task, progress: 0 }).also(it => state.items.push(it))
|
||||
try {
|
||||
const addTask = (id: string, task: string) => ({ id, task, progress: 0 }).also(it => state.items.push(it))
|
||||
const processLyrics = async () => {
|
||||
// 1. Get Lyrics
|
||||
const taskLyrics = addTask('从网易云获取歌词')
|
||||
const taskLyrics = addTask('lyrics', 'lyrics')
|
||||
const raw = await getLyricsRaw(songId)
|
||||
taskLyrics.progress = 1
|
||||
|
||||
if (raw.lang !== 'jpn') {
|
||||
addTask('错误: 不是日语歌曲').progress = -1
|
||||
return state.status = 'error'
|
||||
}
|
||||
if (raw.lang !== 'jpn') throw new Error('不是日语歌曲')
|
||||
|
||||
// 2. AI Process
|
||||
const taskAI = addTask('AI 标注歌词读音')
|
||||
const taskAI = addTask('ai', 'ai')
|
||||
|
||||
// Check cache
|
||||
if (await checkLyricsProcessed(songId)) taskAI.progress = 1
|
||||
@@ -174,14 +176,16 @@ export const prepareSong = async (songId: number) => {
|
||||
await dbs.lyricsProcessed.replaceOne({ _id: songId as any }, { _id: songId, data: lrc }, { upsert: true })
|
||||
taskAI.progress = 1
|
||||
}
|
||||
}
|
||||
|
||||
const processMusic = async () => {
|
||||
// 3. Audio
|
||||
const taskAudio = addTask('从网易云获取音乐')
|
||||
const taskAudio = addTask('music', 'music')
|
||||
await getSongUrl(songId)
|
||||
taskAudio.progress = 1
|
||||
|
||||
// 4. Source Separation
|
||||
const taskSeparation = addTask('AI 人声分离')
|
||||
const taskSeparation = addTask('separation', 'separation')
|
||||
const inputPath = path.join(CACHE_DIR, `${songId}/exhigh.mp3`)
|
||||
const outputDir = path.join(CACHE_DIR, `${songId}`)
|
||||
|
||||
@@ -189,14 +193,18 @@ export const prepareSong = async (songId: number) => {
|
||||
await separateSong(inputPath, outputDir)
|
||||
taskSeparation.progress = 1
|
||||
} catch (e: any) {
|
||||
addTask(`错误: ${e.message}`).progress = -1
|
||||
addTask('error', e.message).progress = -1
|
||||
// Don't fail the whole process, just this step
|
||||
}
|
||||
|
||||
state.status = 'done'
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([processLyrics(), processMusic()])
|
||||
state.status = 'done'
|
||||
console.log(`Song ${songId} preparation done`)
|
||||
} catch (e) {
|
||||
addTask(`错误: ${eToString(e)}`).progress = -1
|
||||
console.error(`Song ${songId} preparation failed`, e)
|
||||
addTask('error', eToString(e)).progress = -1
|
||||
state.status = 'error'
|
||||
}
|
||||
}
|
||||
@@ -226,7 +234,7 @@ export const getSession = (id: string) => sessions.get(id)
|
||||
* @returns Import session
|
||||
*/
|
||||
export async function startImport(link: string, userId?: number): Promise<ImportSession> {
|
||||
const meta = await getPlaylistRaw(parsePlaylistRef(link))
|
||||
const meta = await getPlaylistRaw(parsePlaylistRef(link), true)
|
||||
const importId = crypto.randomUUID()
|
||||
|
||||
const session: ImportSession = {
|
||||
@@ -260,7 +268,6 @@ async function processImport(session: ImportSession, data: any) {
|
||||
data.tracks = (await Promise.all(session.songs.map(async item => {
|
||||
try {
|
||||
const lyrics = await getLyricsRaw(item.song.id)
|
||||
console.log(`Song ${item.song.id} lang ${lyrics.lang}`)
|
||||
if (lyrics.lang === 'jpn') {
|
||||
item.status = 'success'
|
||||
return item.song
|
||||
@@ -297,6 +304,7 @@ export const listRecPlaylists = async () => {
|
||||
const list = await dbs.playlists.find({
|
||||
_id: { $in: defaultPlaylists }
|
||||
} as any).map(it => it.data).toArray()
|
||||
console.log(`Listing recommended playlists: ${list.length} found`)
|
||||
return list.sort((a: any, b: any) => defaultPlaylists.indexOf(a.id) - defaultPlaylists.indexOf(b.id))
|
||||
}
|
||||
export const listMyPlaylists = async (user: UserDocument) => (await user.data.myPlaylists?.let(pl => dbs.playlists.find({
|
||||
@@ -304,6 +312,7 @@ export const listMyPlaylists = async (user: UserDocument) => (await user.data.my
|
||||
}).map(it => it.data).toArray())) ?? []
|
||||
|
||||
export const getPlaylist = async (playlistId: number | string) => {
|
||||
console.log(`Getting playlist ${playlistId}`)
|
||||
const plData = await dbs.playlists.findOne({ _id: +playlistId as any })
|
||||
if (!plData) throw error(404, 'Playlist not found')
|
||||
return plData.data
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { OpenRouter } from '@openrouter/sdk'
|
||||
import type { LyricLine, LyricSegment } from '../../types'
|
||||
import { isKana, isKanji } from 'wanakana'
|
||||
import { building } from '$app/environment'
|
||||
|
||||
// Please put OPENROUTER_API_KEY in your environment variables.
|
||||
if (!process.env.OPENROUTER_API_KEY) throw new Error('Please set OPENROUTER_API_KEY in your environment variables.')
|
||||
if (!building && !process.env.OPENROUTER_API_KEY) throw new Error('Please set OPENROUTER_API_KEY in your environment variables.')
|
||||
const client = new OpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY!
|
||||
apiKey: process.env.OPENROUTER_API_KEY ?? ""
|
||||
})
|
||||
const req = {
|
||||
model: "openai/gpt-5-mini",
|
||||
@@ -179,7 +180,7 @@ function parseFuriganaText(text: string): LyricLine[] {
|
||||
else if (kanji && furigana) {
|
||||
let splitIndex = -1
|
||||
for (let i = kanji.length - 1; i >= 0; i--) {
|
||||
if (!isKanji(kanji[i]) && !isKana(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
|
||||
if (!isKanji(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
|
||||
splitIndex = i
|
||||
break
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ void users.createIndex({ syncCode: 1 }, { name: "users_sync_code_idx" })
|
||||
export async function createUser(registUA: string): Promise<string> {
|
||||
const ses = `${crypto.randomUUID()}-${Date.now().toString(36)}`
|
||||
await users.insertOne({ registUA, createdAt: new Date(), sessions: [ses], data: {} })
|
||||
console.log(`Created new user with session ${ses}`)
|
||||
return ses
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ export async function createSyncCode(session: string): Promise<string> {
|
||||
// Sync code is 4 * 5 numbers
|
||||
const code = Array.from({ length: 4 }, () => Math.floor(Math.random() * 100000).toString().padStart(5, '0')).join('-')
|
||||
await users.updateOne({ _id: user._id }, { $set: { syncCode: code, syncCodeCreated: new Date() } })
|
||||
console.log(`Created sync code for user ${user._id}`)
|
||||
return code
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ export async function createSyncCode(session: string): Promise<string> {
|
||||
export async function updateUserData(user: UserDocument, data: Partial<UserData>): Promise<void> {
|
||||
const newData = { ...(user.data || {}), ...data }
|
||||
await users.updateOne({ _id: user._id }, { $set: { data: newData } })
|
||||
console.log(`Updated user data for ${user._id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +83,7 @@ export async function loginWithSyncCode(code: string, newUA: string): Promise<st
|
||||
$unset: { syncCode: "", syncCodeCreated: "" }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`User ${user._id} logged in with sync code`)
|
||||
|
||||
return ses
|
||||
}
|
||||
+4
-1
@@ -85,9 +85,10 @@ export interface ResultDocument {
|
||||
export const typingSettingsDefault = {
|
||||
isFuri: true,
|
||||
allKata: false,
|
||||
showRomaji: true,
|
||||
showRomaji: false,
|
||||
showRomajiOnError: true,
|
||||
hideRepeated: false,
|
||||
ignoreEnglish: false,
|
||||
};
|
||||
|
||||
export type TypingSettings = typeof typingSettingsDefault;
|
||||
@@ -106,4 +107,6 @@ export interface UserData {
|
||||
isFinished: boolean;
|
||||
lastResultId: string | null;
|
||||
};
|
||||
|
||||
vocalsVolume?: number;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Layer } from "m3-svelte";
|
||||
|
||||
let { icon, ...rest } = $props();
|
||||
let { icon, size = 24, ...rest } = $props();
|
||||
</script>
|
||||
|
||||
<button class="cbox size-48px relative rounded-8px" {...rest}>
|
||||
<!-- <Layer/> -->
|
||||
<span class="size-24px {icon}"></span>
|
||||
<span class="{icon}" style="width: {size}px; height: {size}px"></span>
|
||||
</button>
|
||||
@@ -27,14 +27,14 @@
|
||||
|
||||
<LinearProgress percent={percentage ?? 0}/>
|
||||
|
||||
<div class="vbox p-content scroll-here gap-8px">
|
||||
<div class="vbox p-content scroll-here gap-8px overflow-x-hidden">
|
||||
{#each items as item}
|
||||
<div class="hbox gap-12px items-center h-40px">
|
||||
<span class="{item.icon} text-xl"></span>
|
||||
<div class="vbox">
|
||||
<span class="m3-font-title-medium">{item.title}</span>
|
||||
<div class="vbox min-w-0 flex-1">
|
||||
<span class="m3-font-title-medium truncate">{item.title}</span>
|
||||
{#if item.subtitle}
|
||||
<span class="m3-font-body-small mfg-on-surface-variant">{item.subtitle}</span>
|
||||
<span class="m3-font-body-small mfg-on-surface-variant truncate">{item.subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,22 +5,24 @@
|
||||
|
||||
interface Icon {
|
||||
icon: string
|
||||
size?: number
|
||||
onclick: () => void
|
||||
}
|
||||
|
||||
let { title, sub, account, right, children, moreIcon }: {
|
||||
let { title, sub, account, right, children, moreIcon, gradient }: {
|
||||
title?: string
|
||||
sub?: string
|
||||
account?: () => void
|
||||
right?: Icon[]
|
||||
children?: any
|
||||
moreIcon?: string
|
||||
gradient?: boolean
|
||||
} = $props()
|
||||
|
||||
let showMenu = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="hbox h-64px">
|
||||
<div class="hbox h-64px" class:appbar-gradient={gradient}>
|
||||
{#if account}
|
||||
<IconButton icon="i-material-symbols:account-circle" onclick={account} aria-label="Account" />
|
||||
{:else}
|
||||
@@ -34,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
{#each right as item}
|
||||
<IconButton icon={item.icon} onclick={item.onclick} />
|
||||
<IconButton icon={item.icon} onclick={item.onclick} size={item.size} />
|
||||
{/each}
|
||||
|
||||
{#if children}
|
||||
@@ -50,4 +52,15 @@
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
.appbar-gradient
|
||||
background: linear-gradient(180deg, rgba(var(--m3-scheme-surface) / 1), transparent)
|
||||
z-index: 100
|
||||
@media (prefers-color-scheme: dark)
|
||||
.appbar-gradient
|
||||
background: linear-gradient(180deg, rgba(var(--m3-scheme-surface) / 0.2), transparent)
|
||||
// background: none
|
||||
// mix-blend-mode: overlay
|
||||
</style>
|
||||
@@ -17,7 +17,9 @@ export function processLrcLine(line: LyricSegment[]): ProcLrcLine {
|
||||
// Fuzzy matching rules
|
||||
const fuzzyMatch = [['わ', 'は'], ['を', 'お'], ['ず', 'づ'], ['が', 'は'],
|
||||
['ぁ', 'あ'], ['ぃ', 'い'], ['ぅ', 'う'], ['ぇ', 'え'], ['ぉ', 'お'],
|
||||
['ゃ', 'や'], ['ゅ', 'ゆ'], ['ょ', 'よ'], ['っ', 'つ']]
|
||||
['ゃ', 'や'], ['ゅ', 'ゆ'], ['ょ', 'よ'], ['っ', 'つ'],
|
||||
['た', 'だ'], ['て', 'で'], ['か', 'が'],
|
||||
]
|
||||
export function fuzzyEquals(kana1: string, kana2: string): string {
|
||||
[kana1, kana2] = [toHiragana(kana1), toHiragana(kana2)]
|
||||
if (kana1 === kana2) return 'right'
|
||||
@@ -26,14 +28,17 @@ export function fuzzyEquals(kana1: string, kana2: string): string {
|
||||
}
|
||||
|
||||
// List of characters need to be composed instead of directly typed
|
||||
export const composeList = [
|
||||
'っ', 'ゃ', 'ゅ', 'ょ', 'ぁ', 'ぃ', 'ぅ', 'ぇ', 'ぉ',
|
||||
'が', 'ぎ', 'ぐ', 'げ', 'ご',
|
||||
'ざ', 'じ', 'ず', 'ぜ', 'ぞ',
|
||||
'だ', 'ぢ', 'づ', 'で', 'ど',
|
||||
'ば', 'び', 'ぶ', 'べ', 'ぼ',
|
||||
'ぱ', 'ぴ', 'ぷ', 'ぺ', 'ぽ'
|
||||
]
|
||||
export const composeMap = new Map(Object.entries({
|
||||
'っ': 'つ', 'ゃ': 'や', 'ゅ': 'ゆ', 'ょ': 'よ', 'ぁ': 'あ',
|
||||
'ぃ': 'い', 'ぅ': 'う', 'ぇ': 'え', 'ぉ': 'お',
|
||||
|
||||
'が': 'か', 'ぎ': 'き', 'ぐ': 'く', 'げ': 'け', 'ご': 'こ',
|
||||
'ざ': 'さ', 'じ': 'し', 'ず': 'す', 'ぜ': 'せ', 'ぞ': 'そ',
|
||||
'だ': 'た', 'ぢ': 'ち', 'づ': 'つ', 'で': 'て', 'ど': 'と',
|
||||
'ば': 'は', 'び': 'ひ', 'ぶ': 'ふ', 'べ': 'へ', 'ぼ': 'ほ',
|
||||
'ぱ': 'は', 'ぴ': 'ひ', 'ぷ': 'ふ', 'ぺ': 'へ', 'ぽ': 'ほ',
|
||||
}))
|
||||
export const composeList = Array.from(composeMap.keys())
|
||||
|
||||
/**
|
||||
* Remove duplicate lyric lines based on their content.
|
||||
@@ -47,4 +52,6 @@ export function dedupLines(lrc: LyricLine[], hide: boolean) {
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const isEnglish = (str: string | undefined) => str && /^[a-zA-Z\s.,'!?]+$/.test(str)
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte"
|
||||
import { isKana, isKanji, toKatakana, toRomaji } from "wanakana"
|
||||
import type { ProcLrcLine, ProcLrcSeg } from "./IMEHelper"
|
||||
import { isEnglish, type ProcLrcLine, type ProcLrcSeg } from "./IMEHelper"
|
||||
import type { TypingSettings } from "$lib/types"
|
||||
import { animateCaret } from "./animation"
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
const _preprocessKana = (kana: string) => settings.allKata ? toKatakana(kana) : kana
|
||||
const preprocessKana = (kana: string, state?: string) => (settings.showRomaji || (settings.showRomajiOnError && state === 'wrong')) ? `<ruby>${_preprocessKana(kana)}<rt>${toRomaji(kana)}</rt></ruby>` : _preprocessKana(kana)
|
||||
|
||||
|
||||
|
||||
const allStates = (l: number, seg: ProcLrcSeg) => states[l]?.slice(seg.swi, seg.swi + seg.kana.length) ?? []
|
||||
const getKanjiState = (l: number, seg: ProcLrcSeg) => {
|
||||
if (settings.ignoreEnglish && isEnglish(seg.kanji)) return 'ignored'
|
||||
let sts = allStates(l, seg)
|
||||
if (sts.every(s => s === 'right')) return 'right'
|
||||
if (sts.some(s => s === 'wrong')) return 'wrong'
|
||||
if (sts.some(s => s === 'fuzzy')) return 'fuzzy'
|
||||
return 'typing'
|
||||
}
|
||||
|
||||
@@ -67,14 +71,12 @@
|
||||
|
||||
<div bind:this={lrcWrapper} class="lrc-wrapper scroll-here" lang="ja-JP">
|
||||
<div class="vbox gap-12px py-32px relative min-h-full lrc-content">
|
||||
{#if showCaret}
|
||||
<div bind:this={caret} class="absolute bg-amber w-2px h-24px transition-all duration-75 z-10"></div>
|
||||
{/if}
|
||||
<div bind:this={caret} class="absolute bg-amber w-2px h-24px transition-all duration-75 z-5" hidden={!showCaret}></div>
|
||||
{#each lines as line, l}
|
||||
<div class="lrc p-content text-center m3-font-body-large" class:active={l === currentLineIndex} role="button" tabindex="0"
|
||||
onclick={() => onLineClick?.()}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onLineClick?.() } }}>
|
||||
{#each line.parts as seg}
|
||||
{#each line.parts as seg, i}
|
||||
{#if !seg.kanji}
|
||||
{#each seg.kana as char, c}
|
||||
<span class="{states[l]?.[seg.swi + c] ?? ''}" class:here={l === currentLineIndex && currentWordIndex === seg.swi + c}
|
||||
@@ -91,6 +93,9 @@
|
||||
</rt>{/if}
|
||||
</ruby>
|
||||
{/if}
|
||||
{#if line.parts[i+1] && (isEnglish(seg.kanji ?? seg.kana) || isEnglish(line.parts[i+1].kanji ?? line.parts[i+1].kana))}
|
||||
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -126,4 +131,6 @@
|
||||
color: #7b78c2
|
||||
.punctuation
|
||||
opacity: 0.5
|
||||
.ignored
|
||||
opacity: 0.5
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LyricLine } from '$lib/types'
|
||||
export class MusicControl {
|
||||
player: Tone.Player
|
||||
vocalsPlayer?: Tone.Player
|
||||
speed: number = 1
|
||||
lyrics: LyricLine[] = []
|
||||
currentLineIndex: number = 0
|
||||
checkInterval: any
|
||||
@@ -12,7 +13,12 @@ export class MusicControl {
|
||||
audioUrl: string
|
||||
vocalsUrl?: string
|
||||
|
||||
private _lastSpeedChangeTransportTime: number = 0
|
||||
private _accumulatedAudioTime: number = 0
|
||||
|
||||
constructor(audioUrl: string, vocalsUrl?: string) {
|
||||
this.audioUrl = audioUrl
|
||||
this.vocalsUrl = vocalsUrl
|
||||
this.audioUrl = audioUrl
|
||||
this.vocalsUrl = vocalsUrl
|
||||
this.player = new Tone.Player(audioUrl).toDestination()
|
||||
@@ -60,6 +66,10 @@ export class MusicControl {
|
||||
await Promise.all(promises)
|
||||
this.log('Audio loaded')
|
||||
|
||||
// Reset time tracking
|
||||
this._lastSpeedChangeTransportTime = 0
|
||||
this._accumulatedAudioTime = 0
|
||||
|
||||
// Sync player to transport and schedule start at 0
|
||||
// We do this regardless of transport state to ensure it's scheduled
|
||||
this.player.sync().start(0)
|
||||
@@ -72,12 +82,29 @@ export class MusicControl {
|
||||
this.startCheckLoop()
|
||||
}
|
||||
|
||||
setSpeed(speed: number) {
|
||||
// Calculate how much audio time has passed since the last speed change
|
||||
const currentTransportTime = Tone.getTransport().seconds
|
||||
const transportDelta = currentTransportTime - this._lastSpeedChangeTransportTime
|
||||
this._accumulatedAudioTime += transportDelta * this.speed
|
||||
|
||||
this._lastSpeedChangeTransportTime = currentTransportTime
|
||||
|
||||
this.speed = speed
|
||||
this.player.playbackRate = this.speed
|
||||
if (this.vocalsPlayer) {
|
||||
this.vocalsPlayer.playbackRate = this.speed
|
||||
}
|
||||
}
|
||||
|
||||
setVocalsVolume(db: number) {
|
||||
if (this.vocalsPlayer) this.vocalsPlayer.volume.value = db
|
||||
}
|
||||
|
||||
getTime() {
|
||||
return Tone.getTransport().seconds
|
||||
const currentTransportTime = Tone.getTransport().seconds
|
||||
const transportDelta = currentTransportTime - this._lastSpeedChangeTransportTime
|
||||
return this._accumulatedAudioTime + transportDelta * this.speed
|
||||
}
|
||||
|
||||
startCheckLoop() {
|
||||
@@ -91,7 +118,7 @@ export class MusicControl {
|
||||
// In karaoke mode (dual tracks), we don't pause for typing
|
||||
if (this.vocalsPlayer) return
|
||||
|
||||
const ct = Tone.getTransport().seconds
|
||||
const ct = this.getTime()
|
||||
const ni = this.currentLineIndex + 1
|
||||
if (ni >= this.lyrics.length) return
|
||||
const nt = this.parseTime(this.lyrics[ni].time)
|
||||
@@ -121,4 +148,16 @@ export class MusicControl {
|
||||
Tone.getTransport().stop()
|
||||
Tone.getTransport().cancel()
|
||||
}
|
||||
|
||||
togglePlay() {
|
||||
if (Tone.getTransport().state === 'started') {
|
||||
Tone.getTransport().pause()
|
||||
} else {
|
||||
Tone.getTransport().start()
|
||||
}
|
||||
}
|
||||
|
||||
get isPlaying() {
|
||||
return Tone.getTransport().state === 'started'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import AppBar from "$lib/ui/appbar/AppBar.svelte"
|
||||
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
|
||||
import { artistAndAlbum } from "$lib/utils"
|
||||
import type { TypingSettings, UserData, NeteaseSong } from "$lib/types"
|
||||
import type { TypingSettings, UserData, NeteaseSong, NeteasePlaylist } from "$lib/types"
|
||||
import { goto } from "$app/navigation"
|
||||
import { API } from "$lib/client"
|
||||
import { getNextSong, getNextLoc } from "./SongSwitching"
|
||||
|
||||
import { getI18n } from "$lib/i18n"
|
||||
|
||||
interface Props {
|
||||
song: NeteaseSong
|
||||
settings: TypingSettings
|
||||
loc?: UserData['loc']
|
||||
playlist?: NeteasePlaylist
|
||||
showRomajiOnError?: boolean
|
||||
disableHideRepeated?: boolean
|
||||
isKaraoke?: boolean
|
||||
@@ -17,30 +23,56 @@
|
||||
song,
|
||||
settings = $bindable(),
|
||||
loc = $bindable(),
|
||||
playlist,
|
||||
showRomajiOnError = true,
|
||||
disableHideRepeated = false,
|
||||
isKaraoke = false
|
||||
}: Props = $props()
|
||||
|
||||
const t = getI18n().player.menu
|
||||
|
||||
let isHideRepeated = $derived(settings.hideRepeated && !disableHideRepeated)
|
||||
|
||||
const nextSongId = $derived(getNextSong(playlist, loc))
|
||||
async function handleNext() {
|
||||
if (!loc || !playlist) return
|
||||
|
||||
if (nextSongId) {
|
||||
const newLoc = getNextLoc(playlist, loc, nextSongId)
|
||||
loc = newLoc // Update local state
|
||||
await API.saveUserData({ loc })
|
||||
goto(`/song/${nextSongId}`, { replaceState: true })
|
||||
} else {
|
||||
// Playlist finished
|
||||
loc.isFinished = true
|
||||
await API.saveUserData({ loc })
|
||||
goto(`/playlist/${playlist.id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title={song.name} sub={artistAndAlbum(song)}>
|
||||
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注</MenuItem>
|
||||
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"}</MenuItem>
|
||||
<MenuItem icon="i-material-symbols:language-japanese-kana-rounded" onclick={() => settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? "隐藏罗马音" : "显示罗马音"}</MenuItem>
|
||||
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? t.hideFuri : t.showFuri}</MenuItem>
|
||||
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? t.revertHiragana : t.convertToKatakana}</MenuItem>
|
||||
<MenuItem icon="i-material-symbols:language-japanese-kana-rounded" onclick={() => settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? t.hideRomaji : t.showRomaji}</MenuItem>
|
||||
|
||||
{#if showRomajiOnError}
|
||||
<MenuItem icon="i-material-symbols:error-circle-rounded" onclick={() => settings.showRomajiOnError = !settings.showRomajiOnError}>{settings.showRomajiOnError ? "不在错误时显示罗马音" : "错误时显示罗马音"}</MenuItem>
|
||||
<MenuItem icon="i-material-symbols:error-circle-rounded" onclick={() => settings.showRomajiOnError = !settings.showRomajiOnError}>{settings.showRomajiOnError ? t.hideRomajiOnError : t.showRomajiOnError}</MenuItem>
|
||||
{/if}
|
||||
|
||||
<MenuItem icon="i-material-symbols:compress-rounded"
|
||||
disabled={disableHideRepeated}
|
||||
sub={disableHideRepeated ? "音乐模式下不可用" : ""}
|
||||
onclick={() => settings.hideRepeated = !settings.hideRepeated}>{isHideRepeated ? "显示重复行" : "隐藏重复行"}</MenuItem>
|
||||
sub={disableHideRepeated ? t.musicModeUnavailable : ""}
|
||||
onclick={() => settings.hideRepeated = !settings.hideRepeated}>{isHideRepeated ? t.showRepeated : t.hideRepeated}</MenuItem>
|
||||
|
||||
{#if loc}
|
||||
<MenuItem icon={loc.playMode === 'random' ? "i-material-symbols:shuffle-rounded" : "i-material-symbols:repeat-rounded"} onclick={() =>
|
||||
loc.playMode = loc.playMode === 'random' ? 'sequential' : 'random'}>{loc.playMode === 'random' ? "当前:随机播放" : "当前:顺序播放"}</MenuItem>
|
||||
<MenuItem icon={loc.playMode === 'random' ? "i-material-symbols:shuffle-rounded" : "i-material-symbols:repeat-rounded"}
|
||||
onclick={() => loc!.playMode = loc!.playMode === 'random' ? 'sequential' : 'random'}>
|
||||
{loc.playMode === 'random' ? t.shuffle : t.sequential}
|
||||
</MenuItem>
|
||||
|
||||
{#if nextSongId}
|
||||
<MenuItem icon="i-material-symbols:skip-next-rounded" onclick={handleNext}>{t.nextSong}</MenuItem>
|
||||
{/if}
|
||||
{/if}
|
||||
</AppBar>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NeteasePlaylist, UserData } from "$lib/types"
|
||||
|
||||
export function getNextSong(playlist?: NeteasePlaylist, loc?: NonNullable<UserData['loc']>) {
|
||||
if (!playlist || !loc) return null
|
||||
if (loc.playMode === 'random') {
|
||||
const unplayed = playlist.tracks.filter(t => !loc.playedSongIds.includes(t.id))
|
||||
if (unplayed.length > 0) {
|
||||
return unplayed[Math.floor(Math.random() * unplayed.length)].id
|
||||
}
|
||||
} else {
|
||||
const nextIndex = loc.currentSongIndex + 1
|
||||
if (nextIndex < playlist.tracks.length) {
|
||||
return playlist.tracks[nextIndex].id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getNextLoc(playlist: NeteasePlaylist, loc: NonNullable<UserData['loc']>, nextSongId: number): NonNullable<UserData['loc']> {
|
||||
const nextIndex = playlist.tracks.findIndex(t => t.id === nextSongId)
|
||||
return {
|
||||
...loc,
|
||||
currentSongIndex: nextIndex,
|
||||
isFinished: false,
|
||||
playedSongIds: loc.playedSongIds.includes(nextSongId)
|
||||
? loc.playedSongIds
|
||||
: [...loc.playedSongIds, nextSongId]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { API } from "$lib/client"
|
||||
import { typingSettingsDefault, type TypingSettings, type UserData } from "$lib/types"
|
||||
|
||||
export class UserDataSync {
|
||||
settings = $state<TypingSettings>(typingSettingsDefault)
|
||||
loc = $state<UserData['loc']>(undefined)
|
||||
|
||||
constructor(data: any) {
|
||||
this.settings = data.user.data?.typingSettings ?? typingSettingsDefault
|
||||
this.loc = data.user.data.loc
|
||||
|
||||
$effect(() => {
|
||||
data.user.data = data.user.data || {}
|
||||
data.user.data.typingSettings = this.settings
|
||||
API.saveUserData({ typingSettings: this.settings })
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
data.user.data = data.user.data || {}
|
||||
data.user.data.loc = this.loc
|
||||
API.saveUserData({ loc: this.loc })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Layer } from "m3-svelte"
|
||||
import { fade } from "svelte/transition"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
|
||||
let { open = $bindable(), ...p }: {
|
||||
title: string,
|
||||
@@ -13,8 +14,10 @@
|
||||
noClose?: boolean
|
||||
} = $props()
|
||||
|
||||
const t = getI18n().dialog
|
||||
|
||||
let buttons = $derived([...(p.buttons ?? []), ...(p.noClose ? [] : [{
|
||||
text: '关闭', onclick: () => open = false
|
||||
text: t.close, onclick: () => open = false
|
||||
}])])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
import Dialog from "./Dialog.svelte";
|
||||
import { getI18n } from "$lib/i18n"
|
||||
|
||||
let p: {
|
||||
error?: string
|
||||
} = $props()
|
||||
|
||||
const t = getI18n().dialog.error
|
||||
|
||||
let open = $derived(!!p.error)
|
||||
</script>
|
||||
|
||||
<Dialog title="错误" bind:open buttons={[{
|
||||
text: "刷新重试",
|
||||
<Dialog title={t.title} bind:open buttons={[{
|
||||
text: t.refresh,
|
||||
onclick: () => location.reload()
|
||||
}]} noClose>
|
||||
<div class="text-red-500">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import AppBar from "$lib/ui/appbar/AppBar.svelte";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import { getI18n } from "$lib/i18n";
|
||||
|
||||
const t = getI18n().errorPage;
|
||||
</script>
|
||||
|
||||
<AppBar title={t.title} />
|
||||
<div class="vbox p-content flex-1">
|
||||
<p class="mt-12px scroll-here">{@html t.message}</p>
|
||||
<div class="py-16px">
|
||||
<a href="/">
|
||||
<Button big>{t.return}</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,9 +31,28 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>🍬アマオケ🎤</title>
|
||||
<meta name="title" content="🍬アマオケ🎤" />
|
||||
<meta name="description" content="是一个日语卡拉 OK 阅读打字唱歌练习软件!" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://amaoke.app/" />
|
||||
<meta property="og:title" content="🍬アマオケ🎤" />
|
||||
<meta property="og:description" content="是一个日语卡拉 OK 阅读打字唱歌练习软件!" />
|
||||
<meta property="og:image" content="https://amaoke.app/images/meta.png" />
|
||||
|
||||
<!-- X (Twitter) -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://amaoke.app/" />
|
||||
<meta property="twitter:title" content="🍬アマオケ🎤" />
|
||||
<meta property="twitter:description" content="是一个日语卡拉 OK 阅读打字唱歌练习软件!" />
|
||||
<meta property="twitter:image" content="https://amaoke.app/images/meta.png" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="vbox h-screen min-h-screen box-border overflow-hidden relative">
|
||||
<div class="vbox h-screen min-h-screen box-border overflow-hidden relative max-w-1200px mx-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,20 +7,20 @@
|
||||
import { Layer } from "m3-svelte"
|
||||
import { goto } from "$app/navigation"
|
||||
import { getI18n, setLanguage } from "$lib/i18n"
|
||||
import MenuItem from "$lib/ui/material3/MenuItem.svelte";
|
||||
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
|
||||
|
||||
let { data }: PageProps = $props()
|
||||
|
||||
const t = getI18n().home
|
||||
|
||||
console.log(data.recPlaylists)
|
||||
|
||||
const loc = data.user.data.loc
|
||||
const href = loc?.isFinished && loc?.lastResultId ? `/results/${loc.lastResultId}` : `/song/${data.last?.id}`
|
||||
</script>
|
||||
|
||||
|
||||
<AppBar account={() => goto('/user')} moreIcon="i-material-symbols:translate-rounded">
|
||||
<AppBar account={() => goto('/user')} moreIcon="i-material-symbols:translate-rounded" right={[
|
||||
{ icon: 'i-solar:cat-broken', size: 26, onclick: () => goto('/about') },
|
||||
]}>
|
||||
<MenuItem onclick={() => setLanguage('en')}>English</MenuItem>
|
||||
<MenuItem onclick={() => setLanguage('zh')}>中文</MenuItem>
|
||||
<MenuItem onclick={() => setLanguage('ja')}>日本語</MenuItem>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import AppBar from "$lib/ui/appbar/AppBar.svelte"
|
||||
import Button from "$lib/ui/Button.svelte"
|
||||
import { version } from '$app/environment';
|
||||
import "@fontsource/darumadrop-one"
|
||||
|
||||
// Generate 20 random lines of hiragana
|
||||
const hiragana = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん"
|
||||
function genRandomLines() {
|
||||
const lines = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
lines.push(hiragana.split('').sort(() => Math.random() - 0.5).join(''))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
const randomLines = genRandomLines()
|
||||
const spinningMics = 10
|
||||
</script>
|
||||
|
||||
<AppBar title="About" right={[
|
||||
{
|
||||
icon: 'i-mdi:github',
|
||||
onclick: () => window.open('https://github.com/MaigoLabs/amaoke.app', '_blank')
|
||||
}
|
||||
]} gradient></AppBar>
|
||||
|
||||
<!-- <div class="gradient2"></div> -->
|
||||
|
||||
<div class="cbox vbox overflow-hidden! absolute inset-0">
|
||||
<div class="gradient1"></div>
|
||||
<div class="gradient2"></div>
|
||||
<div class="vbox justify-center items-center text-center relative z-10">
|
||||
<div class="cbox absolute inset-0 z-5">
|
||||
<div class="size-360px rounded-full opacity-70 gradient-center"></div>
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<div class="app-name text-32px">アマオケ</div>
|
||||
<div class="m3-font-body-medium mfg-on-surface-variant">FOSS {version}</div>
|
||||
<div class="m3-font-body-medium mfg-on-surface">
|
||||
Made with ♥ and 🔮 by <br>
|
||||
<a href="https://maigo.dev" target="_blank" class="mfg-primary">MaigoLabs</a> 2025
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbox absolute inset-0 z-5 spin-circle opacity-50 pointer-events-none">
|
||||
{#each Array(spinningMics) as _, i}
|
||||
<div class="size-300px rounded-full absolute" style:transform={`rotate(${i * 360 / spinningMics}deg)`}>
|
||||
<div class="rotate--140 text-24px">🎤</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="h-64px w-full"> </div> -->
|
||||
<div class="vbox absolute overflow-hidden">
|
||||
{#each randomLines as line}
|
||||
<div class="mfg-on-surface mix-blend-color-burn opacity-07 truncate text-48px rotate--15 pointer-events-none line-height-64px tracking-8px kana-line">{line}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="p-content py-16px">
|
||||
<Button big href="https://github.com/MaigoLabs/amaoke.app">GitHub</Button>
|
||||
</div> -->
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
// @import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400..700&family=Darumadrop+One&family=Hachi+Maru+Pop&family=Kiwi+Maru&family=Livvic:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,900&family=M+PLUS+Rounded+1c&family=Quicksand:wght@300..700&family=Rampart+One&family=Stick&family=Yuji+Boku&family=Zen+Maru+Gothic&display=swap')
|
||||
|
||||
.app-name
|
||||
// font-family: "Yuji Boku"
|
||||
font-family: "Darumadrop One"
|
||||
font-size: 48px
|
||||
|
||||
.gradient1,.gradient2
|
||||
--size: calc(max(100vw, 100vh) * 1.5)
|
||||
position: absolute
|
||||
width: 200vw
|
||||
height: 200vh
|
||||
border-radius: 50%
|
||||
|
||||
.gradient1
|
||||
// Radial gradient from D9C5F7 100% to FFFFFF 0%
|
||||
background: radial-gradient(50% 50%, rgba(217,197,247,1), rgba(255,255,255,0))
|
||||
bottom: 0
|
||||
left: 0
|
||||
|
||||
.gradient2
|
||||
// Radial gradient from F7C5C5 100% to FFFFFF 0%
|
||||
background: radial-gradient(50% 50%, rgba(247,197,197,1), rgba(255,255,255,0))
|
||||
right: 0
|
||||
top: 0
|
||||
|
||||
.gradient-center
|
||||
background: radial-gradient(50% 50%, rgba(255,255,255,1), rgba(255,255,255,0))
|
||||
|
||||
@media (prefers-color-scheme: dark)
|
||||
.gradient-center
|
||||
background: radial-gradient(50% 50%, rgba(0,0,0,1), rgba(0,0,0,0))
|
||||
opacity: 0.1
|
||||
|
||||
.kana-line
|
||||
opacity: 0.2
|
||||
|
||||
.spin-circle
|
||||
animation: spin 30s linear infinite
|
||||
|
||||
@keyframes spin
|
||||
from
|
||||
transform: rotate(360deg)
|
||||
to
|
||||
transform: rotate(0deg)
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { API } from '$lib/client';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import AppBar from "$lib/ui/appbar/AppBar.svelte";
|
||||
@@ -23,7 +24,8 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await API.netease.checkLogin();
|
||||
const pwd = $page.url.searchParams.get('pwd') ?? undefined;
|
||||
const res = await API.netease.checkLogin(pwd);
|
||||
if (res.code === 801) {
|
||||
if (status !== 'waiting_scan') {
|
||||
status = 'waiting_scan';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as ne from '@neteasecloudmusicapienhanced/api'
|
||||
import { error, json } from '@sveltejs/kit'
|
||||
import { env } from '$env/dynamic/private'
|
||||
import { loginWithSyncCode } from '$lib/server/user'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { db } from '$lib/server/db'
|
||||
@@ -15,6 +16,11 @@ async function createQr() {
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const { pwd } = await request.json().catch(() => ({}))
|
||||
if (env.ADMIN_PASSWORD && pwd !== env.ADMIN_PASSWORD) {
|
||||
throw error(403, 'Invalid password')
|
||||
}
|
||||
|
||||
if (!globalSession.key) await createQr()
|
||||
|
||||
// Check key validity
|
||||
|
||||
@@ -7,6 +7,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (!code) throw error(400, 'Missing sync code')
|
||||
|
||||
const ua = request.headers.get('user-agent') || 'unknown'
|
||||
console.log(`Login attempt with sync code from UA: ${ua}`)
|
||||
const session = await loginWithSyncCode(code, ua)
|
||||
|
||||
// Set session cookie
|
||||
|
||||
@@ -7,7 +7,10 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!id) throw error(400, 'Import ID is required')
|
||||
|
||||
const session = getSession(id)
|
||||
if (!session) throw error(404, 'Session not found')
|
||||
if (!session) {
|
||||
console.log(`API: Import session ${id} not found`)
|
||||
throw error(404, 'Session not found')
|
||||
}
|
||||
|
||||
return json(session);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (!user) throw error(401, 'Unauthorized');
|
||||
|
||||
try {
|
||||
console.log(`API: Starting import for link ${link} by user ${user._id}`)
|
||||
return json(await startImport(link, user._id))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -10,6 +10,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
data.userId = user._id
|
||||
|
||||
// Validate data here if needed
|
||||
console.log(`API: Saving result for user ${user._id}`)
|
||||
const id = await saveResult(data)
|
||||
return json({ id })
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prepareSong, getSongStatus } from '$lib/server/songs'
|
||||
|
||||
export async function POST({ params }) {
|
||||
const songId = +params.id
|
||||
console.log(`API: Requesting preparation for song ${songId}`)
|
||||
prepareSong(songId) // Start in background
|
||||
return json({ status: 'started' })
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const data = await request.json()
|
||||
console.log(`API: Updating user data for ${user._id}`)
|
||||
await updateUserData(user, data)
|
||||
return json({ success: true })
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,5 +7,6 @@ export const POST: RequestHandler = async ({ cookies }) => {
|
||||
if (!session) throw error(401, 'Unauthorized')
|
||||
|
||||
const code = await createSyncCode(session)
|
||||
console.log(`API: Created sync code for session ${session}`)
|
||||
return json({ code })
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
import ErrorDialog from "$lib/ui/status/ErrorDialog.svelte"
|
||||
import ProgressList from "$lib/ui/ProgressList.svelte"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
import { page } from '$app/state'
|
||||
|
||||
const t = getI18n().import.netease
|
||||
|
||||
let link = $state('')
|
||||
let link = $state(page.url.searchParams.get('id') ?? '')
|
||||
let isUpdate = $derived(!!page.url.searchParams.get('id'))
|
||||
|
||||
interface SongImportStatus {
|
||||
song: NeteaseSong
|
||||
@@ -91,7 +93,7 @@
|
||||
|
||||
<div class="py-16px p-content">
|
||||
{#if status === 'idle'}
|
||||
<Button big icon="i-material-symbols:download" onclick={startImport}>{t.btnStart}</Button>
|
||||
<Button big icon={isUpdate ? "i-material-symbols:sync" : "i-material-symbols:download"} onclick={startImport}>{isUpdate ? t.btnUpdate : t.btnStart}</Button>
|
||||
{:else if status === 'success'}
|
||||
<a href="/playlist/{id}">
|
||||
<Button big icon="i-material-symbols:right-arrow">{t.btnView}</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SongInfo from "$lib/ui/listitem/SongInfo.svelte"
|
||||
import { API } from "$lib/client"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
import MenuItem from "$lib/ui/material3/MenuItem.svelte";
|
||||
|
||||
const t = getI18n().playlist.detail
|
||||
|
||||
@@ -49,9 +50,10 @@
|
||||
{
|
||||
icon: isFavorite ? "i-material-symbols:bookmark-rounded" : "i-material-symbols:bookmark-add-outline-rounded",
|
||||
onclick: toggleFavorite
|
||||
},
|
||||
{icon: "i-material-symbols:more-vert", onclick: () => alert('More clicked')}
|
||||
]} />
|
||||
}
|
||||
]}>
|
||||
<MenuItem icon="i-material-symbols:update" onclick={() => goto(`/import/netease?id=${meta.id}`)}>{t.updateFromNetease}</MenuItem>
|
||||
</AppBar>
|
||||
|
||||
<div class="hbox px-16px py-8px gap-24px">
|
||||
<img src="{meta.coverImgUrl}" alt="" class="size-128px rounded-16px">
|
||||
|
||||
@@ -14,6 +14,7 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
result: structuredClone(result),
|
||||
lrc: await getLyricsProcessed(result.songId),
|
||||
song,
|
||||
playlist: await user.data?.loc?.currentPlaylistId?.let(getPlaylist)
|
||||
playlist: await user.data?.loc?.currentPlaylistId?.let(getPlaylist),
|
||||
resultId: params.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Chart from "chart.js/auto"
|
||||
import { API } from "$lib/client"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
import { getNextSong, getNextLoc } from "$lib/ui/player/SongSwitching"
|
||||
|
||||
const t = getI18n().results
|
||||
|
||||
@@ -95,45 +96,24 @@
|
||||
})
|
||||
|
||||
// Playlist Navigation Logic
|
||||
let nextSongId = $state<number | null>(null)
|
||||
let isPlaylistFinished = $state(false)
|
||||
|
||||
const loc = data.user.data.loc
|
||||
const playlist = data.playlist
|
||||
let nextSongId = $state<number | null>(getNextSong(playlist, loc))
|
||||
let isPlaylistFinished = $state(false)
|
||||
|
||||
// Check if this is the latest result for the current playlist session
|
||||
const isCurrentResult = loc?.lastResultId === data.result._id
|
||||
const isCurrentResult = loc?.lastResultId === data.resultId
|
||||
|
||||
// Compute next state immediately
|
||||
if (playlist && loc && isCurrentResult) {
|
||||
if (loc.playMode === 'random') {
|
||||
const unplayed = playlist.tracks.filter((t: NeteaseSong) => !loc.playedSongIds.includes(t.id))
|
||||
if (unplayed.length > 0) {
|
||||
const nextSong = unplayed[Math.floor(Math.random() * unplayed.length)]
|
||||
nextSongId = nextSong.id
|
||||
} else isPlaylistFinished = true
|
||||
} else {
|
||||
const nextIndex = loc.currentSongIndex + 1
|
||||
if (nextIndex < playlist.tracks.length) {
|
||||
nextSongId = playlist.tracks[nextIndex].id
|
||||
} else isPlaylistFinished = true
|
||||
}
|
||||
if (nextSongId === null) isPlaylistFinished = true
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
if (nextSongId !== null) {
|
||||
if (!data.user.data.loc || !data.playlist) return
|
||||
|
||||
const nextIndex = data.playlist.tracks.findIndex((t: NeteaseSong) => t.id === nextSongId)
|
||||
|
||||
const newLoc = {
|
||||
...data.user.data.loc,
|
||||
currentSongIndex: nextIndex,
|
||||
isFinished: false
|
||||
}
|
||||
|
||||
if (!newLoc.playedSongIds.includes(nextSongId)) {
|
||||
newLoc.playedSongIds = [...newLoc.playedSongIds, nextSongId]
|
||||
}
|
||||
const newLoc = getNextLoc(data.playlist, data.user.data.loc, nextSongId)
|
||||
|
||||
data.user.data.loc = newLoc
|
||||
await API.saveUserData({ loc: newLoc })
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getSongRaw, getPlaylist } from "$lib/server/songs"
|
||||
|
||||
export const load = async ({ params, parent }) => {
|
||||
const { user } = await parent()
|
||||
const songId = +params.id
|
||||
const song = await getSongRaw(songId)
|
||||
const playlist = await user.data?.loc?.currentPlaylistId?.let(getPlaylist)
|
||||
|
||||
return { song, playlist }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { getSongRaw } from "$lib/server/songs"
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const song = await getSongRaw(+params.id)
|
||||
return { song }
|
||||
}
|
||||
@@ -2,16 +2,33 @@
|
||||
import { API } from "$lib/client"
|
||||
import { onMount } from "svelte"
|
||||
import Button from "$lib/ui/Button.svelte"
|
||||
import AppBar from "$lib/ui/appbar/AppBar.svelte"
|
||||
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
|
||||
import ProgressList from "$lib/ui/ProgressList.svelte"
|
||||
import { goto } from "$app/navigation"
|
||||
import { artistAndAlbum } from "$lib/utils"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
import { getI18n, useMsg } from "$lib/i18n"
|
||||
import { getNextSong } from "$lib/ui/player/SongSwitching.js"
|
||||
import { UserDataSync } from "$lib/ui/player/state.svelte"
|
||||
|
||||
const t = getI18n().song.mode
|
||||
const getMsg = useMsg()
|
||||
|
||||
let { data } = $props()
|
||||
let loadStatus = $state<"idle" | "loading" | "done">("idle")
|
||||
|
||||
const ud = new UserDataSync(data)
|
||||
|
||||
let taskStatus = $state({
|
||||
lyrics: false,
|
||||
ai: false,
|
||||
music: false,
|
||||
separation: false
|
||||
})
|
||||
|
||||
let modes = $derived([
|
||||
{ icon: "i-material-symbols:keyboard-rounded", label: t.typing, url: `/song/${data.song.id}/play`, disabled: !taskStatus.ai },
|
||||
{ icon: "i-material-symbols:music-note-rounded", label: t.music, url: `/song/${data.song.id}/play?music=true`, disabled: !taskStatus.ai || !taskStatus.music },
|
||||
{ icon: "i-material-symbols:mic-rounded", label: t.karaoke, url: `/song/${data.song.id}/karaoke`, disabled: !taskStatus.ai || !taskStatus.separation },
|
||||
])
|
||||
|
||||
let progressItems = $state<any[]>([])
|
||||
let progressPercentage = $state(0)
|
||||
|
||||
@@ -20,42 +37,60 @@
|
||||
})
|
||||
|
||||
async function startLoading() {
|
||||
loadStatus = "loading"
|
||||
await API.song.prepare(data.song.id)
|
||||
|
||||
// Auto prepare next song
|
||||
const nextSongId = getNextSong(data.playlist, data.user.data.loc)
|
||||
if (nextSongId) await API.song.prepare(nextSongId)
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const res = await API.song.status(data.song.id)
|
||||
const state = res.status
|
||||
|
||||
if (state && state.items) {
|
||||
progressItems = state.items.map((item: any) => ({
|
||||
title: item.task + (item.progress > 0 && item.progress < 1 ? ` (${Math.round(item.progress * 100)}%)` : ''),
|
||||
icon: item.progress === 1 ? 'i-material-symbols:check text-green-500' :
|
||||
item.progress === -1 ? 'i-material-symbols:error text-red-500' :
|
||||
'i-material-symbols:sync animate-spin'
|
||||
}))
|
||||
|
||||
const totalProgress = state.items.reduce((acc: number, cur: any) => acc + Math.max(0, cur.progress), 0)
|
||||
progressPercentage = Math.min(100, Math.round((totalProgress / 4) * 100))
|
||||
// Update task status
|
||||
for (const item of state.items) {
|
||||
if (item.progress !== 1) continue
|
||||
if (item.id === 'lyrics') taskStatus.lyrics = true
|
||||
if (item.id === 'ai') taskStatus.ai = true
|
||||
if (item.id === 'music') taskStatus.music = true
|
||||
if (item.id === 'separation') taskStatus.separation = true
|
||||
}
|
||||
|
||||
progressItems = state.items.map((item: any) => {
|
||||
let taskName = getMsg(`song.prepare.${item.id}`) || item.task
|
||||
if (item.id === 'error') {
|
||||
taskName = getMsg('song.prepare.error') + item.task
|
||||
}
|
||||
|
||||
return {
|
||||
title: taskName + (item.progress > 0 && item.progress < 1 ? ` (${Math.round(item.progress * 100)}%)` : ''),
|
||||
icon: item.progress === 1 ? 'i-material-symbols:check text-green-500' :
|
||||
item.progress === -1 ? 'i-material-symbols:error text-red-500' :
|
||||
'i-material-symbols:sync animate-spin'
|
||||
}
|
||||
})
|
||||
|
||||
const totalProgress = state.items.reduce((acc: number, cur: any) => acc + Math.max(0, cur.progress), 0)
|
||||
progressPercentage = Math.min(100, Math.round((totalProgress / 4) * 100))
|
||||
}
|
||||
|
||||
if (state.status === "done") {
|
||||
clearInterval(interval)
|
||||
loadStatus = "done"
|
||||
progressPercentage = 100
|
||||
} else if (state.status === "error") {
|
||||
clearInterval(interval)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
|
||||
<PlayerAppBar song={data.song} bind:settings={ud.settings} bind:loc={ud.loc} playlist={data.playlist} />
|
||||
|
||||
<ProgressList percentage={progressPercentage} items={progressItems} />
|
||||
|
||||
{#if loadStatus === "done"}
|
||||
<div class="hbox gap-4 p-16px">
|
||||
<Button big icon="i-material-symbols:keyboard-rounded" onclick={() => goto(`/song/${data.song.id}/play`)}>{t.typing}</Button>
|
||||
<Button big icon="i-material-symbols:music-note-rounded" onclick={() => goto(`/song/${data.song.id}/play?music=true`)}>{t.music}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="hbox gap-4 p-16px flex-wrap">
|
||||
{#each modes as mode}
|
||||
<Button big icon={mode.icon} onclick={() => goto(mode.url)} disabled={mode.disabled} class="!w-auto !min-w-[calc(50%-8px)] grow disabled:opacity-50 disabled:cursor-not-allowed">{mode.label}</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getLyricsProcessed, getSongRaw, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
|
||||
import { getLyricsProcessed, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const songId = +params.id
|
||||
const song = await getSongRaw(songId)
|
||||
const hasLrc = await checkLyricsProcessed(songId)
|
||||
|
||||
if (!hasLrc) throw redirect(302, `/song/${songId}`)
|
||||
@@ -12,5 +11,5 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
const lrc = await getLyricsProcessed(songId)!
|
||||
const audioData = await getSongUrl(songId)
|
||||
|
||||
return { song, lrc, audioData }
|
||||
return { lrc, audioData }
|
||||
}
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from "./$types"
|
||||
import { LinearProgress } from "m3-svelte"
|
||||
import { onMount } from "svelte"
|
||||
import { typingSettingsDefault } from "$lib/types"
|
||||
import { processLrcLine, dedupLines, type ProcLrcLine } from "$lib/ui/player/IMEHelper"
|
||||
import "$lib/ext.ts"
|
||||
import { API } from "$lib/client"
|
||||
import { MusicControl } from "$lib/ui/player/MusicControl"
|
||||
import Lyrics from "$lib/ui/player/Lyrics.svelte"
|
||||
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
|
||||
import { UserDataSync } from "$lib/ui/player/state.svelte"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
import { Layer } from "m3-svelte";
|
||||
|
||||
const t = getI18n().song.karaoke
|
||||
|
||||
let { data }: PageProps = $props()
|
||||
|
||||
let li = $state(0)
|
||||
let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault)
|
||||
$effect(() => { API.saveUserData({ typingSettings: settings }) })
|
||||
const ud = new UserDataSync(data)
|
||||
|
||||
let vocalsVolume = $state(100) // 0-100
|
||||
let vocalsVolume = $state(data.user.data.vocalsVolume ?? 100)
|
||||
$effect(() => {
|
||||
API.saveUserData({ vocalsVolume })
|
||||
data.user.data.vocalsVolume = vocalsVolume
|
||||
})
|
||||
let speed = $state(1)
|
||||
let isPlaying = $state(false)
|
||||
|
||||
// Process lyrics
|
||||
const isHideRepeated = $derived(settings.hideRepeated)
|
||||
const isHideRepeated = $derived(ud.settings.hideRepeated)
|
||||
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
|
||||
let processedLrc: ProcLrcLine[] = $derived(deduplicatedLyrics.map(line => processLrcLine(line.lyric)))
|
||||
|
||||
let musicControl = $state<MusicControl>()
|
||||
$effect(() => { li; musicControl?.updateLine(li) })
|
||||
|
||||
// Volume control
|
||||
// Volume, Speed control
|
||||
$effect(() => {
|
||||
if (musicControl) {
|
||||
// Tone.js volume is in decibels. 0 is full, -Infinity is silent.
|
||||
// Simple mapping: 100 -> 0, 0 -> -60 (or mute)
|
||||
const db = vocalsVolume === 0 ? -Infinity : 20 * Math.log10(vocalsVolume / 100)
|
||||
musicControl.setVocalsVolume(db)
|
||||
musicControl.setSpeed(speed)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,6 +75,7 @@
|
||||
}
|
||||
|
||||
if (nextLi !== -1 && nextLi !== li) li = nextLi
|
||||
isPlaying = musicControl.isPlaying
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
@@ -80,7 +87,7 @@
|
||||
|
||||
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
|
||||
|
||||
<PlayerAppBar song={data.song} bind:settings showRomajiOnError={false} isKaraoke={true} disableHideRepeated />
|
||||
<PlayerAppBar song={data.song} bind:settings={ud.settings} bind:loc={ud.loc} showRomajiOnError={false} isKaraoke={true} disableHideRepeated playlist={data.playlist} />
|
||||
|
||||
<div class="vbox p-content py-4 gap-2 mfg-on-surface-variant">
|
||||
{#if data.audioData.vocalsUrl}
|
||||
@@ -94,6 +101,21 @@
|
||||
{t.noVocals}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="hbox gap-4 items-center">
|
||||
<div class="i-material-symbols:speed-rounded text-2xl" title="Speed"></div>
|
||||
<input type="range" min="0.5" max="1.5" step="0.05" bind:value={speed} class="flex-1" />
|
||||
<div class="w-12 text-right">{speed.toFixed(2)}x</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Lyrics lines={processedLrc} currentLineIndex={li} {settings} {states} />
|
||||
<Lyrics lines={processedLrc} currentLineIndex={li} settings={ud.settings} {states} />
|
||||
|
||||
<button
|
||||
class="fixed bottom-6 right-6 z-5 size-64px cbox rounded-full surface-variant mbg-surface-container mfg-on-surface-variant shadow-lg hover:shadow-xl transition-all active:scale-90"
|
||||
onclick={() => { musicControl?.togglePlay(); isPlaying = musicControl?.isPlaying ?? false }}
|
||||
aria-label="Play/Pause"
|
||||
>
|
||||
<Layer />
|
||||
<div class={isPlaying ? "i-material-symbols:pause-rounded text-3xl" : "i-material-symbols:play-arrow-rounded text-3xl"}></div>
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getLyricsProcessed, getSongRaw, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
|
||||
import { getLyricsProcessed, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const songId = +params.id
|
||||
const song = await getSongRaw(songId)
|
||||
const hasLrc = await checkLyricsProcessed(songId)
|
||||
|
||||
if (!hasLrc) throw redirect(302, `/song/${songId}`)
|
||||
|
||||
const lrc = await getLyricsProcessed(songId)!
|
||||
const audioUrl = url.searchParams.get('music') === 'true' ? await getSongUrl(songId) : undefined
|
||||
return { song, lrc, audioUrl }
|
||||
return { lrc, audioUrl }
|
||||
}
|
||||
@@ -4,15 +4,15 @@
|
||||
import { onMount } from "svelte"
|
||||
import { typingSettingsDefault } from "$lib/types.ts"
|
||||
import { isKana, isKanji, toHiragana } from "wanakana"
|
||||
import { composeList, fuzzyEquals, processLrcLine, dedupLines, type ProcLrcLine } from "$lib/ui/player/IMEHelper.ts"
|
||||
import { composeList, fuzzyEquals, processLrcLine, dedupLines, isEnglish, type ProcLrcLine, composeMap } from "$lib/ui/player/IMEHelper.ts"
|
||||
import "$lib/ext.ts"
|
||||
import { API } from "$lib/client.ts"
|
||||
import { goto } from '$app/navigation'
|
||||
import { artistAndAlbum } from "$lib/utils.ts"
|
||||
import { MusicControl } from "$lib/ui/player/MusicControl.ts"
|
||||
import Lyrics from "$lib/ui/player/Lyrics.svelte"
|
||||
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
|
||||
import { getI18n } from "$lib/i18n"
|
||||
import { UserDataSync } from "$lib/ui/player/state.svelte"
|
||||
|
||||
const t = getI18n().song.play
|
||||
|
||||
@@ -26,18 +26,14 @@
|
||||
let inp = $state("")
|
||||
|
||||
// Settings stored in user data
|
||||
let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault)
|
||||
$effect(() => { API.saveUserData({ typingSettings: settings }) })
|
||||
|
||||
// Playlist location state
|
||||
let loc = $state(data.user.data.loc)
|
||||
$effect(() => { API.saveUserData({ loc }) })
|
||||
const ud = new UserDataSync(data)
|
||||
|
||||
// Process each line into segments with swi (start word index) and kanji/kana
|
||||
const isHideRepeated = $derived(settings.hideRepeated && !data.audioUrl)
|
||||
const isHideRepeated = $derived(ud.settings.hideRepeated && !data.audioUrl)
|
||||
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
|
||||
let processedLrc: ProcLrcLine[] = $derived(deduplicatedLyrics.map(line => processLrcLine(line.lyric)))
|
||||
// State tracking for each kana character: UNSEEN, RIGHT, WRONG
|
||||
// svelte-ignore state_referenced_locally
|
||||
let states = $state(processedLrc.map(line => new Array(line.totalLen).fill('unseen')))
|
||||
|
||||
let musicControl: MusicControl | undefined
|
||||
@@ -117,8 +113,10 @@
|
||||
|
||||
// Check if it matches current character
|
||||
let { cLine, cSeg, exp } = findLoc()
|
||||
let exph = toHiragana(exp)
|
||||
let res = fuzzyEquals(char, exp)
|
||||
if (res === 'wrong' && !imeUsed && !isComposed && composeList.includes(exp)) return // Need to compose, stop here
|
||||
// Need to compose, stop here
|
||||
if (res !== 'right' && !imeUsed && !isComposed && composeList.includes(exph) && composeMap.get(exph) === char) return
|
||||
states[li][wi] = res
|
||||
|
||||
// Record stats
|
||||
@@ -133,7 +131,11 @@
|
||||
}
|
||||
|
||||
// Check next expected character, if it's neither kana nor kanji, skip it
|
||||
while (findLoc().let(({ exp, cLine }) => !isKana(exp) && !isKanji(exp) && incr(cLine))) {}
|
||||
while (findLoc().let(({ exp, cLine, cSeg }) => {
|
||||
const isPunctuation = !isKana(exp) && !isKanji(exp)
|
||||
const isIgnoredEnglish = ud.settings.ignoreEnglish && isEnglish(cSeg.kanji)
|
||||
return (isPunctuation || isIgnoredEnglish) && incr(cLine)
|
||||
})) {}
|
||||
}
|
||||
$effect(() => inputChanged(inp, false))
|
||||
|
||||
@@ -146,10 +148,10 @@
|
||||
totalTyped, totalRight, startTime, statsHistory
|
||||
})
|
||||
|
||||
if (loc?.currentPlaylistId) {
|
||||
loc.isFinished = true
|
||||
loc.lastResultId = res.id
|
||||
await API.saveUserData({ loc })
|
||||
if (ud.loc) {
|
||||
ud.loc.isFinished = true
|
||||
ud.loc.lastResultId = res.id
|
||||
await API.saveUserData({ loc: ud.loc })
|
||||
}
|
||||
|
||||
goto(`/results/${res.id}`, { replaceState: true })
|
||||
@@ -158,9 +160,7 @@
|
||||
|
||||
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
|
||||
|
||||
<PlayerAppBar song={data.song} bind:settings bind:loc disableHideRepeated={!!data.audioUrl} />
|
||||
|
||||
<LinearProgress percent={progress} />
|
||||
<PlayerAppBar song={data.song} bind:settings={ud.settings} bind:loc={ud.loc} disableHideRepeated={!!data.audioUrl} playlist={data.playlist} />
|
||||
|
||||
<LinearProgress percent={progress} />
|
||||
|
||||
@@ -182,4 +182,4 @@
|
||||
</div>
|
||||
|
||||
<!-- Lines -->
|
||||
<Lyrics lines={processedLrc} currentLineIndex={li} currentWordIndex={wi} {states} {settings} showCaret={true} onLineClick={() => hiddenInput.focus()} />
|
||||
<Lyrics lines={processedLrc} currentLineIndex={li} currentWordIndex={wi} {states} settings={ud.settings} showCaret={true} onLineClick={() => hiddenInput.focus()} />
|
||||
+1
-1
@@ -37,4 +37,4 @@ body
|
||||
align-self: stretch
|
||||
overflow-y: auto
|
||||
min-height: 0
|
||||
flex: 1
|
||||
flex: 1
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
+5
-2
@@ -1,5 +1,5 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import adapter from "svelte-adapter-bun"
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
@@ -8,6 +8,9 @@ const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
version: {
|
||||
name: process.env.npm_package_version
|
||||
},
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
|
||||
+6
-20
@@ -1,6 +1,5 @@
|
||||
// https://github.com/Menci/Marina/blob/main/apps/web/uno.config.ts
|
||||
import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local';
|
||||
import { defineConfig, presetWind3, presetAttributify, presetIcons, presetTypography, presetWebFonts, transformerDirectives } from 'unocss';
|
||||
import { defineConfig, presetWind3, presetAttributify, presetIcons, presetTypography, transformerDirectives } from 'unocss';
|
||||
import presetAnimations from 'unocss-preset-animations';
|
||||
|
||||
function createColorSchemeConfig(hueBaseVariable: string, hueOffset = 0) {
|
||||
@@ -37,24 +36,7 @@ export default defineConfig({
|
||||
presetTypography(),
|
||||
presetIcons({
|
||||
scale: 1.2,
|
||||
warn: true,
|
||||
}),
|
||||
presetWebFonts({
|
||||
fonts: {
|
||||
sans: {
|
||||
name: 'Normalized Quicksand',
|
||||
provider: 'none',
|
||||
},
|
||||
mono: {
|
||||
name: 'Maple Mono',
|
||||
provider: 'fontsource',
|
||||
},
|
||||
},
|
||||
processors: createLocalFontProcessor({
|
||||
cacheDir: 'node_modules/.cache/unocss/fonts',
|
||||
fontAssetsDir: 'public/assets/fonts/cache',
|
||||
fontServeBaseUrl: '/assets/fonts/cache',
|
||||
}),
|
||||
warn: false,
|
||||
}),
|
||||
],
|
||||
// By default, `.ts` and `.js` files are NOT extracted.
|
||||
@@ -70,6 +52,10 @@ export default defineConfig({
|
||||
'**/shadcn-ui/**/*.{vue,js,ts}',
|
||||
'**/ui/**/*.{vue,js,ts}',
|
||||
],
|
||||
exclude: [
|
||||
'src/lib/server/**/*',
|
||||
'**/*.server.ts',
|
||||
]
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
|
||||
Reference in New Issue
Block a user