开发一个 Streamlit 录音组件(二)——支持 iOS 页面

上一篇文章用 Streamlit 写了一个录音按钮的组件,实现了按下去时开始录音、放开结束录音的功能。但是只支持桌面端网页用鼠标点击,这次对齐进行扩展,使其能够实现在手机端按下录音的功能。 Touch 事件 在桌面端监听的是鼠标的 mousedown/mouseup 事件,但是在移动端则是手指触屏屏幕的事件 touchstart/touchend,因此需要修改按钮的监听事件。另外之前采用样式中 :hover 来实现按下时按钮颜色的变化,现在使用一个变量来控制 <template> <div class="button-container"> <button @touchstart="startRecording" @touchcancel="stopRecording" @touchend="stopRecording" @mousedown="startRecording" @mouseup="stopRecording" @mouseleave="stopRecording" class="red-round-button" :style="{ backgroundColor: buttonColor }" ></button> </div> <audio ref="audioPlayer" controls class="audio-player"></audio> </template> <script setup> ... const buttonColor = ref('red'); const dynamicStyles = computed(() => { return { width: props.args.width, height: props.args.height, backgroundColor: buttonColor }; }); ... </script> 另外,移动端长按按钮的话会唤出 Text Selection,需要取消在监听时间时取消掉,详见 Prevent text selection on tap and hold on iOS 13 / Mobile Safari function startRecording(event) { event.preventDefault(); ... } function stopRecording(event) { event.preventDefault(); ... } 录音格式兼容 由于 iOS 的浏览器录制的音频格式与桌面端浏览器不一样(参考 MediaRecorder: isTypeSupported() static method),需要在代码中先判断平台,再根据平台决定录音格式,其中桌面 Chrome 格式为 webm/opus,iOS Safari 为 mp4/aac,然后要将对应格式传回组件返回值 ...

March 29, 2024

开发一个 Streamlit 录音组件

用 Vue 写一个录音组件 先写一个 Vue 组件,用于录音功能。以下是一个简单的录音组件示例: <template> <div class="button-container"> <button @mousedown="startRecording" @mouseup="stopRecording" @mouseleave="stopRecording" class="red-round-button"></button> </div> </template> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; const mediaRecorder = ref<MediaRecorder | null>(null); const audioChunks = ref<Blob[]>([]); async function startRecording() { if (!navigator.mediaDevices || !window.MediaRecorder) { alert('Media Devices API or MediaRecorder API not supported in this browser.'); return; } const audioConstraints = { audio: { sampleRate: 16000, channelCount: 1, volume: 1.0 } }; try { const stream = await navigator.mediaDevices.getUserMedia(audioConstraints); mediaRecorder.value = new MediaRecorder(stream); audioChunks.value = []; mediaRecorder.value.ondataavailable = (event) => { audioChunks.value.push(event.data); }; mediaRecorder.value.start(); } catch (error) { console.error('Error accessing the microphone', error); } } function stopRecording() { if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') { mediaRecorder.value.stop(); mediaRecorder.value.onstop = async () => { const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' }); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); await audio.play(); }; } } onMounted(() => { // You can perform actions after the component has been mounted }); onUnmounted(() => { // Perform cleanup tasks, such as stopping the media recorder if it's still active if (mediaRecorder.value) { mediaRecorder.value.stop(); } }); </script> <style scoped> .button-container { display: flex; justify-content: center; align-items: center; height: 100vh; /* This assumes you want to center the button vertically in the viewport */ } .red-round-button { background-color: red; border: none; color: white; padding: 10px 20px; border-radius: 50%; /* This creates the round shape */ cursor: pointer; outline: none; font-size: 16px; /* Adjust width and height to make the button round, they must be equal */ width: 50px; height: 50px; /* Optional: Add some transition for interactions */ transition: background-color 0.3s; } .red-round-button:hover { background-color: darkred; } </style> 注意 ...

March 25, 2024

给 API 加一个 HTTPS 网关

给后端 API 接口加一个 HTTPS 网关,提升接口的安全性 申请 SSL 证书 可以在腾讯云上申请免费证书,按照提示添加验证域名解析内容,然后下载,一般选择 Nginx 格式 下载后得到 4 个文件 {host}.key:私钥 {host}.csr:请求签名文件 {host}_bundle.crt/{host}_bundle.pem:这两个内容一样,都是证书文件 如果需要可以单独下载根证书 配置 Apisix 创建一个 Apisix 的配置文件如下,填入相应的 API 服务地址,匹配 URL 路径,自定义的 key-auth,以及私钥和证书内容 upstreams: - id: 1 name: "my-api" type: roundrobin nodes: "api-server:80": 1 # 原服务地址,原 HTTP 服务端口可以只对本机开发无需暴露给互联网 routes: - name: "my-api" uri: /* # URL 匹配路径 methods: ["POST"] # 运行请求的 method upstream_id: 1 # 上面填的 upstream 的 id plugins: key-auth: { "header": "X-Apikey", # 自定义的 header,请求时按照 -H 'X-Apikey: {key}' 传入 } consumers: - username: {username} plugins: key-auth: key: {key} # 为接口加上 key-auth 提升安全性 ssls: - sni: "{domain}" cert: | -----BEGIN CERTIFICATE----- {cert content} -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- key: | -----BEGIN RSA PRIVATE KEY----- {private key} -----END RSA PRIVATE KEY----- #END 启动 Apisix 先启动服务,可以创建 docker 网络讲服务暴露给 apisix,无需对外暴露端口 ...

January 18, 2024

腾讯云 COS 文件上传后下载

上一篇讲了如何通过 Web 直传文件到 COS,在其基础上介绍上传后如何下载文件 后端 STS 服务 首先后端的 STS 服务需要改造一下,增加查询下载 URL 的权限 { Action: []string{ // 下载操作 "name/cos:GetObject", }, Effect: "allow", Resource: []string{ "qcs::cos:ap-guangzhou:uid/" + appId + ":" + bucket + "/*", }, Condition: map[string]map[string]interface{}{}, }, 其他权限列表请可以看: COS API 授权策略使用指引 访问策略生效条件 前端 Demo 改造 参考Javascript SDK,上传后通过 SDK 得到访问 URL,可以在页面展示或者下载 <template> <div> <div> <input type="file" @change="uploadImage" ref="imageInput" /> <button @click="submitImage">Upload Image</button> <button v-if="imageUrl" @click="downloadImage">Download Image</button> </div> <div> <img v-if="imageUrl" :src="imageUrl" alt="Description of image "> </div> </div> </template> <script> import COS from 'cos-js-sdk-v5'; const bucket = ${bucket}; const region = ${region}; export default { data() { return { imageFile: null, cos: null, imageUrl: null, imageKey: null, tmpSecretId: "", tmpSecretKey: "", sessionToken: "", }; }, methods: { uploadImage(event) { this.imageFile = event.target.files && event.target.files[0]; }, submitImage() { if (!this.imageFile) { alert("Please choose an image to upload."); return; } this.initCOS(); this.uploadToCOS(); }, initCOS() { this.cos = new COS({ getAuthorization: function (options, callback) { const url = 'http://${host}:8080/credentials' const xhr = new XMLHttpRequest() let data = null let credentials = null xhr.open('GET', url, true) xhr.onload = function (e) { try { data = JSON.parse(e.target.responseText); credentials = data.credentials; } catch (e) { console.log(e) } if (!data || !credentials) { return console.error('credentials invalid:\n' + JSON.stringify(data, null, 2)) } callback({ TmpSecretId: credentials.tmpSecretId, TmpSecretKey: credentials.tmpSecretKey, SecurityToken: credentials.sessionToken, StartTime: data.startTime, // 时间戳,单位秒,如:1580000000 ExpiredTime: data.expiredTime, // 时间戳,单位秒,如:1580000000 }) } xhr.send(); } }) }, uploadToCOS() { const key = `test/${Date.now()}_${this.imageFile.name}`; this.cos.uploadFile( { Bucket: bucket, Region: region, Key: key, StorageClass: "STANDARD", Body: this.imageFile, }, (err) => { if (err) { console.error("Error uploading image:", err); alert("Error uploading image."); } else { this.imageKey = key; this.getImageUrl() alert("Image uploaded successfully."); this.$refs.imageInput.value = null; this.imageFile = null; } } ); }, getImageUrl() { let that = this this.cos.getObjectUrl({ Bucket: bucket, Region: region, Key: this.imageKey, }, function (err, data) { if (err) return console.log(err); /* 通过指定 response-content-disposition=attachment 实现强制下载 */ const downloadUrl = data.Url + (data.Url.indexOf('?') > -1 ? '&' : '?') + 'response-content-disposition=attachment'; /* 可拼接 filename 来实现下载时重命名 */ /* downloadUrl += ';filename=myname'; */ // (推荐使用 window.open()方式)这里是新窗口打开 url,如果需要在当前窗口打开,可以使用隐藏的 iframe 下载,或使用 a 标签 download 属性协助下载 that.imageUrl = downloadUrl console.log("Image uploaded successfully. URL:", that.imageUrl); }) }, downloadImage() { const link = document.createElement("a"); link.href = this.imageUrl; link.download = this.imageUrl.split("/").pop(); link.click(); }, }, }; </script>

January 5, 2024

Javascript 中的异步操作

最近看 JS 代码,对于 Promise 相关写法不是很熟悉,因此梳理了一下相关概念 Javascript 中的函数写法 在异步操作中会用到的回调函数通常使用匿名函数的写法,这里先复习一下 Javascript 中各种函数写法 function hello () { return "Hello world!" } // with parameters function hello (name) { return "Hello " + name + "!" } // 匿名函数 function (name) { return "Hello " + name + "!" } // Arrow function hello = () => { // 等价于 hello = function() { return "Hello World!"; } // 省略 `{}` 和 `return` hello = (name) => "Hello " + name + "!"; hello = (firstName, lastName) => "Hello " + firstName + " " + lastName + "!"; // 只有一个参数时,括号也可以省略 hello = name => "Hello " + name + "!"; P.S.: ...

January 4, 2024

腾讯云 COS 文件 Web 直传

主要参考以下文档与代码 Web 直传实践 server/sts.js 后端 STS 服务 接口提供临时密钥和 token,服务端代码如下: package main import ( "encoding/json" "log" "net/http" "os" "strconv" "time" "github.com/joho/godotenv" sts "github.com/tencentyun/qcloud-cos-sts-sdk/go" ) const DefaultCredExpireTime = 600 // seconds var ( appId string bucket string region string stsClient *sts.Client credOpts *sts.CredentialOptions stsExpireTime int ) type Credentials struct { TmpSecretId string `json:"tmpSecretId"` TmpSecretKey string `json:"tmpSecretKey"` SessionToken string `json:"sessionToken"` } type CredentialsResp struct { Credentials Credentials `json:"credentials"` StartTime int64 `json:"startTime"` ExpiredTime int64 `json:"expiredTime"` } func init() { err := godotenv.Load() if err != nil { log.Fatal(err) } appId = os.Getenv("COS_APPID") bucket = os.Getenv("COS_BUCKET") region = os.Getenv("COS_REGION") secretId := os.Getenv("COS_SECRET_ID") secretKey := os.Getenv("COS_SECRET_KEY") stsExpireTime, err = strconv.Atoi(os.Getenv("STS_EXPIRE_TIME")) if err != nil { log.Printf("read enviroment `STS_EXPIRE_TIME` error: %v\n", err) log.Printf("set `STS_EXPIRE_TIME` to default %v", DefaultCredExpireTime) stsExpireTime = DefaultCredExpireTime } stsClient = sts.NewClient( secretId, secretKey, nil, ) credOpts = &sts.CredentialOptions{ DurationSeconds: int64(stsExpireTime), Region: region, Policy: &sts.CredentialPolicy{ Statement: []sts.CredentialPolicyStatement{ { Action: []string{ "name/cos:PostObject", "name/cos:PutObject", "name/cos:InitiateMultipartUpload", "name/cos:ListMultipartUploads", "name/cos:ListParts", "name/cos:UploadPart", "name/cos:CompleteMultipartUpload", }, Effect: "allow", Resource: []string{ "qcs::cos:ap-guangzhou:uid/" + appId + ":" + bucket + "/*", }, Condition: map[string]map[string]interface{}{}, }, }, }, } } func getCredentialsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") res, err := stsClient.GetCredential(credOpts) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") resp := CredentialsResp{ Credentials: Credentials{ TmpSecretId: res.Credentials.TmpSecretID, TmpSecretKey: res.Credentials.TmpSecretKey, SessionToken: res.Credentials.SessionToken, }, StartTime: time.Now().Unix(), ExpiredTime: time.Now().Add(time.Duration(stsExpireTime) * time.Second).Unix(), } err = json.NewEncoder(w).Encode(resp) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } func main() { log.Println("RunStsServer starts...") http.HandleFunc("/credentials", getCredentialsHandler) http.ListenAndServe(":8080", nil) } 通过调用接口就可以获得临时密钥 ...

January 3, 2024

腾讯云 COS 访问方法

前置条件 申请账号/子账号:访问管理 - 新建用户 申请 API 密钥:访问管理 - API 密钥管理 申请 COS 存储桶(bucket): 控制台 - 对象存储 签名算法 详见腾讯云文档中心 - 请求签名 签名验证工具:COS 签名工具 签名即输入 SecretId、SecretKey、有效时间时间戳,原始请求,得到以下签名内容的过程: q-sign-algorithm=sha1 // 签名算法 &q-ak=SecretId // SecretId &q-sign-time=KeyTime // 开始/结束时间戳,例如 `1557902800;1557910000` &q-key-time=KeyTime &q-header-list=HeaderList // HTTP 请求头列表,例如 `date;host;x-cos-acl;x-cos-grant-read` &q-url-param-list=UrlParamList // URL 参数列表,例如 `delimiter;max-keys;prefix` &q-signature=Signature // 签名,具体算法见文档 将上述内容放入 HTTP 请求的 Header,例如 Authorization: q-sign-algorithm=sha1&q-ak=...&q-sign-time=1557989753;1557996953&...&q-signature=...,或者 URL 请求参数中,例如 /exampleobject?q-sign-algorithm=sha1&q-ak=...&q-sign-time=1557989753%3B1557996953&...&q-signature=...,即可访问 COS 资源 如果使用临时密钥,还应将对应 token 传入 Header x-cos-security-token: ...,或者路径参数 &x-cos-security-token=... 后端访问 使用永久密钥访问 COS 如果通过 HTTP API 请求,则按照上述算法传入签名参数请求。或者通过对应语言的 SDK 请求 ...

January 1, 2024

Apisix

Apisix Install docker install docker run -d --restart unless-stopped \ --network ${DOCKER_NETWORK} \ --name ${DOCKER_APISIX_NAME} \ -p 9080:9080/tcp \ -p 9091:9091/tcp \ -p 9092:9092/tcp \ -p 9180:9180/tcp \ -p 9443:9443/tcp \ -e APISIX_STAND_ALONE=true \ -v ${PWD}/apisix/apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro \ apache/apisix:${APISIX_VERSION} References APISIX - Getting Started APISIX - Deploy Modes API7 Docs - Configure HTTPS for APISIX APISIX - Centralized Authentication with Apache APISIX and Advanced Tricks APISIX - key-auth Usage Route # create upstream curl "http://127.0.0.1:9180/apisix/admin/upstreams/1" -X PUT -d ' { "type": "roundrobin", "nodes": { "httpbin.org:80": 1 } }' # create route curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT -d ' { "methods": ["GET"], "host": "example.com", "uri": "/anything/*", "upstream_id": "1" }' # route with rate limit curl -i http://127.0.0.1:9180/apisix/admin/routes/1 \ -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' { "uri": "/index.html", "plugins": { "limit-count": { "count": 2, "time_window": 60, "rejected_code": 503, "key_type": "var", "key": "remote_addr" } }, "upstream_id": "1" }' Consumer # consumer with rate limit curl http://127.0.0.1:9180/apisix/admin/consumers -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' { "username":"consumer1", "plugins":{ "key-auth":{ "key":"auth-one" }, "limit-count":{ "count":2, "time_window":60, "rejected_code":403, "rejected_msg":"Requests are too many, please try again later or upgrade your subscription plan.", "key":"remote_addr" } } }' # consumer with key auth curl http://127.0.0.1:9180/apisix/admin/consumers -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' { "username": "jack", "plugins": { "key-auth": { "key": "auth-one" } } }'

January 1, 2000

Auth

Basic Concepts Cookies 一种用于服务器与浏览器交换数据的方式 服务器响应时通过 Set-Cookie 的 HTTP header 写入浏览器 浏览器每次请求自动在 header 中带上 cookies set cookie 时可以设置过期时间、域名、路径、HttpOnly 等条件,满足条件的请求才会带上该 cookie Session 由于 HTTP 请求是无状态的,需要在多次请求中保持用户状态的话需要额外的数据,这个数据可以是一个随机的字符串(Session ID),这样带有同样 Session ID 的请求就可以被关联为同一个会话 Session ID 一般由服务端生成,并保存在服务端,来对每一次的请求进行关联。与客户端的交互可以用 set cookie 的方式实现 Token 客户端发送请求时携带的凭证,用于表明自己的身份(Authentication) 常见 tokens: 随机字符串:通过服务端生成随机字符串(Session ID),然后通过 Set-Cookie 写入客户端的浏览器作为 token 的形式,每次请求会在 header 中的 cookie 带上该 token/session id JWT:一种按照 JWT 通用协议签名过的信息,base64 解码后为 JSON 格式。由于信息经过了签名,有防篡改的功能。 Online Debugger 示例: { "iss": "example.com", # 表示令牌是由 example.com 这个实体发行的 "sub": "1234567890", # 令牌的主题是用户 ID 为 "1234567890" 的用户 "aud": "https://api.example.com", # 令牌的目标接收方 "exp": 1516239022, # 令牌的过期时间 "nbf": 1516238422, # 令牌的生效时间 "iat": 1516237822, # 令牌的发行时间 # 自定义字段,用于传递用户的权限角色、姓名和电子邮件地址等信息 "role": "admin", "name": "John Doe", # "email": "johndoe@example.com", "custom_data": { "department": "Engineering", "team": "Security" } } OTP (One Time Password) 动态口令,一次有效的验证码机制。最常用的 OTP 为 TOTP(Time-based One-Time Password),通常 30s 一变,服务端和客户端(APP)需提前对齐种子、提前校对时间。在同一时间窗口内,客户端(APP)计算的动态口令(OTP)应与服务端一致,从而通过认证。 ...

January 1, 2000

Caddy

References Github - caddy 安装 Binary Keep Caddy Running 下载 binary:https://caddyserver.com/download 运行命令 sudo mv caddy /usr/bin/ sudo mkdir /etc/caddy sudo cp Caddyfile /etc/caddy/Caddyfile sudo groupadd --system caddy sudo useradd --system \ --gid caddy \ --create-home \ --home-dir /var/lib/caddy \ --shell /usr/sbin/nologin \ --comment "Caddy web server" \ caddy 将以下内容写入 /etc/systemd/system/caddy.service 文件 [Unit] Description=Caddy Documentation=https://caddyserver.com/docs/ After=network.target network-online.target Requires=network-online.target [Service] Type=notify User=caddy Group=caddy ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force TimeoutStopSec=5s LimitNOFILE=1048576 PrivateTmp=true ProtectSystem=full AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE [Install] WantedBy=multi-user.target 启动 sudo systemctl daemon-reload sudo systemctl enable --now caddy sudo systemctl status caddy 注意:centos 7 的 systemd 版本太低(219),需要先升级 ...

January 1, 2000