Ethscriptions commited on
Commit
ca7db71
·
verified ·
1 Parent(s): 2551439

推送#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-C7Fp2SME.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-C7Fp2SME.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 _};
 
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-BEN5pyZ1.js"></script>
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: 44100, numberOfChannels: 2, bitrate: 192000 })
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
- export const SR = 44100
 
 
 
 
 
 
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 CB = [1116, 1188, 1277, 1356, 1422, 1491]
540
- const AP = [556, 441, 341]
 
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