Topppy's Blog

FE developer

背景:

  1. The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page, 由于IOS的特殊性,audioContext的初始state=’suspended’, 不会播放声音;必须通过用户交互的回调中ctx.resume()来解锁ctx才能播放。
  2. 某iphone11+IOS14.7.1手机,在客户端播放声音的同时打开webview自动播放audioContext会没有声音,检测ctx.state = ‘interrupted’,同时客户端的声音也没有正常播放。

debug:

  1. 取消客户端声音播放调用,webview正常播放
  2. 修改客户端策略 独占 => 混合, 客户端和webview都正常播放

一些AI答疑:
在iOS 11及以上版本的Webview中,AudioContext的创建和启动必须在用户手势的上下文中进行。这是因为iOS 11引入了一种安全机制,以防止Web应用程序在没有用户交互的情况下自动播放音频。如果你尝试在没有用户交互的情况下创建或启动AudioContext,则会出现The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page.错误。

需要注意的是,iOS 11及以上版本的Webview中,AudioContext的限制不仅限于decodeAudioData方法,而是适用于所有涉及音频播放的API,包括HTMLMediaElement和Web Audio API等。因此,如果你的Web应用程序需要在iOS 11及以上版本的Webview中播放音频,你需要在用户手势的上下文中创建和启动AudioContext。

在iOS的Webview中,如果音频上下文(AudioContext)被中断,它的state属性会返回interrupted。音频上下文可能会被中断,例如当用户接收电话或者切换到其他应用程序时。在这种情况下,音频上下文会暂停,直到用户返回到Web应用程序并重新激活音频上下文。

在iOS的Webview中,如果使用solo策略播放音频,可能会导致AudioContext被中断,从而导致AudioContext.state返回interrupted。solo策略是指在播放音频时,将其他正在播放的音频暂停,以确保当前音频的播放效果最佳。在iOS的Webview中,如果使用solo策略播放音频,可能会导致其他正在播放的音频被暂停,从而导致音频硬件资源被占用,进而导致AudioContext无法正常工作,进而导致AudioContext.state返回interrupted。

在iOS中,音频播放有两种策略:独占策略和混合策略。

独占策略是指在播放音频时,将其他正在播放的音频暂停,以确保当前音频的播放效果最佳。这种策略适用于需要保证音频播放效果的场景,例如游戏、音乐播放器等。在iOS中,如果使用独占策略播放音频,可能会导致其他正在播放的音频被暂停,从而影响用户体验。

混合策略是指在播放音频时,将当前音频与其他正在播放的音频混合在一起播放。这种策略适用于需要同时播放多个音频的场景,例如语音聊天、多人游戏等。在iOS中,如果使用混合策略播放音频,可以同时播放多个音频,从而提高用户体验。

在iOS中,如果想要使用独占策略播放音频,你可以使用AVAudioSession类来设置音频会话的模式为AVAudioSessionCategoryPlayback,例如:

1
2
3
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, options: [])
try session.setActive(true)

)
这个代码会将音频会话的模式设置为AVAudioSessionCategoryPlayback,从而使用独占策略播放音频。注意,这个代码只是示例,你需要根据你的具体情况进行修改。

如果想要使用混合策略播放音频,你可以使用AVAudioSession类来设置音频会话的模式为AVAudioSessionCategoryAmbient,例如:

1
2
3
let session = AVAudioSession.sharedInstance()
try session.setCategory(.ambient, mode: .default, options: [])
try session.setActive(true)

)
这个代码会将音频会话的模式设置为AVAudioSessionCategoryAmbient,从而使用混合策略播放音频。注意,这个代码只是示例,你需要根据你的具体情况进行修改。

一些相关参考:
https://forum.gamemaker.io/index.php?threads/html5-web-audio-context-issues-on-ios.86787/
https://github.com/WebAudio/web-audio-api/issues/2392

背景

抓到video标签的url在network中请求多次,不同range。
先了解一下下mp4的结构,这里使用meidaParser打开mp4文件
image

可以看到是嵌套的类似文件目录的格式,但是呢是有先后顺序的。
如果把整个mp4看做一本书的话,其中Moov 类似于整本书的目录,mdat就是占这本书99%以上的书的具体内容,也就是视频的具体内容。

如果moov排在mdat后面的话,浏览器第一次请求的时候,发现诶,我没有拿到书本的目录,没法播放视频,就会直接从视频最后的moov部分,等拿到moov之后,再重新播放视频。

所以,moov前置就会避免多余的请求次数,浏览器一开始就能拿到视频目录moov,就能立刻开始知道怎么播放了。

简介

弹幕互动游戏 是近年来在游戏(误:直播)行业中越来越受到欢迎的游戏形式。这种游戏通过收集玩家的弹幕信息,将其实时显示在游戏画面中,增加了互动性和趣味性,在抖音、B站等直播平台,目前已经有很多高人气的弹幕互动类游戏。其中既有第三方开发的也有平台自身研发的。

image


特点

弹幕互动游戏最大的特点就是弹幕互动。传统的游戏模式往往是单向的,玩家只是被动地接受游戏的内容。而弹幕互动游戏则不同,玩家可以在游戏中发射弹幕,通过与其他玩家互动,增加了游戏的趣味性和互动性。此外,弹幕互动游戏还具有以下特点:多样化的游戏模式、实时互动的体验、全球玩家的互动等。


游戏模式多样化:

  1. 玩家阵营对抗,用户通过弹幕选择阵营,生成AI小兵做阵营对抗,点赞 or 消费不同金额的礼物可以获取额外的优势(氪金外挂),帮助己方阵营获胜。eg.抖音《森林派对》

image

  1. 玩家与主播同阵营,对抗第三方:AI角色/障碍,
  2. 玩家对抗主播:玩家通过弹幕生产AI角色/障碍来阻碍主播获得胜利。eg。《是兄弟就来砍我》

image

  1. 主播间的对抗,结合2、3玩法,玩家可以选择帮助自己支持的直播间,给同阵营主播提供帮助,给对方阵营主播使绊子,以达到己方获胜的目的。如果说1、2、3更多像一个单机游戏,那么4更像一个网游。
  2. 主播授权给观众操作传统游戏角色的权利,有点儿偏向于社会学实验的性质,操作难度极高,最早是在国外游戏直播平台twitch上出现,代表案例:累计有超过百万名观众通过弹幕参与通关神奇宝贝,后来国内主播也有效仿之作,比如B站的万人原神:

https://www.bilibili.com/video/BV1xQ4y1Q7CU/?vd_source=13a87a9b97c2b7b5b32c8f91714ede90


实时又不“实时”


传统游戏直播模式,

以玩家作为信息的接收方为主,部分主播会制定自己的私人规则,来提升玩家的参与度,比如:

  1. 礼物贡献高的玩家可以直接参与游戏(多人网游场景

image

  1. 主播阅读弹幕互动,“谢谢xxx送的xxxx” 🔥

互动弹幕游戏模式

虽然弹幕互动游戏声称自己是实时的,但是直播弹幕互动实际上是高延迟的一个操作。具体体现在几个阶段:

  1. 用户收到主播游戏画面的延迟
  2. 主播延迟收到用户弹幕
  3. 弹幕作用于游戏的效果再通过直播流延迟播放给用户

用户完成一次弹幕交互,至少需要3次通信,而且是远远滞后的。

image

这就限制了弹幕互动游戏的种类,高实时操作性的游戏,在弹幕互动场景下变成了hard模式,这个一会我们可以体验一下。


我们来试着整一个直播弹幕互动游戏玩一下

几个要素

  • 直播
  • 弹幕
  • 游戏
  • 弹幕和游戏的连接

我们以B站为例


直播


使用b站的官方直播软件:(目前不兼容非M1的mac)

哔哩哔哩直播姬下载

实际上,B站自己的直播端在直播游戏这个场景不太好用,亲测同设备的情况下,为满足直播+游戏性能,清晰度很低画质很烂。


OBS+第三方插件 + b站直播服务器地址和推流码

在B站开启直播间后,可以在个人中心:我的直播间,看到服务器地址和推流码

image

在obs的直播设置中填写服务器地址和推流码

Untitled

OBS 提供了捕捉

  • 窗口
  • 网页
  • 屏幕

等能力,所以我们可以采取的方案可以有

  • 自己开发一个网页,在网页中订阅弹幕+控制游戏,OBS捕捉该网页
  • 自己开发一个弹幕订阅+ 执行native方法的软件, native游戏【客户端同学可以尝试一下

下面分别说一下弹幕和游戏的part

弹幕


b站开放了主播直播端的插件开发开放平台

哔哩哔哩直播开放平台

开发者可以

  1. 申请开发密钥
  2. 开发互动应用
  3. 上架B站的商城

哔哩哔哩饭贩


有了密钥之后

获取直播间弹幕数据

image

B站给开发这提供了获取直播间数据的流程和demo代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122

import asyncio
import json
import websockets
import requests
import time
import hashlib
import hmac
import random
from hashlib import sha256
import proto

class BiliClient:
def __init__(self, roomId, key, secret, host = 'live-open.biliapi.com'):
self.roomId = roomId
self.key = key
self.secret = secret
self.host = host
pass

# 事件循环
def run(self):
loop = asyncio.get_event_loop()
websocket = loop.run_until_complete(self.connect())
tasks = [
asyncio.ensure_future(self.recvLoop(websocket)),
asyncio.ensure_future(self.heartBeat(websocket)),
]
loop.run_until_complete(asyncio.gather(*tasks))

# http的签名
def sign(self, params):
key = self.key
secret = self.secret
md5 = hashlib.md5()
md5.update(params.encode())
ts = time.time()
nonce = random.randint(1,100000)+time.time()
md5data = md5.hexdigest()
headerMap = {
"x-bili-timestamp": str(int(ts)),
"x-bili-signature-method": "HMAC-SHA256",
"x-bili-signature-nonce": str(nonce),
"x-bili-accesskeyid": key,
"x-bili-signature-version": "1.0",
"x-bili-content-md5": md5data,
}

headerList = sorted(headerMap)
headerStr = ''

for key in headerList:
headerStr = headerStr+ key+":"+str(headerMap[key])+"\n"
headerStr = headerStr.rstrip("\n")

appsecret = secret.encode()
data = headerStr.encode()
signature = hmac.new(appsecret, data, digestmod=sha256).hexdigest()
headerMap["Authorization"] = signature
headerMap["Content-Type"] = "application/json"
headerMap["Accept"] = "application/json"
return headerMap

# 获取长链信息
def websocketInfoReq(self, postUrl, params):
headerMap = self.sign(params)
r = requests.post(url=postUrl, headers=headerMap, data=params, verify=False)
data = json.loads(r.content)
print(data)
return "ws://" + data['data']['host'][0]+":"+str(data['data']['ws_port'][0])+"/sub", data['data']['auth_body']

# 长链的auth包
async def auth(self, websocket, authBody):
req = proto.Proto()
req.body = authBody
req.op = 7
await websocket.send(req.pack())
buf = await websocket.recv()
resp = proto.Proto()
resp.unpack(buf)
respBody = json.loads(resp.body)
if respBody["code"] != 0:
print("auth 失败")
else:
print("auth 成功")

# 长链的心跳包
async def heartBeat(self, websocket):
while True:
await asyncio.ensure_future(asyncio.sleep(20))
req = proto.Proto()
req.op = 2
await websocket.send(req.pack())
print("[BiliClient] send heartBeat success")

# 长链的接受循环
async def recvLoop(self, websocket):
print("[BiliClient] run recv...")
while True:
recvBuf = await websocket.recv()
resp = proto.Proto()
resp.unpack(recvBuf)

async def connect(self):
postUrl = "https://%s/v1/common/websocketInfo"%self.host
params = '{"room_id":%s}'%self.roomId
addr, authBody = self.websocketInfoReq(postUrl, params)
print(addr, authBody)
websocket = await websockets.connect(addr)
await self.auth(websocket, authBody)
return websocket

if __name__=='__main__':
try:
cli = BiliClient(
roomId = 23105976,
key = "",
secret = "",
host = "live-open.biliapi.com")
cli.run()
except Exception as e:
print("err", e)

参考这个流程那么互动弹幕的核心逻辑就是:

  1. 获取ws地址端口
  2. 建立ws链接
  3. 收发数据
  4. 解析出弹幕
  5. 执行游戏指令/操作

我们可以看一下效果

image

这里演示的是开源项目https://github.com/xfgryujk/blivechat的本地python服务器,这里就是实现了上述流程(mock版本)

如果我们把room ID换成B站线上正在开播的直播间ID,同样可以抓到弹幕信息。

好,弹幕我们已经搞到了,下一步,选择游戏

游戏

这里为了对比出效果,我选择了两类游戏, 实时操作类 和非实时解谜类,代表作


网页版红白机游戏

网页版红白机游戏的基本原理

  • JavaScript NES 模拟器
  • 加载 .nes 文件
  • 注册事件监听
  • 循环frameTicker
    • 使用canvas渲染每一帧
    • audioContext播放音频采样

我们以模拟器https://github.com/bfirsh/jsnes 为例

核心使用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 实例化NES模拟器
this.nes = new NES({
onFrame: this.screen.setBuffer, // canvas
onStatusUpdate: console.log,
onAudioSample: this.speakers.writeSample, // 音频
sampleRate: this.speakers.getSampleRate()
});

// 事件
this.gamepadController = new GamepadController({
onButtonDown: this.nes.buttonDown,
onButtonUp: this.nes.buttonUp
});

this.keyboardController = new KeyboardController({
onButtonDown: this.gamepadController.disableIfGamepadEnabled(
this.nes.buttonDown
),
onButtonUp: this.gamepadController.disableIfGamepadEnabled(
this.nes.buttonUp
)
});

// Load keys from localStorage (if they exist)
this.keyboardController.loadKeys();
document.addEventListener("keydown", this.keyboardController.handleKeyDown);
document.addEventListener("keyup", this.keyboardController.handleKeyUp);
document.addEventListener(
"keypress",
this.keyboardController.handleKeyPress
);

// 加载.nes:ROM
this.nes.loadROM(this.props.romData);

其Web UI

image

好我们目前至少跑起来了一个游戏了,下一步


如何把游戏跟弹幕连接起来

一个思路:解析弹幕执行游戏指令

红白机游戏的游戏内只有6个控制键

  • up
  • down
  • left
  • right
  • A
  • B

游戏外当然还有start\pause等(暂时先不管

在js的NES 模拟器中,这些控制键被映射成为了键盘的的按键

image

我们要做的就是

  • 收到用户’wasd’弹幕
  • 解析出来每个字符
  • 模拟触发浏览器的键盘事件
  • 控制游戏

遇到了第一个问题:

为了方便插拔游戏,我把游戏加载在iframe中,遇到了iframe跨域问题,无法获取iframe的内容窗口并派发键盘事件,这个解决方案非常常见就是使用postMessage

在弹幕订阅页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import KEY_MAP from '../keyboard'
/**
* 忍者神龟4等NES游戏
*/

const delay = sec => new Promise(resolve => setTimeout(resolve, sec))

export default class TurtleTrigger {
constructor() {
this.reg = /([A-Za-z0-9])/g
// iframe
this.dom = document.getElementById('iframeContain').contentWindow
this.processing = false
}

// 发送模拟键盘事件给iframe
_run = async key => {
const evtOpt = KEY_MAP[key.toUpperCase()]
this.dom.postMessage({ key: 'keydown', opt: evtOpt }, "*")
return new Promise(resolve => {
setTimeout(() => {
this.dom.postMessage({ key: 'keyup', opt: evtOpt }, "*")
resolve()
}, 100)
})
}

// 弹幕处理函数
process = async danmu => {
if (this.processing) {
console.log('trigger proccessing')
return
}
this.processing = true

// 正则把字母提取出来
const matched = danmu.match(this.reg)
console.log('matched', matched)
if (!matched) {
this.processing = false
return false
}
// console.log('run matched', matched)

// 逐一执行
for (const value of matched) {
await this._run(value)
await delay(30)
}
this.processing = false
}
}

在NES游戏页面

1
2
3
4
5
6
7
8
9
componentDidMount() {
window.addEventListener('message', e => {
console.log('msg=====',e.data)
const { key ,opt} = e.data
const evt = new KeyboardEvent(key, opt)
document.dispatchEvent(evt)
})
}


超级玛丽

这个游戏遇到了一个问题,超级玛丽中,长按和短按事有不同效果的

  • 短按jump,跳得矮
  • 长按jump:跳得高
  • 短按方向键:短暂拥有一下下加速度
  • 长按方向键:一直加速到最高

而游戏中关卡被设计得是必须长按才能过去的,因此这里处理弹幕到时候,得实现长按效果

image

思路:

合并相同key,延长按压时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

// 发送模拟键盘事件给iframe
_run = async(key, duration = 1) => {
const evtOpt = KEY_MAP[key.toUpperCase()]
this.dom.postMessage({ key: 'keydown', opt: evtOpt }, "*")
return new Promise(resolve => {
setTimeout(() => {
this.dom.postMessage({ key: 'keyup', opt: evtOpt }, "*")
resolve()
// 可调节按压时长
}, duration * 100)
})
}

// 合并相同按键
sumSame(chars) {
const bucket = []
let temp = {
key: chars[0],
count: 1
}
let i = 1
while (i <= chars.length - 1) {
if (temp.key === chars[i]) {
temp.count++
} else {
bucket.push(temp)
temp = {
key: chars[i],
count: 1
}
}
i++
}
bucket.push(temp)
console.log(bucket)
return bucket
}

process = async danmu => {
// ...
if (this.mergeSameKey) {
const sum = this.sumSame(matched)
for (const value of sum) {
console.log(value)
await this._run(value.key, value.count)
await delay(30)
}
}
// ...
}


扫雷

模式是类似的

  • ws订阅
  • 弹幕提取
  • 发消息给游戏窗口

不同的点在于

image

扫雷的操作方式:

  • 左键点击坐标:翻开
  • 右键点击格子:插旗

这里如果转化为弹幕操作我们需要提取三个数据

  • 操作类型: left or right
  • x坐标
  • y坐标

首先设定弹幕格式为4部分,

  • 首字符L或者R,表示操作类型
  • 数字表示x坐标
  • 空格,用来分割两个数字
  • 数字表示y坐标
1
2
3
L0 0
R0 1

那么整体的代码流程就很清晰了


代码

弹幕订阅器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

/**
* 扫雷
*/

export default class SweeperTrigger {
constructor(props) {
this.reg = /^([lLRr])([0-9]+)\s([0-9]+)/
this.dom = document.getElementById('iframeContain').contentWindow
this.processing = false
this.mergeSameKey = (props && props.mergeSameKey) || false
}

_run = async(key, x, y) => {
console.log('_run', key, x, y)
this.dom.postMessage({ key: key, opt: [x, y] }, "*")
}

process = async danmu => {
if (this.processing) {
console.log('trigger proccessing')
return
}
this.processing = true

const matched = this.reg.exec(danmu)
console.log('matched', danmu, matched)
// 非法过滤
if (!matched
|| matched.length !== 4
|| !['L', 'l', 'R', 'r'].includes(matched[1])
|| isNaN(parseInt(matched[2]))
|| isNaN(parseInt(matched[3]))) {
console.log('非法指令')
this.processing = false
return false
}

await this._run(matched[1].toUpperCase(), parseInt(matched[2]), parseInt(matched[3]))

this.processing = false
}
}

游戏页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


componentDidMount() {
window.addEventListener('message', (e) => {
const { key, opt } = e.data
if (!['L', 'R'].includes(key)) return
console.log('msg=====', e.data)
// 边界检测
if (opt[0] < 0 || opt[0] > this.props.rowNum || opt[1] < 0 || opt[1] > this.props.rowNum) {
return
}
// 左键右键
if (key === 'L') {
this.handleSquareClick(opt[1], opt[0])
} else {
this.handleSquareContextMenu(opt[1], opt[0])
}
})
}

总结&展望

web的互动游戏可以分为三层结构

  • 订阅层:处理ws等链接
  • Trigger 层: 根据每个游戏设定的规则来处理弹幕转化为游戏指令
  • 游戏层:接受指令,执行操作,渲染游戏

未来发展中,可以探索的几个方向

  • 抽离专门的Trigger编辑器,支持自定义规则
  • 开发Native Trigger,接入单机游戏尝试
  • 针对直播弹幕场景专门设计游戏:既有的游戏无法很好的适配弹幕互动长江,导致很多体验是有问题的
  • 互动游戏的平衡性设计
    • 多人参与感,而不是头部用户
    • 避免金钱至上,保证直播间流量

猜测思路:

  1. drawio保存时:png图片中额外保存了xml格式的图表信息,
  2. drawio导入自己导出的png图片的时候,解析xml数据并替换png绘制图表到工作区

在阅读到这篇文章后由了验证的方法

PNG文件格式详解

image

首先使用工具https://www.nayuki.io/page/png-file-chunk-inspector 解码分析png的数据

image

可以看到文本信息数据块tEXt(textual data)内有xml数据,

我们复制出来Text string的值,经过decodeURIComponent处理后的到

image

这段应该就是drawio保存的图表信息

如何实现读取和写入

如果需要使用 nodejs 对指定区进行修改和提取,则可以利用 png-chunks-encode
 和 png-chunks-extract

  • 读取本地文件
  • 提取tEXtchunk内容
  • 修改并写入png
  • 保存为png图片

DEMO

https://codesandbox.io/s/magicpng-y27un0

附录

xml在线format:https://jsonformatter.org/xml-editor

pngchunk分析:https://www.nayuki.io/page/png-file-chunk-inspector

pngcheck:

pngcheck Home Page

png文件格式详解:https://blog.mythsman.com/post/5d2d62b4a2005d74040ef7eb/

背景

dts文件or 一些ts的types声明缺少注释的情况下,不仅可读性差,也无法体验到vscode智能提示的优势。

考虑自己写一个eslint rule,大概思路是解析AST的一些节点前面没有comments就报错,。

具体执行步骤是先看看官方的eslint(ts的)开发文档,看一下别的comments相关的rules是怎么写的,有了基础的了解之后,评估是否有可行的方案,并设计方案 & 写代码实现。

学习准备

eslint插件开发文档:https://eslint.org/docs/latest/developer-guide/working-with-rules#contextoptions
typescript-eslint插件开发文档:https://typescript-eslint.io/docs/development/custom-rules
typescript的AST playground:https://typescript-eslint.io/play/#ts=4.7.2&sourceType=module&showAST=ts

开发项目

https://github.com/Topppy/require-dts-comment

背景

  • 基于乐谱的音符时值和音高生成平滑曲线路径
  • 以乐谱时间为x,沿路径运动动画

anime.js

animejs提供了svg路径动画能力,svg的点坐标是基于路径的长度获取的,整体的运动函数是线性函数,也就是说:

1
2
3
4
f(x,y) = path.getPointAtLength(distance) 
distance = totalLength * t / duration = g(totalLength, t)
f(x,y) = path.getPointAtLength(totalLength * t / duration)
f(x,y) = p(totalLength, t)

时间t0点的 x坐标 取决于 路径总长度。不同乐谱在相同t0时间点的x坐标是不一致的。

image

图中红色为基于路径总长度的svg动画,黑色为linear直线动画,可以看到在当前时间点,x是对不上的。

而我们需要的音乐路径动画,是无论是什么乐谱,在t0点的x坐标是一致的

即: y = pathPoint(x) = g(x) ; x = maxX * t/duration = f(t);

1
2
3
4
5
y = pathPoint(x) = g(x)  
x = maxX * t/ duration
maxX = step * duration
x = step * t
x = f(t)

或者降级为:音符内不严格 x=f(t), 每个音符结束的时间点符合 x = f(t);

实现正比于x|t的路径动画

切分乐谱的总svg路径,每个音符的svg为一个path,每个path生成一个路径动画,duration = 音符持续时间,全部动画拼成一个timeline,总时长= 乐谱时长, (中间休止符需要补全直线?)

image

图中蓝色块为每个音符起止时间点与x对应的动画(音符区间内x、t不严格对应)

demo:

https://codepen.io/Topppy/pen/dymGGrb

  • 块1是x线性t的位移
  • 块2是一整条svg路径动画
  • 块3是两段path拼接的一条svg路径

块1作为基准,可以看出块2全程x都与块1无法对齐,块2在中间点和结束点是对齐的,但是中间的不规则路径跟块1的x位移没有对齐,点击seek1按钮定位时间到中间点,可以明显看出来。

块3是可以降级满足音乐旋律路径走谱的。

根据乐谱绘制平滑旋律路径

乐谱中需要占据时间的节点有两种

  • 音符(svg C)
  • 休止符(svg L/H),(也可以忽略休止符)

数据处理逻辑:(具体可以参考d3)

  • 点数据: 合并有序数组音符(notes) 和 休止符(rests)
  • C曲线控制点数据: (这里percentage = 0.5)
    • 控制点1: [x0 + 0.5 * (x1-x0), y0]
    • 控制点2: [x0 + 0.5 * (x1-x0), y1]
  • svgPathList:
    • 如果onset=0的点不是note, 需要从(0,0)开始
    • 终点(score.duration, 0)

IMG_6772

曲线的的曲率变化

对称:p1.x =distance(x1,x2)- p2.x

非对称: p1.x = p2.x

也可以独立控制两个控制点的x的percentage

IMG_6771

最后demo:

https://codepen.io/Topppy/pen/JjLGmBd?editors=1010

参考

svg路径编辑器

SvgPathEditor

d3的curve效果:https://github.com/d3/d3-shape/blob/v3.1.0/README.md#curves

d3的natural curve 源码:https://github.com/d3/d3-shape/blob/main/src/curve/natural.js

web audio 调节播放音频的音高 : https://zpl.fi/pitch-shifting-in-web-audio-api/

midi文件格式解析:https://www.jianshu.com/p/59d74800b43b

Magenta魔改记-2:数据格式与数据集(涉及midi和mxl格式对比https://zhuanlan.zhihu.com/p/49539387

乐谱渲染:https://github.com/0xfe/vexflow

mxl乐谱渲染:https://github.com/opensheetmusicdisplay/opensheetmusicdisplayddemo

demo页:https://opensheetmusicdisplay.org/demos/public-typescript-demo/

❤️‍🔥基于机器学习的自动作曲:https://magenta.tensorflow.org/

https://magenta.tensorflow.org/demos/web/

https://github.com/magenta/magenta

music21 指南:计算音乐学分析python库:https://github.com/lukewys/Magenta-Modification/blob/master/Music21简明指南.ipynb

背景:

最近发现drawio的一些功能很黑魔法。
比如首次创建一个文件,浏览器系统弹窗保存到本地,(到这为止都是常规保存文件的套路,很好实现)。
接下来,你在浏览器中继续绘制编辑你创建的图表,ctrol+S 保存之后,神奇的事情发生了,没有任何系统弹窗,最新的内容已经被静默地写入了首次创建的文件中。

这个行为是有点反直觉的,相当于在无感知的情况下,直接读写系统文件。浏览器一般不会给这么大的权限。

如何实现

如果有,也只能是浏览器提供的能力,于是,找到了这个 API: FileSystemWritableFileStream

https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream

兼容性很不好:

image

思路:

  1. 首次保存创造了一个FileSystemFileHandle对象:newHandle,提供对该读写文件的能力。
  2. 更新文件内容的时候,通过FileSystemFileHandle创造一个对该文件写入流 :writableStream。
  3. 使用writableStream.write 写入最新的文本Blob,(注意此处是覆盖式的)。
  4. 关闭流

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>file save</title>
<style>
#addText {
width: 400px;
height: 200px;
}
</style>
</head>
<body>
<h1>保存为本地文件并持续实时保存最新编辑的内容</h1>
<h4>text to save:</h4>
<div>
<textarea id="addText" name="addText">hello</textarea>
</div>
<button onclick="saveFile()">1. start save</button>
<button onclick="updateFile()">2. update</button>
<h4>file contents:</h4>
<p id="fileContent"></p>
<script>
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
let $textToAdd;
let $fileContent;
let newHandle;

const pickerOpts = {
types: [
{
description: "Text file",
accept: {
"text/plain": [".txt"],
},
},
],
suggestedName: "testFile",
excludeAcceptAllOption: true,
multiple: false,
};

async function saveFile() {
// create a new handle
console.log("showSaveFilePicker");
newHandle = await window.showSaveFilePicker(pickerOpts);
console.log("createWritable");
// create a FileSystemWritableFileStream to write to
const writableStream = await newHandle.createWritable();

updateFile();
}

async function getFileContents() {
const fileData = await newHandle.getFile();
const res = await fileData.text();
console.log("fileText: ", res);
$fileContent.innerText = res;
}

async function updateFileContent(text) {
// create a FileSystemWritableFileStream to write to
const writableStream = await newHandle.createWritable();

const data = new Blob([text], { type: "text" });
// write our file
await writableStream.write(data);

// close the file and write the contents to disk.
await writableStream.close();
}

async function updateFile() {
const text = $textToAdd.value;
await updateFileContent(text);
await getFileContents();
}

document.addEventListener("DOMContentLoaded", () => {
$textToAdd = document.getElementById("addText");
$fileContent = document.getElementById("fileContent");
});
</script>
</body>
</html>

发散一下:可以利用这个特性进行攻击吗?

比如前端通过js改写下载文件的内容。

可以继续研究一下这个API有什么安全限制。

https://juejin.cn/post/7086054628294557733

hook return component

首先在react中:

  • React Component == UI
  • React Hook == behavior/Logic

hook的设计之初的几个点:

  • 解决“reuse stateful logic between components”的问题
  • 允许按照功能划分复杂逻辑,而不是按照生命周期混在一起

在hook出现之前,我们只可以将UI抽离成可以复用的component,但是相似的state和loggic还需要写很多遍。hook之后我们解决了这个问题。

通常在业务中,UI和behavior是耦合的,强相关的,比如isOpen和closeModal通常跟Modal 组件强相关联,总是成对出现。
有时候,父组件需要使用behavior数据

  • 那么behavior数据应当被提升到父组件吗?即使behavior数据跟子组件强耦合。
  • 例如:父组件创建了一个需要知道何时关闭的Modal组件以便取消请求。那么父组件只能管理isOpen状态吗?
  • behavior和component的绑定代码,还需要一遍遍重复吗?

返回组件的模式可能是一个优雅的解决方案。【1】,在hook中,不仅抽离了state和逻辑,组件以及组件和hook的绑定也都放在hook中,hook 返回{ state, method, component }

就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import useMenu from './useMenu'

export const Demo = () => {
// 这里返回的Menu是一个组件,openMenu, closeMenu 是方法,isOpen是state
const { Menu, openMenu, closeMenu, isOpen } = useMenu()

return (
<React.Fragment>
<Button onClick={openMenu} variant="contained">
Example Menu open ?:{ isOpen ? 'yes' : 'no'}
</Button>

<Menu />
</React.Fragment>
);
}

熟悉hook的一定可以注意到一点,Demo每次执行,useMenu都会执行一遍,返回新的component,这显然是不太完美的。

一个解决方案是:

  • 返回Component静态定义而不是Component实例
  • Returned Compoent和 hook 之间使用Context来共享数据和组件实例引用

    【2】Third iteration: Return statically defined component from hooks

参考文章

【1】New React Hooks Pattern? Return a Component

【2】React Design Patterns: Return Component From Hooks

【3】React Hooks: Compound Components

https://www.typescriptlang.org/docs/handbook/2/functions.html

函数声明

两种:

  1. 箭头函数式

    1
    2
    3
    function greeter(fn: (a: string) => void) {
    fn("Hello, World");
    }
  2. 对象式

    1
    2
    3
    4
    5
    6
    type DescribableFunction = {
    (someArg: number): boolean;
    };
    function doSomething(fn: DescribableFunction) {
    console.log( fn(6));
    }

    这种允许声明函数属性:

    1
    2
    3
    4
    5
    6
    7
    type DescribableFunction = {
    description: string;
    (someArg: number): boolean;
    };
    function doSomething(fn: DescribableFunction) {
    console.log(fn.description + " returned " + fn(6));
    }

    允许重载声明

    1
    2
    3
    4
    5
    6
    7
    8
    type DescribableFunction = {
    (someArg: number): boolean;
    (someArg: string): boolean;
    };

    function doSomething(fn: DescribableFunction) {
    console.log( fn(6));
    }

    允许声明构造函数

    1
    2
    3
    4
    5
    6
    type SomeConstructor = {
    new (s: string): SomeObject;
    };
    function fn(ctor: SomeConstructor) {
    return new ctor("hello");
    }

    那么既支持new操作符也支持直接调用的函数,可以利用重载+new声明:

    1
    2
    3
    4
    interface CallOrConstruct {
    new (s: string): Date;
    (n?: number): number;
    }

泛型

为了解决支持不同类型参数的通用函数的声明问题。我们声明一个类型的变量:泛型,用来指代未知的类型。可以理解为一元一次数学方程中的x变量: 2x+1=5, 只要x能满足函数方程就可以,x可以是任何类型。

1
2
3
4
5
6
7
8
9
10
function firstElement<Type>(arr: Type[]): Type | undefined {
return arr[0];
}

// s is of type 'string'
const s = firstElement(["a", "b", "c"]);
// n is of type 'number'
const n = firstElement([1, 2, 3]);
// u is of type undefined
const u = firstElement([]);

上例中,泛型Type的具体类型是ts推断出来的,我们没有明确的手动告诉ts。

也可以约束泛型坍塌为具有某些特性的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function longest<Type extends { length: number }>(a: Type, b: Type) {
if (a.length >= b.length) {
return a;
} else {
return b;
}
}

// longerArray is of type 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString is of type 'alice' | 'bob'
const longerString = longest("alice", "bob");
// Error! Numbers don't have a 'length' property
const notOK = longest(10, 100);

更严谨的可以手动指定类型(有时候ts没那么“聪明”可以自己推断出正确的类型)

1
2
3
4
function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
return arr1.concat(arr2);
}
const arr = combine<string | number>([1, 2, 3], ["hello"]);

泛型的实践指南

类型参数下移(泛型尽可能下沉到类型的叶子结点, 别太“泛”了)

1
2
3
4
5
6
7
8
9
10
11
12
function firstElement1<Type>(arr: Type[]) {
return arr[0];
}

function firstElement2<Type extends any[]>(arr: Type) {
return arr[0];
}

// a: number (good)
const a = firstElement1([1, 2, 3]);
// b: any (bad)
const b = firstElement2([1, 2, 3]);

这里 第二种写法跟any一样,泛型白用

尽可能减少泛型的数量(能用一个 就不要用两个0

1
2
3
4
5
6
7
8
9
10
function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
return arr.filter(func);
}

function filter2<Type, Func extends (arg: Type) => boolean>(
arr: Type[],
func: Func
): Type[] {
return arr.filter(func);
}

这里Func就是一个没啥用的泛型

泛型变量应该出现2次(如果这个变量不会用到第二次,那为啥要声明这个泛型呢?)

反例:Str 不如直接用string

1
2
3
4
5
function greet<Str extends string>(s: Str) {
console.log("Hello, " + s);
}

greet("world");

在函数中声明this

大部分时候ts通过推断来判断this指向谁,但是js里this的指向的判定规则还蛮复杂的,ts支持我们告诉他this应该是谁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const user = {
id: 123,

admin: false,
becomeAdmin: function () {
this.admin = true;
},
};

interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}

const db = getDB();
// 这里我们声明,this的类型应该是User
const admins = db.filterUsers(function (this: User) {
return this.admin;
});

这种技巧非常适合在回调函数的类型声明中使用,

https://www.typescriptlang.org/docs/handbook/2/everyday-types.html

字面量类型推断

数字和字符串的字面量会被推断为 number 和string类型,所以常见的报错:

1
2
3
4
const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);

> Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

的解决方法

1
2
3
4
// Change 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// Change 2
handleRequest(req.url, req.method as "GET");

类型断言

ts 不允许 ‘不可能’的类型断言。比如

1
2
3
const x = "hello" as number;

> Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

这种情况下的解决方法就是断言2次,先转化为any/unknown, 再断言为其他类型

1
const a = (expr as any) as T;

Type 和 Interface的区别

Type 创建后不可二次修改, Interface 始终可扩展编辑的
image

联合类型使用

type U= A|B

如果使用的是A 跟B都有的属性,那么ts不会报错,
如果使用的是A或者 B 独有的属性,那么使用前需要先判断具体是哪一个类型,ts才不会报错。

1
2
3
4
5
6
7
8
9
function printId(id: number | string) {
if (typeof id === "string") {
// In this branch, id is of type 'string'
console.log(id.toUpperCase());
} else {
// Here, id is of type 'number'
console.log(id);
}
}

https://jkchao.github.io/typescript-book-chinese/typings/discrominatedUnion.html#%E8%AF%A6%E7%BB%86%E7%9A%84%E6%A3%80%E6%9F%A5

Null 和 Undefined

项目建议开启 strictNullChecks

如果你知道变量一定存在,则可以使用:非空断言操作符

1
2
3
4
function liveDangerously(x?: number | null) {
// No error
console.log(x!.toFixed());
}
0%