k-l-lambda commited on
Commit
f97fef1
·
1 Parent(s): 59e4843

Deploy dense piano fitting in both Space input modes

Browse files
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
- English and Chinese examples are available instantly through the two example buttons. They contain about eight seconds of speech each and use the full refinement algorithm. New jobs default to a smaller search budget for responsive CPU experimentation. The technique approximates spectral structure; it does not guarantee intelligible words or perform conventional melody transcription.
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; research diagnostics remain in the generated files.
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
- ## Algorithm
39
 
40
- The renderer is FluidSynth with bank 0, program 0 of the bundled MuseScore General SoundFont, fixed gain 0.8, and disabled chorus/reverb. Each candidate MIDI event has a pitch, velocity, onset and gate duration. We seek events whose **rendered sound** resembles the input, rather than merely extracting its fundamental frequency.
41
 
42
- 1. Convert the input to mono at 24 kHz and normalize RMS to 0.035. Analyze magnitudes using a 2048-sample Hann STFT with 240-sample (10 ms) hops and project them onto 96 triangular frequency bands.
43
- 2. Render a dictionary of 657 templates: pitches 36–108, velocities 45/75/105, and gate durations 60/120/240 ms. Each template includes a short release tail. Cache the dictionary by renderer configuration and SoundFont hash.
44
- 3. Weight each band inversely to the square root of its RMS magnitude, with a floor to avoid amplifying near-silence. Greedily select a template and onset that most reduce weighted squared residual error. For residual $R$ and a shifted atom $A$, the improvement is
 
 
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 = \frac{1}{BT}\sum_{b,t}\left|\log(1+F(y)_{b,t})-\log(1+F(S(M))_{b,t})\right|.
 
56
  $$
57
 
58
- Here $F$ is band-magnitude analysis, $y$ is the normalized target, $M$ is MIDI, and $S$ is the fixed piano renderer. The quick setting runs dictionary search and global velocity refinement; it skips the per-note sweep. The baseline rounds strong spectral peaks to piano pitches and separately calibrates global velocity.
59
 
60
- Saved diagnostic spectrograms use the same color scale for all four panels. Recorded metrics compare the input-length interval; audio includes a 0.5-second release tail. Log-band MAE is the optimization target, while spectral convergence is reported separately and need not improve. Neither metric measures speech intelligibility. Magnitude addition is only an approximation because real notes interact in phase, which motivates the renderer refinement step.
61
 
62
- The fitting core is vendored from [Speaking MIDI](https://github.com/k-l-lambda/speaking-midi/tree/932f8eb), with a portable SoundFont path and an optional quick-refinement flag. See that project for full experiments, derivations, and reproducible demo provenance.
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. No GPU is needed for this path.
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 a smaller note budget.') from None
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/'refined.mid'))
73
 
74
 
75
- def run(mode, audio, text, language, description, seconds, budget, refine, progress=gr.Progress()):
76
- if mode not in {'Audio → MIDI', 'Text → speech → MIDI'} or not .5 <= float(seconds) <= 12 or not 20 <= int(budget) <= 240:
77
- raise gr.Error('Choose a valid mode, duration (0.5–12 s), and budget (20–240).')
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 ('1/4 · 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,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'2/4 · Analyzing the first {duration:.2f} s and building piano templates.',
105
  str(directory/'input.wav'), '', None)
106
- progress(.3, desc='Fitting piano note templates; then checking the real synthesizer')
107
- command = ['-m', 'speaking_midi', str(directory/'input.wav'), '--out', str(directory),
108
- '--soundfont', str(ROOT/'assets/gm.sf3'), '--max-notes', str(int(budget))]
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
- yield result(directory, f'4/4 · Complete in {time.monotonic()-started:.1f} s on {DEVICE}. '
114
- + ('Full renderer refinement.' if refine else 'Quick fit with global velocity refinement.'))
115
 
116
 
117
  def example(language):
118
- return result(ROOT/'examples'/language, 'Precomputed VoiceDesign example · full renderer refinement · generated on GPU.')
 
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 English example')
127
- chinese = gr.Button('Listen to Chinese example')
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
- budget = gr.Slider(20, 240, value=80, step=20, label='Search iteration budget')
139
- refine = gr.Checkbox(value=False, label='Full per-note refinement (slower)')
140
  submit = gr.Button('Generate piano MIDI', variant='primary')
141
- gr.Markdown('CPU inference is supported. Speech generation and full refinement can take several minutes. '
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:** synthesize 657 piano templates → match weighted spectral bands → '
159
- 'choose discrete notes → re-render MIDI → reduce measured spectral error. '
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,budget,refine], outputs,
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 / 'refined.mid'):
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 / 'refined.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
 
 
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,20,False))
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/'metrics.json').read_text())
26
- self.assertEqual(report['pursuit']['device'],'cpu')
27
- self.assertGreater(report['results']['refined']['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, 20, False))
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/'metrics.json').read_text())
39
- self.assertEqual(report['pursuit']['device'], 'cpu')
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()