Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 42 additions & 35 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,75 +24,77 @@
OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech"
OPENAI_KEY = os.environ.get("OPENAI_API_KEY", "")

CATEGORIES = ["医疗健康", "教育", "文化艺术", "劳动者", "商务科技", "生活"]

PRESETS = [
{"id": "lingnan_uncle", "name": "岭南大叔", "file": "lingnan_uncle.png", "voice": "onyx", "gender": "male",
"desc": "50 岁广东老街坊", "samples": ["今天带大家品品正宗广州早茶", "来来来,饮啖茶先"]},
"category": "生活", "desc": "50 岁广东老街坊", "samples": ["今天带大家品品正宗广州早茶", "来来来,饮啖茶先"]},
Comment on lines +27 to +31
{"id": "shenzhen_student", "name": "深圳中学生", "file": "shenzhen_student.png", "voice": "shimmer", "gender": "female",
"desc": "15 岁活泼女生", "samples": ["同学们好!今天聊聊科技创新", "暑假我参加了编程夏令营"]},
"category": "生活", "desc": "15 岁活泼女生", "samples": ["同学们好!今天聊聊科技创新", "暑假我参加了编程夏令营"]},
{"id": "news_anchor", "name": "新闻主播", "file": "news_anchor.png", "voice": "nova", "gender": "female",
"desc": "30 岁专业女主播", "samples": ["观众朋友们好,欢迎收看今天的新闻", "接下来是一则重要报道"]},
"category": "商务科技", "desc": "30 岁专业女主播", "samples": ["观众朋友们好,欢迎收看今天的新闻", "接下来是一则重要报道"]},
{"id": "tech_engineer", "name": "IT工程师", "file": "tech_engineer.png", "voice": "echo", "gender": "male",
"desc": "28 岁码农小王", "samples": ["今天来聊聊大模型的最新进展", "这个 bug 我找了三天终于搞定了"]},
"category": "商务科技", "desc": "28 岁码农小王", "samples": ["今天来聊聊大模型的最新进展", "这个 bug 我找了三天终于搞定了"]},
{"id": "retired_professor", "name": "退休教授", "file": "retired_professor.png", "voice": "onyx", "gender": "male",
"desc": "65 岁历史学者", "samples": ["让我们回顾一下这段波澜壮阔的历史", "读书破万卷,下笔如有神"]},
"category": "教育", "desc": "65 岁历史学者", "samples": ["让我们回顾一下这段波澜壮阔的历史", "读书破万卷,下笔如有神"]},
{"id": "college_girl", "name": "大学生小林", "file": "college_girl.png", "voice": "nova", "gender": "female",
"desc": "20 岁开朗学姐", "samples": ["学弟学妹们,选课有什么想问的吗?", "大学四年最重要的是找到自己的方向"]},
"category": "教育", "desc": "20 岁开朗学姐", "samples": ["学弟学妹们,选课有什么想问的吗?", "大学四年最重要的是找到自己的方向"]},
{"id": "fitness_coach", "name": "健身教练", "file": "fitness_coach.png", "voice": "echo", "gender": "male",
"desc": "32 岁运动达人", "samples": ["来!今天做三组深蹲,跟我一起动", "健身不只是练肌肉,更是练意志力"]},
"category": "生活", "desc": "32 岁运动达人", "samples": ["来!今天做三组深蹲,跟我一起动", "健身不只是练肌肉,更是练意志力"]},
{"id": "doctor_li", "name": "李医生", "file": "doctor_li.png", "voice": "nova", "gender": "female",
"desc": "38 岁温柔医师", "samples": ["别紧张,我来给你解释一下检查结果", "预防胜于治疗,记得定期体检"]},
"category": "医疗健康", "desc": "38 岁温柔医师", "samples": ["别紧张,我来给你解释一下检查结果", "预防胜于治疗,记得定期体检"]},
{"id": "hip_hop_youth", "name": "说唱少年", "file": "hip_hop_youth.png", "voice": "echo", "gender": "male",
"desc": "22 岁地下说唱", "samples": ["这首歌献给所有追梦的年轻人", "我的音乐就是我的态度"]},
"category": "生活", "desc": "22 岁地下说唱", "samples": ["这首歌献给所有追梦的年轻人", "我的音乐就是我的态度"]},
{"id": "business_woman", "name": "商务总监", "file": "business_woman.png", "voice": "nova", "gender": "female",
"desc": "42 岁职场精英", "samples": ["这个季度的业绩目标,我们一起看看", "谈判的关键是找到双赢的方案"]},
"category": "商务科技", "desc": "42 岁职场精英", "samples": ["这个季度的业绩目标,我们一起看看", "谈判的关键是找到双赢的方案"]},
{"id": "grandma_chen", "name": "陈奶奶", "file": "grandma_chen.png", "voice": "shimmer", "gender": "female",
"desc": "75 岁慈祥老人", "samples": ["孩子们,奶奶给你们讲个故事", "做人啊,最重要的就是善良"]},
"category": "生活", "desc": "75 岁慈祥老人", "samples": ["孩子们,奶奶给你们讲个故事", "做人啊,最重要的就是善良"]},
{"id": "child_host", "name": "小小主持人", "file": "child_host.png", "voice": "alloy", "gender": "male",
"desc": "10 岁阳光少年", "samples": ["大家好!我是今天的小主持人", "接下来请欣赏我的才艺表演"]},
"category": "教育", "desc": "10 岁阳光少年", "samples": ["大家好!我是今天的小主持人", "接下来请欣赏我的才艺表演"]},
{"id": "yoga_teacher", "name": "瑜伽老师", "file": "yoga_teacher.png", "voice": "shimmer", "gender": "female",
"desc": "30 岁身心教练(半身)", "samples": ["深呼吸,感受当下的平静", "今天的主题是肩颈放松"]},
"category": "生活", "desc": "30 岁身心教练(半身)", "samples": ["深呼吸,感受当下的平静", "今天的主题是肩颈放松"]},
{"id": "street_musician", "name": "街头歌手", "file": "street_musician.png", "voice": "echo", "gender": "male",
"desc": "25 岁独立音乐人(全身)", "samples": ["这首歌送给每一个在路上的人", "音乐不需要舞台,哪里都可以唱"]},
"category": "文化艺术", "desc": "25 岁独立音乐人(全身)", "samples": ["这首歌送给每一个在路上的人", "音乐不需要舞台,哪里都可以唱"]},
{"id": "chef_wang", "name": "王大厨", "file": "chef_wang.png", "voice": "onyx", "gender": "male",
"desc": "45 岁川菜大师(半身)", "samples": ["今天教大家做一道正宗的麻婆豆腐", "火候是厨艺的灵魂"]},
"category": "生活", "desc": "45 岁川菜大师(半身)", "samples": ["今天教大家做一道正宗的麻婆豆腐", "火候是厨艺的灵魂"]},
{"id": "nurse_zhang", "name": "张护士", "file": "nurse_zhang.png", "voice": "nova", "gender": "female",
"desc": "28 岁温暖守护者", "samples": ["别怕,打针一点都不疼", "记得按时吃药,早日康复"]},
"category": "医疗健康", "desc": "28 岁温暖守护者", "samples": ["别怕,打针一点都不疼", "记得按时吃药,早日康复"]},
{"id": "kindergarten_teacher", "name": "幼儿园老师", "file": "kindergarten_teacher.png", "voice": "shimmer", "gender": "female",
"desc": "25 岁孩子王", "samples": ["小朋友们,今天我们来画画吧", "每个孩子都是独一无二的星星"]},
"category": "教育", "desc": "25 岁孩子王", "samples": ["小朋友们,今天我们来画画吧", "每个孩子都是独一无二的星星"]},
{"id": "farmer_uncle", "name": "老农大伯", "file": "farmer_uncle.png", "voice": "onyx", "gender": "male",
"desc": "55 岁稻田守望者", "samples": ["今年的稻子长势不错", "种地这事,急不来,得看天"]},
"category": "劳动者", "desc": "55 岁稻田守望者", "samples": ["今年的稻子长势不错", "种地这事,急不来,得看天"]},
{"id": "firefighter", "name": "消防战士", "file": "firefighter.png", "voice": "echo", "gender": "male",
"desc": "30 岁逆行英雄", "samples": ["我们的使命是保护每一个人的安全", "火灾预防比救火更重要"]},
"category": "劳动者", "desc": "30 岁逆行英雄", "samples": ["我们的使命是保护每一个人的安全", "火灾预防比救火更重要"]},
{"id": "delivery_rider", "name": "外卖小哥", "file": "delivery_rider.png", "voice": "echo", "gender": "male",
"desc": "28 岁城市骑手(半身)", "samples": ["您的订单马上就到", "风里雨里,我们一直在路上"]},
"category": "劳动者", "desc": "28 岁城市骑手(半身)", "samples": ["您的订单马上就到", "风里雨里,我们一直在路上"]},
{"id": "elderly_calligrapher", "name": "书法老人", "file": "elderly_calligrapher.png", "voice": "onyx", "gender": "male",
"desc": "75 岁翰墨传承人", "samples": ["写字如做人,一笔一划都要端正", "这幅字送给有缘人"]},
"category": "文化艺术", "desc": "75 岁翰墨传承人", "samples": ["写字如做人,一笔一划都要端正", "这幅字送给有缘人"]},
{"id": "deaf_artist", "name": "无声画家", "file": "deaf_artist.png", "voice": "shimmer", "gender": "female",
"desc": "29 岁听障艺术家", "samples": ["我的画笔就是我的声音", "色彩能传达语言无法表达的情感"]},
"category": "文化艺术", "desc": "29 岁听障艺术家", "samples": ["我的画笔就是我的声音", "色彩能传达语言无法表达的情感"]},
{"id": "taxi_driver", "name": "出租车师傅", "file": "taxi_driver.png", "voice": "onyx", "gender": "male",
"desc": "50 岁城市百科全书", "samples": ["我开了二十年出租,这城市的故事都装在车里", "上车吧,我带你抄近路"]},
"category": "劳动者", "desc": "50 岁城市百科全书", "samples": ["我开了二十年出租,这城市的故事都装在车里", "上车吧,我带你抄近路"]},
{"id": "tea_master", "name": "茶艺师", "file": "tea_master.png", "voice": "nova", "gender": "female",
"desc": "42 岁传统文化守护者", "samples": ["品茶如品人生,需要慢慢体会", "这壶龙井,是今年明前的新茶"]},
"category": "文化艺术", "desc": "42 岁传统文化守护者", "samples": ["品茶如品人生,需要慢慢体会", "这壶龙井,是今年明前的新茶"]},
{"id": "postal_worker", "name": "乡邮员", "file": "postal_worker.png", "voice": "echo", "gender": "male",
"desc": "45 岁大山信使", "samples": ["这条山路我走了二十年,每一家都认识", "再远的地方,信也能送到"]},
"category": "劳动者", "desc": "45 岁大山信使", "samples": ["这条山路我走了二十年,每一家都认识", "再远的地方,信也能送到"]},
{"id": "welder_woman", "name": "女焊工", "file": "welder_woman.png", "voice": "nova", "gender": "female",
"desc": "35 岁钢铁玫瑰", "samples": ["焊缝要均匀,就像做人要踏实", "谁说女人不能做这行?"]},
"category": "劳动者", "desc": "35 岁钢铁玫瑰", "samples": ["焊缝要均匀,就像做人要踏实", "谁说女人不能做这行?"]},
{"id": "blind_musician", "name": "盲人琴师", "file": "blind_musician.png", "voice": "onyx", "gender": "male",
"desc": "62 岁二胡演奏家", "samples": ["音乐是光,即使看不见也能感受到", "这首二泉映月,送给所有在黑暗中坚持的人"]},
"category": "文化艺术", "desc": "62 岁二胡演奏家", "samples": ["音乐是光,即使看不见也能感受到", "这首二泉映月,送给所有在黑暗中坚持的人"]},
{"id": "migrant_mom", "name": "打工妈妈", "file": "migrant_mom.png", "voice": "nova", "gender": "female",
"desc": "35 岁远方的牵挂", "samples": ["孩子,妈妈在外面一切都好", "等攒够了钱,就回家陪你"]},
"category": "生活", "desc": "35 岁远方的牵挂", "samples": ["孩子,妈妈在外面一切都好", "等攒够了钱,就回家陪你"]},
{"id": "game_developer", "name": "游戏开发者", "file": "game_developer.png", "voice": "echo", "gender": "male",
"desc": "26 岁独立开发者", "samples": ["这个游戏的灵感来自我的童年", "做游戏最重要的是好玩"]},
"category": "商务科技", "desc": "26 岁独立开发者", "samples": ["这个游戏的灵感来自我的童年", "做游戏最重要的是好玩"]},
{"id": "librarian", "name": "图书管理员", "file": "librarian.png", "voice": "nova", "gender": "female",
"desc": "52 岁知识守门人", "samples": ["这本书我推荐了三十年", "图书馆是最公平的地方,人人都能来"]},
"category": "教育", "desc": "52 岁知识守门人", "samples": ["这本书我推荐了三十年", "图书馆是最公平的地方,人人都能来"]},
{"id": "fisherman", "name": "老渔民", "file": "fisherman.png", "voice": "onyx", "gender": "male",
"desc": "55 岁海上老人(全身)", "samples": ["大海教会我的,比学校多得多", "今天浪不大,出海正好"]},
"category": "劳动者", "desc": "55 岁海上老人(全身)", "samples": ["大海教会我的,比学校多得多", "今天浪不大,出海正好"]},
{"id": "ballet_dancer", "name": "芭蕾舞者", "file": "ballet_dancer.png", "voice": "shimmer", "gender": "female",
"desc": "23 岁翩翩起舞(半身)", "samples": ["每一次旋转都是和自己的对话", "台上一分钟,台下十年功"]},
"category": "文化艺术", "desc": "23 岁翩翩起舞(半身)", "samples": ["每一次旋转都是和自己的对话", "台上一分钟,台下十年功"]},
{"id": "construction_worker", "name": "建筑工人", "file": "construction_worker.png", "voice": "echo", "gender": "male",
"desc": "35 岁城市建造者", "samples": ["这栋楼是我们一砖一瓦盖起来的", "虽然辛苦,但看到高楼拔地而起很自豪"]},
"category": "劳动者", "desc": "35 岁城市建造者", "samples": ["这栋楼是我们一砖一瓦盖起来的", "虽然辛苦,但看到高楼拔地而起很自豪"]},
{"id": "flower_shop_owner", "name": "花店老板娘", "file": "flower_shop_owner.png", "voice": "nova", "gender": "female",
"desc": "40 岁用花传情(半身)", "samples": ["每束花都有它的语言", "今天这束向日葵,送给需要阳光的人"]},
"category": "生活", "desc": "40 岁用花传情(半身)", "samples": ["每束花都有它的语言", "今天这束向日葵,送给需要阳光的人"]},
]

VOICE_LABELS = {
Expand Down Expand Up @@ -123,20 +125,25 @@ def _find_preset(avatar_id):
async def list_voices():
return [{"id": k, "label": v} for k, v in VOICE_LABELS.items()]

@app.get("/api/categories")
async def list_categories():
return CATEGORIES

@app.get("/api/avatars")
async def list_avatars():
result = []
for p in PRESETS:
result.append({
"id": p["id"], "name": p["name"], "preset": True,
"voice": p["voice"], "gender": p["gender"],
"category": p.get("category", ""),
"desc": p.get("desc", ""), "samples": p.get("samples", []),
})
for aid, info in _load_meta().items():
result.append({
"id": aid, "name": info["name"], "preset": False,
"voice": info.get("voice", "nova"), "gender": info.get("gender", "unknown"),
"desc": "", "samples": [],
"category": "", "desc": "", "samples": [],
})
return result

Expand Down
89 changes: 68 additions & 21 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ const $$ = (s) => document.querySelectorAll(s);

let selectedAvatar = null;
let avatarData = {};
let allAvatars = [];
let generating = false;
let currentMode = "direct";
let currentEngine = "sadtalker";
let currentCategory = null;
let voiceLabels = {};

function toast(msg, ms = 3000) {
Expand All @@ -26,31 +28,75 @@ async function loadVoices() {
} catch {}
}

async function loadAvatars() {
async function loadCategories() {
try {
const r = await fetch("/api/avatars");
const r = await fetch("/api/categories");
if (!r.ok) return;
const avatars = await r.json();
const list = $("#avatar-list");
list.innerHTML = "";
avatars.forEach((a) => {
avatarData[a.id] = a;
const card = document.createElement("div");
card.className = "avatar-card" + (selectedAvatar === a.id ? " active" : "");
card.title = a.desc || a.name;
card.innerHTML = `
<img src="/api/avatars/${a.id}/image" alt="${a.name}" loading="lazy">
<span class="avatar-name">${a.name}</span>
`;
card.addEventListener("click", () => {
selectedAvatar = a.id;
$$(".avatar-card").forEach((c) => c.classList.remove("active"));
card.classList.add("active");
showSamples(a);
updateUI();
const cats = await r.json();
const container = $("#category-filter");
if (!container) return;
container.innerHTML = "";

const allChip = document.createElement("button");
allChip.className = "category-chip active";
allChip.textContent = "全部";
allChip.addEventListener("click", () => {
currentCategory = null;
$$(".category-chip").forEach((c) => c.classList.remove("active"));
allChip.classList.add("active");
renderAvatars();
});
container.appendChild(allChip);

cats.forEach((cat) => {
const chip = document.createElement("button");
chip.className = "category-chip";
chip.textContent = cat;
chip.addEventListener("click", () => {
currentCategory = cat;
$$(".category-chip").forEach((c) => c.classList.remove("active"));
chip.classList.add("active");
Comment on lines +40 to +58
renderAvatars();
});
list.appendChild(card);
container.appendChild(chip);
});
} catch (e) {
console.error("loadCategories:", e);
}
}

function renderAvatars() {
const list = $("#avatar-list");
list.innerHTML = "";
const filtered = currentCategory
? allAvatars.filter((a) => a.category === currentCategory)
: allAvatars;
filtered.forEach((a) => {
const card = document.createElement("div");
card.className = "avatar-card" + (selectedAvatar === a.id ? " active" : "");
card.title = a.desc || a.name;
card.innerHTML = `
<img src="/api/avatars/${a.id}/image" alt="${a.name}" loading="lazy">
<span class="avatar-name">${a.name}</span>
`;
card.addEventListener("click", () => {
selectedAvatar = a.id;
$$(".avatar-card").forEach((c) => c.classList.remove("active"));
card.classList.add("active");
showSamples(a);
updateUI();
});
list.appendChild(card);
});
}

async function loadAvatars() {
try {
const r = await fetch("/api/avatars");
if (!r.ok) return;
allAvatars = await r.json();
allAvatars.forEach((a) => { avatarData[a.id] = a; });
renderAvatars();
} catch (e) {
console.error("loadAvatars:", e);
}
Expand Down Expand Up @@ -307,6 +353,7 @@ $("#send-btn").addEventListener("click", generate);
// Init
(async () => {
await loadVoices();
await loadCategories();
await loadAvatars();
checkHealth();
updateUI();
Expand Down
5 changes: 3 additions & 2 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数字人直播</title>
<link rel="stylesheet" href="style.css?v=9">
<link rel="stylesheet" href="style.css?v=10">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600&display=swap" rel="stylesheet">
</head>
Expand All @@ -24,6 +24,7 @@

<div class="section">
<div class="section-label">选择角色</div>
<div id="category-filter" class="category-filter"></div>
<div id="avatar-list" class="avatar-grid"></div>
</div>

Expand Down Expand Up @@ -90,6 +91,6 @@

<div id="toast" class="toast hidden"></div>

<script src="app.js?v=9"></script>
<script src="app.js?v=10"></script>
</body>
</html>
Loading