speaking-midi / app.py
k-l-lambda's picture
Default audio fitting duration to 30 seconds
04f9110
Raw History Blame Contribute Delete
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')])