| | |
| | import gradio as gr |
| | import spaces |
| | import os |
| | import tempfile |
| | import shutil |
| | from pathlib import Path |
| | from datetime import datetime |
| |
|
| | from utils import ( |
| | separate_vocals_and_instrumental, |
| | merge_vocals_and_instrumental, |
| | optimize_audio, |
| | ) |
| | from rvc_infer import rvc_convert |
| |
|
| | PROJECT_ROOT = Path(__file__).parent |
| |
|
| | SONGS_CONFIG = [ |
| | {"year": 2017, "file": "outputs/爱的故事上集-孙耀威_cloned.wav", |
| | "original": "songs/爱的故事上集-孙耀威.mp3", |
| | "message": "星的光点点洒于午夜,我们的故事,从这一年开始书写 💕"}, |
| | {"year": 2018, "file": "outputs/周杰伦 - 告白气球_cloned.wav", |
| | "original": "songs/周杰伦 - 告白气球.mp3", |
| | "message": "你说你有点难追,想让我知难而退。我没有退,这一年,我们更近了 ❤️"}, |
| | {"year": 2019, "file": "outputs/林俊杰 - 修炼爱情_cloned.wav", |
| | "original": "songs/林俊杰 - 修炼爱情.mp3", |
| | "message": "爱情需要修炼,每一年的陪伴,都是我们爱情的见证 🌟"}, |
| | {"year": 2020, "file": "outputs/周深-雪落下的声音_cloned.wav", |
| | "original": "songs/周深-雪落下的声音.mp3", |
| | "message": "就像雪花轻轻落下,你已经填满我的心 🎨"}, |
| | {"year": 2021, "file": "outputs/胡夏&郁可唯-知否知否_cloned.wav", |
| | "original": "songs/胡夏&郁可唯-知否知否.mp3", |
| | "message": "知否知否,时光荏苒,但我们的爱依然如初 💖"}, |
| | {"year": 2022, "file": "outputs/陈奕迅 - 陪你度过漫长岁月_cloned.wav", |
| | "original": "songs/陈奕迅 - 陪你度过漫长岁月.mp3", |
| | "message": "陪你把独自孤单,变成了勇敢 🌸"}, |
| | {"year": 2023, "file": "outputs/Edd_Sheeran_-_Perfect_cloned.wav", |
| | "original": "songs/Edd_Sheeran_-_Perfect.mp3", |
| | "message": "Baby, you're perfect in my eyes ✨"}, |
| | {"year": 2024, "file": "outputs/Michael_Learns_To_Rock_-_Take_Me_To_Your_Heart_Original_Version_cloned.wav", |
| | "original": "songs/Michael_Learns_To_Rock_-_Take_Me_To_Your_Heart_Original_Version.mp3", |
| | "message": "Take me to your heart, take me to your soul 🏠"}, |
| | {"year": 2025, "file": "outputs/Richard_Marx-Right_here_waiting_for_you_(mp3.pm)_cloned.wav", |
| | "original": "songs/Richard_Marx-Right_here_waiting_for_you_(mp3.pm).mp3", |
| | "message": "I will be right here waiting for you. 9年了,爱依然如故 💝"}, |
| | ] |
| |
|
| | def get_audio_path(song, version="cloned"): |
| | key = "file" if version == "cloned" else "original" |
| | path = PROJECT_ROOT / song[key] |
| | return str(path) if path.exists() else None |
| |
|
| | @spaces.GPU(duration=300) |
| | def convert_voice(audio_file, progress=gr.Progress()): |
| | if audio_file is None: |
| | return None, "❌ 请上传一个音频文件" |
| |
|
| | progress(0.05, desc="🎵 开始处理...") |
| |
|
| | with tempfile.TemporaryDirectory() as tmpdir: |
| | tmpdir = Path(tmpdir) |
| | input_path = Path(audio_file) |
| |
|
| | progress(0.1, desc="步骤1: 读谱 - 分离人声和伴奏...") |
| | vocals_path, instrumental_path = separate_vocals_and_instrumental(input_path, tmpdir) |
| |
|
| | if vocals_path is None: |
| | progress(0.3, desc="⚠️ 跳过分离,直接转换...") |
| | target_audio = input_path |
| | instrumental_path = None |
| | else: |
| | progress(0.4, desc="✅ 人声分离完成") |
| | target_audio = vocals_path |
| |
|
| | progress(0.5, desc="步骤2: 清嗓子 - 声线转换...") |
| | converted_vocals = tmpdir / "converted.wav" |
| |
|
| | model_dir = PROJECT_ROOT / "models" |
| | model_path = None |
| | for name in ["xiujia-1220-best", "xiujia-best", "xiujia"]: |
| | test = model_dir / f"{name}.pth" |
| | if test.exists(): |
| | model_path = test |
| | break |
| |
|
| | if model_path and model_path.exists(): |
| | rvc_convert(str(target_audio), str(converted_vocals), str(model_path)) |
| | else: |
| | shutil.copy(target_audio, converted_vocals) |
| | progress(0.7, desc="⚠️ 未找到模型,使用原音") |
| |
|
| | progress(0.8, desc="✅ 声线转换完成") |
| | progress(0.85, desc="步骤3: 开唱 - 合成音频...") |
| |
|
| | final_output = tmpdir / "final.wav" |
| |
|
| | if instrumental_path and instrumental_path.exists(): |
| | merge_vocals_and_instrumental(converted_vocals, instrumental_path, final_output) |
| | else: |
| | optimize_audio(converted_vocals, final_output) |
| |
|
| | result_name = f"converted_{datetime.now().strftime('%H%M%S')}.wav" |
| | result_path = PROJECT_ROOT / "outputs" / result_name |
| | result_path.parent.mkdir(exist_ok=True) |
| | shutil.copy(final_output, result_path) |
| |
|
| | progress(1.0, desc="✅ 完成!") |
| | return str(result_path), "🎉 转换成功!听听看吧~" |
| |
|
| | css = """ |
| | /* 背景:奶油粉渐变 + 轻微纹理感 */ |
| | .gradio-container{ |
| | background: radial-gradient(circle at 20% 10%, #fff7fb 0%, transparent 40%), |
| | radial-gradient(circle at 80% 20%, #fff0f7 0%, transparent 45%), |
| | linear-gradient(135deg, #fff7fb, #fff0f6, #fff7fb) !important; |
| | } |
| | |
| | /* 全局字体与字距更温柔 */ |
| | * { letter-spacing: .2px; } |
| | |
| | /* 标题更像纪念册 */ |
| | h1, h2, h3 { |
| | color: #d63384 !important; |
| | text-align: center; |
| | } |
| | h1 { font-weight: 800 !important; } |
| | |
| | /* 主容器:卡片化 */ |
| | #app_wrap{ |
| | max-width: 980px; |
| | margin: 0 auto; |
| | padding: 18px 18px 28px 18px; |
| | } |
| | |
| | /* Hero(封面) */ |
| | .hero{ |
| | border-radius: 24px; |
| | padding: 22px 18px; |
| | background: linear-gradient(135deg, rgba(255, 221, 238, .55), rgba(255, 255, 255, .75)); |
| | border: 1px solid rgba(214, 51, 132, .18); |
| | box-shadow: 0 10px 30px rgba(214, 51, 132, .10); |
| | } |
| | .badges{ |
| | display:flex; |
| | justify-content:center; |
| | gap:10px; |
| | margin-top:10px; |
| | flex-wrap: wrap; |
| | } |
| | .badge{ |
| | font-size: 12px; |
| | padding: 6px 10px; |
| | border-radius: 999px; |
| | background: rgba(255,255,255,.75); |
| | border: 1px solid rgba(214,51,132,.18); |
| | color:#b02a5b; |
| | } |
| | |
| | /* 卡片块通用 */ |
| | .soft-card{ |
| | border-radius: 20px; |
| | padding: 16px 14px; |
| | background: rgba(255,255,255,.72); |
| | border: 1px solid rgba(214,51,132,.14); |
| | box-shadow: 0 8px 22px rgba(214,51,132,.08); |
| | } |
| | |
| | /* Tabs 更圆润 */ |
| | .tabitem{ |
| | border-radius: 18px !important; |
| | } |
| | .tabs{ |
| | border-radius: 20px !important; |
| | overflow: hidden; |
| | } |
| | |
| | /* 按钮:奶油粉果冻感 */ |
| | button.primary{ |
| | border-radius: 999px !important; |
| | background: linear-gradient(135deg, #ff77b7, #ff5aa8) !important; |
| | border: none !important; |
| | box-shadow: 0 10px 22px rgba(255, 90, 168, .22) !important; |
| | } |
| | button.primary:hover{ |
| | transform: translateY(-1px); |
| | box-shadow: 0 14px 26px rgba(255, 90, 168, .28) !important; |
| | } |
| | |
| | /* 输入组件更圆润 */ |
| | input, textarea{ |
| | border-radius: 14px !important; |
| | } |
| | |
| | /* 图片边框更像相册 */ |
| | img{ |
| | border-radius: 18px !important; |
| | } |
| | |
| | /* Audio 组件卡片感 */ |
| | .audio{ |
| | border-radius: 18px !important; |
| | border: 1px solid rgba(214,51,132,.12) !important; |
| | background: rgba(255,255,255,.65) !important; |
| | } |
| | /* ===== Accordion(年份折叠面板)温馨主题 ===== */ |
| | |
| | /* 外层卡片 */ |
| | .accordion{ |
| | border-radius: 18px !important; |
| | border: 1px solid rgba(214,51,132,.14) !important; |
| | background: rgba(255,255,255,.62) !important; |
| | box-shadow: 0 8px 18px rgba(214,51,132,.08); |
| | overflow: hidden; |
| | } |
| | |
| | /* Accordion 标题行(收起/展开那一条) */ |
| | .accordion > .label-wrap, |
| | .accordion .label-wrap{ |
| | background: linear-gradient(135deg, rgba(255, 223, 238, .65), rgba(255,255,255,.65)) !important; |
| | border-bottom: 1px solid rgba(214,51,132,.12) !important; |
| | } |
| | |
| | /* ✅ 关键:把左侧“黑色竖条/边线”变成粉色(不同版本 gradio 结构不同,多个选择器兜底) */ |
| | .accordion, |
| | .accordion *{ |
| | --ring-color: rgba(255, 90, 168, .35); |
| | } |
| | |
| | /* 有些版本左边那条是 border-left 或 box-shadow */ |
| | .accordion{ |
| | border-left: 6px solid rgba(255, 90, 168, .35) !important; |
| | } |
| | |
| | /* 标题文字 */ |
| | .accordion .label, |
| | .accordion .label span, |
| | .accordion .label-wrap span{ |
| | color: #b02a5b !important; |
| | font-weight: 700 !important; |
| | } |
| | |
| | /* hover:更温柔的提亮 */ |
| | .accordion:hover{ |
| | transform: translateY(-1px); |
| | box-shadow: 0 12px 26px rgba(214,51,132,.12); |
| | border-left: 6px solid rgba(255, 90, 168, .55) !important; |
| | } |
| | |
| | /* 展开状态:更明显但不刺眼 |
| | Gradio 不同版本的“open”类名不一样,这里多写几个兜底 */ |
| | .accordion.open, |
| | .accordion[open], |
| | .accordion:has(.wrap) { |
| | border-left: 6px solid rgba(255, 90, 168, .75) !important; |
| | } |
| | |
| | /* 里面内容区域 */ |
| | .accordion .wrap, |
| | .accordion .panel, |
| | .accordion .content{ |
| | background: rgba(255,255,255,.55) !important; |
| | } |
| | |
| | /* 小箭头/图标颜色(避免默认黑) */ |
| | .accordion svg{ |
| | color: rgba(176, 42, 91, .75) !important; |
| | fill: rgba(176, 42, 91, .75) !important; |
| | } |
| | |
| | /* Accordion 内 Markdown 文字更柔和 */ |
| | .accordion .md, |
| | .accordion p, |
| | .accordion em{ |
| | color: rgba(80, 10, 30, .78) !important; |
| | } |
| | """ |
| |
|
| | css += """ |
| | /* ===== 强制修复:年份 Accordion 左侧黑条清除(适配不同 gradio DOM) ===== */ |
| | .year-acc{ |
| | position: relative !important; |
| | overflow: hidden !important; |
| | |
| | /* ✅ 直接把可能的黑色左边框干掉 */ |
| | border-left: 0 !important; |
| | box-shadow: 0 8px 20px rgba(214,51,132,.10) !important; |
| | } |
| | |
| | /* 兼容:如果内部某个元素画了左边框/阴影,全部清零 */ |
| | .year-acc *{ |
| | border-left-color: transparent !important; |
| | } |
| | |
| | /* ✅ 有些版本 Accordion 本体其实是 details/summary/button,逐个兜底灭掉左边黑条 */ |
| | .year-acc details, |
| | .year-acc summary, |
| | .year-acc button, |
| | .year-acc .label-wrap, |
| | .year-acc .label, |
| | .year-acc .wrap{ |
| | border-left: 0 !important; |
| | box-shadow: none !important; |
| | } |
| | |
| | /* ✅ 用我们自己的粉色竖条盖住最左侧(放到最顶层) */ |
| | .year-acc::before{ |
| | content: ""; |
| | position: absolute; |
| | left: 0; top: 0; bottom: 0; |
| | width: 10px; |
| | z-index: 5; |
| | pointer-events: none; |
| | background: linear-gradient(180deg, rgba(255, 90, 168, .85), rgba(255, 205, 226, .75)); |
| | border-top-left-radius: 18px; |
| | border-bottom-left-radius: 18px; |
| | } |
| | |
| | /* 标题区域往右挪,避免被粉条盖住 */ |
| | .year-acc .label-wrap, |
| | .year-acc summary, |
| | .year-acc button{ |
| | padding-left: 14px !important; |
| | } |
| | |
| | /* 箭头/图标别黑 */ |
| | .year-acc svg, |
| | .year-acc svg *{ |
| | stroke: rgba(176, 42, 91, .75) !important; |
| | fill: rgba(176, 42, 91, .75) !important; |
| | } |
| | |
| | /* hover 更温馨 */ |
| | .year-acc:hover::before{ |
| | background: linear-gradient(180deg, rgba(255, 90, 168, .95), rgba(255, 205, 226, .85)); |
| | } |
| | |
| | /* =============================== |
| | 彻底去掉年份 Accordion 的深色背景 |
| | =============================== */ |
| | |
| | /* Accordion header 本体(summary / button) */ |
| | .year-acc summary, |
| | .year-acc button, |
| | .year-acc .label-wrap, |
| | .year-acc [role="button"]{ |
| | background: linear-gradient( |
| | 135deg, |
| | rgba(255, 230, 240, 0.95), |
| | rgba(255, 250, 252, 0.95) |
| | ) !important; |
| | |
| | color: #b02a5b !important; |
| | border: none !important; |
| | box-shadow: none !important; |
| | } |
| | |
| | /* 有些版本深色来自 ::before / ::after,直接干掉 */ |
| | .year-acc summary::before, |
| | .year-acc summary::after, |
| | .year-acc button::before, |
| | .year-acc button::after{ |
| | background: transparent !important; |
| | } |
| | |
| | /* 去掉 hover 时恢复深色的问题 */ |
| | .year-acc summary:hover, |
| | .year-acc button:hover, |
| | .year-acc:hover summary{ |
| | background: linear-gradient( |
| | 135deg, |
| | rgba(255, 210, 230, 0.98), |
| | rgba(255, 245, 248, 0.98) |
| | ) !important; |
| | } |
| | |
| | /* 标题文字 */ |
| | .year-acc span, |
| | .year-acc .label, |
| | .year-acc summary span{ |
| | color: #b02a5b !important; |
| | font-weight: 700 !important; |
| | } |
| | |
| | /* 箭头图标别再黑 */ |
| | .year-acc svg, |
| | .year-acc svg *{ |
| | fill: rgba(176, 42, 91, .8) !important; |
| | stroke: rgba(176, 42, 91, .8) !important; |
| | } |
| | |
| | /* 展开态也保持浅色 */ |
| | .year-acc[open] summary, |
| | .year-acc.open summary{ |
| | background: linear-gradient( |
| | 135deg, |
| | rgba(255, 205, 225, 1), |
| | rgba(255, 245, 248, 1) |
| | ) !important; |
| | } |
| | |
| | """ |
| |
|
| | css += """ |
| | /* ===== Timeline 九年样式(只作用在九年歌曲集区域) ===== */ |
| | #timeline{ |
| | position: relative; |
| | padding-left: 22px; |
| | margin-top: 8px; |
| | } |
| | |
| | /* 竖线 */ |
| | #timeline::before{ |
| | content:""; |
| | position:absolute; |
| | left: 10px; |
| | top: 6px; |
| | bottom: 6px; |
| | width: 4px; |
| | border-radius: 999px; |
| | background: linear-gradient(180deg, rgba(255, 90, 168, .55), rgba(255, 205, 226, .35)); |
| | } |
| | |
| | /* 每个年份卡片容器 */ |
| | .year-card{ |
| | position: relative; |
| | margin: 12px 0; |
| | border-radius: 18px !important; |
| | border: 1px solid rgba(214,51,132,.14) !important; |
| | background: rgba(255,255,255,.70) !important; |
| | box-shadow: 0 10px 22px rgba(214,51,132,.08) !important; |
| | overflow: hidden !important; |
| | } |
| | |
| | /* 左侧圆点 */ |
| | .year-card::before{ |
| | content:""; |
| | position:absolute; |
| | left: -18px; |
| | top: 18px; |
| | width: 14px; |
| | height: 14px; |
| | border-radius: 999px; |
| | background: linear-gradient(135deg, rgba(255, 90, 168, .95), rgba(255, 205, 226, .95)); |
| | box-shadow: 0 6px 14px rgba(255, 90, 168, .18); |
| | border: 2px solid rgba(255,255,255,.9); |
| | } |
| | |
| | /* ===== 彻底去掉 Accordion header 的深色背景(关键) ===== */ |
| | .year-card summary, |
| | .year-card button, |
| | .year-card .label-wrap, |
| | .year-card [role="button"]{ |
| | background: linear-gradient(135deg, rgba(255,230,240,.95), rgba(255,250,252,.95)) !important; |
| | color: #b02a5b !important; |
| | border: none !important; |
| | box-shadow: none !important; |
| | } |
| | |
| | /* 防止深色来自伪元素 */ |
| | .year-card summary::before, |
| | .year-card summary::after, |
| | .year-card button::before, |
| | .year-card button::after{ |
| | background: transparent !important; |
| | } |
| | |
| | /* 标题文字 */ |
| | .year-card .label, |
| | .year-card span{ |
| | color: rgba(176, 42, 91, .92) !important; |
| | font-weight: 800 !important; |
| | } |
| | |
| | /* hover 更温柔 */ |
| | .year-card:hover summary, |
| | .year-card summary:hover{ |
| | background: linear-gradient(135deg, rgba(255,210,230,.98), rgba(255,245,248,.98)) !important; |
| | } |
| | |
| | /* 内容区 */ |
| | .year-card .wrap, |
| | .year-card .panel, |
| | .year-card .content{ |
| | background: rgba(255,255,255,.62) !important; |
| | } |
| | |
| | /* 箭头图标 */ |
| | .year-card svg, |
| | .year-card svg *{ |
| | stroke: rgba(176, 42, 91, .78) !important; |
| | fill: rgba(176, 42, 91, .78) !important; |
| | } |
| | |
| | /* 小提示文字更柔 */ |
| | .year-card .md, |
| | .year-card p, |
| | .year-card em{ |
| | color: rgba(80, 10, 30, .78) !important; |
| | } |
| | |
| | /* 让每年的标题更“章节感” */ |
| | .year-title{ |
| | font-size: 16px; |
| | font-weight: 900; |
| | } |
| | """ |
| |
|
| |
|
| |
|
| | with gr.Blocks(title="💕 9周年纪念", theme=gr.themes.Soft(primary_hue="pink"), css=css) as demo: |
| | gr.Markdown("# 💕 9th Anniversary Celebration 💕\n### 2017 - 2025 · 九年,久远") |
| |
|
| | with gr.Row(): |
| | for img_name in ["couple.png", "couple1.png"]: |
| | img_path = PROJECT_ROOT / img_name |
| | if img_path.exists(): |
| | gr.Image(str(img_path), show_label=False, height=220, container=False) |
| |
|
| | with gr.Tab("🎵 九年歌曲集"): |
| | gr.Markdown("## 🎵 九年,唱不尽的爱") |
| | gr.Markdown("像翻纪念册一样,打开每一年听听我们的旋律。") |
| | |
| | with gr.Column(elem_id="timeline"): |
| | for song in SONGS_CONFIG: |
| | with gr.Accordion( |
| | f"💗 {song['year']} 年", |
| | open=False, |
| | elem_classes=["year-card"], |
| | ): |
| | gr.Markdown(f"*{song['message']}*") |
| | with gr.Row(): |
| | cloned = get_audio_path(song, "cloned") |
| | original = get_audio_path(song, "original") |
| | if cloned: |
| | gr.Audio(cloned, label="🎤 老公唱") |
| | if original: |
| | gr.Audio(original, label="🎵 原唱") |
| |
|
| |
|
| | with gr.Tab("🎤 上传歌曲"): |
| | gr.Markdown("## 🎤 上传MP3,我唱给你听!") |
| | with gr.Row(): |
| | with gr.Column(): |
| | audio_in = gr.Audio(label="选择歌曲 🎵", type="filepath", sources=["upload"]) |
| | btn = gr.Button("✨ 开始转换", variant="primary", size="lg") |
| | status = gr.Textbox(label="状态", interactive=False) |
| | with gr.Column(): |
| | audio_out = gr.Audio(label="🎵 老公开唱", type="filepath") |
| | btn.click(convert_voice, [audio_in], [audio_out, status]) |
| |
|
| | gr.Markdown("---\n## 💝 九年不是终点,而是我们故事的第九章 💝") |
| |
|
| | with gr.Row(): |
| | for img_name in ["family.png", "family2.png"]: |
| | img_path = PROJECT_ROOT / img_name |
| | if img_path.exists(): |
| | gr.Image(str(img_path), show_label=False, height=220, container=False) |
| |
|
| | if __name__ == "__main__": |
| | demo.launch() |
| |
|