@plotdb/lotion 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/vector.min.js ADDED
@@ -0,0 +1 @@
1
+ !function(){var C,A,t,e,n,r,R,B,w,o,v,L,P,x,T,z,I,i,m,a,l,s,f,u,h,F,g,W,j,N,b,y,c,S=window.lotion,d=S.libs;for(d.satori="https://cdn.jsdelivr.net/npm/satori@0.36.0/+esm",d.woff2="https://cdn.jsdelivr.net/npm/woff2-encoder@2.0.0/dist/decompress.js",d.hbsubset="https://cdn.jsdelivr.net/npm/harfbuzzjs@1.6.3/dist/harfbuzz-subset.wasm",C=["color","fontFamily","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","textAlign","whiteSpace","wordBreak","textTransform","textShadow","textIndent","backgroundColor","backgroundImage","backgroundPosition","backgroundSize","backgroundRepeat","backgroundClip","borderTopWidth","borderRightWidth","borderBottomWidth","borderLeftWidth","borderTopStyle","borderRightStyle","borderBottomStyle","borderLeftStyle","borderTopColor","borderRightColor","borderBottomColor","borderLeftColor","borderTopLeftRadius","borderTopRightRadius","borderBottomRightRadius","borderBottomLeftRadius","paddingTop","paddingRight","paddingBottom","paddingLeft","boxShadow","opacity","filter","clipPath","overflow","zIndex","transform","transformOrigin","maskImage","maskPosition","maskSize","maskRepeat"],A={textShadow:"none",textIndent:"0px",textTransform:"none",backgroundColor:"rgba(0, 0, 0, 0)",backgroundImage:"none",backgroundPosition:"0% 0%",backgroundSize:"auto",backgroundRepeat:"repeat",backgroundClip:"border-box",boxShadow:"none",opacity:"1",filter:"none",clipPath:"none",overflow:"visible",zIndex:"auto",transform:"none",maskImage:"none",maskPosition:"0% 0%",maskSize:"auto",maskRepeat:"repeat",lineHeight:"normal",letterSpacing:"normal"},t=0,e=(d=["Top","Right","Bottom","Left"]).length;t<e;++t)n=d[t],A["border"+n+"Width"]="0px",A["border"+n+"Style"]="none",A["padding"+n]="0px";for(t=0,e=(d=["TopLeft","TopRight","BottomRight","BottomLeft"]).length;t<e;++t)r=d[t],A["border"+r+"Radius"]="0px";function O(t,e){for(var n=-1,r=e.length>>>0;++n<r;)if(t===e[n])return 1}R=["color","fontFamily","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","textAlign","whiteSpace","wordBreak","textTransform","textShadow","textIndent"],B={backdropFilter:"none",textDecorationLine:"none"},w=function(t){for(var e,n=new Uint8Array(t),r="",o=0,i=n.length;o<i;o+=32768)e=o,r+=String.fromCharCode.apply(null,n.subarray(e,e+32768));return btoa(r)},o=function(t){return w((new TextEncoder).encode(t))},v=function(t){return"data:image/svg+xml;base64,"+o(t)},L=function(t){return t.replace(/url\("data:image\/svg\+xml(?:;charset=[^,;]+)?(?:;utf8)?,([^"]*)"\)/g,function(t,e){return'url("'+v((()=>{try{return decodeURIComponent(e)}catch(t){return e}})())+'")'})},P=function(t,e,n,r){var o,i;return null!=t.offsetWidth?(o=t.offsetLeft,i=t.offsetTop,t.offsetParent!==e&&e.offsetParent===t.offsetParent&&(o-=e.offsetLeft+e.clientLeft,i-=e.offsetTop+e.clientTop),[o,i,t.offsetWidth,t.offsetHeight]):(o=(e=[parseFloat(n.width)||0,parseFloat(n.height)||0])[0],i=e[1],"absolute"===(e=n.position)||"fixed"===e?[parseFloat(n.left)||0,parseFloat(n.top)||0,o,i]:(r.warn("<"+t.tagName.toLowerCase()+"> not absolutely positioned: placed at its parent's origin"),[0,0,o,i]))},x=["font-family","font-size","font-weight","font-style","letter-spacing","text-anchor","dominant-baseline","fill","fill-opacity","fill-rule","stroke","stroke-width","stroke-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","paint-order","opacity","visibility","display","filter","mask","clip-path","color"],T=function(t,e,n,h,c){var d,p,r,o,i,a,l,s,f,u,g,m,b,y=t.cloneNode(!0);for(y.setAttribute("width",e),y.setAttribute("height",n),y.getAttribute("viewBox")||y.setAttribute("viewBox","0 0 "+e+" "+n),y.removeAttribute("style"),e=[t].concat(Array.from(t.querySelectorAll("*"))),d=[y].concat(Array.from(y.querySelectorAll("*"))),p=[],e.map(function(t,e){for(var n,r,o,i=d[e],a=getComputedStyle(t),l=0===e?null:getComputedStyle(t.parentNode),s=[],f=0,u=(n=x).length;f<u;++f)r=n[f],o=a.getPropertyValue(r),l&&l.getPropertyValue(r)===o||(0!==e||"opacity"!==r&&"visibility"!==r&&"display"!==r&&"filter"!==r&&"mask"!==r&&"clip-path"!==r)&&s.push(r+":"+o);if(s.length&&i.setAttribute("style",s.join(";")),("text"===(n=t.tagName.toLowerCase())||"tspan"===n||"textpath"===n)&&!t.querySelector("tspan, textPath"))return h.text(a,t.textContent,t={}),p.push([i,t])}),r=new Set,o=0,i=d.length;o<i;++o)for(a=d[o],l=0,f=(s=Array.from(a.attributes)).length;l<f;++l)for(u=s[l],g=/url\(\s*['"]?#([^'")\s]+)['"]?\s*\)|^#(.+)$/g;m=g.exec(u.value);)r.add(m[1]||m[2]);return b=null,r.forEach(function(t){if(!y.querySelector('[id="'+CSS.escape(t)+'"]')&&(t=document.getElementById(t)))return b||(b=document.createElementNS("http://www.w3.org/2000/svg","defs"),y.insertBefore(b,y.firstChild)),b.appendChild(t.cloneNode(!0))}),h.later(function(t){for(var e,n,r,o,i,a,l,s,f=new Set,u=0,h=(e=p).length;u<h;++u)if(r=(n=e[u])[0],(o=n[1]).fontFamily)for(i=r.getAttribute("style")||"",r.setAttribute("style",i+";font-family:"+o.fontFamily),a=0,l=(n=o.fontFamily.split(",")).length;a<l;++a)s=n[a],f.add(s.trim().replace(/'/g,""));return(t=t.filter(function(t){return f.has(t.name)}).map(function(t){return"@font-face{font-family:'"+t.name+"';font-weight:"+t.weight+";font-style:"+t.style+";src:url(data:font/ttf;base64,"+w(t.data)+") format('truetype')}"})).length&&((i=document.createElementNS("http://www.w3.org/2000/svg","style")).textContent=t.join(""),y.insertBefore(i,y.firstChild)),c.props.src=v((new XMLSerializer).serializeToString(y))}),""},z=function(t,e){var n,r=t.props.style;return(r.maskImage||r.clipPath)&&r.transform&&((n=(n=/^matrix\(([^)]+)\)$/.exec(r.transform))?n[1].split(",").map(function(t){return+t}):null)&&1===n[0]&&0===n[1]&&0===n[2]&&1===n[3]?(r.left+=n[4],r.top+=n[5],delete r.transform,delete r.transformOrigin):e.warn("mask / clip-path with a non-translation transform: misplaced")),t},I=function(t,e,n){var r,o,i,a,l,s,f,u,h,c,d,p,g,m,b,y,w,v,x,S,_,k=0===(n=null==n?0:n);if("none"===(r=getComputedStyle(t)).display||0==+r.opacity)return null;for(h in o=t.tagName.toLowerCase(),a=(i=P(t,t.parentNode,r,e))[0],l=i[1],s=i[2],f=i[3],u="visible"!==r.visibility,i=B)c=i[h],r[h]&&r[h]!==c&&e.warn(h+": "+r[h]);for((d="normal"!==r.mixBlendMode?r.mixBlendMode:null)&&1!==n&&e.warn("mixBlendMode on a nested element: "+d+" ignored"),p=(S=Array.from(t.childNodes)).filter(function(t){return 3===t.nodeType&&t.textContent.trim()}),g=S.filter(function(t){return 1===t.nodeType}),p.length&&g.length&&e.warn("mixed text and elements in <"+o+">"),m={display:"flex",boxSizing:"border-box",width:s,height:f},k||(m.position="absolute",m.left=a,m.top=l),b=0,y=(i=C).length;b<y;++b)h=i[b],!p.length&&O(h,R)||(c=r[h])&&A[h]!==c&&(m[h]=/data:image\/svg/.test(c)?L(c):c);for(b=0,y=(i=["Top","Right","Bottom","Left"]).length;b<y;++b)m["border"+(w=i[b])+"Width"]||delete m["border"+w+"Color"];if(m.overflow&&(m.overflow="hidden"),m.transform?m.transformOrigin=r.transformOrigin:delete m.transformOrigin,u){for(b=0,y=(i=["backgroundColor","backgroundImage","boxShadow","color","textShadow"]).length;b<y;++b)delete m[h=i[b]];for(b=0,y=(i=["Top","Right","Bottom","Left"]).length;b<y;++b)delete m["border"+(w=i[b])+"Width"]}if("img"===o||"canvas"===o||"svg"===o)return u?null:((v={blend:1===n?d:null,type:"img",props:{width:s,height:f,style:m}}).props.src=(()=>{switch(o){case"img":return t.currentSrc||t.src;case"canvas":return t.toDataURL();case"svg":return T(t,s,f,e,v)}})(),z(v,e));for(x=[],p.length&&!u&&(S=p.map(function(t){return t.textContent}).join(""),e.text(r,S,m),x.push(S),k={center:"center",right:"flex-end",end:"flex-end"}[r.textAlign])&&!g.length&&(m.justifyContent=k),b=0,y=g.length;b<y;++b)_=g[b],(_=I(_,e,n+1))&&x.push(_);return v={type:"div",props:{style:m,children:x}},d&&1===n&&(v.blend=d),z(v,e)},i={},m=function(t){return t.trim().replace(/^["']|["']$/g,"")},a=function(t){return t?t.split(",").map(function(t){var e;return t=t.trim().replace(/^u\+/i,""),/\?/.test(t)?[parseInt(t.replace(/\?/g,"0"),16),parseInt(t.replace(/\?/g,"F"),16)]:(e=(t=t.split("-"))[0],t=t[1],[parseInt(e,16),parseInt(t||e,16)])}):[[0,1114111]]},l=function(t,e){for(var n,r=[],o=/url\(\s*(['"]?)([^'")]+)\1\s*\)\s*(?:format\(\s*['"]?([^'")]+)['"]?\s*\))?/g;n=o.exec(t);)try{r.push({url:new URL(n[2],e).href,format:(n[3]||n[2].split(".").pop()).toLowerCase()})}catch(t){}return r},s=function(t,e){return{family:m(t("font-family")),weight:(t("font-weight")||"400").split(/\s+/).map(function(t){return"normal"===t?400:"bold"===t?700:+t}),style:t("font-style")||"normal",range:a(t("unicode-range")),src:l(t("src"),e)}},f=function(t,n){return(t.match(/@font-face\s*\{[^}]*\}/g)||[]).map(function(e){return s(function(t){return(new RegExp("(?:^|[{;\\s])"+t+"\\s*:\\s*([^;}]+)").exec(e)||[])[1]},n)})},u=function(t){var e,n=t.href||location.href;try{e=t.cssRules}catch(t){e=null}return e?Promise.all(Array.from(e).map(function(e){return e.styleSheet?u(e.styleSheet):5===e.type?[s(function(t){return e.style.getPropertyValue(t)},n)]:[]})).then(function(t){return t.flat()}):t.href?fetch(t.href).then(function(t){return t.text()}).then(function(t){return f(t,n)}).catch(function(){return[]}):Promise.resolve([])},h=function(){return Promise.all(Array.from(document.styleSheets).map(u)).then(function(t){return t.flat()})},F=function(t){var e,n=t.find(function(t){return"truetype"===(t=t.format)||"opentype"===t||"woff"===t||"ttf"===t||"otf"===t})||t.find(function(t){return"woff2"===t.format});return n?null!=(e=i[t=n.url])?e:i[t]=fetch(n.url).then(function(t){return t.arrayBuffer()}).then(function(e){return"woff2"!==n.format?e:S.lib("woff2").then(function(t){return(t.decompress||t.default)(e)}).then(function(t){return t.buffer.slice(t.byteOffset,t.byteOffset+t.byteLength)})}):Promise.resolve(null)},g=function(){return fetch(S.libs.hbsubset).then(function(t){return t.arrayBuffer()}).then(function(t){return WebAssembly.instantiate(t,{})}).then(function(t){t=t.instance.exports;return t._initialize&&t._initialize(),t})},W=function(c,d,p){return g().then(function(t){var e,n,r,o,i,a,l,s,f,u=c.byteLength,h=t.malloc(u);for(new Uint8Array(t.memory.buffer).set(new Uint8Array(c),h),u=t.hb_blob_create(h,u,1,0,0),e=t.hb_face_create(u,0),t.hb_blob_destroy(u),u=t.hb_subset_input_create_or_fail(),n=t.hb_subset_input_unicode_set(u),r=0,i=(o=p).length;r<i;++r)a=o[r],t.hb_set_add(n,a);return t.hb_subset_input_pin_axis_location(u,e,2003265652,d),l=t.hb_subset_or_fail(e,u),t.hb_subset_input_destroy(u),u=null,l&&(s=t.hb_face_reference_blob(l),f=t.hb_blob_get_data(s,0),u=new Uint8Array(t.memory.buffer).slice(f,f+t.hb_blob_get_length(s)).buffer,t.hb_blob_destroy(s),t.hb_face_destroy(l)),t.hb_face_destroy(e),t.free(h),u})},j=function(t,e,n){return Math.min(n,Math.max(e,t))},N=function(t,e){var t=[t.weight[0],t.weight[1]||t.weight[0]],n=t[0],t=t[1];return n<=e&&e<=t?0:500<e?e<n?n-e:1e3+e-t:e<400?t<e?e-t:1e3+n-e:e<n&&n<=500?n-e:t<e?1e3+e-t:2e3+n-e},b=function(C,A){return h().then(function(t){var e,n,r,o,i,a,l,s,f,u,h,c,d,p,g,m,b,y,w={},v=0,x=0;for(e in n=C)for((r=n[e]).names=[],o=0,a=(i=Array.from(r.chars)).length;o<a;++o)if(g=i[o],!((l=g.codePointAt(0))<33)){for(s=null,f=0,h=(u=r.families).length;f<h;++f)if(c=u[f],(d=t.filter(S)).length){s=(d=(p=d.filter(_)).length?p:d).reduce(k);break}s?(g=j(r.weight,s.weight[0],s.weight[1]||s.weight[0]),m=s.src[0].url+"|"+r.weight+"|"+r.style,(m=null!=(u=w[m])?u:w[m]={name:"lotion-font-"+x++,face:s,weight:r.weight,pin:g,style:r.style,cps:new Set([32])}).cps.add(l),O(m.name,r.names)||r.names.push(m.name)):v++}return v&&A.warn(v+" characters have no web font ( system fonts are not available to satori )"),Promise.all((()=>{var t,e=[];for(b in t=w)y=t[b],e.push(y);return e})().map(function(e){return F(e.face.src).then(function(t){return t?W(t,e.pin,Array.from(e.cps)).then(function(t){return t?{name:e.name,data:t,weight:e.weight,style:e.style}:(A.warn("font "+e.face.family+": subset failed"),null)}):null})})).then(function(t){return t.filter(function(t){return t})});function S(t){return t.family===c&&t.src.length&&t.range.some(function(t){return l>=t[0]&&l<=t[1]})}function _(t){return t.style===r.style}function k(t,e){return N(e,r.weight)<N(t,r.weight)?e:t}})},y=function(t,r,o){var e,n,i,a,l,s=r.props.children;if(!s.some(function(t){return t.blend}))return t(r,o);for(e=[{kids:[]}],n=0,i=s.length;n<i;++n)(a=s[n]).blend?e.push({blend:a.blend,kids:[a]}):e[e.length-1].blend?e.push({kids:[a]}):e[e.length-1].kids.push(a);return l={display:"flex",width:o.width,height:o.height,overflow:"hidden"},Promise.all(e.map(function(e,n){return t({type:"div",props:{style:0===n?r.props.style:l,children:e.kids}},o).then(function(t){t=t.replace(/^<svg[^>]*>/,"").replace(/<\/svg>\s*$/,"").replace(/satori_/g,"satori_"+n+"_");return e.blend?'<g style="mix-blend-mode:'+e.blend+'">'+t+"</g>":"<g>"+t+"</g>"})})).then(function(t){return'<svg width="'+o.width+'" height="'+o.height+'" viewBox="0 0 '+o.width+" "+o.height+'" xmlns="http://www.w3.org/2000/svg">'+t.join("")+"</svg>"})},S.vector=c=function(t,u){var o,h,c,e,d,p,g;return null==u&&(u={}),o={},h={},c=[],d=I(t,e={warn:function(t){return o[t]=(o[t]||0)+1},later:function(t){return c.push(t)},text:function(t,e,n){for(var r,o,i=+t.fontWeight||400,a=t.fontFamily+"|"+i+"|"+t.fontStyle,l=null!=(r=h[a])?r:h[a]={families:t.fontFamily.split(",").map(m),weight:i,style:t.fontStyle,chars:new Set,styles:[]},s=0,f=(r=Array.from(e)).length;s<f;++s)o=r[s],l.chars.add(o);return l.styles.push(n)}}),p=u.width||t.offsetWidth,g=u.height||t.offsetHeight,(t=d.props.style).width=p,t.height=g,t.overflow="hidden",delete d.props.style.transform,delete d.props.style.transformOrigin,Promise.all([(t=u.satori)?Promise.resolve(t):S.lib("satori"),(t=u.fonts)?Promise.resolve(t):b(h,e)]).then(function(t){var e,n,r,o,i,a,l=t[0],s=t[1];if(!u.fonts)for(e in n=h)for(o=0,a=(i=(r=n[e]).styles).length;o<a;++o)i[o].fontFamily=r.names.map(f).join(", ");for(o=0,a=(n=c).length;o<a;++o)(0,n[o])(s);return t=l.default||l.satori||l,y(t,d,{width:p,height:g,fonts:s});function f(t){return"'"+t+"'"}}).then(function(t){var n,r;return{svg:t,warnings:(()=>{var t,e=[];for(n in t=o)r=t[n],e.push(n+(1<r?" ( x"+r+" )":""));return e})()}})},S.player.prototype.vector=function(e,t){var n=this;return null==t&&(t={}),null!=e&&this.opt.seek(e),c(this.stage,t).then(function(t){return null!=e&&n.opt.seek(n.t),t})}}.call(this);
package/audio/beats.py DELETED
@@ -1,93 +0,0 @@
1
- # 節拍分析: 估計 BPM 與第一拍位置, 輸出節拍格線 ( 整理自 0926-motion 的 bpm / grid / refine ).
2
- # python audio/beats.py <music> [--start s] [--dur s] [--min 70] [--max 180] [--out grid.json]
3
- # - BPM: onset 包絡的自相關, 同時考慮 1 / 2 / 4 倍拍長, 再以相位一致性細調到 0.005 BPM
4
- # - 第一拍: 在一個週期內掃描起點, 取節拍位置上包絡總和最大者 ( 以低頻為主 ); 並補償分析窗的延遲
5
- # - 小節起點: 比較 4 個相位上低頻 ( kick ) 的強度, 取最強者
6
- # - 限制: bassline 落在反拍、kick 又是切分時, 拍點可能偏半拍; 以 --shift 0.5 ( 單位: 拍 ) 校正
7
- # - 輸出: {bpm, offset, beats, downbeats}; 時間皆相對於 --start
8
- import numpy as np, argparse, json
9
- from common import load
10
-
11
- SR = 22050
12
- HOP = 256
13
- FPS = SR / HOP
14
-
15
- def frames(x, n=1024):
16
- m = (len(x) - n) // HOP
17
- return np.lib.stride_tricks.as_strided(x, (m, n), (x.strides[0] * HOP, x.strides[0])) * np.hanning(n)
18
-
19
- def onset_env(x):
20
- S = np.log1p(100 * np.abs(np.fft.rfft(frames(x), axis=1)))
21
- fl = np.maximum(0, np.diff(S, axis=0)).sum(1)
22
- fl = fl - np.convolve(fl, np.ones(32) / 32, 'same')
23
- return np.maximum(fl, 0)
24
-
25
- def low_env(x, cut=150):
26
- S = np.abs(np.fft.rfft(frames(x), axis=1))
27
- f = np.fft.rfftfreq(1024, 1 / SR)
28
- return np.maximum(0, np.diff(np.log1p(100 * S[:, f < cut]).sum(1)))
29
-
30
- def coarse_tempo(env, lo, hi):
31
- e = env - env.mean()
32
- ac = np.correlate(e, e, 'full')[len(e) - 1:]
33
- bpms = np.arange(lo, hi + 0.01, 0.1)
34
- def score(b):
35
- lag, s = 60 * FPS / b, 0
36
- for k in (1, 2, 4):
37
- L = lag * k; i = int(L); f = L - i
38
- if i + 1 < len(ac): s += (ac[i] * (1 - f) + ac[i + 1] * f) / k
39
- return s
40
- sc = np.array([score(b) for b in bpms])
41
- return bpms[sc.argmax()], sc.max() / ac[0]
42
-
43
- def fine_tempo(env, b0):
44
- t = np.arange(len(env)) / FPS
45
- bs = np.arange(b0 - 1, b0 + 1, 0.005)
46
- sc = [abs(sum(np.sum(env * np.exp(-2j * np.pi * (b / 60) * k * t)) for k in (1, 2, 4))) for b in bs]
47
- return bs[int(np.argmax(sc))]
48
-
49
- # 分析窗的延遲: diff 後第 i 個值反映的是第 i + 1 個窗, 窗的中心再晚半個窗長
50
- LAG = (HOP + 512) / SR
51
-
52
- def norm(v): return v / (v.max() + 1e-9)
53
-
54
- def grid(x, bpm):
55
- env = onset_env(x); low = low_env(x)
56
- m = min(len(env), len(low))
57
- # 拍點以低頻 ( kick / bass 起音 ) 為主, 避免被反拍的 hat 或 16 分音符拉偏
58
- e = norm(low[:m]) * 2 + norm(env[:m])
59
- t = np.arange(m) / FPS
60
- period = 60 / bpm
61
- idx = np.arange(m)
62
- def score(o): return np.interp(np.arange(o, t[-1] - 0.1, period) * FPS, idx, e).sum()
63
- cands = np.arange(0, period, 0.001)
64
- off = cands[int(np.argmax([score(o) for o in cands]))]
65
- beats = np.arange(off, t[-1] - 0.1, period)
66
- lb = np.interp(beats * FPS, np.arange(len(low)), low)
67
- phase = int(np.argmax([lb[i::4].mean() for i in range(4)]))
68
- beats = beats + LAG
69
- return beats, beats[phase::4]
70
-
71
- if __name__ == '__main__':
72
- ap = argparse.ArgumentParser(description='estimate bpm and beat grid of a music file')
73
- ap.add_argument('music')
74
- ap.add_argument('--start', type=float, default=0)
75
- ap.add_argument('--dur', type=float, default=None)
76
- ap.add_argument('--min', type=float, default=70)
77
- ap.add_argument('--max', type=float, default=180)
78
- ap.add_argument('--out', default=None, help='write grid json here')
79
- ap.add_argument('--shift', type=float, default=0, help='shift the grid by this many beats')
80
- a = ap.parse_args()
81
- x = load(a.music, sr=SR, start=a.start, dur=a.dur)
82
- b0, conf = coarse_tempo(onset_env(x), a.min, a.max)
83
- bpm = fine_tempo(onset_env(x), b0)
84
- beats, downbeats = grid(x, bpm)
85
- if a.shift:
86
- d = a.shift * 60 / bpm
87
- beats, downbeats = beats + d, downbeats + d
88
- print('bpm %.3f ( coarse %.1f, confidence %.2f ) first beat %.4fs first downbeat %.4fs beats %d'
89
- % (bpm, b0, conf, beats[0], downbeats[0], len(beats)))
90
- if a.out:
91
- json.dump({'bpm': round(float(bpm), 3), 'offset': round(float(beats[0]), 4),
92
- 'beats': [round(float(v), 4) for v in beats], 'downbeats': [round(float(v), 4) for v in downbeats]},
93
- open(a.out, 'w'), indent=1)
package/audio/bgm.py DELETED
@@ -1,181 +0,0 @@
1
- # 簡易背景音樂合成: 以重複的 bassline 為骨幹, 加上鼓組與和弦墊音. 只依賴 numpy.
2
- # python audio/bgm.py <out.wav> [--bpm 120] [--bars 16] [--key A] [--scale minor] [--prog 1,6,3,7]
3
- # [--bass pulse] [--drums four] [--pad] [--intro 2] [--outro 2] [--loop] [--grid grid.json]
4
- #
5
- # 設計:
6
- # - 和弦進行以音階級數表示 ( --prog 1,6,3,7 ), 每小節一個和弦, 循環使用
7
- # - bassline 是一小節 16 格的樣式 ( --bass ), 每格為相對於和弦根音的半音數, '.' 為休止; 每小節重複, 隨和弦移調
8
- # - 鼓組樣式 ( --drums ): kick / hat / clap 各 16 格
9
- # - 編排: 前 --intro 小節只有 bass 與 hat, 最後 --outro 小節逐漸收掉; --loop 時不做 intro / outro,
10
- # 並把超出尾端的音尾繞回開頭, 可無縫循環
11
- # - t = 0 即第一小節的第一拍, 方便與動畫時間軸對齊; --grid 會輸出節拍格線 ( 與 beats.py 格式相同 )
12
- import numpy as np, argparse, json
13
- from common import SR, write, normalize
14
-
15
- NOTE = {'C': 0, 'C#': 1, 'Db': 1, 'D': 2, 'D#': 3, 'Eb': 3, 'E': 4, 'F': 5, 'F#': 6, 'Gb': 6,
16
- 'G': 7, 'G#': 8, 'Ab': 8, 'A': 9, 'A#': 10, 'Bb': 10, 'B': 11}
17
- SCALE = {
18
- 'major': [0, 2, 4, 5, 7, 9, 11], 'minor': [0, 2, 3, 5, 7, 8, 10],
19
- 'dorian': [0, 2, 3, 5, 7, 9, 10], 'mixolydian': [0, 2, 4, 5, 7, 9, 10]
20
- }
21
- # bassline 樣式: 16 格, 數字為相對和弦根音的半音數, '.' 休止, '-' 延續前一音
22
- BASS = {
23
- 'pulse': '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0',
24
- 'octave': '0 . 12 . 0 . 12 . 0 . 12 . 0 . 12 .',
25
- 'house': '. . 0 . . . 0 . . . 0 . . . 0 12',
26
- 'drive': '0 . 0 12 . 0 7 . 0 . 0 12 . 7 5 .',
27
- 'walk': '0 - - - 7 - - - 12 - - - 7 - 5 -',
28
- }
29
- DRUMS = {
30
- 'four': {'kick': 'x...x...x...x...', 'hat': '..x...x...x...x.', 'clap': '....x.......x...'},
31
- 'half': {'kick': 'x.......x.......', 'hat': 'x.x.x.x.x.x.x.x.', 'clap': '........x.......'},
32
- 'broken': {'kick': 'x.....x...x.....', 'hat': '..x...x...x...xx', 'clap': '....x.......x..x'},
33
- 'none': {'kick': '................', 'hat': '................', 'clap': '................'},
34
- }
35
-
36
- rng = np.random.default_rng(3)
37
-
38
- def hz(midi): return 440 * 2 ** ((midi - 69) / 12)
39
-
40
- def env(n, a, d, sustain=0.0, r=0.0):
41
- """attack / 指數衰減至 sustain; 結尾 r 秒線性釋放"""
42
- t = np.arange(n) / SR
43
- e = (1 - np.exp(-t / max(a, 1e-4))) * (sustain + (1 - sustain) * np.exp(-t / d))
44
- if r > 0:
45
- k = min(n, int(r * SR)); e[n - k:] *= np.linspace(1, 0, k)
46
- return e
47
-
48
- def saw(f, n, detune=0.0):
49
- t = np.arange(n) / SR
50
- x = 2 * ((f * t) % 1) - 1
51
- if detune: x = 0.5 * x + 0.5 * (2 * ((f * (1 + detune) * t + 0.37) % 1) - 1)
52
- return x
53
-
54
- def lowpass(x, cutoff, q=0.9):
55
- """時變截止頻率的 state variable filter. cutoff 可為陣列 ( 每個 sample 一個值 )"""
56
- cutoff = np.broadcast_to(cutoff, x.shape)
57
- f = 2 * np.sin(np.pi * np.clip(cutoff, 20, SR / 6) / SR)
58
- low = band = 0.0
59
- y = np.empty_like(x)
60
- damp = 1 / q
61
- for i in range(len(x)):
62
- low += f[i] * band
63
- high = x[i] - low - damp * band
64
- band += f[i] * high
65
- y[i] = low
66
- return y
67
-
68
- def bass_note(midi, dur, accent=1.0):
69
- n = int(dur * SR)
70
- x = saw(hz(midi), n, detune=0.004) + 0.5 * np.sin(2 * np.pi * hz(midi - 12) * np.arange(n) / SR)
71
- cut = 180 + 1400 * accent * np.exp(-np.arange(n) / SR / 0.09)
72
- return lowpass(x, cut, q=1.4) * env(n, 0.003, 0.25, 0.55, 0.02) * 0.5
73
-
74
- def kick():
75
- n = int(0.35 * SR); t = np.arange(n) / SR
76
- f = 48 + 110 * np.exp(-t / 0.035)
77
- return np.sin(2 * np.pi * np.cumsum(f) / SR) * env(n, 0.001, 0.16) * 0.95
78
-
79
- def noise_hp(n, cut):
80
- X = np.fft.rfft(rng.standard_normal(n)); fq = np.fft.rfftfreq(n, 1 / SR)
81
- X[fq < cut] = 0
82
- y = np.fft.irfft(X, n); return y / (np.abs(y).max() + 1e-9)
83
-
84
- def hat(open_=False):
85
- n = int((0.18 if open_ else 0.05) * SR)
86
- return noise_hp(n, 7000) * env(n, 0.0005, 0.06 if open_ else 0.012) * 0.22
87
-
88
- def clap():
89
- n = int(0.22 * SR)
90
- x = noise_hp(n, 1200) * env(n, 0.001, 0.06)
91
- for d in (0.011, 0.022): # 拍手的多重起音
92
- k = int(d * SR); x[k:] += noise_hp(n - k, 1200) * env(n - k, 0.001, 0.012) * 0.6
93
- return x * 0.28
94
-
95
- def pad_chord(midis, dur):
96
- n = int(dur * SR)
97
- x = sum(saw(hz(m), n, detune=0.006) for m in midis) / len(midis)
98
- return lowpass(x, 900, q=0.7) * env(n, 0.35, 10, 0.9, 0.4) * 0.16
99
-
100
- def parse_steps(s):
101
- tok = s.split() if ' ' in s.strip() else list(s)
102
- if len(tok) != 16: raise SystemExit('pattern must have 16 steps: %r' % s)
103
- return tok
104
-
105
- if __name__ == '__main__':
106
- ap = argparse.ArgumentParser(description='synthesize a simple looping bassline track')
107
- ap.add_argument('out')
108
- ap.add_argument('--bpm', type=float, default=120)
109
- ap.add_argument('--bars', type=int, default=16)
110
- ap.add_argument('--key', default='A', help='tonic, e.g. A, C#, Eb')
111
- ap.add_argument('--octave', type=int, default=2, help='octave of the bass tonic')
112
- ap.add_argument('--scale', default='minor', choices=list(SCALE))
113
- ap.add_argument('--prog', default='1,6,3,7', help='chord degrees, one per bar, cycled')
114
- ap.add_argument('--bass', default='drive', help='preset (%s) or 16 custom steps' % ', '.join(BASS))
115
- ap.add_argument('--drums', default='four', choices=list(DRUMS))
116
- ap.add_argument('--pad', action='store_true', help='add sustained chord pad')
117
- ap.add_argument('--intro', type=int, default=2, help='bars with bass and hat only')
118
- ap.add_argument('--outro', type=int, default=2, help='bars fading out at the end')
119
- ap.add_argument('--loop', action='store_true', help='seamless loop: no intro / outro, tails wrap around')
120
- ap.add_argument('--swing', type=float, default=0, help='0 ~ 0.3, delays every second 16th')
121
- ap.add_argument('--grid', default=None, help='write beat grid json here')
122
- a = ap.parse_args()
123
-
124
- scale = SCALE[a.scale]
125
- tonic = 12 * (a.octave + 1) + NOTE[a.key]
126
- prog = [int(v) for v in a.prog.split(',')]
127
- bass = parse_steps(BASS.get(a.bass, a.bass))
128
- drums = {k: parse_steps(v) for k, v in DRUMS[a.drums].items()}
129
- intro, outro = (0, 0) if a.loop else (a.intro, a.outro)
130
-
131
- step = 60 / a.bpm / 4
132
- total = a.bars * 16 * step
133
- n = int(round(total * SR))
134
- out = np.zeros((n + int(SR * 2), 2), np.float32) # 多留 2 秒放音尾
135
-
136
- def place(x, t, gain=1.0, pan=0.0):
137
- s = int(round(t * SR)); e = s + len(x)
138
- l, r = np.cos((pan + 1) * np.pi / 4), np.sin((pan + 1) * np.pi / 4)
139
- out[s:e, 0] += x * gain * l * 1.414
140
- out[s:e, 1] += x * gain * r * 1.414
141
-
142
- def degree(d, shift=0):
143
- i = d - 1 + shift
144
- return scale[i % 7] + 12 * (i // 7)
145
-
146
- K, C = kick(), clap()
147
- for bar in range(a.bars):
148
- root = tonic + degree(prog[bar % len(prog)])
149
- t0 = bar * 16 * step
150
- # 編排: intro 只有 bass + hat; outro 逐小節降低音量
151
- level = 1.0 if bar < a.bars - outro else (a.bars - bar) / (outro + 1)
152
- full = bar >= intro
153
- if a.pad and full:
154
- d = prog[bar % len(prog)]
155
- chord = [tonic + 12 + degree(d), tonic + 12 + degree(d, 2), tonic + 12 + degree(d, 4)]
156
- place(pad_chord(chord, 16 * step), t0, level)
157
- for i in range(16):
158
- t = t0 + i * step + (a.swing * step if i % 2 else 0)
159
- s = bass[i]
160
- if s not in ('.', '-'):
161
- length = 1
162
- while i + length < 16 and bass[i + length] == '-': length += 1
163
- place(bass_note(root + int(s), length * step * 0.92, 1.0 if i % 4 == 0 else 0.6), t, level)
164
- if full and drums['kick'][i] == 'x': place(K, t, level)
165
- if full and drums['clap'][i] == 'x': place(C, t, level * 0.9, 0.15)
166
- if drums['hat'][i] == 'x': place(hat(), t, level * (1 if full else 0.7), -0.25)
167
-
168
- if a.loop:
169
- tail = out[n:].copy()
170
- out = out[:n]
171
- k = min(len(tail), n)
172
- out[:k] += tail[:k]
173
- else:
174
- tail = int(SR * 1.5)
175
- out = out[:n + tail]
176
- out[n:] *= np.linspace(1, 0, tail)[:, None]
177
- write(a.out, normalize(out * 0.9, 0.95))
178
- beats = [round(i * 4 * step, 4) for i in range(a.bars * 4)]
179
- print('%s %.1f bpm %d bars %.2fs key %s %s prog %s' % (a.out, a.bpm, a.bars, total, a.key, a.scale, a.prog))
180
- if a.grid:
181
- json.dump({'bpm': a.bpm, 'offset': 0, 'beats': beats, 'downbeats': beats[::4]}, open(a.grid, 'w'), indent=1)
package/audio/common.py DELETED
@@ -1,30 +0,0 @@
1
- # 音訊工具共用: 讀檔 ( 經 ffmpeg ) 與寫 wav. 只依賴 numpy.
2
- import numpy as np, subprocess, wave
3
-
4
- SR = 48000
5
-
6
- def load(path, sr=SR, start=0, dur=None, channels=1):
7
- """以 ffmpeg 解碼任意音訊檔, 回傳 float32 陣列 ( channels > 1 時為 (n, channels) )"""
8
- cmd = ['ffmpeg', '-v', 'quiet', '-ss', str(start)] + (['-t', str(dur)] if dur else []) + \
9
- ['-i', path, '-ac', str(channels), '-ar', str(sr), '-f', 'f32le', '-']
10
- x = np.frombuffer(subprocess.run(cmd, capture_output=True, check=True).stdout, dtype=np.float32)
11
- return x.reshape(-1, channels).copy() if channels > 1 else x.copy()
12
-
13
- def write(path, x, sr=SR):
14
- """寫入 16-bit wav. x 為 (n,) 或 (n, channels), 數值範圍 -1 ~ 1"""
15
- x = np.clip(np.asarray(x, dtype=np.float32), -1, 1)
16
- ch = 1 if x.ndim == 1 else x.shape[1]
17
- with wave.open(path, 'wb') as w:
18
- w.setnchannels(ch); w.setsampwidth(2); w.setframerate(sr)
19
- w.writeframes((x * 32767).astype('<i2').tobytes())
20
-
21
- def read_wav(path):
22
- """讀取 16-bit wav, 回傳 (資料, sr). 多聲道時為 (n, channels)"""
23
- with wave.open(path) as w:
24
- ch, sr = w.getnchannels(), w.getframerate()
25
- x = np.frombuffer(w.readframes(w.getnframes()), '<i2').astype(np.float32) / 32767
26
- return (x.reshape(-1, ch) if ch > 1 else x), sr
27
-
28
- def normalize(x, peak=0.98):
29
- m = np.abs(x).max()
30
- return x * (peak / m) if m > peak else x
package/audio/mix.py DELETED
@@ -1,51 +0,0 @@
1
- # 混音: 背景音樂 + 依 cue 放置的音效 ( 整理自 0926-motion ).
2
- # python audio/mix.py <cues.json> <out.wav> --dur s [--music f] [--start s] [--sfx dir] [--fade s] [--loop]
3
- # - cues.json: [{t, sfx, gain?}, ...], 通常由 `lotion cues` 從頁面匯出
4
- # - 音效以 sfx 目錄裡 peaks.json 量到的 transient peak 對齊 cue 時間, 而非檔案開頭 ( 見 sfx.py )
5
- # - --music: 任意音訊檔 ( 例如 bgm.py 的輸出 ); --start 為音樂中對齊 t = 0 的位置
6
- # - --loop: 超出尾端的音效回繞到開頭, 音訊可無縫循環; 否則尾端以 --fade 秒淡出
7
- import numpy as np, json, os, argparse
8
- from common import SR, load, read_wav, write, normalize
9
-
10
- ap = argparse.ArgumentParser(description='mix background music and cue-aligned sound effects')
11
- ap.add_argument('cues')
12
- ap.add_argument('out')
13
- ap.add_argument('--dur', type=float, required=True, help='length of the output in seconds')
14
- ap.add_argument('--music', default=None)
15
- ap.add_argument('--start', type=float, default=0, help='position in music aligned to t = 0')
16
- ap.add_argument('--sfx', default='sfx', help='directory of sfx wav files and peaks.json')
17
- ap.add_argument('--fade', type=float, default=0)
18
- ap.add_argument('--loop', action='store_true')
19
- ap.add_argument('--sfx-gain', type=float, default=0.55)
20
- ap.add_argument('--music-gain', type=float, default=0.8)
21
- a = ap.parse_args()
22
-
23
- n = int(SR * a.dur)
24
- mix = np.zeros((n, 2), np.float32)
25
- if a.music:
26
- music = load(a.music, start=a.start, dur=a.dur, channels=2)[:n]
27
- mix[:len(music)] = music * a.music_gain
28
-
29
- cues = json.load(open(a.cues))
30
- peaks = json.load(open(os.path.join(a.sfx, 'peaks.json'))) if cues else {}
31
- cache = {}
32
- for c in cues:
33
- name = c['sfx']
34
- if name not in cache:
35
- x, _ = read_wav(os.path.join(a.sfx, name + '.wav'))
36
- cache[name] = x if x.ndim == 1 else x.mean(1)
37
- x = cache[name] * c.get('gain', 1) * a.sfx_gain
38
- s = int(round((c['t'] - peaks.get(name, 0)) * SR))
39
- idx = s + np.arange(len(x))
40
- if a.loop: idx %= n
41
- else:
42
- m = (idx >= 0) & (idx < n); idx, x = idx[m], x[m]
43
- np.add.at(mix[:, 0], idx, x)
44
- np.add.at(mix[:, 1], idx, x)
45
-
46
- if a.fade > 0 and not a.loop:
47
- f = int(SR * a.fade)
48
- mix[n - f:] *= np.linspace(1, 0, f)[:, None]
49
- pk = float(np.abs(mix).max())
50
- write(a.out, normalize(mix))
51
- print('%s cues %d peak %.3f' % (a.out, len(cues), pk))
package/audio/sfx.py DELETED
@@ -1,93 +0,0 @@
1
- # UI 音效合成 ( 整理自 0926-motion ). 輸出 <outdir>/*.wav, 並量測每個檔的 transient peak 寫入 <outdir>/peaks.json
2
- # python audio/sfx.py <outdir> [--list]
3
- # peak 定義: 3ms RMS 包絡第一次達到全域最大 80% 的位置
4
- # - 多音符音效 ( chime / toggle ) 取第一個音的擊點, 而非較晚但較響的音
5
- # - 混音時以 peak 而非檔案開頭對齊 cue 時間 ( 見 mix.py )
6
- import numpy as np, json, os, argparse
7
- from common import SR, write
8
-
9
- rng = np.random.default_rng(7)
10
-
11
- def t_(dur): return np.arange(int(SR * dur)) / SR
12
- def env(t, a, d): return (1 - np.exp(-t / max(a, 1e-4))) * np.exp(-t / d)
13
- def bandnoise(n, lo, hi):
14
- x = rng.standard_normal(n)
15
- X = np.fft.rfft(x); f = np.fft.rfftfreq(n, 1 / SR)
16
- X[(f < lo) | (f > hi)] = 0
17
- y = np.fft.irfft(X, n); return y / (np.abs(y).max() + 1e-9)
18
- def sine(t, f, ph=0): return np.sin(2 * np.pi * f * t + ph)
19
- def pad(x, pre=0.0, post=0.0): return np.concatenate([np.zeros(int(SR * pre)), x, np.zeros(int(SR * post))])
20
-
21
- def click():
22
- t = t_(0.06)
23
- body = sine(t, 1850) * env(t, 0.0004, 0.006) * 0.7 + sine(t, 520) * env(t, 0.0008, 0.012) * 0.5
24
- return body + bandnoise(len(t), 2500, 9000) * env(t, 0.0002, 0.003) * 0.5
25
-
26
- def tick():
27
- t = t_(0.03)
28
- return sine(t, 3400) * env(t, 0.0003, 0.004) * 0.6 + bandnoise(len(t), 4000, 12000) * env(t, 0.0002, 0.0015) * 0.3
29
-
30
- def scrub():
31
- t = t_(0.015)
32
- return bandnoise(len(t), 5000, 12000) * env(t, 0.0002, 0.0012) * 0.5
33
-
34
- def pop():
35
- t = t_(0.14)
36
- f = 380 + 520 * np.exp(-t / 0.018)
37
- return np.sin(2 * np.pi * np.cumsum(f) / SR) * env(t, 0.001, 0.035) * 0.9
38
-
39
- def toggle():
40
- a = click() * 0.6
41
- t = t_(0.05)
42
- b = sine(t, 2600) * env(t, 0.0004, 0.005) * 0.6 + sine(t, 780) * env(t, 0.0006, 0.01) * 0.4
43
- out = np.zeros(int(SR * 0.12)); out[:len(a)] += a
44
- o = int(SR * 0.028); out[o:o + len(b)] += b
45
- return out
46
-
47
- def chime(notes, gap, dur=0.5):
48
- out = np.zeros(int(SR * (gap * len(notes) + dur)))
49
- for i, f in enumerate(notes):
50
- t = t_(dur)
51
- x = (sine(t, f) + 0.25 * sine(t, f * 2.01) + 0.08 * sine(t, f * 3.02)) * env(t, 0.002, 0.16)
52
- o = int(SR * gap * i); out[o:o + len(x)] += x * (0.8 if i == 0 else 0.65)
53
- return out * 0.45
54
-
55
- def success(): return chime([1318.5, 1975.5], 0.07)
56
- def notify(): return chime([1567.98, 2093.0, 2637.0], 0.06, 0.45)
57
-
58
- def typing():
59
- t = t_(0.05)
60
- return bandnoise(len(t), 1200, 6000) * env(t, 0.0003, 0.005) * 0.55 + sine(t, 210) * env(t, 0.001, 0.01) * 0.35
61
-
62
- def whoosh():
63
- t = t_(0.32)
64
- n = bandnoise(len(t), 300, 4000)
65
- e = np.sin(np.pi * np.clip(t / 0.32, 0, 1)) ** 2.5
66
- return n * e * 0.18
67
-
68
- SFX = dict(click=click, tick=tick, scrub=scrub, pop=pop, toggle=toggle, success=success, notify=notify,
69
- type=typing, whoosh=whoosh)
70
-
71
- def peak(x):
72
- win = int(SR * 0.003)
73
- e = np.sqrt(np.convolve(x * x, np.ones(win) / win, 'same'))
74
- i = int(np.argmax(e >= 0.8 * e.max()))
75
- # 往後找到該段的局部最大
76
- while i + 1 < len(e) and e[i + 1] >= e[i]: i += 1
77
- return i
78
-
79
- if __name__ == '__main__':
80
- ap = argparse.ArgumentParser(description='synthesize ui sound effects')
81
- ap.add_argument('outdir')
82
- ap.add_argument('--list', action='store_true', help='only list available effects')
83
- a = ap.parse_args()
84
- if a.list:
85
- print(' '.join(SFX)); raise SystemExit
86
- os.makedirs(a.outdir, exist_ok=True)
87
- peaks = {}
88
- for name, fn in SFX.items():
89
- x = pad(fn(), 0.005, 0.02)
90
- write(os.path.join(a.outdir, name + '.wav'), x)
91
- peaks[name] = round(peak(x) / SR, 5)
92
- json.dump(peaks, open(os.path.join(a.outdir, 'peaks.json'), 'w'), indent=1)
93
- print(json.dumps(peaks, indent=1))