跳转到主要内容
Chal1ce blog

我的项目:AutoPodcast(上:设计与实现)

给大家分享一下我之前的练手小项目:自动化生成播客

AutoPodcast:AI驱动的播客生成工具

前段时间Google推出的NotebookLLM很火,我就在思考能不能自己也做一个简单的自动化播客工具,后面空闲时花了一周多的时间,借助cursor的帮助,让我完成了这一个简单的Web程序,代码链接放在这:AutoPodcast ,这篇文章主要是讲一下整个程序的架构设计和实现思路。

核心功能

  1. 文本输入:用户可以通过两种方式输入内容(截止至2024年10月20日):

    • 上传PDF文件
    • 输入主题
  2. AI对话生成:系统使用多种大语言模型(如OpenAI、Deepseek、Yi等),根据指定的主题或上传的文件生成对话内容。

  3. 交互式编辑:生成的对话可以在界面上直接编辑,方便用户对对对话内容进行修改。

  4. TTS音频生成:用户可以选择API模式(如Reecho)或本地模式(如CoquiTTS)来生成逼真的语音。

  5. 音频预览和下载:用户可以在浏览器中预览生成的音频,并下载最终的播客文件。

技术架构

Autopodcast采用了前后端分离的架构:

前端(Next.js)

前端使用Next.js框架,提供了响应式和用户友好的界面。主要组件包括:

  • 文件上传组件
  • 主题输入表单
  • 对话内容生成对话框
  • 响应显示和编辑区域
  • 音频播放器

后端(FastAPI)

后端使用FastAPI框架,提供了高性能的API服务。主要功能包括:

  • PDF文本提取
  • 对话生成(多模型支持)
  • TTS音频生成(多模型支持)

框架流程图

整体工作流程:

整体工作流程

功能如图所示:

PDF文件内容提取流程:

设计考虑

  1. 模块化设计:前后端都采用了模块化的结构,便于扩展和维护。
  2. 灵活的模型选择:支持多种AI模型,用户可以根据需求选择不同的对话生成和TTS模型。
  3. 本地模型支持:除了云API,还支持本地模型部署,增加了灵活性和隐私保护。
  4. 用户体验优化:提供了直观的界面和实时预览功能,提升了用户体验。
  5. 错误处理和状态管理:实现了全面的错误处理和状态管理,提高了应用的稳定性。

如何实现

我们从前端页面开始讲,首先我们要有一个选择框,让用户来选择是用主题来生成对话还是根据文件内容来生成播客对话。以下我们先定义两个值,一个是根据主题生成播客音频,另一个是根据文件生成播客音频,具体代码可以查看 app/page.js

const [selectedOption, setSelectedOption] = useState('prompt');

const options = [
  { value: 'prompt', label: '根据主题生成播客音频' },
  { value: 'file', label: '根据文件生成播客音频' }
];

// 获取选择的方式
<select
  id="option-select"
  value={selectedOption}
  onChange={(e) => setSelectedOption(e.target.value)}
  className="select-box w-full"
>
  {options.map((option) => (
    <option key={option.value} value={option.value}>
      {option.label}
    </option>
  ))}
</select>

有了对话文本生成方式之后,是不是可以有一个选项让我们选择使用什么方式调用大模型,并且选择哪一个大模型来生成对话?我们先把所需要的值定义好:

const [modelMode, setModelMode] = useState('api');
const [selectedModel, setSelectedModel] = useState('deepseek');

const modelModes = [
  { value: 'api', label: 'API模式' },
  { value: 'local', label: '本地模式' }
];

const apiModels = [
  { value: 'deepseek', label: 'Deepseek' },
  { value: 'openai', label: 'OpenAI' },
  { value: 'yi', label: 'Yi' }
];

const localModels = [
  { value: 'llama', label: 'LLaMA' },
  { value: 'qwen', label: 'Qwen' },
  { value: 'yi', label: 'Yi' }
];

// 选择API模式或者本地模式
<div>
  <span className="model-select-label">生成对话文本模式</span>
  <ToggleButton
    options={modelModes}
    selectedOption={modelMode}
    onSelect={setModelMode}
  />
</div>
// 选择模型
<div>
  <span className="model-select-label">选择模型</span>
  <ToggleButton
    options={modelMode === 'api' ? apiModels : localModels}
    selectedOption={selectedModel}
    onSelect={setSelectedModel}
  />
</div>

有了这些之后,我们还需要对话的角色,我们可以设置两种情况,一种是一个主持人+两个专家,另一种情况是一个主持人+一个专家+一个嘉宾,然后我们先照例把值定义好:

const [format, setFormat] = useState('一个主持人,一个嘉宾,一个专家');
const formatOptions = [
  '一个主持人,两个专家',
  '一个主持人,一个专家,一个嘉宾'
];

上面提到的是两种生成模式共有的相同的工作,接下来就是开始分情况讨论。

首先我们先来看根据主题生成对话的情况,先仔细思考,除了上面提到的东西,现在在前端要生成对话文本是不是还需要一个主题,只有将主题和上述提到的部份东西发送给后端,后端才可以调用大模型来根据提供的主题生成播客对话。

在这里我们需要构建一个包含主题对话角色的请求体,然后通过 fetch API 发送请求到后端,后端呢则是需要先定义一个包含与前端发送请求体里面数据类型相同的请求体,然后使用 FastAPI 框架定义相应的路由来接收请求,以此为例,前端的部份代码如下,具体代码可以查看 app/compoents/form.js

const handleGenerateScript = async () => {
  console.log("开始生成脚本");
  setIsLoading(true);
  setGeneratedText([]);
  try {
    // 定义请求体
    const requestBody = JSON.stringify({ topic, format });
    console.log("发送的请求体:", requestBody);
		// 向后端发送请求体
    const response = await fetch(`${apiUrl}/${model}/topic2talk`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: requestBody,
    });
		
    // 错误情况处理
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
		// 获取后端返回的数据
    const data = await response.json();
    console.log("收到响应:", data);

    setGeneratedText(data);
  } catch (error) { //错误处理
    console.error("生成脚本时出错:", error);
    setGeneratedText([{ speaker: '错误', content: `发生错误: ${error.message}` }]);
  } finally {
    setIsLoading(false);
    console.log("脚本生成结束");
  }

后端的部份代码如下,这里定义了一个 POST 路由 /file2talk,它接收一个 File2TalkRequest 类型的请求体。FastAPI 会自动解析请求体并将其转换为 Python 对象,生成对话文本之后,将对话处理为讲话人和所讲的话的文本对列表,将其返回到前端,具体代码可以查看 backend/api/OpenAI.py

# 定义的请求体
class File2TalkRequest(BaseModel):
    text: str
    characters: str

# FastAPI接口,接收从前端传过来的请求体
@openai_router.post("/file2talk")
async def openai_file2talk(request_data: File2TalkRequest):
    text = request_data.text
    characters = request_data.characters

    sys_prompt = f"""
        ## 角色:\n你是一个拥有二十年经验的专家.严格遵守以下要求,并且标点符号一律使用英文的,不要进行任何的改动.\n
        ## 任务:\n请你根据所给的内容,写出一篇内容详细的高质量的播客.\n
        ## 内容要求:\n有{characters},对内容进行讨论,向听众解释内容.\n
        ## 输出要求:\n主持人:xxxxx\n嘉宾:xxxxx\n专家:xxxxx\n或\n主持人:xxxxx\n专家1:xxxxx\n专家2:xxxxx\n
    """

    print(f"System prompt: {sys_prompt}")

    try:
        final_res = []
        res = client.chat.completions.create(
            model=openai_model.model,
            messages=[
                {"role": "system", "content": sys_prompt},
                {"role": "user", "content": "内容: " + text}
            ],
            stream=False
        )
        print(f"Response: {res.choices[0].message.content}")
        res = res.choices[0].message.content.replace("\n\n", "\n")
        res = res.split("\n")
        for r in res:
            speaker, content = r.split(":")
            final_res.append({"speaker": speaker, "content": content})
        return final_res
    except Exception as e:
        print(f"Error in generate_res: {str(e)}")
        raise HTTPException(status_code=500, detail=str(e))

上面这部份是根据主题生成对话文本的功能实现思路,那如果是上传文件生成对话文本功能呢?且看下方的实现思路。

首先在前端方面,要上传文件我们是不是需要一个区域来上传一个或多个文件,并且由于我们这里是限定上传PDF文件,还需要一个函数来判断文件类型,前端使用了一个拖拽上传区域和一个隐藏的文件输入框来实现文件上传功能,通过创建一个可拖拽的区域,用户可以将文件拖入或点击选择文件。当文件被选中或拖入时,会触发handleDrop或handleChange函数。主要代码在FileUpload组件中:

return (
  <div className="w-full max-w-md mx-auto space-y-6">
    <div
      className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors"
      onDrop={handleDrop}
      onDragOver={(e) => e.preventDefault()}
    >
      <input
        type="file"
        onChange={handleChange}
        className="hidden"
        id="fileInput"
        multiple
        accept=".pdf"
      />
      <label htmlFor="fileInput" className="cursor-pointer text-lg font-semibold text-gray-700 hover:text-gray-900">
        拖拽PDF文件到这里或点击选择文件
      </label>
    </div>

    {error && (
      <div className="text-red-500 text-sm text-center">{error}</div>
    )}

    {files.length > 0 && (
      <ul className="bg-white rounded-lg shadow-sm overflow-hidden">
        {files.map((file, index) => (
          <li key={index} className="flex justify-between items-center p-3 border-b last:border-b-0">
            <span className="text-sm text-gray-600 truncate max-w-xs">{file.name}</span>
            <button 
              onClick={() => removeFile(index)}
              className="text-red-500 hover:text-red-700 text-sm font-medium ml-4"
            >
              删除
            </button>
          </li>
        ))}

以下是关于上传文件的部份代码,具体代码可以查看 app/compoents/FileUpload.js

const [files, setFiles] = useState([]);

const isPDF = (file) => {
  return file.type === "application/pdf";
};

const handleFiles = (newFiles) => {
  const pdfFiles = newFiles.filter(file => isPDF(file));
  const nonPdfFiles = newFiles.filter(file => !isPDF(file));

  if (nonPdfFiles.length > 0) {
    setError(`以下文件不是PDF格式,已被忽略:${nonPdfFiles.map(f => f.name).join(', ')}`);
    setTimeout(() => setError(""), 5000);
  }

  setFiles(prevFiles => [...prevFiles, ...pdfFiles]);
};

const handleDrop = (e) => {
  e.preventDefault();
  const droppedFiles = Array.from(e.dataTransfer.files);
  handleFiles(droppedFiles);
};

const handleChange = (e) => {
  const selectedFiles = Array.from(e.target.files);
  handleFiles(selectedFiles);
};

const removeFile = (index) => {
  setFiles(prevFiles => prevFiles.filter((_, i) => i !== index));
};

const handleRoleChange = (e) => {
  setSelectedRole(e.target.value);
};

当文件上传完,用户就可以点击一个按钮来上传文件处理文件,我们的重点不在于按钮如何实现,而是点击按钮之后触发的操作。我们来梳理一下,当用户点击上传文件按钮之后,会触发的前后端的所有操作:

  • 文件上传到后端(前端)
  • 接收文件(后端)
  • 提取文本(后端)
  • 文本处理并返回(后端)
  • 接收处理完的对话文本对(前端)

有了思路就可以定义具体的代码,以下是前端上传文件的部份代码:

const uploadFiles = async () => {
  setButtonState("processing"); // 设置按钮状态为处理文件中
  setProcessingStatus(""); // 重置处理状态
  const formData = new FormData();

  files.forEach((file) => {
    formData.append('files', file);
  });

  try {
    const response = await fetch('http://127.0.0.1:8000/process/process_file', {
      method: 'POST',
      body: formData,
    });

    if (response.ok) {
      const result = await response.text();
      setProcessedText(result);
      setProcessingStatus("处理成功"); // 设置成功状态
      setButtonState("generating"); // 设置按钮状态为生成对话中

      // 发送处理后的文本和角色到新的端点
      await sendProcessedTextAndRole(result, selectedRole);
    } else {
      throw new Error('处理失败');
    }
  } catch (error) {
    setError('文件处理失败,重试。');
    setProcessingStatus("处理失败"); // 设置失败状态
    setButtonState("idle"); // 重置按钮状态
    setTimeout(() => setError(""), 5000);
    }

后端使用FastAPI框架来处理文件上传。主要代码在process.py文件中,使用FastAPi自带的UploadFile类型来接收多个上传的文件。FastAPI会自动处理multipart/form-data请求,并将文件转换为UploadFile对象。以下是一个简单文件处理函数的代码:

@process_router.post("/process_file")
async def process_file(files: List[UploadFile] = File(...)):
    """
    处理多个PDF文件的函数
    
    参数:
    files (List[UploadFile]): PDF文件列表
    
    返回:
    str: 提取的文本内容
    """
    try:
        all_text = ""
        for file in files:
            # 创建PDF阅读器对象
            pdf_reader = pypdf.PdfReader(file.file)
            # 初始化文本变量
            text = ""
            # 遍历所有页面并提取文本
            for page in pdf_reader.pages:
                if len(page.extract_text()) > 5:
                    text += page.extract_text()
            # 去除引用文献
            text = re.sub(r'\[\d+\]', '', text)
            # 去除注释(假设注释是在括号内的)
            text = re.sub(r'\([^()]*\)', '', text)
            # 去除多余的空白字符
            text = re.sub(r'\s+', ' ', text).strip()
            all_text += text + "\n\n"

        return all_text
    except Exception as e:
        print(f"处理PDF时出错: {str(e)}")
        return {"error": str(e)}

文件处理完成之后,会将处理后的文本与提示词发送到后端的大模型接口中,由大模型来生成对话文本。

如果生成成功,前端会更新状态,显示生成的对话文本,可若是生成的结果我们感觉不太理想,我们是不是可以对其进行修改,这时候我们不仅仅需要显示结果,还要将结果放在文本框中,方便我们对生成的对话文本进行修正。这部份代码可以见app/components/ResponseDisplay.js

确定下对话文本之后,我们就可以进行最后的操作:文本转语音,文本转语音同样需要两个按钮,一个能让我们选择用本地模式还是API模式来调用模型,另一个按钮能让我们选择使用哪一个模型或者哪一个接口,功能实现与最上方选择生成对话模型那一块类似。

接下来我们来说下发送部份以及后端处理部份,我们提前设定好对应的语音角色,然后前端将[{speaker: content}]格式的对话字典列表发送到后端,后端接收到这个字典列表后,根据speaker来分配对应的角色,生成对应的文本语音,分配语音角色的代码例子:

# 匹配讲话人的语音包
def map_speaker_to_voice_id(speaker: str) -> str:
    if "主持人" in speaker:
        return reecho_model.voice1_id
    elif "嘉宾" in speaker:
        return reecho_model.voice2_id
    elif "专家1" in speaker:
        return reecho_model.voice2_id
    elif "专家2" in speaker:
        return reecho_model.voice3_id
    elif "专家" in speaker:
        return reecho_model.voice3_id
    else:
        return reecho_model.voice2_id # 默认情况

此时TTS生成语音分为两种情况:

  • 调用API生成
  • 调用本地模型

在这个项目中,如果是调用API生成,以reecho为例,调用reecho上的角色来生成语音,会先创建一个生成任务,由于这个任务需要时间,并且我们不确定完成这个语音生成任务所需要的时间是多久,所以我们在获取生成结果时,需要设置一个重试的次数以及重试的时间,这些功能我们都在后端进行实现,相关的代码如下:

# 获取任务完成后的语音链接,max_retries为最大重试次数, retry_delay重试间隔时间
def get_audio_url(session_id: str, max_retries=5, retry_delay=30):
    url = f"https://v1.reecho.cn/api/tts/generate/{session_id}?stream"
    headers = {
        'Authorization': f'Bearer {reecho_model.api_key}',
        'Content-Type': 'application/json'
    }
    payload = {}

    for attempt in range(max_retries):
        try:
            response = requests.request("GET", url, headers=headers, data=payload)
            response.raise_for_status()
            data = response.json()
            
            if 'data' in data and 'metadata' in data['data'] and 'audio' in data['data']['metadata']:
                return data['data']['metadata']['audio']
            else:
                print(f"尝试 {attempt + 1}: 音频 URL 尚未准备好。响应内容: {data}")
                if attempt < max_retries - 1:
                    time.sleep(retry_delay)
                else:
                    raise HTTPException(status_code=500, detail="无法获取音频 URL")
        except requests.exceptions.RequestException as e:
            print(f"尝试 {attempt + 1} 失败: {str(e)}")
            if attempt < max_retries - 1:
                time.sleep(retry_delay)
            else:
                raise HTTPException(status_code=500, detail=f"获取音频 URL 失败: {str(e)}")

# 创建新的TTS任务
@reecho_router.post("/reecho")
async def reecho(text: list[dict]):
    print(f"Received text: {text}")  # 添加这行来打印接收到的数据
    url = "https://v1.reecho.cn/api/tts/generate"

    contents = []
    for item in text:
        speaker = item['speaker']
        voice_id = map_speaker_to_voice_id(speaker)
        contents.append({
            "voiceId": voice_id,
            "text": item['content'],
            "promptId": "default"
        })
    payload = json.dumps({
        "contents": contents,
        "randomness": reecho_model.randomness,
        "stability_boost": reecho_model.stability_boost,
        "probability_optimization": reecho_model.probability_optimization,
        "break_clone": reecho_model.break_clone,
        "sharpen": reecho_model.sharpen,
        "flash": reecho_model.flash,
        "stream": reecho_model.stream,
        "srt": reecho_model.srt,
        "seed": reecho_model.seed,
        "dictionary": reecho_model.dictionary,
        "origin_audio": False,
        "model": "reecho-neural-voice-001"
    })
    headers = {
        'Authorization': f'Bearer {reecho_model.api_key}',
        'Content-Type': 'application/json'
    }

    try:
        response = requests.request("POST", url, headers=headers, data=payload)
        response.raise_for_status()
        print(response.status_code)
        
        if response.text:
            session_id = response.json()['data']['id']
            print(f"获取到的 session_id: {session_id}")
            audio_url = get_audio_url(session_id)
            return audio_url
        else:
            raise HTTPException(status_code=500, detail="服务器返回了空响应")
    except requests.exceptions.RequestException as e:
        raise HTTPException(status_code=500, detail=f"请求失败: {str(e)}")
    except json.JSONDecodeError:
        raise HTTPException(status_code=500, detail=f"无法解析服务器响应: {response.text}")
    except KeyError as e:
        raise HTTPException(status_code=500, detail=f"响应中缺少关键字段: {str(e)}")

如果是调用本地模型,以CoquiTTS为例,直接通过配置好本地模型路径,遍历文本列表,生成各个文本对应的语音,然后再将语音进行拼接合成,返回最终合成的语音,代码思路如下:

@coqui_tts_router.post("/tts_to_file")
async def tts_to_file(text: list[dict]):
    if os.path.exists("audios"):
        pass
    else:
        os.mkdir("audios")
    tts = TTS(coqui_tts.model, progress_bar=True).to(device)
    for idx, item in enumerate(text):
        speaker = item['speaker']
        speaker_wav = map_speaker_to_wav(speaker)
        try:
            tts.tts_to_file(text=item['content'], speaker_wav=speaker_wav, language=language, file_path=f"audios/{idx}"+file_path)
        except:
            tts.tts_to_file(text=item['content'], language=language, file_path=f"audios/{idx}"+file_path)

        if idx == 0:
            sound = AudioSegment.from_wav(f"audios/{idx}"+file_path)
        else:
            sound += AudioSegment.from_wav(f"audios/{idx}"+file_path)
    
    # 将合并后的音频保存为文件
    sound.export(output_file, format="wav")

    # 使用 FileResponse 返回音频文件
    return FileResponse(output_file, media_type="audio/wav", filename="output.wav")

考虑到调用本地模型生成的语音文件会占用硬盘空间,所以我们还需要一个清理函数,在结束服务死能够将生成的临时文件进行删除处理。清理函数如下:

async def cleanup():
    if os.path.exists("output.wav"):
        os.remove("output.wav")
    for file in os.listdir("audios"):
        if file.endswith(".wav"):
            os.remove(os.path.join("audios", file))

后端处理完成之后,自然是将音频文件或者音频链接要返回到前端,那么我们前端除了接收音频文件以外,还需要能够播放和下载音频文件,这个实现利用了HTML5的原生音频功能和JavaScript的DOM操作来提供一个无缝的音频播放和下载,此时前端的工作流程如下:

当后端返回音频URL后,前端会更新状态并渲染一个audio元素,这个audio元素使用了HTML5的原生控件,允许用户直接在页面上播放音频。:

{audioUrl && (
  <div className="audio-player">
    <audio ref={audioRef} src={audioUrl} controls className="w-full" />
  </div>

下载音频的功能则通过handleDownloadAudio函数实现,根据TTS模式(API或本地),前端会以不同方式处理音频URL:

  • 对于API模式(如Reecho),直接使用返回的URL。
  • 对于本地模式(如CoquiTTS),使用URL.createObjectURL创建一个本地Blob URL。

然后创建一个临时的<a>元素,设置这个元素的href属性为音频URL,设置download属性为期望的文件名,将这个元素添加到文档中,模拟点击这个元素,触发下载。最后 从文档中移除这个临时元素。

const handleDownloadAudio = () => {
  if (audioUrl) {
    const link = document.createElement('a');
    link.href = audioUrl;
    link.download = '对话音频.wav'; // 改为 .wav 扩展名
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  }

至此,整个Web程序的实现思路告一段落,具体代码请见Github,同时呢我也欢迎各位大佬对项目进行魔改、改进等等。