Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions automation/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ def status(self) -> str:
"""What the status bar along the bottom of the window says."""
return self.bridge.ask(lambda: read_label(STATUS_BAR))

def status_drawn(self) -> str:
"""What the status bar says, read on the render thread itself, which a recording takes after every frame."""
return read_label(STATUS_BAR)

def shows_text(self, words: str) -> bool:
"""Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open."""
return self.bridge.ask(lambda: read_visible_text(words))
Expand Down
1 change: 1 addition & 0 deletions automation/vocabulary/graphs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Final

WAVEFORM_REGENERATING: Final[str] = "global.graph.message.waveform_regenerating"
WAVEFORM_READING: Final[str] = "global.graph.message.waveform_reading"
WAVEFORM_RECONSTRUCTION: Final[str] = "global.graph.label.waveform_reconstruction"
10 changes: 8 additions & 2 deletions docs/development/application/reconstruction-edits.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,15 @@ moves the choice the listening makes on the new record, so its landing reads the
listening waiting answers every choice made before it is taken, and a listening asked for during a
rebuild reads the rebuilt document. A freshly opened document is shown the same way, so its channels
render on the worker too. The showing the panel owes happens once the sound lands, and a hearing the
cache already answers is shown at once.
cache already answers is shown at once. A WAV export is a reading of what plays like a showing: it
writes the kept sound at once, and where the cache keeps none it is owed beside the showing and written
once the worker's reading lands, for what the reader hears then.

## The waveform fades while the line is busy

The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the
document is being rewritten for the whole span, however many steps it takes.
document is being rewritten for the whole span, however many steps it takes. The status bar names what
the fade waits for: an edit on its way reads "Regenerating reconstruction...", and a change to what is
heard, or a document just opened, reads "Preparing the sound...". The line reports the kind of work it
is busy with, so a hand-over from an edit to a reading of the sound changes the words under the same
fade.
5 changes: 2 additions & 3 deletions docs/development/bugs-and-todos.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,10 @@ dimension the import starts carrying.
entry down to the frames the edit changed ([undo](application/undo.md)).
* What the Reconstructions tab still computes on the render thread. The regeneration worker renders,
mixes and exports what the reader hears, and the landing installs it ([Editing the open
reconstruction](application/reconstruction-edits.md)); three readings stay on the render thread. The
reconstruction](application/reconstruction-edits.md)); two readings stay on the render thread. The
sequencer's preview of a sample mixes the sample cold on its first read, since it reads the render
cache directly. The original mix of the recordings heard is mixed again on the render thread whenever a
recording is left out, which is a few milliseconds per recording on a two-minute document. A WAV export
of a hearing the cache keeps no sound for renders it on the spot, where the reader waits for the file.
recording is left out, which is a few milliseconds per recording on a two-minute document.
* Improve performance of the browser's favorite scan of the entire tree per click
* The `fft` and `logfft` analysis floors. Their window spans two cycles of the pulse's lowest note, so the
triangle's lowest octave reaches them by its harmonics alone. Covering it doubles their window and
Expand Down
14 changes: 8 additions & 6 deletions src/sampletones_application/coordinators/reconstruction.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome
from sampletones_application.view_model.reconstruction.work import Work
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.project.voices.instrument import Instrument
from sampletones_core.project.voices.sample import Sample
Expand Down Expand Up @@ -102,7 +103,7 @@ def __init__(
self._rewrites.on_heard_rendered = self._show_listening
self._rewrites.on_dropped = self._redraw_open_document
self._rewrites.on_failed = self._report_rebuild_failure
self._rewrites.on_busy_changed = self._set_reconstruction_dimmed
self._rewrites.on_work_changed = self._show_work
self._reconstruction_manager.set_callbacks(
on_reconstruction_loaded=self.on_reconstruction_loaded,
on_reconstruction_closed=self._on_closed,
Expand Down Expand Up @@ -579,16 +580,17 @@ def _report_rebuild_failure(self, exception: Exception) -> None:
logger.error_with_traceback(exception, "Regeneration failed")
self._dialogs.show_error(exception)

def _set_reconstruction_dimmed(self, dimmed: bool) -> None:
"""Fades the reconstruction waveform while the open document is being rewritten.
def _show_work(self, work: Optional[Work]) -> None:
"""Fades the reconstruction waveform under the words naming what the line is busy with.

The dim follows the rewrites' busy span, so a continuous edit stream keeps the waveform
faded until the last step lands, and it restores once nothing runs or waits.
The fade follows the rewrites' busy span, so a continuous edit stream keeps the waveform
faded until the last step lands, and it restores once nothing runs or waits. The words
follow the work, so a hand-over from an edit to a reading of the sound changes them.
"""
if self._reconstructions_tab is None:
return

self._reconstructions_tab.set_reconstruction_dimmed(dimmed)
self._reconstructions_tab.show_work(work)

def _on_state_changed(self) -> None:
self._on_session_state_changed_callback()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
from sampletones_application.view_model.reconstruction.work import Work
from sampletones_application.view_model.shared.audio_data import AudioData
from sampletones_application.view_model.shared.recording import NamedRecordingViewModel
from sampletones_core.audio import AudioDeviceManager
Expand Down Expand Up @@ -822,8 +823,8 @@ def redraw_listening(self, *, envelopes_changed: bool) -> None:
if envelopes_changed:
self._reconstruction_instruments_logic.update_display()

def set_reconstruction_dimmed(self, dimmed: bool) -> None:
self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed)
def show_work(self, work: Optional[Work]) -> None:
self._reconstruction_plot_panel.show_work(work)

def toggle_channel(self, channel: ChannelName) -> None:
"""Switches one channel's slice in and out of the waveform and of what plays."""
Expand Down
18 changes: 0 additions & 18 deletions src/sampletones_application/logic/reconstruction/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from sampletones_core.reconstructions.reconstruction.stems.hearing import (
HeardRenders,
Hearing,
heard_renders,
)
from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
from sampletones_shared.logger import logger
Expand Down Expand Up @@ -240,23 +239,6 @@ def waveform_data(

return self._waveform_data(self.original_mix_for(hearing.selection), heard)

def audio_for(
self,
renders: RenderCache,
hearing: Hearing,
) -> np.ndarray:
"""What ``hearing`` plays of the document, rendered here where the cache keeps none of it.

An export writes what plays and runs where the reader waits for it, so it reads what is
kept and renders the rest on the spot.
"""
heard = renders.reading(self.reconstruction, hearing)
if heard is not None:
return heard.playing

channels = renders.channels(self.reconstruction)
return heard_renders(self.reconstruction, hearing, channels, renders.mix(self.reconstruction)).playing

def _waveform_data(
self,
original_audio: Optional[np.ndarray],
Expand Down
51 changes: 42 additions & 9 deletions src/sampletones_application/logic/reconstruction/reconstruction.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def __init__(

self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION
self._owed: Optional[OwedShowing] = None
self._export_owed: Optional[Path] = None

self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None
self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None
Expand Down Expand Up @@ -195,13 +196,16 @@ def update_reconstruction(self, *, refit_waveform: bool = False) -> None:
)

def redraw_listening(self) -> None:
"""Shows what the reader hears once its sound is kept, where a showing waited for it."""
owed = self._owed
"""Shows what the reader hears once its sound is kept, where a showing or an export waited for it."""
reconstruction_data = self._reconstruction_data
if owed is None or not reconstruction_data:
if not reconstruction_data:
return

self._show(reconstruction_data, owed)
if self._owed is not None:
self._show(reconstruction_data, self._owed)

if self._export_owed is not None:
self._export(reconstruction_data, self._export_owed)

def _show(self, reconstruction_data: ReconstructionData, showing: OwedShowing) -> None:
"""Draws and plays what the reader hears, or asks for its sound where the screen holds none yet.
Expand Down Expand Up @@ -264,6 +268,10 @@ def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLo
def close_reconstruction(self) -> None:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
self._owed = None
if self._export_owed is not None:
logger.info(f"The export to {self._export_owed} waited for the sound of a document put away")
self._export_owed = None

self.call(self.on_audio_data_changed, None)
self.call(self.on_waveform_cleared)
self.call(
Expand Down Expand Up @@ -702,13 +710,38 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None:
logger.warning("No reconstruction data available for WAV export")
return

audio_snapshot = reconstruction_data.audio_for(
self._reconstruction_manager.renders,
self._listening.hearing,
)
self._export(reconstruction_data, filepath)

def _export(
self,
reconstruction_data: ReconstructionData,
filepath: Path,
) -> None:
"""Writes what the reader hears to ``filepath``, or asks for its sound where the screen holds none yet.

An export is a reading of what plays like a showing, so it reads the kept sound and computes
no sample on the render thread. An export the cache cannot answer is owed beside a showing:
the worker is asked, and the file is written once the sound lands, for what the reader hears
then. A document put away before that takes the export with it.
"""
waveform = reconstruction_data.waveform_data(self._reconstruction_manager.renders, self._listening.hearing)
if waveform is None:
self._export_owed = filepath
self.call(self.on_renders_wanted)
return

self._export_owed = None
self._write_wav(reconstruction_data, waveform.playing, filepath)

def _write_wav(
self,
reconstruction_data: ReconstructionData,
playing: np.ndarray,
filepath: Path,
) -> None:
sample_rate = reconstruction_data.reconstruction.config.sample_rate
self._session_manager.set_audio_path(filepath)
self._export_service.export_wav(filepath, sample_rate, audio_snapshot)
self._export_service.export_wav(filepath, sample_rate, playing)

def handle_locate_original_audio(self) -> None:
if not self._reconstruction_manager.source_paths:
Expand Down
42 changes: 33 additions & 9 deletions src/sampletones_application/logic/reconstruction/rewrites/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from sampletones_application.view_model.reconstruction.envelopes import (
ChannelEnvelopesViewModel,
)
from sampletones_application.view_model.reconstruction.work import Work
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.reconstructions.reconstruction.stems.removal import (
can_remove_stem,
Expand Down Expand Up @@ -96,7 +97,8 @@ class ReconstructionRewrites(CallbackMixin):
lands, ``on_heard_rendered`` once a listening's sound is kept for the open document (with
whether its envelopes changed what the instruments panel draws), ``on_dropped`` when the panel
draws a change that will never land, ``on_failed`` with a job's failure, and
``on_busy_changed`` as the line fills and empties.
``on_work_changed`` as the line fills, hands over from one kind of work to the other, and
empties.
"""

def __init__(
Expand All @@ -109,20 +111,42 @@ def __init__(
self._waiting: List[Rewrite] = []
self._running: Optional[Running] = None
self._redraw_owed: bool = False
self._reported_busy: bool = False
self._reported_work: Optional[Work] = None

self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None
self.on_heard_rendered: Optional[Callable[[bool], None]] = None
self.on_dropped: Optional[VoidCallback] = None
self.on_failed: Optional[Callable[[Exception], None]] = None
self.on_busy_changed: Optional[Callable[[bool], None]] = None
self.on_work_changed: Optional[Callable[[Optional[Work]], None]] = None

regeneration.subscribe(self._on_result)

@property
def work(self) -> Optional[Work]:
"""What the line is busy with, which the waveform's fade names, or ``None`` while it stands empty.

The running job says what it does. While steps wait with none running, the first of them
says what comes: a change to what is heard has the sound read, and every other step
rewrites the document.
"""
match self._running:
case Rebuild():
return Work.REBUILDING
case Listening():
return Work.READING

match self._waiting[0] if self._waiting else None:
case None:
return None
case ListeningChange():
return Work.READING
case _:
return Work.REBUILDING

@property
def is_busy(self) -> bool:
"""Whether a job runs or a step waits, which is the span the open document's sound is on its way."""
return self._running is not None or bool(self._waiting)
return self.work is not None

def request(self, rewrite: Rewrite) -> None:
"""Puts a step at the end of the line, and takes it at once where nothing runs before it.
Expand Down Expand Up @@ -365,12 +389,12 @@ def _let_go(self, running: Running) -> None:
self.call(self.on_dropped)

def _settle(self) -> None:
"""Pays a redraw owed once the line empties, and reports the line filling or emptying."""
"""Pays a redraw owed once the line empties, and reports what the line is busy with as it changes."""
if not self.is_busy and self._redraw_owed:
self._redraw_owed = False
self.call(self.on_dropped)

busy = self.is_busy
if busy != self._reported_busy:
self._reported_busy = busy
self.call(self.on_busy_changed, busy)
work = self.work
if work != self._reported_work:
self._reported_work = work
self.call(self.on_work_changed, work)
Loading
Loading