Spaces:
Sleeping
Sleeping
Download app.py from k-l-lambda/speaking-midi: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/k-l-lambda/speaking-midi/resolve/main/app.py
- Command line
-
hf download hf://spaces/k-l-lambda/speaking-midi/app.py
-
curl -L -o app.py https://huggingface.co/spaces/k-l-lambda/speaking-midi/resolve/main/app.py
10.8 kB
| """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')]) | |