File size: 10,795 Bytes
bc3f652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59e4843
bc3f652
 
 
 
 
16f9689
 
 
 
 
bc3f652
 
 
 
 
 
 
16f9689
bc3f652
16f9689
bc3f652
 
 
 
 
 
 
 
f97fef1
bc3f652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5a62b9
 
 
bc3f652
 
f5a62b9
 
 
 
 
 
bc3f652
 
 
 
 
 
59e4843
bc3f652
 
 
 
f97fef1
bc3f652
 
 
 
 
 
 
16f9689
 
 
 
 
bc3f652
 
f5a62b9
59e4843
f5a62b9
 
 
f97fef1
bc3f652
59e4843
f97fef1
f5a62b9
bc3f652
 
 
f97fef1
 
bc3f652
 
 
 
f5a62b9
bc3f652
 
f97fef1
 
bc3f652
 
 
 
 
 
 
 
f5a62b9
 
bc3f652
04f9110
f97fef1
 
bc3f652
f97fef1
bc3f652
 
 
 
59e4843
bc3f652
 
 
 
f5a62b9
 
bc3f652
 
 
 
 
f5a62b9
f97fef1
bc3f652
 
59e4843
bc3f652
 
f5a62b9
bc3f652
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""CPU-capable, renderer-constrained speech-to-piano demonstration."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import shutil
import time

os.environ.setdefault('OMP_NUM_THREADS', '2')
os.environ.setdefault('MPLBACKEND', 'Agg')
import gradio as gr
import numpy as np
import soundfile as sf
from player import player_component, player_payload

ROOT = Path(__file__).resolve().parent
VOICE = '一位温柔自信的年轻女性,声音清晰,语气亲切,表达轻快而富有感染力。'
MODEL = 'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign'
DEVICE = os.getenv('INFERENCE_DEVICE', 'cpu')
if DEVICE not in {'cpu', 'cuda', 'auto', 'zerogpu'}:
    raise ValueError('INFERENCE_DEVICE must be cpu, cuda, auto or zerogpu')
if DEVICE == 'zerogpu':
    from zero_tts import generate_speech as generate_zero_speech

RUNS = ROOT / 'outputs' / 'runs'
RUNS.mkdir(parents=True, exist_ok=True)


def worker(command, log, timeout=1800):
    """Bound each job and release all model memory when the child exits."""
    env = os.environ.copy()
    if DEVICE in {'cpu', 'zerogpu'}:
        env['CUDA_VISIBLE_DEVICES'] = ''
        env['SPACES_ZERO_GPU'] = '0'
    with log.open('w') as stream:
        process = subprocess.Popen([sys.executable, *command], cwd=ROOT, env=env,
                                   stdout=stream, stderr=subprocess.STDOUT)
        try:
            process.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            process.kill()
            process.wait()
            raise gr.Error('This job exceeded 30 minutes. Try a shorter clip or fewer fitting iterations.') from None
        except BaseException:
            process.kill()
            process.wait()
            raise
    if process.returncode:
        print(log.read_text()[-6000:], flush=True)
        raise gr.Error('Generation failed. Please try a shorter clip; server logs contain details.')


def prepare_audio(path, out, seconds):
    if not path:
        raise gr.Error('Upload or record an audio clip first.')
    info = sf.info(path)
    if not 8000 <= info.samplerate <= 192000 or info.channels > 8:
        raise gr.Error('Use audio with 1–8 channels and an 8–192 kHz sample rate.')
    # Read only the selected prefix, bounding memory even for long uploads.
    audio, rate = sf.read(path, frames=int(float(seconds) * info.samplerate), always_2d=True)
    audio = audio.mean(axis=1)
    if len(audio) < rate * .2 or not np.isfinite(audio).all() or np.max(np.abs(audio)) < 1e-7:
        raise gr.Error('Audio must contain at least 0.2 seconds of finite, non-silent sound.')
    sf.write(out, audio, rate, subtype='FLOAT')
    return len(audio) / rate


def result(directory, status, stem='dense'):
    return (status, str(directory/'target.wav'), player_payload(directory, stem=stem),
            str(directory/f'{stem}.mid'))


def run(mode, audio, text, language, description, seconds, steps, fitting_style='Speech (dense)', progress=gr.Progress()):
    if mode not in {'Audio → MIDI', 'Text → speech → MIDI'} or not .5 <= float(seconds) <= 30 or not 200 <= int(steps) <= 1200:
        raise gr.Error('Choose a valid mode, duration (0.5–30 s), and iteration count (200–1200).')
    if fitting_style not in {'Speech (dense)', 'Instrumental (sparse)'}:
        raise gr.Error('Choose Speech (dense) or Instrumental (sparse) fitting.')
    stem = 'instrumental' if fitting_style == 'Instrumental (sparse)' else 'dense'
    if language not in {'Auto', 'English', 'Chinese', 'Japanese', 'Korean', 'French', 'German', 'Spanish', 'Portuguese', 'Russian'}:
        raise gr.Error('Choose a supported language.')
    for old in RUNS.glob('job-*'):
        if old.is_dir() and time.time() - old.stat().st_mtime > 86400:
            shutil.rmtree(old)
    directory = Path(tempfile.mkdtemp(prefix='job-', dir=RUNS))
    empty = (None, '', None)
    started = time.monotonic()
    if mode == 'Text → speech → MIDI':
        if not text.strip() or len(text) > 300 or not description.strip() or len(description) > 600:
            raise gr.Error('Enter 1–300 text characters and a voice description of 1–600 characters.')
        yield ('Generating speech. '+('Waiting for a shared GPU.' if DEVICE == 'zerogpu' else 'CPU VoiceDesign can take several minutes on first use.'), *empty)
        progress(.05, desc='Loading VoiceDesign and generating speech')
        prompts = directory/'prompts.json'
        prompts.write_text(json.dumps([dict(name='speech', text=text.strip(), language=language, instruct=description.strip())]))
        command = ['scripts/generate_speech.py', '--device', DEVICE, '--model', MODEL,
                   '--samples', str(prompts), '--out', str(directory)]
        if os.getenv('QWEN_MODEL_PATH'):
            command += ['--model-path', os.environ['QWEN_MODEL_PATH']]
        if DEVICE == 'zerogpu':
            wav, rate = generate_zero_speech(text.strip(), language, description.strip())
            sf.write(directory/'speech.wav', wav, rate, subtype='FLOAT')
        else:
            worker(command, directory/'tts.log')
        audio = str(directory/'speech.wav')
    duration = prepare_audio(audio, directory/'input.wav', seconds)
    yield (f'Fitting the first {duration:.2f} s · {fitting_style}.',
           str(directory/'input.wav'), '', None)
    progress(.3, desc=f'Fitting {stem} piano notes and rendering the MIDI')
    module = 'speaking_midi.instrumental_demo' if stem == 'instrumental' else 'speaking_midi.dense'
    command = ['-m', module, str(directory/'input.wav'), '--out', str(directory),
               '--soundfont', str(ROOT/'assets/gm.sf3'), '--steps', str(int(steps)), '--device', 'cpu']
    worker(command, directory/'fit.log')
    progress(.95, desc='Preparing the MIDI player')
    report = json.loads((directory/'report.json').read_text())
    yield result(directory, f"Complete in {time.monotonic()-started:.1f} s · {report['notes']:,} piano notes · {fitting_style}.", stem=stem)


def example(language):
    name = {'en': 'the-tyger', 'zh': 'quiet-night'}[language]
    return result(ROOT/'examples/dense'/name, 'Precomputed poetry voice · dense piano fitting.')


def build_ui():
    with gr.Blocks(title='Speaking MIDI') as demo:
        gr.Markdown('# Speaking MIDI\nTurn speech or music into piano notes. Listen, follow the piano roll, and download the MIDI.\n'
                    'This is experimental sound approximation: words may become difficult to understand.')
        with gr.Row():
            english = gr.Button('Listen to The Tyger · English')
            chinese = gr.Button('Listen to Quiet Night · Chinese')
        with gr.Row():
            with gr.Column(scale=1):
                mode = gr.Radio(['Audio → MIDI', 'Text → speech → MIDI'], value='Audio → MIDI', label='Start with')
                audio = gr.Audio(sources=['upload', 'microphone'], type='filepath', label='Input audio')
                with gr.Group(visible=False) as speech_controls:
                    text = gr.Textbox(value='Hello, can you hear the piano speaking?', label='Words to speak', lines=3, max_length=300)
                    language = gr.Dropdown(['Auto', 'English', 'Chinese', 'Japanese', 'Korean', 'French', 'German', 'Spanish', 'Portuguese', 'Russian'], value='English', label='Language')
                    description = gr.Textbox(value=VOICE, label='Voice description', lines=3, max_length=600)
                fitting_style = gr.Radio(['Speech (dense)', 'Instrumental (sparse)'], value='Speech (dense)',
                                         label='Fitting style', info='Speech keeps dense short notes. Instrumental uses fewer, longer notes.')
                with gr.Accordion('Fitting options', open=False):
                    seconds = gr.Slider(.5, 30, value=30, step=.5, label='Fit the first N seconds')
                    steps = gr.Slider(200, 1200, value=600, step=200, label='Fitting iterations',
                                      info='600 is the tested setting. More iterations take longer and may not improve clarity.')
                submit = gr.Button('Generate piano MIDI', variant='primary')
                gr.Markdown('CPU inference is supported. Speech generation can take several minutes; piano fitting runs on CPU. '
                            'Try the ready-made examples for instant playback. Clips are processed up to the selected duration.')
            with gr.Column(scale=2):
                status = gr.Markdown('Choose an example or create a new fit.')
                source = gr.Audio(label='1 · Source used for fitting', interactive=False)
                player = player_component(gr)
                midi = gr.File(label='Download MIDI', interactive=False)
        poetry = json.loads((ROOT/'examples/poetry/prompts.json').read_text())
        gr.Examples(
            examples=[['Audio → MIDI', str(ROOT/'examples/poetry'/f"{row['name']}.wav"),
                       row['text'], row['language'], 12, 'Speech (dense)'] for row in poetry],
            inputs=[mode, audio, text, language, seconds, fitting_style], cache_examples=False,
            label='Poetry voices · select to listen, then generate piano MIDI',
            example_labels=[f"{row['title']} — {row['author']} ({row['language']})" for row in poetry],
        )
        selected_text = gr.Textbox(label='Selected text', interactive=False, lines=2)
        text.change(lambda value: value, text, selected_text, queue=False)
        gr.Markdown('**How it works:** render piano templates → fit dense short notes or sparse held notes → '
                    'convert strengths to MIDI velocities → render the MIDI with the same piano sound. '
                    '[Algorithm and experiments](https://github.com/k-l-lambda/speaking-midi) · '
                    '[Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign)')
        outputs = [status, source, player, midi]
        mode.change(lambda value: (gr.update(visible=value=='Audio → MIDI'), gr.update(visible=value!='Audio → MIDI')),
                    mode, [audio, speech_controls], queue=False)
        submit.click(run, [mode,audio,text,language,description,seconds,steps,fitting_style], outputs,
                     concurrency_limit=1, concurrency_id='compute', api_name='generate')
        english.click(lambda: example('en'), outputs=outputs, api_name='english_example')
        chinese.click(lambda: example('zh'), outputs=outputs, api_name='chinese_example')
    return demo.queue(max_size=8, default_concurrency_limit=1)


if __name__ == '__main__':
    build_ui().launch(server_name='0.0.0.0', server_port=int(os.getenv('PORT', '7860')),
                      max_file_size='25mb', allowed_paths=[str(RUNS), str(ROOT/'examples')])