Spaces:
Running
Running
推送#7(最终版):移动端音频内存优化、AAC/Opus 兼容提示、完整使用说明与资料来源
Browse files
README.md
CHANGED
|
@@ -30,7 +30,7 @@ pinned: false
|
|
| 30 |
| 8 | 导出选项 | 分辨率 2K / 1080P / 720P;帧率 24 / 30 / 60;是否包含背景音乐;横屏/竖屏 | ✅ |
|
| 31 |
| 9 | 可复制的标题、描述、标签 | 小红书 / 抖音 / 微信视频号 / 哔哩哔哩 四平台文案,带字数与话题数校验、一键复制 | ✅ |
|
| 32 |
| 10 | 封面 4:3、3:4、16:9 多张可多选 | 每个比例 4 种设计(共 12 张),Canvas 绘制,勾选后批量下载 PNG | ✅ |
|
| 33 |
-
| 11 | 每个大动作推送到 HF Spaces 同步进度 | `huggingface_hub.upload_folder` 推送静态构建产物 |
|
| 34 |
|
| 35 |
### 平台文案约束
|
| 36 |
1. 小红书:标题 ≤ 20 字,话题 ≤ 10 个
|
|
@@ -115,6 +115,44 @@ fonts/*.woff2 ← 子集化字体(思源宋体 Noto Serif SC + 马
|
|
| 115 |
- [x] 推送 #1:计划写入 README,清空旧仓库
|
| 116 |
- [x] 推送 #2:旁白文案定稿(27 段),edge-tts 生成中文男声旁白(zh-CN-YunjianNeural,语速 -6%),按词边界自动对齐字幕时间轴,全片约 5 分 41 秒;多音字(相邦/所好/所不为/乐之)用同音字替换保证读音正确
|
| 117 |
- [x] 推送 #3:Canvas 渲染引擎 + 27 个场景全部绘制完成(水墨晕染、眼睛成形、竹简展开、咸阳城门、八角星盘、八观动态母题:光芒/阶梯/金流/声波足迹/明月/双圆/寒梅/风竹,六验情绪场与心电波形、六戚四隐关系网、雨中行人、历史长卷、铜镜),网页播放器
|
|
|
|
| 118 |
- [x] 推送 #6:四平台发布文案面板(小红书/抖音/视频号/B站,每平台 3 个备选标题 + 描述 + 话题,实时字数/话题数校验,一键复制);封面生成器(4 种设计 × 16:9 / 4:3 / 3:4,可多选比例与单张,批量下载 PNG);性能优化(书法大字渲染缓存、预览分辨率上限)
|
| 119 |
- [x] 推送 #5:浏览器本地导出 MP4(WebCodecs H.264 High + AAC/Opus,mp4-muxer 封装;2K/1080P/720P × 24/30/60fps × 是否含背景音乐 × 横/竖屏 × 是否烧录字幕;大文件优先直写磁盘),竖屏 9:16 自适应版式(手机竖屏访问自动切换)。已在无头浏览器实测导出 720p30 与 2K60 竖屏文件并用 ffmpeg 校验可正常解码
|
| 120 |
- [x] 推送 #4:程序化配乐与音效——五声音阶(羽调式)古琴 Karplus-Strong 拨弦、带限波表 Pad、FM 钟磬、低鼓/心跳、颤音弦、雨声、转场风声;Freeverb 混响;旁白闪避(ducking)。为提升速度,从 WebAudio 节点图改为 Float32 直接 DSP 合成(页面加载数秒内完成)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
| 8 | 导出选项 | 分辨率 2K / 1080P / 720P;帧率 24 / 30 / 60;是否包含背景音乐;横屏/竖屏 | ✅ |
|
| 31 |
| 9 | 可复制的标题、描述、标签 | 小红书 / 抖音 / 微信视频号 / 哔哩哔哩 四平台文案,带字数与话题数校验、一键复制 | ✅ |
|
| 32 |
| 10 | 封面 4:3、3:4、16:9 多张可多选 | 每个比例 4 种设计(共 12 张),Canvas 绘制,勾选后批量下载 PNG | ✅ |
|
| 33 |
+
| 11 | 每个大动作推送到 HF Spaces 同步进度 | `huggingface_hub.upload_folder` 推送静态构建产物(共 7 次推送) | ✅ |
|
| 34 |
|
| 35 |
### 平台文案约束
|
| 36 |
1. 小红书:标题 ≤ 20 字,话题 ≤ 10 个
|
|
|
|
| 115 |
- [x] 推送 #1:计划写入 README,清空旧仓库
|
| 116 |
- [x] 推送 #2:旁白文案定稿(27 段),edge-tts 生成中文男声旁白(zh-CN-YunjianNeural,语速 -6%),按词边界自动对齐字幕时间轴,全片约 5 分 41 秒;多音字(相邦/所好/所不为/乐之)用同音字替换保证读音正确
|
| 117 |
- [x] 推送 #3:Canvas 渲染引擎 + 27 个场景全部绘制完成(水墨晕染、眼睛成形、竹简展开、咸阳城门、八角星盘、八观动态母题:光芒/阶梯/金流/声波足迹/明月/双圆/寒梅/风竹,六验情绪场与心电波形、六戚四隐关系网、雨中行人、历史长卷、铜镜),网页播放器
|
| 118 |
+
- [x] 推送 #7(最终版):手机/低内存设备自动降采样合成音轨(防止移动端内存不足)、导出时 AAC 不可用自动提示并回退 Opus、合成过程分段让出主线程(进度实时更新)、移除外部字体依赖,完成全部自测(桌面/手机 UI、四平台文案校验、封面批量下载、MP4 导出与 ffmpeg 解码校验)
|
| 119 |
- [x] 推送 #6:四平台发布文案面板(小红书/抖音/视频号/B站,每平台 3 个备选标题 + 描述 + 话题,实时字数/话题数校验,一键复制);封面生成器(4 种设计 × 16:9 / 4:3 / 3:4,可多选比例与单张,批量下载 PNG);性能优化(书法大字渲染缓存、预览分辨率上限)
|
| 120 |
- [x] 推送 #5:浏览器本地导出 MP4(WebCodecs H.264 High + AAC/Opus,mp4-muxer 封装;2K/1080P/720P × 24/30/60fps × 是否含背景音乐 × 横/竖屏 × 是否烧录字幕;大文件优先直写磁盘),竖屏 9:16 自适应版式(手机竖屏访问自动切换)。已在无头浏览器实测导出 720p30 与 2K60 竖屏文件并用 ffmpeg 校验可正常解码
|
| 121 |
- [x] 推送 #4:程序化配乐与音效——五声音阶(羽调式)古琴 Karplus-Strong 拨弦、带限波表 Pad、FM 钟磬、低鼓/心跳、颤音弦、雨声、转场风声;Freeverb 混响;旁白闪避(ducking)。为提升速度,从 WebAudio 节点图改为 Float32 直接 DSP 合成(页面加载数秒内完成)
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## 七、使用说明
|
| 126 |
+
|
| 127 |
+
1. **观看**:打开 Space 页面,等待「正在合成配乐与旁白」进度走完(电脑约 2~5 秒),点击画面播放。空格键播放/暂停,← → 快退快进,右下角可开关字幕、背景音乐、全屏。右侧「章节」可跳转。
|
| 128 |
+
2. **横屏 / 竖屏**:页面右上角切换 16:9 / 9:16;手机竖屏访问自动进入竖屏版式(同一套代码自适应重排,不是简单裁切)。
|
| 129 |
+
3. **导出 MP4**:右侧「导出视频」→ 选方向、分辨率(2K/1080P/720P)、帧率(24/30/60)、是否导出背景音乐、是否烧录字幕 → 「导出 MP4」。全程在浏览器本地逐帧渲染编码,不上传任何数据。建议使用电脑版 Chrome / Edge(支持 AAC 音频);大文件会先弹出保存对话框直接写入磁盘。
|
| 130 |
+
4. **发布文案**:右侧「发布文案」→ 选平台 → 点选标题、复制描述与话题,或一键复制全部;标题字数和话题数实时校验。
|
| 131 |
+
5. **封面**:右侧「封面」→ 勾选比例(16:9 / 4:3 / 3:4,可多选)与具体封面(每个比例 4 张)→ 下载所选 PNG(1920×1080 / 1440×1080 / 1080×1440)。
|
| 132 |
+
|
| 133 |
+
## 八、目录
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
index.html, assets/ 构建后的网页(React + TypeScript + Vite)
|
| 137 |
+
audio/ 27 段旁白 MP3(edge-tts 生成)
|
| 138 |
+
fonts/ 子集化字体:思源宋体(400/700)、马善政毛笔楷书(均为 SIL OFL 开源协议)
|
| 139 |
+
source/frontend/src/ 全部源码
|
| 140 |
+
video/timeline.json 旁白时间轴与字幕
|
| 141 |
+
video/data.ts 八观 / 六验 / 章节 / 人物长卷等内容数据
|
| 142 |
+
video/core.ts 噪声、缓动、文字特效、书法揭示、印章、粒子等绘制基础
|
| 143 |
+
video/scenes.ts 27 个场景的编排
|
| 144 |
+
video/motifs.ts 八观母题与六验情绪场
|
| 145 |
+
video/renderer.ts 逐帧渲染总控(背景、转场、字幕、章节标)
|
| 146 |
+
video/audio.ts 程序化配乐、音效、混响、闪避与播放器
|
| 147 |
+
video/exporter.ts WebCodecs + mp4-muxer 导出
|
| 148 |
+
video/covers.ts 封面绘制
|
| 149 |
+
video/publish.ts 四平台文案
|
| 150 |
+
source/tools/ 旁白生成(gen_vo.py)、字体子集化(make_fonts.py)脚本
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
## 九、资料来源
|
| 154 |
+
|
| 155 |
+
- 《吕氏春秋·季春纪·论人》原文与译文:易文言 ewenyan.com、吕氏春秋网 lvshichunqiu.com、百度百科「八观六验」「六戚四隐」
|
| 156 |
+
- 《吕氏春秋》成书与结构:维基百科、百度百科、汉典古籍
|
| 157 |
+
- 「一字千金」:《史记·吕不韦列传》
|
| 158 |
+
- 同源识人思想:《论语·为政》、《史记·魏世家》(李克五视)、《庄子·列御寇》(九征)、诸葛亮《心书·知人性》(知人七法)
|
assets/{App-C7Fp2SME.js → App-CRC37evO.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/{index-BEN5pyZ1.js → index-B3wvZbI9.js}
RENAMED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./index-Br42UDbl.js","./index-BlGQAcOD.js","./client-BlQxAbrl.js","./index-tKuM8enU.js","./App-
|
| 2 |
-
(function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))d(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const s of t.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&d(s)}).observe(document,{childList:!0,subtree:!0});function i(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function d(e){if(e.ep)return;e.ep=!0;const t=i(e);fetch(e.href,t)}})();const E="modulepreload",v=function(r,o){return new URL(r,o).href},w={},y=function(o,i,d){let e=Promise.resolve();if(i&&i.length>0){let s=function(n){return Promise.all(n.map(u=>Promise.resolve(u).then(p=>({status:"fulfilled",value:p}),p=>({status:"rejected",reason:p}))))};const l=document.getElementsByTagName("link"),f=document.querySelector("meta[property=csp-nonce]"),h=(f==null?void 0:f.nonce)||(f==null?void 0:f.getAttribute("nonce"));e=s(i.map(n=>{if(n=v(n,d),n in w)return;w[n]=!0;const u=n.endsWith(".css"),p=u?'[rel="stylesheet"]':"";if(!!d)for(let m=l.length-1;m>=0;m--){const g=l[m];if(g.href===n&&(!u||g.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${n}"]${p}`))return;const c=document.createElement("link");if(c.rel=u?"stylesheet":E,u||(c.as="script"),c.crossOrigin="",c.href=n,h&&c.setAttribute("nonce",h),document.head.appendChild(c),u)return new Promise((m,g)=>{c.addEventListener("load",m),c.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${n}`)))})}))}function t(s){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=s,window.dispatchEvent(l),!l.defaultPrevented)throw s}return e.then(s=>{for(const l of s||[])l.status==="rejected"&&t(l.reason);return o().catch(t)})},_="__dep_reload",L=6,O=6e4;function P(){try{return JSON.parse(sessionStorage.getItem(_)||"{}")}catch{return{c:0,t:0}}}async function R(){const r=document.getElementById("root");try{let o=function(){if(!document.querySelector("[data-surf-placeholder]"))try{window.parent.postMessage({type:"surf-app-ready"},"*")}catch{}};const i=await y(()=>import("./index-Br42UDbl.js").then(a=>a.i),__vite__mapDeps([0,1]),import.meta.url);if(typeof i.useState!="function")throw new Error("dep-stub");const{createElement:d}=i,{createRoot:e,hydrateRoot:t}=await y(async()=>{const{createRoot:a,hydrateRoot:c}=await import("./client-BlQxAbrl.js").then(m=>m.c);return{createRoot:a,hydrateRoot:c}},__vite__mapDeps([2,1]),import.meta.url),{QueryClient:s,QueryClientProvider:l}=await y(async()=>{const{QueryClient:a,QueryClientProvider:c}=await import("./index-tKuM8enU.js");return{QueryClient:a,QueryClientProvider:c}},__vite__mapDeps([3,0,1]),import.meta.url),{default:f}=await y(async()=>{const{default:a}=await import("./App-
|
|
|
|
| 1 |
+
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./index-Br42UDbl.js","./index-BlGQAcOD.js","./client-BlQxAbrl.js","./index-tKuM8enU.js","./App-CRC37evO.js","./jsx-dev-runtime-Czb3f8pQ.js","./ErrorBoundary-DwultlP_.js"])))=>i.map(i=>d[i]);
|
| 2 |
+
(function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))d(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const s of t.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&d(s)}).observe(document,{childList:!0,subtree:!0});function i(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function d(e){if(e.ep)return;e.ep=!0;const t=i(e);fetch(e.href,t)}})();const E="modulepreload",v=function(r,o){return new URL(r,o).href},w={},y=function(o,i,d){let e=Promise.resolve();if(i&&i.length>0){let s=function(n){return Promise.all(n.map(u=>Promise.resolve(u).then(p=>({status:"fulfilled",value:p}),p=>({status:"rejected",reason:p}))))};const l=document.getElementsByTagName("link"),f=document.querySelector("meta[property=csp-nonce]"),h=(f==null?void 0:f.nonce)||(f==null?void 0:f.getAttribute("nonce"));e=s(i.map(n=>{if(n=v(n,d),n in w)return;w[n]=!0;const u=n.endsWith(".css"),p=u?'[rel="stylesheet"]':"";if(!!d)for(let m=l.length-1;m>=0;m--){const g=l[m];if(g.href===n&&(!u||g.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${n}"]${p}`))return;const c=document.createElement("link");if(c.rel=u?"stylesheet":E,u||(c.as="script"),c.crossOrigin="",c.href=n,h&&c.setAttribute("nonce",h),document.head.appendChild(c),u)return new Promise((m,g)=>{c.addEventListener("load",m),c.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${n}`)))})}))}function t(s){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=s,window.dispatchEvent(l),!l.defaultPrevented)throw s}return e.then(s=>{for(const l of s||[])l.status==="rejected"&&t(l.reason);return o().catch(t)})},_="__dep_reload",L=6,O=6e4;function P(){try{return JSON.parse(sessionStorage.getItem(_)||"{}")}catch{return{c:0,t:0}}}async function R(){const r=document.getElementById("root");try{let o=function(){if(!document.querySelector("[data-surf-placeholder]"))try{window.parent.postMessage({type:"surf-app-ready"},"*")}catch{}};const i=await y(()=>import("./index-Br42UDbl.js").then(a=>a.i),__vite__mapDeps([0,1]),import.meta.url);if(typeof i.useState!="function")throw new Error("dep-stub");const{createElement:d}=i,{createRoot:e,hydrateRoot:t}=await y(async()=>{const{createRoot:a,hydrateRoot:c}=await import("./client-BlQxAbrl.js").then(m=>m.c);return{createRoot:a,hydrateRoot:c}},__vite__mapDeps([2,1]),import.meta.url),{QueryClient:s,QueryClientProvider:l}=await y(async()=>{const{QueryClient:a,QueryClientProvider:c}=await import("./index-tKuM8enU.js");return{QueryClient:a,QueryClientProvider:c}},__vite__mapDeps([3,0,1]),import.meta.url),{default:f}=await y(async()=>{const{default:a}=await import("./App-CRC37evO.js");return{default:a}},__vite__mapDeps([4,5,1,0]),import.meta.url),{default:h}=await y(async()=>{const{default:a}=await import("./ErrorBoundary-DwultlP_.js");return{default:a}},__vite__mapDeps([6,5,1,0]),import.meta.url),n=new s({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:3,retryDelay:a=>Math.min(1e3*2**a,1e4),staleTime:3e4}}}),u=d(h,null,d(l,{client:n},d(f))),p=!!r.querySelector("[data-surf-placeholder]");r.childNodes.length>0&&r.innerHTML!=="<!--ssr-outlet-->"&&!p?t(r,u):(r.innerHTML="",e(r).render(u)),window.__reactOk=!0,sessionStorage.removeItem(_),o(),new MutationObserver(o).observe(r,{childList:!0,subtree:!0})}catch{const o=P(),i=Date.now()-o.t>O?0:o.c;i<L?(r.innerHTML=['<div style="padding:24px;text-align:center;font-family:system-ui,sans-serif">','<p style="color:#3b82f6;font-weight:600;margin:0 0 4px">Loading dependencies...</p>','<p style="color:#3b82f6;opacity:0.7;font-size:12px;margin:0">Reloading automatically</p>',"</div>"].join(""),sessionStorage.setItem(_,JSON.stringify({c:i+1,t:Date.now()})),setTimeout(()=>location.reload(),3e3+i*1e3)):r.innerHTML=['<div style="padding:24px;text-align:center;font-family:system-ui,sans-serif">','<p style="color:#c0392b;font-weight:600;margin:0 0 4px">Failed to load dependencies</p>','<p style="color:#c0392b;opacity:0.8;font-size:12px;margin:0">Please refresh the page</p>',"</div>"].join("")}}R();export{y as _};
|
index.html
CHANGED
|
@@ -58,7 +58,7 @@
|
|
| 58 |
<script>
|
| 59 |
document.documentElement.classList.add('dark');
|
| 60 |
</script>
|
| 61 |
-
<script type="module" crossorigin src="./assets/index-
|
| 62 |
<link rel="stylesheet" crossorigin href="./assets/index-BT71Y65q.css">
|
| 63 |
</head>
|
| 64 |
<body>
|
|
|
|
| 58 |
<script>
|
| 59 |
document.documentElement.classList.add('dark');
|
| 60 |
</script>
|
| 61 |
+
<script type="module" crossorigin src="./assets/index-B3wvZbI9.js"></script>
|
| 62 |
<link rel="stylesheet" crossorigin href="./assets/index-BT71Y65q.css">
|
| 63 |
</head>
|
| 64 |
<body>
|
source/frontend/src/components/ExportPanel.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useEffect, useRef, useState } from 'react'
|
| 2 |
import { Download, Loader2, X } from 'lucide-react'
|
| 3 |
-
import { Soundtrack } from '@/video/audio'
|
| 4 |
import { ExportProgress, Res, exportMp4, exportSupported } from '@/video/exporter'
|
| 5 |
import { DURATION } from '@/video/data'
|
| 6 |
|
|
@@ -36,7 +36,7 @@ export default function ExportPanel({ soundtrack, portrait }: { soundtrack: Soun
|
|
| 36 |
const [aac, setAac] = useState<boolean | null>(null)
|
| 37 |
useEffect(() => {
|
| 38 |
if (!supported) return
|
| 39 |
-
AudioEncoder.isConfigSupported({ codec: 'mp4a.40.2', sampleRate:
|
| 40 |
.then((r) => setAac(!!r.supported))
|
| 41 |
.catch(() => setAac(false))
|
| 42 |
}, [supported])
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from 'react'
|
| 2 |
import { Download, Loader2, X } from 'lucide-react'
|
| 3 |
+
import { SR, Soundtrack } from '@/video/audio'
|
| 4 |
import { ExportProgress, Res, exportMp4, exportSupported } from '@/video/exporter'
|
| 5 |
import { DURATION } from '@/video/data'
|
| 6 |
|
|
|
|
| 36 |
const [aac, setAac] = useState<boolean | null>(null)
|
| 37 |
useEffect(() => {
|
| 38 |
if (!supported) return
|
| 39 |
+
AudioEncoder.isConfigSupported({ codec: 'mp4a.40.2', sampleRate: SR, numberOfChannels: 2, bitrate: 192000 })
|
| 40 |
.then((r) => setAac(!!r.supported))
|
| 41 |
.catch(() => setAac(false))
|
| 42 |
}, [supported])
|
source/frontend/src/video/audio.ts
CHANGED
|
@@ -5,7 +5,13 @@ import { cueAt, rand } from './core'
|
|
| 5 |
import { DURATION, SCENES, Scene, sceneById } from './data'
|
| 6 |
import { assetUrl } from './fonts'
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export interface Soundtrack {
|
| 11 |
music: AudioBuffer // stereo: score
|
|
@@ -536,13 +542,14 @@ function buildSfx(bus: Bus) {
|
|
| 536 |
|
| 537 |
// ------------------------------------------------------------------ streaming stereo reverb (Freeverb-style)
|
| 538 |
async function reverbInto(inL: Float32Array, inR: Float32Array | null, outL: Float32Array, outR: Float32Array, wet: number, room = 0.86, damp = 0.28) {
|
| 539 |
-
const
|
| 540 |
-
const
|
|
|
|
| 541 |
const mk = (n: number) => ({ b: new Float32Array(n), i: 0, f: 0 })
|
| 542 |
const cl = CB.map(mk),
|
| 543 |
-
cr = CB.map((n) => mk(n + 23))
|
| 544 |
const al = AP.map(mk),
|
| 545 |
-
ar = AP.map((n) => mk(n + 23))
|
| 546 |
const len = outL.length
|
| 547 |
const g = 0.018 * wet
|
| 548 |
// one-pole high-pass on the send (~180 Hz) keeps the tail clear of low rumble
|
|
|
|
| 5 |
import { DURATION, SCENES, Scene, sceneById } from './data'
|
| 6 |
import { assetUrl } from './fonts'
|
| 7 |
|
| 8 |
+
/** Sample rate: full quality on desktop, lighter on phones / low-memory devices (keeps memory in check). */
|
| 9 |
+
export const SR = (() => {
|
| 10 |
+
if (typeof navigator === 'undefined') return 44100
|
| 11 |
+
const dm = (navigator as unknown as { deviceMemory?: number }).deviceMemory
|
| 12 |
+
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
| 13 |
+
return mobile || (dm !== undefined && dm <= 4) ? 24000 : 44100
|
| 14 |
+
})()
|
| 15 |
|
| 16 |
export interface Soundtrack {
|
| 17 |
music: AudioBuffer // stereo: score
|
|
|
|
| 542 |
|
| 543 |
// ------------------------------------------------------------------ streaming stereo reverb (Freeverb-style)
|
| 544 |
async function reverbInto(inL: Float32Array, inR: Float32Array | null, outL: Float32Array, outR: Float32Array, wet: number, room = 0.86, damp = 0.28) {
|
| 545 |
+
const sc = SR / 44100
|
| 546 |
+
const CB = [1116, 1188, 1277, 1356, 1422, 1491].map((n) => Math.round(n * sc))
|
| 547 |
+
const AP = [556, 441, 341].map((n) => Math.round(n * sc))
|
| 548 |
const mk = (n: number) => ({ b: new Float32Array(n), i: 0, f: 0 })
|
| 549 |
const cl = CB.map(mk),
|
| 550 |
+
cr = CB.map((n) => mk(n + Math.round(23 * sc)))
|
| 551 |
const al = AP.map(mk),
|
| 552 |
+
ar = AP.map((n) => mk(n + Math.round(23 * sc)))
|
| 553 |
const len = outL.length
|
| 554 |
const g = 0.018 * wet
|
| 555 |
// one-pole high-pass on the send (~180 Hz) keeps the tail clear of low rumble
|