Spaces:
Running on Zero
Running on Zero
Commit ·
f97fef1
1
Parent(s): 59e4843
Deploy dense piano fitting in both Space input modes
Browse files- README.md +16 -19
- VALIDATION.md +9 -0
- app.py +23 -24
- examples/dense/README.md +5 -0
- examples/dense/manifest.json +23 -0
- examples/dense/quiet-night/dense.mid +3 -0
- examples/dense/quiet-night/dense.wav +3 -0
- examples/dense/quiet-night/report.json +149 -0
- examples/dense/quiet-night/target.wav +3 -0
- examples/dense/the-tyger/dense.mid +3 -0
- examples/dense/the-tyger/dense.wav +3 -0
- examples/dense/the-tyger/report.json +149 -0
- examples/dense/the-tyger/target.wav +3 -0
- player.py +3 -3
- requirements.txt +1 -0
- speaking_midi/dense.py +140 -0
- tests/test_app.py +7 -7
- tests/test_dense.py +39 -0
README.md
CHANGED
|
@@ -16,13 +16,13 @@ models:
|
|
| 16 |
|
| 17 |
An interactive demonstration of **fixed-timbre inverse synthesis**: approximate speech or other audio using only Grand Piano MIDI notes. Upload/record audio, or generate a voice from text with Qwen3-TTS, then listen to the source and play the resulting MIDI in an interactive piano roll. Download the actual MIDI from the same result.
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
## MIDI player
|
| 22 |
|
| 23 |
The demo centers on a piano roll inspired by the interaction design of `klstudio/app/views/midi-player.vue`: horizontal note blocks, active-note highlighting, a playback cursor, click-to-seek, and automatic scrolling at higher zoom. Controls provide play/pause, restart, a seek slider, playback speed, and zoom; left/right arrows on the roll seek by one second. Loading another result stops the previous playback.
|
| 24 |
|
| 25 |
-
The displayed notes are decoded directly from the exported MIDI. Playback uses the corresponding FluidSynth Grand Piano render, with the audio clock driving the cursor and highlights; it does not substitute a different browser synthesizer. The render includes release tails, while note blocks show MIDI key-hold durations. The browser receives a self-contained audio-and-note payload and needs no external JavaScript or sound library. Spectrogram, static roll, baseline player, and metric panels have been removed from the demo interface;
|
| 26 |
|
| 27 |
## Poetry voice examples
|
| 28 |
|
|
@@ -35,31 +35,28 @@ Five ready-made Qwen VoiceDesign recordings cover Chinese and English public-dom
|
|
| 35 |
|
| 36 |
Inputs are limited to 25 MB, 300 text characters, and a fitted prefix of at most 12 seconds. Longer recordings are read only through the selected prefix. New jobs share one compute queue, with at most eight queued requests. Temporary outputs are ephemeral, are cleaned after 24 hours when another job starts, and disappear when the Space restarts. Do not treat this public demonstration as private storage.
|
| 37 |
|
| 38 |
-
##
|
| 39 |
|
| 40 |
-
The renderer is FluidSynth with bank 0, program 0 of the bundled
|
| 41 |
|
| 42 |
-
1. Convert
|
| 43 |
-
2. Render
|
| 44 |
-
3.
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
$$
|
| 47 |
-
\Delta = 2\langle R,A\rangle - \lVert A\rVert_2^2.
|
| 48 |
-
$$
|
| 49 |
-
|
| 50 |
-
Subtract the selected atom and forbid overlapping holds of the same MIDI pitch. Stop on nonpositive improvement or the iteration budget. The budget counts search attempts, so the accepted note count may be smaller.
|
| 51 |
-
|
| 52 |
-
4. Export and decode the MIDI, then render it through the actual synthesizer. Test global velocity offsets against a log-band objective. With **Full per-note refinement**, also try each note's velocity ±10, onset ±10 ms, gate duration ±20 ms, and deletion, accepting only measured improvements:
|
| 53 |
|
| 54 |
$$
|
| 55 |
-
L
|
|
|
|
| 56 |
$$
|
| 57 |
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
The
|
| 63 |
|
| 64 |
## CPU and ZeroGPU execution
|
| 65 |
|
|
@@ -67,7 +64,7 @@ The hosted [Speaking MIDI Space](https://huggingface.co/spaces/k-l-lambda/speaki
|
|
| 67 |
|
| 68 |
The requirements use `torch==2.10.0` and matching torchaudio. Do not add `+cpu` or the CPU-only wheel index to the hosted requirements: ZeroGPU rejects that version specification and needs GPU-capable PyTorch.
|
| 69 |
|
| 70 |
-
Local execution defaults to `INFERENCE_DEVICE=cpu`. TTS uses float32 with SDPA in a separate process, releasing model memory before MIDI fitting.
|
| 71 |
|
| 72 |
```bash
|
| 73 |
# Python 3.10; install libfluidsynth3, libsndfile1 and sox using your OS package manager.
|
|
|
|
| 16 |
|
| 17 |
An interactive demonstration of **fixed-timbre inverse synthesis**: approximate speech or other audio using only Grand Piano MIDI notes. Upload/record audio, or generate a voice from text with Qwen3-TTS, then listen to the source and play the resulting MIDI in an interactive piano roll. Download the actual MIDI from the same result.
|
| 18 |
|
| 19 |
+
The two instant-play examples now use the dense algorithm: **The Tyger** in English and **Quiet Night Thoughts** in Chinese. Both input modes use the same dense fitter, with 600 optimization iterations by default. The research backtest reduced pooled Chinese CER from 100% to 68.3% and English WER from 116.7% to 50% on five poetry recordings. Recognition remains imperfect, and these results do not establish general human intelligibility. See the [ASR report](https://github.com/k-l-lambda/speaking-midi/blob/5caf4e2/experiments/DENSE_ASR.md).
|
| 20 |
|
| 21 |
## MIDI player
|
| 22 |
|
| 23 |
The demo centers on a piano roll inspired by the interaction design of `klstudio/app/views/midi-player.vue`: horizontal note blocks, active-note highlighting, a playback cursor, click-to-seek, and automatic scrolling at higher zoom. Controls provide play/pause, restart, a seek slider, playback speed, and zoom; left/right arrows on the roll seek by one second. Loading another result stops the previous playback.
|
| 24 |
|
| 25 |
+
The displayed notes are decoded directly from the exported MIDI. Playback uses the corresponding FluidSynth Grand Piano render, with the audio clock driving the cursor and highlights; it does not substitute a different browser synthesizer. The render includes release tails, while note blocks show MIDI key-hold durations. The browser receives a self-contained audio-and-note payload and needs no external JavaScript or sound library. Spectrogram, static roll, baseline player, and metric panels have been removed from the demo interface; fitting settings and numerical results remain in each job’s `report.json`.
|
| 26 |
|
| 27 |
## Poetry voice examples
|
| 28 |
|
|
|
|
| 35 |
|
| 36 |
Inputs are limited to 25 MB, 300 text characters, and a fitted prefix of at most 12 seconds. Longer recordings are read only through the selected prefix. New jobs share one compute queue, with at most eight queued requests. Temporary outputs are ephemeral, are cleaned after 24 hours when another job starts, and disappear when the Space restarts. Do not treat this public demonstration as private storage.
|
| 37 |
|
| 38 |
+
## Dense fitting algorithm
|
| 39 |
|
| 40 |
+
The renderer is FluidSynth with bank 0, program 0 of the bundled Grand Piano SoundFont, fixed gain 0.8, and disabled chorus/reverb. The output contains only piano MIDI notes: no source-speech layer, pitch bend, or output EQ.
|
| 41 |
|
| 42 |
+
1. Convert input to 24 kHz mono and normalize its RMS to 0.035. Analyze squared STFT magnitudes with FFT 1024 / hop 240 and 128 Slaney-normalized mel bands spanning 40–11,000 Hz.
|
| 43 |
+
2. Render temporal templates for all 88 piano keys (MIDI 21–108), at velocity 48 and a 20 ms gate. A 400 ms template window retains attack, decay and release. Cache templates and per-pitch velocity calibration by SoundFont hash and configuration.
|
| 44 |
+
3. Give every key an onset candidate every 20 ms. Jointly optimize nonnegative strengths with projected Adam rather than greedily selecting a small note list. The default is 600 iterations, with a cosine learning-rate schedule from 0.04 to 0.001.
|
| 45 |
+
4. Map strengths to integer velocities using measured energy ratios at 13 velocity levels. Drop strengths below the velocity-8 threshold and events extending beyond the clip. The selected research clips contain 1,218–3,140 notes; the iteration setting is not a note-count limit.
|
| 46 |
+
5. Save MIDI, reload it and render through FluidSynth. The interactive player displays these exported events and plays their corresponding piano WAV, including a 0.5-second release tail.
|
| 47 |
|
| 48 |
+
For target mel power $M$, temporal templates $D$, and nonnegative note strengths $A$, fitting minimizes:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
$$
|
| 51 |
+
L=\operatorname{mean}\left[\log(1+\widehat M/10^{-3})-\log(1+M/10^{-3})\right]^2,
|
| 52 |
+
\qquad \widehat M=D*A,\quad A\geq0.
|
| 53 |
$$
|
| 54 |
|
| 55 |
+
The convolution sums shifted template powers. It approximates phase interactions, velocity-dependent timbre and repeated-note behavior, so validation uses actual re-rendered MIDI. No transcript or ASR gradient enters the optimizer. ASR selected a configuration on two pilot clips before testing three further poetry clips; repeated renders, temperature-zero decoding and a complete CPU path confirmed the corpus-level improvement. Some conventional spectral metrics worsen, and there is no blind human listening result.
|
| 56 |
|
| 57 |
+
`Fitting iterations` ranges from 200 to 1,200; 600 is the tested setting. More iterations cost more time and do not guarantee clearer speech. The old note-budget and full-refinement controls have been removed from the demo.
|
| 58 |
|
| 59 |
+
The dense fitter is vendored without algorithm changes from [Speaking MIDI commit 5caf4e2](https://github.com/k-l-lambda/speaking-midi/tree/5caf4e2). The shared rendering core retains the Space’s portable SoundFont path. The instant examples in `examples/dense` are byte-identical MIDI/WAV copies of the selected research outputs, with normalized source audio for comparison and a hash manifest. Archived sparse examples remain in `examples/en` and `examples/zh` for provenance.
|
| 60 |
|
| 61 |
## CPU and ZeroGPU execution
|
| 62 |
|
|
|
|
| 64 |
|
| 65 |
The requirements use `torch==2.10.0` and matching torchaudio. Do not add `+cpu` or the CPU-only wheel index to the hosted requirements: ZeroGPU rejects that version specification and needs GPU-capable PyTorch.
|
| 66 |
|
| 67 |
+
Local execution defaults to `INFERENCE_DEVICE=cpu`. TTS uses float32 with SDPA in a separate process, releasing model memory before MIDI fitting. Dense fitting explicitly uses CPU in every app mode, including ZeroGPU. Both template creation and optimization work without a GPU.
|
| 68 |
|
| 69 |
```bash
|
| 70 |
# Python 3.10; install libfluidsynth3, libsndfile1 and sox using your OS package manager.
|
VALIDATION.md
CHANGED
|
@@ -24,3 +24,12 @@ These timings are the app's reported job durations, excluding client upload/down
|
|
| 24 |
## Interactive MIDI player (2026-10-02)
|
| 25 |
|
| 26 |
The demo now shows source audio and an interactive piano roll instead of diagnostic tabs. Seven backend tests passed, including real CPU fitting and the ZeroGPU dispatch test. Browser checks passed for playback and pause, click and keyboard seeking, zoom and scrolling, replacing a playing example, removal of diagnostic tabs, and mobile layout. No JavaScript errors were observed in those checks. The player decodes note events from the downloaded MIDI and synchronizes them to that MIDI's Grand Piano audio render.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
## Interactive MIDI player (2026-10-02)
|
| 25 |
|
| 26 |
The demo now shows source audio and an interactive piano roll instead of diagnostic tabs. Seven backend tests passed, including real CPU fitting and the ZeroGPU dispatch test. Browser checks passed for playback and pause, click and keyboard seeking, zoom and scrolling, replacing a playing example, removal of diagnostic tabs, and mobile layout. No JavaScript errors were observed in those checks. The player decodes note events from the downloaded MIDI and synchronizes them to that MIDI's Grand Piano audio render.
|
| 27 |
+
|
| 28 |
+
## Dense fitting deployment (2026-10-02)
|
| 29 |
+
|
| 30 |
+
- Vendored `speaking_midi/dense.py` is byte-identical to research commit `5caf4e2`; the portable bundled-SoundFont renderer is retained.
|
| 31 |
+
- Ten automated tests passed, including an actual CPU audio job, mocked ZeroGPU speech dispatch followed by real CPU dense fitting, single-note dense recovery, input validation, MIDI round-trip and packaged example loading.
|
| 32 |
+
- A local Gradio API job fitted the complete 8.64-second Quiet Night recording at 600 iterations, producing 2,758 notes in 14.1 seconds. Small floating-point/renderer differences can change the discrete notes; these are workstation timings, not hosted benchmarks.
|
| 33 |
+
- Chrome/Playwright verified the 3,140-note English example and 2,759-note Chinese example, playback/pause, replacement while loaded, updated fitting controls, and a 390-pixel mobile layout. No JavaScript errors or horizontal overflow were observed.
|
| 34 |
+
- The instant-play MIDI and WAV assets are exact copies of the dense research examples. Their hashes and research revision are in `examples/dense/manifest.json`. Both input modes now invoke the same dense CPU worker; the old note-budget/refinement controls are removed.
|
| 35 |
+
- Earlier hosted measurements above describe the previous sparse implementation and must not be treated as dense-performance measurements.
|
app.py
CHANGED
|
@@ -42,7 +42,7 @@ def worker(command, log, timeout=1800):
|
|
| 42 |
except subprocess.TimeoutExpired:
|
| 43 |
process.kill()
|
| 44 |
process.wait()
|
| 45 |
-
raise gr.Error('This job exceeded 30 minutes. Try a shorter clip or
|
| 46 |
except BaseException:
|
| 47 |
process.kill()
|
| 48 |
process.wait()
|
|
@@ -68,13 +68,13 @@ def prepare_audio(path, out, seconds):
|
|
| 68 |
|
| 69 |
|
| 70 |
def result(directory, status):
|
| 71 |
-
return (status, str(directory/'target.wav'), player_payload(directory),
|
| 72 |
-
str(directory/'
|
| 73 |
|
| 74 |
|
| 75 |
-
def run(mode, audio, text, language, description, seconds,
|
| 76 |
-
if mode not in {'Audio → MIDI', 'Text → speech → MIDI'} or not .5 <= float(seconds) <= 12 or not
|
| 77 |
-
raise gr.Error('Choose a valid mode, duration (0.5–12 s), and
|
| 78 |
if language not in {'Auto', 'English', 'Chinese', 'Japanese', 'Korean', 'French', 'German', 'Spanish', 'Portuguese', 'Russian'}:
|
| 79 |
raise gr.Error('Choose a supported language.')
|
| 80 |
for old in RUNS.glob('job-*'):
|
|
@@ -86,7 +86,7 @@ def run(mode, audio, text, language, description, seconds, budget, refine, progr
|
|
| 86 |
if mode == 'Text → speech → MIDI':
|
| 87 |
if not text.strip() or len(text) > 300 or not description.strip() or len(description) > 600:
|
| 88 |
raise gr.Error('Enter 1–300 text characters and a voice description of 1–600 characters.')
|
| 89 |
-
yield ('
|
| 90 |
progress(.05, desc='Loading VoiceDesign and generating speech')
|
| 91 |
prompts = directory/'prompts.json'
|
| 92 |
prompts.write_text(json.dumps([dict(name='speech', text=text.strip(), language=language, instruct=description.strip())]))
|
|
@@ -101,21 +101,20 @@ def run(mode, audio, text, language, description, seconds, budget, refine, progr
|
|
| 101 |
worker(command, directory/'tts.log')
|
| 102 |
audio = str(directory/'speech.wav')
|
| 103 |
duration = prepare_audio(audio, directory/'input.wav', seconds)
|
| 104 |
-
yield (f'
|
| 105 |
str(directory/'input.wav'), '', None)
|
| 106 |
-
progress(.3, desc='Fitting piano
|
| 107 |
-
command = ['-m', 'speaking_midi', str(directory/'input.wav'), '--out', str(directory),
|
| 108 |
-
'--soundfont', str(ROOT/'assets/gm.sf3'), '--
|
| 109 |
-
if not refine:
|
| 110 |
-
command.append('--skip-local-refinement')
|
| 111 |
worker(command, directory/'fit.log')
|
| 112 |
progress(.95, desc='Preparing the MIDI player')
|
| 113 |
-
|
| 114 |
-
|
| 115 |
|
| 116 |
|
| 117 |
def example(language):
|
| 118 |
-
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
def build_ui():
|
|
@@ -123,8 +122,8 @@ def build_ui():
|
|
| 123 |
gr.Markdown('# Speaking MIDI\nTurn a voice into piano notes. Listen to your voice on piano, follow the notes, and download the MIDI.\n'
|
| 124 |
'This is experimental sound approximation: words may become difficult to understand.')
|
| 125 |
with gr.Row():
|
| 126 |
-
english = gr.Button('Listen to
|
| 127 |
-
chinese = gr.Button('Listen to
|
| 128 |
with gr.Row():
|
| 129 |
with gr.Column(scale=1):
|
| 130 |
mode = gr.Radio(['Audio → MIDI', 'Text → speech → MIDI'], value='Audio → MIDI', label='Start with')
|
|
@@ -135,10 +134,10 @@ def build_ui():
|
|
| 135 |
description = gr.Textbox(value=VOICE, label='Voice description', lines=3, max_length=600)
|
| 136 |
with gr.Accordion('Fitting options', open=False):
|
| 137 |
seconds = gr.Slider(.5, 12, value=8, step=.5, label='Fit the first N seconds')
|
| 138 |
-
|
| 139 |
-
|
| 140 |
submit = gr.Button('Generate piano MIDI', variant='primary')
|
| 141 |
-
gr.Markdown('CPU inference is supported. Speech generation
|
| 142 |
'Try the ready-made examples for instant playback. Clips are processed up to the selected duration.')
|
| 143 |
with gr.Column(scale=2):
|
| 144 |
status = gr.Markdown('Choose an example or create a new fit.')
|
|
@@ -155,14 +154,14 @@ def build_ui():
|
|
| 155 |
)
|
| 156 |
selected_text = gr.Textbox(label='Selected text', interactive=False, lines=2)
|
| 157 |
text.change(lambda value: value, text, selected_text, queue=False)
|
| 158 |
-
gr.Markdown('**How it works:**
|
| 159 |
-
'
|
| 160 |
'[Algorithm and experiments](https://github.com/k-l-lambda/speaking-midi) · '
|
| 161 |
'[Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign)')
|
| 162 |
outputs = [status, source, player, midi]
|
| 163 |
mode.change(lambda value: (gr.update(visible=value=='Audio → MIDI'), gr.update(visible=value!='Audio → MIDI')),
|
| 164 |
mode, [audio, speech_controls], queue=False)
|
| 165 |
-
submit.click(run, [mode,audio,text,language,description,seconds,
|
| 166 |
concurrency_limit=1, concurrency_id='compute', api_name='generate')
|
| 167 |
english.click(lambda: example('en'), outputs=outputs, api_name='english_example')
|
| 168 |
chinese.click(lambda: example('zh'), outputs=outputs, api_name='chinese_example')
|
|
|
|
| 42 |
except subprocess.TimeoutExpired:
|
| 43 |
process.kill()
|
| 44 |
process.wait()
|
| 45 |
+
raise gr.Error('This job exceeded 30 minutes. Try a shorter clip or fewer fitting iterations.') from None
|
| 46 |
except BaseException:
|
| 47 |
process.kill()
|
| 48 |
process.wait()
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def result(directory, status):
|
| 71 |
+
return (status, str(directory/'target.wav'), player_payload(directory, stem="dense"),
|
| 72 |
+
str(directory/'dense.mid'))
|
| 73 |
|
| 74 |
|
| 75 |
+
def run(mode, audio, text, language, description, seconds, steps, progress=gr.Progress()):
|
| 76 |
+
if mode not in {'Audio → MIDI', 'Text → speech → MIDI'} or not .5 <= float(seconds) <= 12 or not 200 <= int(steps) <= 1200:
|
| 77 |
+
raise gr.Error('Choose a valid mode, duration (0.5–12 s), and iteration count (200–1200).')
|
| 78 |
if language not in {'Auto', 'English', 'Chinese', 'Japanese', 'Korean', 'French', 'German', 'Spanish', 'Portuguese', 'Russian'}:
|
| 79 |
raise gr.Error('Choose a supported language.')
|
| 80 |
for old in RUNS.glob('job-*'):
|
|
|
|
| 86 |
if mode == 'Text → speech → MIDI':
|
| 87 |
if not text.strip() or len(text) > 300 or not description.strip() or len(description) > 600:
|
| 88 |
raise gr.Error('Enter 1–300 text characters and a voice description of 1–600 characters.')
|
| 89 |
+
yield ('Generating speech. '+('Waiting for a shared GPU.' if DEVICE == 'zerogpu' else 'CPU VoiceDesign can take several minutes on first use.'), *empty)
|
| 90 |
progress(.05, desc='Loading VoiceDesign and generating speech')
|
| 91 |
prompts = directory/'prompts.json'
|
| 92 |
prompts.write_text(json.dumps([dict(name='speech', text=text.strip(), language=language, instruct=description.strip())]))
|
|
|
|
| 101 |
worker(command, directory/'tts.log')
|
| 102 |
audio = str(directory/'speech.wav')
|
| 103 |
duration = prepare_audio(audio, directory/'input.wav', seconds)
|
| 104 |
+
yield (f'Fitting the first {duration:.2f} s with dense piano notes.',
|
| 105 |
str(directory/'input.wav'), '', None)
|
| 106 |
+
progress(.3, desc='Fitting dense piano notes and rendering the MIDI')
|
| 107 |
+
command = ['-m', 'speaking_midi.dense', str(directory/'input.wav'), '--out', str(directory),
|
| 108 |
+
'--soundfont', str(ROOT/'assets/gm.sf3'), '--steps', str(int(steps)), '--device', 'cpu']
|
|
|
|
|
|
|
| 109 |
worker(command, directory/'fit.log')
|
| 110 |
progress(.95, desc='Preparing the MIDI player')
|
| 111 |
+
report = json.loads((directory/'report.json').read_text())
|
| 112 |
+
yield result(directory, f"Complete in {time.monotonic()-started:.1f} s · {report['notes']:,} piano notes.")
|
| 113 |
|
| 114 |
|
| 115 |
def example(language):
|
| 116 |
+
name = {'en': 'the-tyger', 'zh': 'quiet-night'}[language]
|
| 117 |
+
return result(ROOT/'examples/dense'/name, 'Precomputed poetry voice · dense piano fitting.')
|
| 118 |
|
| 119 |
|
| 120 |
def build_ui():
|
|
|
|
| 122 |
gr.Markdown('# Speaking MIDI\nTurn a voice into piano notes. Listen to your voice on piano, follow the notes, and download the MIDI.\n'
|
| 123 |
'This is experimental sound approximation: words may become difficult to understand.')
|
| 124 |
with gr.Row():
|
| 125 |
+
english = gr.Button('Listen to The Tyger · English')
|
| 126 |
+
chinese = gr.Button('Listen to Quiet Night · Chinese')
|
| 127 |
with gr.Row():
|
| 128 |
with gr.Column(scale=1):
|
| 129 |
mode = gr.Radio(['Audio → MIDI', 'Text → speech → MIDI'], value='Audio → MIDI', label='Start with')
|
|
|
|
| 134 |
description = gr.Textbox(value=VOICE, label='Voice description', lines=3, max_length=600)
|
| 135 |
with gr.Accordion('Fitting options', open=False):
|
| 136 |
seconds = gr.Slider(.5, 12, value=8, step=.5, label='Fit the first N seconds')
|
| 137 |
+
steps = gr.Slider(200, 1200, value=600, step=200, label='Fitting iterations',
|
| 138 |
+
info='600 is the tested setting. More iterations take longer and may not improve clarity.')
|
| 139 |
submit = gr.Button('Generate piano MIDI', variant='primary')
|
| 140 |
+
gr.Markdown('CPU inference is supported. Speech generation can take several minutes; piano fitting runs on CPU. '
|
| 141 |
'Try the ready-made examples for instant playback. Clips are processed up to the selected duration.')
|
| 142 |
with gr.Column(scale=2):
|
| 143 |
status = gr.Markdown('Choose an example or create a new fit.')
|
|
|
|
| 154 |
)
|
| 155 |
selected_text = gr.Textbox(label='Selected text', interactive=False, lines=2)
|
| 156 |
text.change(lambda value: value, text, selected_text, queue=False)
|
| 157 |
+
gr.Markdown('**How it works:** render piano templates → jointly fit dense 20 ms notes → '
|
| 158 |
+
'convert strengths to MIDI velocities → render the MIDI with the same piano sound. '
|
| 159 |
'[Algorithm and experiments](https://github.com/k-l-lambda/speaking-midi) · '
|
| 160 |
'[Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign)')
|
| 161 |
outputs = [status, source, player, midi]
|
| 162 |
mode.change(lambda value: (gr.update(visible=value=='Audio → MIDI'), gr.update(visible=value!='Audio → MIDI')),
|
| 163 |
mode, [audio, speech_controls], queue=False)
|
| 164 |
+
submit.click(run, [mode,audio,text,language,description,seconds,steps], outputs,
|
| 165 |
concurrency_limit=1, concurrency_id='compute', api_name='generate')
|
| 166 |
english.click(lambda: example('en'), outputs=outputs, api_name='english_example')
|
| 167 |
chinese.click(lambda: example('zh'), outputs=outputs, api_name='chinese_example')
|
examples/dense/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dense piano examples
|
| 2 |
+
|
| 3 |
+
The instant-play English and Chinese examples use The Tyger and Quiet Night Thoughts, respectively. `dense.mid` and `dense.wav` are copied byte-for-byte from the selected outputs of [research commit 5caf4e2](https://github.com/k-l-lambda/speaking-midi/tree/5caf4e2/examples/poetry/dense). `target.wav` is the corresponding source normalized to 24 kHz mono and RMS 0.035 for fitting. Source voice provenance is in `../poetry/manifest.json`.
|
| 4 |
+
|
| 5 |
+
The MIDI was reloaded and rendered with FluidSynth and the bundled Grand Piano preset. No source speech is mixed into the piano audio. Each output includes 0.5 seconds of release. File hashes are recorded in `manifest.json`; the original fitting reports are retained per sample.
|
examples/dense/manifest.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"research_commit": "5caf4e2",
|
| 3 |
+
"samples": [
|
| 4 |
+
{
|
| 5 |
+
"name": "quiet-night",
|
| 6 |
+
"files": {
|
| 7 |
+
"report.json": "38bf70c1a63eb1751437db2ba67a4ee31724fa5097480ba9b0a54fa5af4b7377",
|
| 8 |
+
"dense.wav": "4cc520461e30b844776b5a38445fd66beed5b9ffc85877e59d72a83be0099d42",
|
| 9 |
+
"dense.mid": "5408c4eba06808db8f15d5f1fc59340313bc8b1dde0855f2d4dd13e9c844374d",
|
| 10 |
+
"target.wav": "850ffb95218374a0934cc2af2e0e9b8eff62f88a777c8f9503943ee47ad28baa"
|
| 11 |
+
}
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"name": "the-tyger",
|
| 15 |
+
"files": {
|
| 16 |
+
"report.json": "1a24a559190165b63b744817db6916c1a2c40a7c05e588ae68398142f7e6abf8",
|
| 17 |
+
"dense.wav": "de80935e2f6dc060b88b5bb756cfce4eb58fcb16f64a466b038ea492b2c79281",
|
| 18 |
+
"dense.mid": "9300cdbce9a1fd07655fa48845ba225a8f9b8ce9b83e07ef8bd471962fe62b13",
|
| 19 |
+
"target.wav": "b102db0c980e4b31962656c7487e4edcb730870e08c98b97e77e0fb44bf77fa2"
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
}
|
examples/dense/quiet-night/dense.mid
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5408c4eba06808db8f15d5f1fc59340313bc8b1dde0855f2d4dd13e9c844374d
|
| 3 |
+
size 17250
|
examples/dense/quiet-night/dense.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4cc520461e30b844776b5a38445fd66beed5b9ffc85877e59d72a83be0099d42
|
| 3 |
+
size 877520
|
examples/dense/quiet-night/report.json
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"config": {
|
| 3 |
+
"power": 2,
|
| 4 |
+
"gate": 0.02,
|
| 5 |
+
"velocity": 48,
|
| 6 |
+
"sr": 24000,
|
| 7 |
+
"nfft": 1024,
|
| 8 |
+
"hop": 240,
|
| 9 |
+
"pitches": [
|
| 10 |
+
21,
|
| 11 |
+
22,
|
| 12 |
+
23,
|
| 13 |
+
24,
|
| 14 |
+
25,
|
| 15 |
+
26,
|
| 16 |
+
27,
|
| 17 |
+
28,
|
| 18 |
+
29,
|
| 19 |
+
30,
|
| 20 |
+
31,
|
| 21 |
+
32,
|
| 22 |
+
33,
|
| 23 |
+
34,
|
| 24 |
+
35,
|
| 25 |
+
36,
|
| 26 |
+
37,
|
| 27 |
+
38,
|
| 28 |
+
39,
|
| 29 |
+
40,
|
| 30 |
+
41,
|
| 31 |
+
42,
|
| 32 |
+
43,
|
| 33 |
+
44,
|
| 34 |
+
45,
|
| 35 |
+
46,
|
| 36 |
+
47,
|
| 37 |
+
48,
|
| 38 |
+
49,
|
| 39 |
+
50,
|
| 40 |
+
51,
|
| 41 |
+
52,
|
| 42 |
+
53,
|
| 43 |
+
54,
|
| 44 |
+
55,
|
| 45 |
+
56,
|
| 46 |
+
57,
|
| 47 |
+
58,
|
| 48 |
+
59,
|
| 49 |
+
60,
|
| 50 |
+
61,
|
| 51 |
+
62,
|
| 52 |
+
63,
|
| 53 |
+
64,
|
| 54 |
+
65,
|
| 55 |
+
66,
|
| 56 |
+
67,
|
| 57 |
+
68,
|
| 58 |
+
69,
|
| 59 |
+
70,
|
| 60 |
+
71,
|
| 61 |
+
72,
|
| 62 |
+
73,
|
| 63 |
+
74,
|
| 64 |
+
75,
|
| 65 |
+
76,
|
| 66 |
+
77,
|
| 67 |
+
78,
|
| 68 |
+
79,
|
| 69 |
+
80,
|
| 70 |
+
81,
|
| 71 |
+
82,
|
| 72 |
+
83,
|
| 73 |
+
84,
|
| 74 |
+
85,
|
| 75 |
+
86,
|
| 76 |
+
87,
|
| 77 |
+
88,
|
| 78 |
+
89,
|
| 79 |
+
90,
|
| 80 |
+
91,
|
| 81 |
+
92,
|
| 82 |
+
93,
|
| 83 |
+
94,
|
| 84 |
+
95,
|
| 85 |
+
96,
|
| 86 |
+
97,
|
| 87 |
+
98,
|
| 88 |
+
99,
|
| 89 |
+
100,
|
| 90 |
+
101,
|
| 91 |
+
102,
|
| 92 |
+
103,
|
| 93 |
+
104,
|
| 94 |
+
105,
|
| 95 |
+
106,
|
| 96 |
+
107,
|
| 97 |
+
108
|
| 98 |
+
],
|
| 99 |
+
"soundfont_sha256": "5b85b6c2c61d10b2b91cddd41efcce7b25cd31c8271d511c73afafbef20b6fa3",
|
| 100 |
+
"revision": 1
|
| 101 |
+
},
|
| 102 |
+
"optimization": {
|
| 103 |
+
"steps": 600,
|
| 104 |
+
"grid_frames": 2,
|
| 105 |
+
"floor": 0.001,
|
| 106 |
+
"device": "cuda",
|
| 107 |
+
"lr_schedule": "cosine .04 to .001"
|
| 108 |
+
},
|
| 109 |
+
"metrics": {
|
| 110 |
+
"spectral_convergence": 0.8104307055473328,
|
| 111 |
+
"log_band_mae": 0.4648427665233612,
|
| 112 |
+
"waveform_rmse": 0.03722667321562767,
|
| 113 |
+
"peak": 0.10009765625,
|
| 114 |
+
"mr_log_mae": 0.07809968789418538,
|
| 115 |
+
"envelope_correlation": 0.8604701076958263
|
| 116 |
+
},
|
| 117 |
+
"notes": 2759,
|
| 118 |
+
"loss_history": [
|
| 119 |
+
[
|
| 120 |
+
0,
|
| 121 |
+
4.463901519775391
|
| 122 |
+
],
|
| 123 |
+
[
|
| 124 |
+
100,
|
| 125 |
+
0.7258548736572266
|
| 126 |
+
],
|
| 127 |
+
[
|
| 128 |
+
200,
|
| 129 |
+
0.7223665118217468
|
| 130 |
+
],
|
| 131 |
+
[
|
| 132 |
+
300,
|
| 133 |
+
0.7200113534927368
|
| 134 |
+
],
|
| 135 |
+
[
|
| 136 |
+
400,
|
| 137 |
+
0.7193714380264282
|
| 138 |
+
],
|
| 139 |
+
[
|
| 140 |
+
500,
|
| 141 |
+
0.7192818522453308
|
| 142 |
+
],
|
| 143 |
+
[
|
| 144 |
+
599,
|
| 145 |
+
0.7192714214324951
|
| 146 |
+
]
|
| 147 |
+
],
|
| 148 |
+
"wall_seconds": 4.883578613866121
|
| 149 |
+
}
|
examples/dense/quiet-night/target.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:850ffb95218374a0934cc2af2e0e9b8eff62f88a777c8f9503943ee47ad28baa
|
| 3 |
+
size 829520
|
examples/dense/the-tyger/dense.mid
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9300cdbce9a1fd07655fa48845ba225a8f9b8ce9b83e07ef8bd471962fe62b13
|
| 3 |
+
size 19577
|
examples/dense/the-tyger/dense.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:de80935e2f6dc060b88b5bb756cfce4eb58fcb16f64a466b038ea492b2c79281
|
| 3 |
+
size 839120
|
examples/dense/the-tyger/report.json
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"config": {
|
| 3 |
+
"power": 2,
|
| 4 |
+
"gate": 0.02,
|
| 5 |
+
"velocity": 48,
|
| 6 |
+
"sr": 24000,
|
| 7 |
+
"nfft": 1024,
|
| 8 |
+
"hop": 240,
|
| 9 |
+
"pitches": [
|
| 10 |
+
21,
|
| 11 |
+
22,
|
| 12 |
+
23,
|
| 13 |
+
24,
|
| 14 |
+
25,
|
| 15 |
+
26,
|
| 16 |
+
27,
|
| 17 |
+
28,
|
| 18 |
+
29,
|
| 19 |
+
30,
|
| 20 |
+
31,
|
| 21 |
+
32,
|
| 22 |
+
33,
|
| 23 |
+
34,
|
| 24 |
+
35,
|
| 25 |
+
36,
|
| 26 |
+
37,
|
| 27 |
+
38,
|
| 28 |
+
39,
|
| 29 |
+
40,
|
| 30 |
+
41,
|
| 31 |
+
42,
|
| 32 |
+
43,
|
| 33 |
+
44,
|
| 34 |
+
45,
|
| 35 |
+
46,
|
| 36 |
+
47,
|
| 37 |
+
48,
|
| 38 |
+
49,
|
| 39 |
+
50,
|
| 40 |
+
51,
|
| 41 |
+
52,
|
| 42 |
+
53,
|
| 43 |
+
54,
|
| 44 |
+
55,
|
| 45 |
+
56,
|
| 46 |
+
57,
|
| 47 |
+
58,
|
| 48 |
+
59,
|
| 49 |
+
60,
|
| 50 |
+
61,
|
| 51 |
+
62,
|
| 52 |
+
63,
|
| 53 |
+
64,
|
| 54 |
+
65,
|
| 55 |
+
66,
|
| 56 |
+
67,
|
| 57 |
+
68,
|
| 58 |
+
69,
|
| 59 |
+
70,
|
| 60 |
+
71,
|
| 61 |
+
72,
|
| 62 |
+
73,
|
| 63 |
+
74,
|
| 64 |
+
75,
|
| 65 |
+
76,
|
| 66 |
+
77,
|
| 67 |
+
78,
|
| 68 |
+
79,
|
| 69 |
+
80,
|
| 70 |
+
81,
|
| 71 |
+
82,
|
| 72 |
+
83,
|
| 73 |
+
84,
|
| 74 |
+
85,
|
| 75 |
+
86,
|
| 76 |
+
87,
|
| 77 |
+
88,
|
| 78 |
+
89,
|
| 79 |
+
90,
|
| 80 |
+
91,
|
| 81 |
+
92,
|
| 82 |
+
93,
|
| 83 |
+
94,
|
| 84 |
+
95,
|
| 85 |
+
96,
|
| 86 |
+
97,
|
| 87 |
+
98,
|
| 88 |
+
99,
|
| 89 |
+
100,
|
| 90 |
+
101,
|
| 91 |
+
102,
|
| 92 |
+
103,
|
| 93 |
+
104,
|
| 94 |
+
105,
|
| 95 |
+
106,
|
| 96 |
+
107,
|
| 97 |
+
108
|
| 98 |
+
],
|
| 99 |
+
"soundfont_sha256": "5b85b6c2c61d10b2b91cddd41efcce7b25cd31c8271d511c73afafbef20b6fa3",
|
| 100 |
+
"revision": 1
|
| 101 |
+
},
|
| 102 |
+
"optimization": {
|
| 103 |
+
"steps": 600,
|
| 104 |
+
"grid_frames": 2,
|
| 105 |
+
"floor": 0.001,
|
| 106 |
+
"device": "cuda",
|
| 107 |
+
"lr_schedule": "cosine .04 to .001"
|
| 108 |
+
},
|
| 109 |
+
"metrics": {
|
| 110 |
+
"spectral_convergence": 0.7890517115592957,
|
| 111 |
+
"log_band_mae": 0.4172348976135254,
|
| 112 |
+
"waveform_rmse": 0.03725587949156761,
|
| 113 |
+
"peak": 0.088348388671875,
|
| 114 |
+
"mr_log_mae": 0.07024333005150159,
|
| 115 |
+
"envelope_correlation": 0.860843754328336
|
| 116 |
+
},
|
| 117 |
+
"notes": 3140,
|
| 118 |
+
"loss_history": [
|
| 119 |
+
[
|
| 120 |
+
0,
|
| 121 |
+
4.080715656280518
|
| 122 |
+
],
|
| 123 |
+
[
|
| 124 |
+
100,
|
| 125 |
+
0.6443037390708923
|
| 126 |
+
],
|
| 127 |
+
[
|
| 128 |
+
200,
|
| 129 |
+
0.6393406987190247
|
| 130 |
+
],
|
| 131 |
+
[
|
| 132 |
+
300,
|
| 133 |
+
0.6365151405334473
|
| 134 |
+
],
|
| 135 |
+
[
|
| 136 |
+
400,
|
| 137 |
+
0.6353092193603516
|
| 138 |
+
],
|
| 139 |
+
[
|
| 140 |
+
500,
|
| 141 |
+
0.6349682807922363
|
| 142 |
+
],
|
| 143 |
+
[
|
| 144 |
+
599,
|
| 145 |
+
0.634937584400177
|
| 146 |
+
]
|
| 147 |
+
],
|
| 148 |
+
"wall_seconds": 3.3393354159779847
|
| 149 |
+
}
|
examples/dense/the-tyger/target.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b102db0c980e4b31962656c7487e4edcb730870e08c98b97e77e0fb44bf77fa2
|
| 3 |
+
size 791120
|
player.py
CHANGED
|
@@ -8,12 +8,12 @@ import soundfile as sf
|
|
| 8 |
UI = Path(__file__).parent / 'ui'
|
| 9 |
|
| 10 |
|
| 11 |
-
def player_payload(directory):
|
| 12 |
directory = Path(directory)
|
| 13 |
clock = 0.0
|
| 14 |
active = {}
|
| 15 |
notes = []
|
| 16 |
-
for message in mido.MidiFile(directory / '
|
| 17 |
clock += message.time
|
| 18 |
if message.type == 'note_on' and message.velocity:
|
| 19 |
active[(message.channel, message.note)] = (clock, message.velocity)
|
|
@@ -22,7 +22,7 @@ def player_payload(directory):
|
|
| 22 |
notes.append(dict(pitch=message.note, start=start, duration=clock-start, velocity=velocity))
|
| 23 |
if active:
|
| 24 |
raise ValueError('MIDI contains unterminated notes')
|
| 25 |
-
wav = directory / '
|
| 26 |
return json.dumps(dict(notes=notes, duration=sf.info(wav).duration,
|
| 27 |
audio='data:audio/wav;base64,' + base64.b64encode(wav.read_bytes()).decode()))
|
| 28 |
|
|
|
|
| 8 |
UI = Path(__file__).parent / 'ui'
|
| 9 |
|
| 10 |
|
| 11 |
+
def player_payload(directory, stem="refined"):
|
| 12 |
directory = Path(directory)
|
| 13 |
clock = 0.0
|
| 14 |
active = {}
|
| 15 |
notes = []
|
| 16 |
+
for message in mido.MidiFile(directory / f'{stem}.mid'):
|
| 17 |
clock += message.time
|
| 18 |
if message.type == 'note_on' and message.velocity:
|
| 19 |
active[(message.channel, message.note)] = (clock, message.velocity)
|
|
|
|
| 22 |
notes.append(dict(pitch=message.note, start=start, duration=clock-start, velocity=velocity))
|
| 23 |
if active:
|
| 24 |
raise ValueError('MIDI contains unterminated notes')
|
| 25 |
+
wav = directory / f'{stem}.wav'
|
| 26 |
return json.dumps(dict(notes=notes, duration=sf.info(wav).duration,
|
| 27 |
audio='data:audio/wav;base64,' + base64.b64encode(wav.read_bytes()).decode()))
|
| 28 |
|
requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ pyfluidsynth==1.4.0
|
|
| 10 |
mido==1.3.3
|
| 11 |
matplotlib==3.10.8
|
| 12 |
spaces
|
|
|
|
|
|
| 10 |
mido==1.3.3
|
| 11 |
matplotlib==3.10.8
|
| 12 |
spaces
|
| 13 |
+
librosa==0.11.0
|
speaking_midi/dense.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dense convolutional piano fitting without text or an ASR model in the optimizer."""
|
| 2 |
+
import argparse
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import librosa
|
| 8 |
+
import numpy as np
|
| 9 |
+
import soundfile as sf
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
from .core import Piano, Note, SR, SFONT, load_audio, render_saved, metrics
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class DenseFeatures:
|
| 16 |
+
def __init__(self, device, nfft=1024, power=2):
|
| 17 |
+
self.device, self.nfft, self.power = device, nfft, power
|
| 18 |
+
self.bank = torch.tensor(librosa.filters.mel(sr=SR, n_fft=nfft, n_mels=128,
|
| 19 |
+
fmin=40, fmax=11000), device=device)
|
| 20 |
+
self.window = torch.hann_window(nfft, device=device)
|
| 21 |
+
|
| 22 |
+
def __call__(self, y):
|
| 23 |
+
x = torch.as_tensor(y, device=self.device, dtype=torch.float32)
|
| 24 |
+
spectrum = torch.stft(x, self.nfft, 240, window=self.window,
|
| 25 |
+
pad_mode='constant', return_complex=True).abs()
|
| 26 |
+
return self.bank @ spectrum.pow(self.power)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def decode_activations(activation, calibration, pitches, velocities, gate, grid_frames, seconds):
|
| 30 |
+
"""Map per-pitch nonnegative energy coefficients to calibrated MIDI velocity."""
|
| 31 |
+
notes = []
|
| 32 |
+
for pi, pitch in enumerate(pitches):
|
| 33 |
+
curve = np.maximum.accumulate(calibration[pi])
|
| 34 |
+
for frame, amount in enumerate(activation[pi]):
|
| 35 |
+
onset = frame * grid_frames * .01
|
| 36 |
+
if onset + gate > seconds or amount < curve[2]:
|
| 37 |
+
continue
|
| 38 |
+
velocity = int(np.clip(np.rint(np.interp(amount, curve, velocities)), 1, 127))
|
| 39 |
+
notes.append(Note(pitch, velocity, onset, gate))
|
| 40 |
+
return notes
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def fit_dense(y, piano, *, cache='outputs/cache', device=None, nfft=1024,
|
| 44 |
+
power=2, gate=.02, grid_frames=2, velocity=48, floor=.001, steps=600):
|
| 45 |
+
"""Fit dense note strengths and quantize them; callers must render saved MIDI.
|
| 46 |
+
|
| 47 |
+
No reference text, recognizer, pitch bend, source audio layer, or output EQ is
|
| 48 |
+
used. Templates approximate additive mel energy, not the exact synthesizer.
|
| 49 |
+
"""
|
| 50 |
+
if (steps < 1 or grid_frames < 1 or gate <= 0 or gate > grid_frames*.01+1e-9
|
| 51 |
+
or not np.isfinite(floor) or floor <= 0 or power not in [1,2]
|
| 52 |
+
or not 1 <= velocity <= 127 or nfft < 2 or len(y) == 0
|
| 53 |
+
or not np.isfinite(y).all()):
|
| 54 |
+
raise ValueError('Invalid dense fitting configuration or audio')
|
| 55 |
+
started = time.monotonic()
|
| 56 |
+
device = device or ('cuda' if torch.cuda.is_available() else 'cpu')
|
| 57 |
+
feature = DenseFeatures(device, nfft, power)
|
| 58 |
+
pre, length = 4, 40
|
| 59 |
+
pitches = list(range(21,109))
|
| 60 |
+
velocities = [1,4,8,12,16,24,32,48,64,80,96,112,127]
|
| 61 |
+
config = dict(power=power, gate=gate, velocity=velocity, sr=SR, nfft=nfft, hop=240,
|
| 62 |
+
pitches=pitches, soundfont_sha256=hashlib.sha256(Path(piano.path).read_bytes()).hexdigest(), revision=1)
|
| 63 |
+
key = hashlib.sha256(json.dumps(config,sort_keys=True).encode()).hexdigest()[:16]
|
| 64 |
+
path = Path(cache)/f'dense-{key}.npz'
|
| 65 |
+
path.parent.mkdir(parents=True,exist_ok=True)
|
| 66 |
+
cache_hit = path.exists()
|
| 67 |
+
if cache_hit:
|
| 68 |
+
with np.load(path) as data:
|
| 69 |
+
kernels, calibration = data['kernels'], data['calibration']
|
| 70 |
+
else:
|
| 71 |
+
kernels, calibration = [], []
|
| 72 |
+
for pitch in pitches:
|
| 73 |
+
base = piano.render([Note(pitch,velocity,pre*.01,gate)],length*.01)
|
| 74 |
+
kernels.append(feature(base).cpu().numpy()[:,:length])
|
| 75 |
+
levels = []
|
| 76 |
+
for value in velocities:
|
| 77 |
+
wave = piano.render([Note(pitch,value,pre*.01,gate)],length*.01)
|
| 78 |
+
levels.append(float(np.sum(wave*wave)))
|
| 79 |
+
calibration.append((np.array(levels)/max(float(np.sum(base*base)),1e-12))**(power/2))
|
| 80 |
+
kernels, calibration = np.array(kernels), np.array(calibration)
|
| 81 |
+
np.savez_compressed(path,kernels=kernels,calibration=calibration)
|
| 82 |
+
dictionary_seconds = time.monotonic()-started
|
| 83 |
+
kernel = torch.tensor(kernels,device=device)
|
| 84 |
+
target = feature(y)[None]
|
| 85 |
+
frames = target.shape[-1]
|
| 86 |
+
count = (frames+grid_frames-1)//grid_frames
|
| 87 |
+
activation = torch.full((1,len(pitches),count),.01,device=device,requires_grad=True)
|
| 88 |
+
optimizer = torch.optim.Adam([activation],lr=.04)
|
| 89 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer,steps,eta_min=.001)
|
| 90 |
+
target_log = torch.log1p(target/floor)
|
| 91 |
+
history = []
|
| 92 |
+
for iteration in range(steps):
|
| 93 |
+
optimizer.zero_grad()
|
| 94 |
+
estimate = F.conv_transpose1d(activation,kernel,stride=grid_frames)[:,:,pre:pre+frames]
|
| 95 |
+
loss = (torch.log1p(estimate.clamp_min(0)/floor)-target_log).square().mean()
|
| 96 |
+
loss.backward()
|
| 97 |
+
optimizer.step()
|
| 98 |
+
scheduler.step()
|
| 99 |
+
with torch.no_grad():
|
| 100 |
+
activation.clamp_(min=0,max=500)
|
| 101 |
+
if iteration%100 == 0 or iteration == steps-1:
|
| 102 |
+
history.append([iteration,float(loss.detach())])
|
| 103 |
+
print('dense',iteration,float(loss.detach()),flush=True)
|
| 104 |
+
values = activation.detach().cpu().numpy()[0]
|
| 105 |
+
notes = decode_activations(values,calibration,pitches,velocities,gate,grid_frames,len(y)/SR)
|
| 106 |
+
report = dict(config=config, optimization=dict(steps=steps,grid_frames=grid_frames,floor=floor,
|
| 107 |
+
device=device,lr_schedule='cosine .04 to .001'), loss_history=history,
|
| 108 |
+
dictionary=dict(cache_hit=cache_hit,seconds=dictionary_seconds,
|
| 109 |
+
sha256=hashlib.sha256(path.read_bytes()).hexdigest()),
|
| 110 |
+
fitting_seconds=time.monotonic()-started)
|
| 111 |
+
return notes, report, values
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 116 |
+
ap.add_argument('input')
|
| 117 |
+
ap.add_argument('--out',type=Path,default=Path('outputs/dense-demo'))
|
| 118 |
+
ap.add_argument('--soundfont',default=SFONT)
|
| 119 |
+
ap.add_argument('--steps',type=int,default=600)
|
| 120 |
+
ap.add_argument('--cache',type=Path,default=Path('outputs/cache'))
|
| 121 |
+
ap.add_argument('--device',choices=['auto','cpu','cuda'],default='auto')
|
| 122 |
+
args = ap.parse_args()
|
| 123 |
+
if args.steps < 1:ap.error('steps must be positive')
|
| 124 |
+
args.out.mkdir(parents=True,exist_ok=True)
|
| 125 |
+
started = time.monotonic()
|
| 126 |
+
y = load_audio(args.input)
|
| 127 |
+
sf.write(args.out/'target.wav',y,SR,subtype='FLOAT')
|
| 128 |
+
piano = Piano(args.soundfont)
|
| 129 |
+
try:
|
| 130 |
+
notes, report, activation = fit_dense(y,piano,steps=args.steps,cache=args.cache,
|
| 131 |
+
device=None if args.device=='auto' else args.device)
|
| 132 |
+
audio = render_saved(piano,notes,args.out/'dense',len(y)/SR+.5)
|
| 133 |
+
np.savez_compressed(args.out/'activation.npz',activation=activation)
|
| 134 |
+
report.update(metrics=metrics(y,audio),notes=len(notes),wall_seconds=time.monotonic()-started)
|
| 135 |
+
(args.out/'report.json').write_text(json.dumps(report,indent=2))
|
| 136 |
+
print(json.dumps(report['metrics'],indent=2))
|
| 137 |
+
finally:piano.close()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == '__main__':main()
|
tests/test_app.py
CHANGED
|
@@ -17,26 +17,26 @@ class AppTests(unittest.TestCase):
|
|
| 17 |
sf.write(src,np.zeros(16000),16000)
|
| 18 |
with self.assertRaises(Exception): prepare_audio(src,out,1)
|
| 19 |
def test_cpu_audio_pipeline(self):
|
| 20 |
-
events=list(run('Audio → MIDI','examples/en/source.wav','','English','',.5,
|
| 21 |
self.assertEqual(len(events),2)
|
| 22 |
self.assertIn('Complete',events[-1][0])
|
| 23 |
for path in (events[-1][1], events[-1][3]): self.assertTrue(Path(path).exists())
|
| 24 |
self.assertGreater(len(json.loads(events[-1][2])['notes']), 0)
|
| 25 |
-
report=json.loads((Path(events[-1][3]).parent/'
|
| 26 |
-
self.assertEqual(report['
|
| 27 |
-
self.assertGreater(report['
|
| 28 |
def test_zerogpu_speech_dispatch_keeps_fitting_on_cpu(self):
|
| 29 |
wave = .05 * np.sin(2*np.pi*220*np.arange(12000)/24000)
|
| 30 |
with patch.object(app, 'DEVICE', 'zerogpu'), patch.object(
|
| 31 |
app, 'generate_zero_speech', return_value=(wave, 24000), create=True) as generate:
|
| 32 |
events = list(run('Text → speech → MIDI', None, 'Hello.', 'English',
|
| 33 |
-
app.VOICE, .5,
|
| 34 |
generate.assert_called_once_with('Hello.', 'English', app.VOICE)
|
| 35 |
output = Path(events[-1][3]).parent
|
| 36 |
self.assertTrue((output/'speech.wav').exists())
|
| 37 |
self.assertFalse((output/'tts.log').exists())
|
| 38 |
-
report = json.loads((output/'
|
| 39 |
-
self.assertEqual(report['
|
| 40 |
|
| 41 |
def test_cached_examples(self):
|
| 42 |
for language in ['en','zh']:
|
|
|
|
| 17 |
sf.write(src,np.zeros(16000),16000)
|
| 18 |
with self.assertRaises(Exception): prepare_audio(src,out,1)
|
| 19 |
def test_cpu_audio_pipeline(self):
|
| 20 |
+
events=list(run('Audio → MIDI','examples/en/source.wav','','English','',.5,200))
|
| 21 |
self.assertEqual(len(events),2)
|
| 22 |
self.assertIn('Complete',events[-1][0])
|
| 23 |
for path in (events[-1][1], events[-1][3]): self.assertTrue(Path(path).exists())
|
| 24 |
self.assertGreater(len(json.loads(events[-1][2])['notes']), 0)
|
| 25 |
+
report=json.loads((Path(events[-1][3]).parent/'report.json').read_text())
|
| 26 |
+
self.assertEqual(report['optimization']['device'],'cpu')
|
| 27 |
+
self.assertGreater(report['notes'],0)
|
| 28 |
def test_zerogpu_speech_dispatch_keeps_fitting_on_cpu(self):
|
| 29 |
wave = .05 * np.sin(2*np.pi*220*np.arange(12000)/24000)
|
| 30 |
with patch.object(app, 'DEVICE', 'zerogpu'), patch.object(
|
| 31 |
app, 'generate_zero_speech', return_value=(wave, 24000), create=True) as generate:
|
| 32 |
events = list(run('Text → speech → MIDI', None, 'Hello.', 'English',
|
| 33 |
+
app.VOICE, .5, 200))
|
| 34 |
generate.assert_called_once_with('Hello.', 'English', app.VOICE)
|
| 35 |
output = Path(events[-1][3]).parent
|
| 36 |
self.assertTrue((output/'speech.wav').exists())
|
| 37 |
self.assertFalse((output/'tts.log').exists())
|
| 38 |
+
report = json.loads((output/'report.json').read_text())
|
| 39 |
+
self.assertEqual(report['optimization']['device'], 'cpu')
|
| 40 |
|
| 41 |
def test_cached_examples(self):
|
| 42 |
for language in ['en','zh']:
|
tests/test_dense.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
import unittest
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
from speaking_midi.core import Piano, Note, SR, write_midi, read_midi, metrics
|
| 6 |
+
from speaking_midi.dense import decode_activations, fit_dense
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DenseFitting(unittest.TestCase):
|
| 10 |
+
def test_calibration_and_nonoverlapping_midi_grid(self):
|
| 11 |
+
coefficients=np.array([[0.,.3,.6,.15],[.9,0.,0.,0.]])
|
| 12 |
+
calibration=np.array([[0.,.1,.2,.4,1.],[0.,.2,.4,.8,2.]])
|
| 13 |
+
notes=decode_activations(coefficients,calibration,[60,64],[1,4,8,16,32],.02,2,.08)
|
| 14 |
+
self.assertEqual([(n.pitch,n.velocity) for n in notes],[(60,12),(60,21),(64,17)])
|
| 15 |
+
with tempfile.TemporaryDirectory() as d:
|
| 16 |
+
path=Path(d)/'control.mid';write_midi(notes,path)
|
| 17 |
+
decoded=read_midi(path)
|
| 18 |
+
self.assertEqual(len(decoded),3)
|
| 19 |
+
for n in decoded:
|
| 20 |
+
self.assertGreaterEqual(n.start,0)
|
| 21 |
+
self.assertLessEqual(n.start+n.duration,.08+1e-9)
|
| 22 |
+
|
| 23 |
+
def test_single_piano_note_recovery_on_cpu(self):
|
| 24 |
+
piano=Piano()
|
| 25 |
+
try:
|
| 26 |
+
y=piano.render([Note(60,64,.06,.02)],.3)
|
| 27 |
+
notes,report,_=fit_dense(y,piano,device='cpu',steps=150)
|
| 28 |
+
self.assertEqual(report['optimization']['device'],'cpu')
|
| 29 |
+
self.assertLess(report['loss_history'][-1][1],report['loss_history'][0][1])
|
| 30 |
+
self.assertTrue(any(n.pitch==60 and abs(n.start-.06)<=.02 for n in notes))
|
| 31 |
+
rendered=piano.render(notes,.8)
|
| 32 |
+
self.assertLess(metrics(y,rendered)['log_band_mae'],metrics(y,np.zeros_like(y))['log_band_mae'])
|
| 33 |
+
finally:piano.close()
|
| 34 |
+
|
| 35 |
+
def test_reject_overlapping_gate(self):
|
| 36 |
+
with self.assertRaises(ValueError):
|
| 37 |
+
fit_dense(np.ones(SR,dtype=np.float32),None,gate=.04,grid_frames=2)
|
| 38 |
+
|
| 39 |
+
if __name__=='__main__':unittest.main()
|