中文小说 tts 生成音视频

img

一、背景

平时里工作学习都用眼过度,一直喜欢听什么东西来填补碎片化的时间。除了播客、有声书等他人产出,有时候自己可以找到喜欢的文本作品,也希望可以简单的生成音频来听,所以有了这个项目。

GitHub - Wizna/txt2audio: transform txt book to audio book

二、方案

  • 整个项目分为几个部分:

    • cli 交互部分

    • 小说文本处理

    • tts

    • 音频转视频

  • 最终产物示例

2.1 cli 交互部分

  • 用户首先进入项目目录,然后通过类似如下的指令开启交互:python3 src/transform_to_audio.py demo/《英雄志》(校对第1-22卷)作者:孙晓.txt,其中第二个输入即我们的目标小说文件路径

  • 输入完成后,代码即会读取目标小说文件,然后处理看一共有多少章节并标号

  • 之后会有 prompt 提示输入需要 tts 的章节,支持 e.g. 3-10 来 tts 第 3 到第 10 个章节

  • 代码会自动检测已经产生过的内容(在 output 路径下)然后跳过,其它则正常生成

2.2 小说文本处理

  • 会对常见的 “序言”,“楔子”, “后记” 等做处理,作为单独的章节 special_delimiter

  • 目前分章节方式只有识别 “卷”,“章”,目前小说(来源 精校吧 )基本上使用这两者,后续也会考虑支持更多分段关键字

  • 标志每一个章节的其实是一个 string list: [book_name, 第 x 卷, 第 y 章, special_delimiter]

2.3 tts

  • 识别出的每一个章节会分片进行 tts,每片 6300 个汉字,大约 27 min (目前快手有 30 分钟的视频上传限制)

  • 每片会进行句子分割(根据标点符号),保证每个单句长度相近,30 字左右,此时 tts 语速、效果比较好

  • def split_long_sentences(input_str, model_limit=30) -> List[str]:
      if not input_str:
          return []
      pieces = math.ceil(len(input_str) / model_limit)
      character_for_each_piece = len(input_str) // pieces
      candidates = re.split(r'([,。?!:“”])', input_str)
      result = []
      current_s = []
      for v in candidates:
          current_s.append(v)
          if not v or v in ',。?!:“”':
              continue
          possible = ''.join(current_s)
          if len(possible) > character_for_each_piece:
              if len(current_s) > 1:
                  result.append(''.join(current_s[:-1]))
                  current_s = [v]
              else:
                  result.append(v)
                  current_s = []
      
      if current_s:
          result.append(''.join(current_s))
      return result
    
  • 目前使用的是 coqui tts 的 xtts_v2, 多语言,后续可扩展英语 tts

  • 结果保存本地 wav

2.4 音频转视频

  • tts 结果还是希望分发出去让别人也可以利用(毕竟是我的 mac 辛辛苦苦运行出来的),所以 wav 转换成 mp4 方便分发

  • 每一本书希望封面一致,所以底色是通过书名 hash 得到

  • def get_color_from_text(s, lightness=127):
        value = int(hashlib.sha1(s.encode("utf-8")).hexdigest(), 16)
        r = value % lightness
        value //= lightness
        g = value % lightness
        value //= lightness
        b = value % lightness
        return r, g, b
    
  • 之后就是简单的 textbbox 写上书名、作者、章节等信息

  • def draw_underlined_text(draw, pos, text, font, **options):
        (left, top, right, bottom) = draw.textbbox(xy=(0, 0), text=text, font=font)
      
        text_width = right - left
        text_height = bottom - top
        lx, ly = pos[0], pos[1] + text_height + 20
        draw.text(pos, text, font=font, **options)
        draw.line((lx, ly, lx + text_width, ly), width=4, **options)
      
    def create_image_from_text(number, toc, audio, max_w=720, max_h=1280):
        r, g, b = get_color_from_text(s=toc.split('/')[0])
        img = Image.new('RGB', (max_w, max_h), color=(r, g, b))
      
        font = ImageFont.truetype(
            f'{os.path.dirname(__file__)}/../resources/YunFengFeiYunTi-2.ttf',
            80)
        smaller_font = ImageFont.truetype(
            f'{os.path.dirname(__file__)}/../resources/YangRenDongZhuShiTi-Extralight-2.ttf',
            70)
        number_font = ImageFont.truetype(f'{os.path.dirname(__file__)}/../resources/DTM-Mono-1.otf', 40)
      
        d = ImageDraw.Draw(img)
      
        current_h, pad = 200, 40
        for idx, sub_para in enumerate(toc.split('/')):
            sub_para = re.sub(r'(.+)', ' ', sub_para)
            for line in sub_para.split(' '):
                line = line.strip()
      
                if not line:
                    continue
      
                selected_font = font if idx == 0 else smaller_font
                (left, top, right, bottom) = d.textbbox(xy=(0, 0), text=line, font=selected_font)
                w = right - left
                h = bottom - top
                d.text(((max_w - w) / 2, current_h), line, font=selected_font)
                current_h += h + pad
      
        (left, top, right, bottom) = d.textbbox(xy=(0, 0), text=f'{number}', font=number_font)
        w = right - left
        h = bottom - top
        draw_underlined_text(d, ((max_w - w) / 2, max_h - 300), f'{number}', font=number_font)
      
        result = f'{os.path.dirname(audio)}/cover.jpg'
        img.save(result)
        return result
    
  • 最后通过 subprocess 使用 ffmpeg 转化成视频 ffmpeg -loop 1 -i {image} -i {audio} -c:v libx264 -tune stillimage -c:a aac -b:a 192k -pix_fmt yuv420p -shortest {video_path} && rm -f {audio}

三、总结

  • 项目并不复杂,目前也没涉及 tts model 的 fine-tuning,未来提升质量应该会从数据集、模型上继续下功夫

  • 整体看还是很有价值的,没事可以听一听

Written on May 19, 2024