2024年4月

概述

几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。

HTTP 认证

HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。

HTTP 认证的对话框

基本认证

常见的叫法是
HTTP Basic
,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:

GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==

虽然这种方式简单,但并不安全,因为
base64
编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。

摘要认证

主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:

GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"

补充:
另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回
401 Unauthorized
状态码,示例:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"

这一规范目前应用在所有的身份认证流程中,并且沿用至今。

Web 认证

表单认证

虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:

  1. 前端通过表单收集用户的账号和密码
  2. 通过协商的方式发送服务端进行验证的方式。

常见的表单认证页面通常如下:

<!DOCTYPE html>
<html>
<head>
    <title>Login Page</title>
</head>
<body>
    <h2>Login Form</h2>
    <form action="/perform_login" method="post">
        <div class="container">
            <label for="username"><b>Username</b></label>
            <input type="text" placeholder="Enter Username" name="username" required>
            
            <label for="password"><b>Password</b></label>
            <input type="password" placeholder="Enter Password" name="password" required>
            
            <button type="submit">Login</button>
        </div>
    </form>
</body>
</html>

为什么表单认证会成为主流 ?主要有以下几点原因:

  • 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。
  • 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。
  • 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。

表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。

WebAuthn

WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。

webauthn registration

相比于传统的密码,WebAuthn 具有以下优势:

  1. 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。
  2. 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。
  3. 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。

总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。

实现效果

当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:

WebAuthn login

实现原理

WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:

webauthn 交互时序图

登录流程大致可以分为以下步骤:

  1. 用户访问登录页面,填入用户名后即可点击登录按钮。
  2. 服务器返回随机字符串 Challenge、用户 UserID。
  3. 浏览器将 Challenge 和 UserID 转发给验证器。
  4. 验证器提示用户进行认证操作。
  5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。

WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;

备注:你可以通过访问 webauthn.me 了解到更多消息的信息

因为公众号文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:

  1. 先跑起来,通过文档和实践熟悉业务流程

    这一步可以通过
    看官方文档
    开始,要注意的是一些项目是
    更新先于文档
    的,比如新版本启动方式有变更,但是文档还没更新。跟着文档不一定能把项目跑起来,需要借助 GitHub Issue 或者是 Slack 这样的工具以获取即时的帮助

  2. 看测试,通过测试了解流程

    如果是开源项目,可以通过
    GitHub Action 快速了解需要哪些依赖、如何快速运行测试
    ,便于在本地运行测试,通过这些集成测试可以快速弄懂业务主线

  3. 通过 debug 高效快速地梳理流程

    通过断点可以一步一步跟踪程序的运行,可以比较直观地看调用栈、变量等等的

    对于一些无法本地调试的项目来说,我们可以退而求其次,断点它的测试,这也是一个很有效的方法

  4. 画图:降低复杂度

    很多项目会使用一些比较优雅的设计或是引入一些抽象层,这样代码读起来就会跳来跳去,层级深的话就很容把人给绕晕了

    可以用
    draw.io
    或者
    excalidraw
    等工具,根据实际情况画一画 活动图、时序图等

  5. 提出具体的问题,带着问题看项目

    如果只是盲目地看项目代码,可能看完还是一头雾水,但是如果能提出一个具体问题,或是带着一个需求去看,效果就会好得多

    比如我提出问题:“某个任务在集群内是如何完成的?”,我可能会先去找到该任务的创建入口,然后顺藤摸瓜,找到任务的调度逻辑,顺着
    happy path
    找到下发任务的逻辑,再找到 Woker 的处理逻辑,这样就能弄懂整个调度流程

    最后如果能用
    一句话
    回答提出的问题,那可能能说明你对这个问题涉及的知识已经有了一个比较好的理解

  6. 英语很重要

    大多数项目的注释、日志等的都是英文,看懂这些能极大提高效率

引言

在我们上一篇文章了解了单元测试的基本概念和用法之后,今天我们来聊一下 TDD(测试驱动开发)

测试驱动开发 (TDD)

测试驱动开发英文全称是
Test Driven Development
简称 TDD。

根据
UncleBob
的 TDD 描述总结

我们先创建一个测试项目

直接在 VS 创建即可,可以参考上一篇文章的创建过程

The Three Laws of TDD.

  • You are not allowed to write any production code unless it is to make a failing unit test pass.
  • You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  • You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

这是本文的描述的三个
TDD
开发的原则,它确保了代码的质量和可维护性。

下面对这三条内容做详细的解释

  • 第一条规则指出 不允许编写任何的生产代码,除非是在让单元测试通过时。
    • 简单理解就是在编写任何实际的业务逻辑代码之前,必须先编写一个或者多个单元测试,这些单元测试因为没有实现所以会失败,有了失败的单元测试之后我们才可以去在生产代码中实现业务逻辑
  • 第二条规则指出 不允许编写比失败所需更多的单元测试代码;编译失败也是失败:


    • 这可以理解为 在编写单元测试时,应该只编写足够使测试失败的最小代码量。这样,可以立即知道新写的生产代码是否解决了问题。编译失败同样被视为测试失败,因为编译不通过意味着代码无法运行。

那一个我们上一章节的一个数学计算类的例子


namespace dotNetParadise_TDD.Test;

public class MathCalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnSum()
    {
        // Arrange
        var calculator = new MathCalculator();

        // Act
        var result = calculator.Add(3, 5);

        // Assert
        Assert.Equal(8, result);
    }
}

因为我们没有 MathCalculator 这个类的实现所以,代码会编译失败。

image

在这个示例中,我们展示了如何编写一个简单的单元测试,测试 Calculator 类的 Add 方法是否能够正确地将两个数字相加并返回正确的结果。根据 TDD 原则,我们只编写了必要部分的代码来测试这个功能,并且在这个阶段测试应该会失败,因为 Add 方法还未实现。编译失败也会被视为测试失败,这强调了编写足够简洁和精确的单元测试的重要性,符合第二条准则。

  • 第三条规则指出 不允许编写比通过单个失败单元测试所需更多的生产代码
    可以理解为在编写生产代码时,只需编写足够让失败的单元测试通过的代码,而不是一次性编写完整的功能。这有助于保持代码的小步前进,并且每次更改都有明确的测试验证。

现在把
MathCalculator
类中增加一个参数*2 即翻倍的一个功能

第一步编写一个单元测试方法,

    [Fact]
    public void DoubleNumber_WhenGivenSingleNumber_ReturnsDouble()
    {
        // Arrange
        var calculator = new MathCalculator();

        // Act
        var result = calculator.DoubleNumber(2);

        // Assert
        Assert.Equal(4, result);
    }

第二步 编写足够让失败的单元测试通过的代码

namespace dotNetParadise_TDD.Test;

public class MathCalculator
{
    public int DoubleNumber(int number)
    {
        throw new NotImplementedException();
    }
}

接下来运行单元测试

 dotNetParadise_TDD.Test.MathCalculatorTests.DoubleNumber_WhenGivenSingleNumber_ReturnsDouble
   源: MathCalculatorTests.cs 行 20
   持续时间: 371 毫秒

  消息: 
System.NotImplementedException : The method or operation is not implemented.

  堆栈跟踪: 
MathCalculator.DoubleNumber(Int32 number) 行 7
MathCalculatorTests.DoubleNumber_WhenGivenSingleNumber_ReturnsDouble() 行 26
RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

结果和预期一样,测试没有成功

image

现在来重构一下这个方法

    public int DoubleNumber(int number)
    {
        //throw new NotImplementedException();
        return 2 * number;
    }

再次运行单元测试

image

可以看到单元测试成功了!

TDD 开发流程图

最后

通常我们进行单元测试的时候都是先写业务逻辑,然后再单元测试,当系统业务逻辑变复杂之后可能会遗漏一些测试
Case

TDD
的出现就是解决这个问题,通过测试
Case
来写重构业务代码的模式。

这三个规则确保了
TDD
的核心循环:红(测试失败)、绿(测试通过)、重构。通过不断地重复这个过程,开发者能够编写出高质量、可测试、易于维护的代码。

本文完整源代码
image

引言

Python的turtle模块是一个直观的图形化编程工具,让用户通过控制海龟在屏幕上的移动来绘制各种形状和图案。turtle模块的独特之处在于其简洁易懂的操作方式以及与用户的互动性。用户可以轻松地通过使用诸如前进、后退、左转、右转等基本命令,来编写程序控制海龟的行动路径,从而创造出丰富多彩的作品。就像是给海龟下达指令,让它在屏幕上留下痕迹一样。

在接下来的文章中,我将通过一个生动的例子——绘制一幅樱花树图画——来深入探讨turtle模块的实用性。

了解turtle模块

在创建这幅生动的樱花树图画时,我们将会利用turtle模块的一系列主要功能,这些功能包括:

  1. 初始化和设置画布

    • T.Turtle()
      :创建一个新的海龟对象,用于绘制图形。
    • T.Screen()
      :获取当前的画布对象,并可以对其进行操作,比如设置背景颜色。
    • w.screensize(bg='wheat')
      :设置画布的背景颜色为小麦色,为樱花树提供自然背景。
  2. 控制海龟的行为

    • t.hideturtle()
      :隐藏海龟图标,让绘制更加干净。
    • t.speed(0)
      :设置海龟的移动速度为最快,加快绘图过程。
    • t.penup()

      t.pendown()
      :控制海龟的笔是否接触画布,用于开始和结束绘制。
  3. 绘制图形

    • t.forward(branch)

      t.backward(branch)
      :让海龟向前或向后移动,绘制树枝。
    • t.right(20 * a)

      t.left(40 * a)
      :控制海龟的转向,用于绘制树枝分叉。
    • t.color('lightcoral')

      t.color('sienna')
      :设置海龟笔的颜色,用于绘制不同颜色的樱花树枝。
  4. 递归绘制樱花树

    • Tree(branch, t)
      :定义了一个递归函数,用于绘制樱花树的树干和分支。递归是编程中一种常见的技术,它可以简化重复性的代码,并使得绘制复杂的结构(如树木)变得简单。
  5. 绘制樱花花瓣

    • Petal(m, t)
      :定义了一个函数,用于绘制多个樱花花瓣。通过循环和随机数生成,模拟樱花瓣随风飘落的效果。

实现代码

import turtle as T
import random

# 绘制樱花树的函数,参数为树枝长度和绘图海龟对象
def draw_tree(trunk_length, turtle_obj):
    if trunk_length > 3:
        # 根据树枝长度决定颜色和粗细
        if 8 <= trunk_length <= 12:
            color = 'snow' if random.randint(0, 1) == 0 else 'lightcoral'
            turtle_obj.pensize(trunk_length / 3)
        elif trunk_length < 8:
            color = 'snow' if random.randint(0, 1) == 0 else 'lightcoral'
            turtle_obj.pensize(trunk_length / 2)
        else:
            color = 'sienna'
            turtle_obj.pensize(trunk_length / 10)
        turtle_obj.color(color)  # 设置颜色
        turtle_obj.forward(trunk_length)  # 向前画树枝
        angle_a = 20 * random.random()  # 随机角度a
        turtle_obj.right(angle_a)  # 向右转
        branch_reduction = 10 * random.random()  # 随机减少量
        draw_tree(trunk_length - branch_reduction, turtle_obj)  # 递归画子树枝
        turtle_obj.left(2 * angle_a)  # 向左转
        draw_tree(trunk_length - branch_reduction, turtle_obj)  # 递归画子树枝
        turtle_obj.right(angle_a)  # 回转角度a
        turtle_obj.penup()  # 提起笔
        turtle_obj.backward(trunk_length)  # 向后画树枝
        turtle_obj.pendown()  # 放下笔

# 绘制樱花花瓣的函数,参数为花瓣数量和绘图海龟对象
def draw_petals(petal_count, turtle_obj):
    for _ in range(petal_count):
        turtle_obj.penup()  # 提起笔
        distance = 200 - 400 * random.random()  # 随机花瓣落下的距离
        turtle_obj.forward(distance)  # 向前移动
        turtle_obj.left(90)  # 转向
        turtle_obj.forward(10 - 20 * random.random())  # 随机花瓣大小
        turtle_obj.down()  # 放下笔
        turtle_obj.color('lightcoral')  # 设置花瓣颜色
        turtle_obj.begin_fill()  # 开始填充颜色
        turtle_obj.circle(1)  # 画一个圆形花瓣
        turtle_obj.end_fill()  # 结束填充颜色
        turtle_obj.penup()  # 提起笔
        turtle_obj.backward(distance)  # 向后移动
        turtle_obj.right(90)  # 转向

# 初始化绘图环境
turtle_obj = T.Turtle()
turtle_screen = turtle_obj.getscreen()
turtle_screen.bgcolor("wheat")  # 设置背景颜色为小麦色
turtle_obj.hideturtle()  # 隐藏海龟图标
turtle_obj.speed(0)  # 设置绘制速度为最快
turtle_obj.left(90)  # 转向
turtle_obj.penup()  # 提起笔
turtle_obj.backward(150)  # 向后移动
turtle_obj.pendown()  # 放下笔
turtle_obj.color("sienna")  # 设置画笔颜色为赭色

# 绘制樱花树和花瓣
draw_tree(60, turtle_obj)
draw_petals(200, turtle_obj)

# 点击窗口关闭程序
turtle_screen.exitonclick()

总结

通过本文的学习和实践,我们掌握了使用Python的turtle模块来创作樱花树图画的技巧,这个过程中,我们深入了解了turtle模块的基本命令和递归等编程概念,这些都是构建更复杂项目的重要基础。也希望读者能够将这些知识应用到自己的项目中。无论是绘制其他自然景物还是创造抽象艺术作品,都可以借助turtle模块来实现。

没有Ai不会写代码了

前两天淘宝购买的IDEA copilot插件的账号不能用,没有Ai的加持感觉不会写代码了。于是启用了尘封好久的通义灵码,也可能是用法不对,总感觉没有 copilot智能,毕竟廖胜于无嘛... 看着又在一行行自动生成的代码,陷入了沉思:我们是Ai的工具,还是Ai是我们工具。最近密集的面试过程中,发现大部分人也没在用,甚至都没听过这样的工具。《劝学》中有云:君子生非异也,善假于物。可能,对我这样普普通通的程序员而言,三位一体全方位拥抱,学习,改造这些工具方是良策。前一篇文章《短视频文案提取的简单实现》提到的文案提取功能,其实也是借住一些工具简单实现了,今天再来聊一聊短视频配音的简单实现。

探索配音实现

一开始看轻抖小程序上的配音功能,有停顿,有多音字,有语速等配置,顿时感觉挺有意思的。10前年做外卖配送系统时,为了方便提醒配送员抢单,用科大讯飞的TTS实现了订单语音播报,但只是简单的朗读而已。摸索了一番后,了解腾讯云已经有相关TTS接口了,看到腾讯云已经提供的能力时,我感觉基本就是调用一个接口就基本ok了, 隐约看到了我自己在小程序上实现了智能配音功能。事实证明,真是纸上得来终觉浅,绝知此事要躬行。

语音合同的核心接口比较简单,就两接口

  • 基础合成(156字以内) - 同步返回
  • 长语音合成(10万字以内)- 异步返回

代码实现上使用策略模式处理不同字数的场景,使用Spring Event 统一同步与异步处理逻辑,音频文件上传到cos方便下载,前端使用setTimeout 轮询查询,核心类图如下:

有了通义灵码的辅助,三下五除二,便以迅雷不及掩耳之势就写好了基础代码。这里贴下基础合成语音的代码,长语音合成就是加了一个回调url的地址,返回的是任务id, 通过回调拿到语音文件的临时地址。

/*** @Author: JJ
* @CreateTime: 2023-11-21 09:49
* @Description: 腾讯云tts - 一句话接口(150字以下)
*/@Component
@Slf4j
public class SentenceTtsProcessor implementsTtsProcessor {private static Credential cred = newCredential(AppConstant.Tencent.asrSecretId, AppConstant.Tencent.asrSecretKey);/***@paramcomplexAudioReq
* @description: tts
*
@author: JJ
* @date: 11/21/23 09:48
*
@param: [bytes]
*
@return: java.lang.String*/@OverridepublicTtsRes run(ComplexAudioReq complexAudioReq) {

String reqId
=complexAudioReq.getRequestId();//如果为这。生成uuid if(Strings.isBlank(reqId)){
reqId
=UUID.randomUUID().toString();
}
log.info(
"tts - 基础合成 {}", reqId);//实例化一个http选项,可选的,没有特殊需求可以跳过 HttpProfile httpProfile = newHttpProfile();
httpProfile.setEndpoint(
"tts.tencentcloudapi.com");//实例化一个client选项,可选的,没有特殊需求可以跳过 ClientProfile clientProfile = newClientProfile();
clientProfile.setHttpProfile(httpProfile);
//实例化要请求产品的client对象,clientProfile是可选的 TtsClient client = new TtsClient(cred, "ap-shanghai", clientProfile);//实例化一个请求对象,每个接口都会对应一个request对象 TextToVoiceRequest req = newTextToVoiceRequest();
req.setText(complexAudioReq.getTtsText());
req.setSessionId(reqId);
req.setVolume(complexAudioReq.getVolume().floatValue());
req.setSpeed(complexAudioReq.getSpeed().floatValue());
req.setProjectId(
88L);
req.setModelType(
1L);
req.setVoiceType(complexAudioReq.getVoiceTypeId());
req.setPrimaryLanguage(
1L);
req.setEnableSubtitle(
false);
req.setEmotionCategory(EmotionMap.getEmotion(complexAudioReq.getEmotionCategory()));
req.setEmotionIntensity(complexAudioReq.getEmotionIntensity());
try{//返回的resp是一个TextToVoiceResponse的实例,与请求对象对应 TextToVoiceResponse resp =client.TextToVoice(req);
log.info(
"tts - 基础合成完成 SessionId={},req={}", reqId, resp.getRequestId());
TtsRes ttsRes
=TtsRes.builder()
.ttsType(TtsTypeEnum.SENTENCE.code())
.requestId(resp.getRequestId())
.data(resp.getAudio())
.build();
returnttsRes;
}
catch(TencentCloudSDKException e) {
log.error(
"一句话tts失败:{}",e);throw newBusinessException(SENTENCE_TTS_ERROR.code(), SENTENCE_TTS_ERROR.desc());
}
}
/***@paramreq
* @description: filter 根据参数选
*
@author: JJ
* @date: 3/3/24 18:54
*
@param:
*
@return:*/@OverridepublicBoolean filter(ComplexAudioReq req) {//字数小于150 if (req.getTtsTextLength() <AppConstant.Tencent.Sentence_TTS_Max_Word_Count){return true;
}
return false;
}
}

收到合成成功的回调后,发送事件,监听器异步处理。

            //发送异步事件,上传cos
            AudioUploadCosEvent uploadCosEvent =AudioUploadCosEvent.builder()
.eventTime(System.currentTimeMillis()
/ 1000)
.recordId(usageRecordEntity.getId())
.remote(
true)
.dataUrl(req.getResultUrl()).build();

applicationContext.publishEvent(uploadCosEvent);

事件监听核心逻辑就是上传cos,并修改合成记录状态,代码非常简单,大致如下:

 /*** 音频异步上传cos
*
@paramevent*/@Async
@EventListener
public voidaudioUploadCos(AudioUploadCosEvent event) {//上传cos InputStream inputStream = null;//根据是否远程走不同的逻辑 if(event.isRemote()){//跟url 下载 生成inputStream log.info("开始下载音频{} ", updateModel.getId());
MediaDownloadReq videoReq
= newMediaDownloadReq();
videoReq.setUrl(event.getDataUrl());
videoReq.setTargetFileSuffix(
"wav");
inputStream
=mediaDownloader.run(videoReq);

}
else{byte[] decodedBytes =Base64.decode(event.getData());
inputStream
= newByteArrayInputStream(decodedBytes);
}
//上传音频到cos String yyyyMM = DateUtils.dateFormatDateTime(newDate(), DateUtils.formatyyyyMM);
String path
= "/lp/audio/"+yyyyMM+"/"+updateModel.getId()+".wav";
OssUploadResponse ossUploadResponse
=OSSFactory.build().upload(inputStream, path);//关闭InputStream inputStream.close();
log.info(
"上传音频到cos完成{}", ossUploadResponse.getUrl());//修改记录状态 }

原来还在半山腰

最近在搞2024团队规划,Boss希望我们能预估到大致人日,之前也有过这样的预估实际上每次都预估偏差都不小,毕竟人日可能都在细节上。最后退而求其次,预估下两个月的人日,没有PRD,没有技术方案大概率预估的人日可能只会在半山腰,就如同配音的功能写到这里,我以为已经“会当临绝顶,一览众山小了”,哪知道一山还有一山高。

第一难就是多音字,要得到正确的发音,就需要明确指出发音与声调。比如对于腾讯云的语音合成接口支持 SSML 标记语言,比如我们需要让“长”发音为“zhang”,就需要做这样的标记。

<speak><phoneme alphabet="py" ph="zhang3">长</phoneme></speak>

对于后端而言,这个是简单的,通过pinyin4j可以快速找出一段文本中的多音字及其所有读音,几行代码就解决了。

 /*** 多音字检测
*
@paramreq
*
@return*@throwsException*/ public List<PolyphoneVo>run(PolyphoneQuery req) {//遍历字符串,找出多音字 char[] chars =req.getTtsText().toCharArray();
List
<PolyphoneVo> polyphoneVoList = new ArrayList<>();
Set
<String> polyphoneWordSet = new HashSet<>();for (charc : chars) {if((c >= 0x4e00)&&(c <= 0x9fbb)) {
String[] pinyinList
=PinyinHelper.toHanyuPinyinStringArray(c);if (pinyinList != null && pinyinList.length > 1 && !polyphoneWordSet.contains(c+"")) {
PolyphoneVo polyphoneVo
= newPolyphoneVo();
polyphoneVo.setWord(c
+"");
polyphoneVo.setReadList(Arrays.asList(pinyinList));
polyphoneVoList.add(polyphoneVo);
polyphoneWordSet.add(c
+"");
}
}
}
returnpolyphoneVoList;
}

   

前端难点在于如何让多音字可以点击以及点击后的交互,以及SSML 标记语言替换。给大家来两张效果图,有兴趣的可以脑补下前端实现。主要两个地方注意下:显示文本的数据结构和正则表达式。

行文至此,停顿有了,发音正常了,音频文件也有了,播放音频也正常了,只是进度条着实费了一些神,官方没有进度条的实现,最后用 van-slider 模拟实现了。唯一bug是无法获取正确的音时长,正当一筹莫展时,又有一个问题出现了:mp3格式无法正常在小程序中保存文件,官方文档中的描述确实只支持mp4文件。一番斗争后,觉得这个问题更为重要,于是又开始了摸索。

音频转视频的意外收获

mp3转成mp4,还是javaCV,有了之前的视频中分离音频的经历,这次就顺利多了。唯一的问题就是:视频文件的祯没有内容,所以是一片黑。于是想着用一张固定的图片做为祯内容。中间最麻烦的是音频与祯如何同步。最后还是与GPT4进行了一次多轮对话才完美了解决了。 用小程序二维码图做为默认的祯,效果如下图。在转换过程中,又根据FFmpegFrameGrabber.getLengthInTime 获取到了音频进长,顺道解决了前面无法解决的问题。真可谓是“无心插柳柳成荫”。代码与上一篇文章中的文案提取的代码基本雷同,就不贴了。

写在最后

写到这里,坑算是基本都趟过了。虽说实际所花的时间早已远超之前的计划了,好在出来效果还不错,也顺道补充了一些基本见识,也是不错了。再回来Ai辅助编程的话题,Ai知道的东西很多,会越来越多,如果提升个人思维能力,如何利用AI 估计很快会成为大部分的程序员了必修课了。

有兴趣的同学可以扫码体验下小程序。

小程序名称 :智能配音实用工具;

小程序二维码 :