diff --git a/Base/res/apps/SampleEditor.af b/Base/res/apps/SampleEditor.af new file mode 100755 index 00000000000000..3c45516e04724b --- /dev/null +++ b/Base/res/apps/SampleEditor.af @@ -0,0 +1,7 @@ +[App] +Name=Sample Editor +Executable=/bin/SampleEditor +Category=&Media + +[Launcher] +FileTypes=wav,flac diff --git a/Base/res/icons/16x16/app-sample-editor.png b/Base/res/icons/16x16/app-sample-editor.png new file mode 100644 index 00000000000000..89df8d0d2d6c51 Binary files /dev/null and b/Base/res/icons/16x16/app-sample-editor.png differ diff --git a/Base/res/icons/32x32/app-sample-editor.png b/Base/res/icons/32x32/app-sample-editor.png new file mode 100644 index 00000000000000..38d0ee534f7852 Binary files /dev/null and b/Base/res/icons/32x32/app-sample-editor.png differ diff --git a/Base/usr/share/man/man1/Applications/SampleEditor.md b/Base/usr/share/man/man1/Applications/SampleEditor.md new file mode 100644 index 00000000000000..01c1c7b74a614c --- /dev/null +++ b/Base/usr/share/man/man1/Applications/SampleEditor.md @@ -0,0 +1,109 @@ +## Name + +![Icon](/res/icons/16x16/app-sample-editor.png) Sample Editor - Audio sample editor + +[Open](launch:///bin/SampleEditor) + +## Synopsis + +```sh +$ SampleEditor [file] +``` + +## Description + +`Sample Editor` is a graphical audio sample editor for editing audio files. It supports WAV and FLAC formats for both loading and saving, with full support for playback, copy/cut/paste operations, and basic audio editing features. + +## User Interface + +### Toolbar + +The toolbar provides quick access to common operations: + +- **New** - Create a new empty sample +- **Open** - Open an audio file (WAV or FLAC) +- **Save** - Save the current sample +- **Save As** - Save with a new name (choose WAV or FLAC format) +- **Copy** - Copy the selected audio region to clipboard +- **Cut** - Cut the selected audio region to clipboard +- **Paste** - Paste audio from clipboard at cursor position +- **Select All** - Select the entire sample +- **Clear Selection** - Remove the current selection +- **Play** - Play audio (from cursor position or selection) +- **Stop** - Stop playback +- **Zoom In** - Zoom in on the waveform +- **Zoom Out** - Zoom out on the waveform + +### Mouse Controls + +- **Click** - Place the cursor at a position for playback or paste operations +- **Click and drag** - Select a region of audio + +### Main Workspace + +The main workspace displays the audio waveform. The waveform shows the amplitude of the audio over time. + +### Playback + +Use the Play button or press `Space` to play audio: + +- **With selection** - Plays the selected region +- **With cursor placed** - Plays from the cursor position to the end +- **No cursor/selection** - Plays the entire sample + +Press Stop or `Space` again to stop playback. + +### Editing Operations + +#### Copy and Cut + +1. Select a region of audio by clicking and dragging +2. Click Copy or Cut to place it on the clipboard +3. Cut will remove the selected audio from the sample + +#### Paste + +1. Place the cursor by clicking at the desired position +2. Click Paste to insert clipboard content at the cursor + +**Note:** Paste operations require matching sample formats (sample rate, channels, and bit depth). The exception is when pasting into an empty sample, where any format is accepted. + +### Zoom Controls + +Use the zoom buttons or keyboard shortcuts to adjust the view: + +- **Zoom In** - Show more detail of the waveform +- **Zoom Out** - Show more of the timeline + +### File Menu + +- **New** (`Ctrl+N`) - Reset to initial empty state +- **Open** (`Ctrl+O`) - Open an audio file (WAV or FLAC) +- **Save** (`Ctrl+S`) - Save the current file +- **Save As** (`Ctrl+Shift+S`) - Save with a new filename and format (WAV or FLAC) +- **Quit** (`Ctrl+Q`) - Exit the application + +### Edit Menu + +- **Copy** (`Ctrl+C`) - Copy selected audio +- **Cut** (`Ctrl+X`) - Cut selected audio +- **Paste** (`Ctrl+V`) - Paste audio at cursor +- **Select All** (`Ctrl+A`) - Select entire sample +- **Clear Selection** - Remove selection + +### View Menu + +- **Zoom In** - Increase waveform detail +- **Zoom Out** - Decrease waveform detail + +## Arguments + +- `file`: Optional audio file to open on startup (WAV or FLAC) + +## Examples + +```sh +$ SampleEditor +$ SampleEditor /home/anon/Music/sample.wav +$ SampleEditor /home/anon/Music/track.flac +``` diff --git a/Userland/Applications/CMakeLists.txt b/Userland/Applications/CMakeLists.txt index bbb87d72e40c4d..54110b65d77769 100644 --- a/Userland/Applications/CMakeLists.txt +++ b/Userland/Applications/CMakeLists.txt @@ -37,6 +37,7 @@ add_subdirectory(Presenter) add_subdirectory(Run) add_subdirectory(Screenshot) add_subdirectory(Settings) +add_subdirectory(SampleEditor) add_subdirectory(SoundPlayer) add_subdirectory(SpaceAnalyzer) add_subdirectory(Spreadsheet) diff --git a/Userland/Applications/SampleEditor/CMakeLists.txt b/Userland/Applications/SampleEditor/CMakeLists.txt new file mode 100755 index 00000000000000..06d1dbce66844c --- /dev/null +++ b/Userland/Applications/SampleEditor/CMakeLists.txt @@ -0,0 +1,21 @@ +serenity_component( + SampleEditor + REQUIRED + TARGETS SampleEditor +) + +set(SOURCES + SampleSourceFile.cpp + SampleEditorPalette.cpp + SampleFileBlock.cpp + SampleNullBlock.cpp + SampleBlockContainer.cpp + SampleRenderer.cpp + SampleWidget.cpp + MainWidget.cpp + main.cpp +) + +serenity_app(SampleEditor ICON app-sample-editor) + +target_link_libraries(SampleEditor PRIVATE LibAudio LibCore LibDesktop LibFileSystem LibFileSystemAccessClient LibGfx LibGUI LibIPC LibMain LibConfig LibURL) diff --git a/Userland/Applications/SampleEditor/MainWidget.cpp b/Userland/Applications/SampleEditor/MainWidget.cpp new file mode 100644 index 00000000000000..d586ba3e298a1b --- /dev/null +++ b/Userland/Applications/SampleEditor/MainWidget.cpp @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MainWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "SampleFileBlock.h" + +MainWidget::MainWidget() +{ + set_layout(); + set_fill_with_background_color(true); +} + +ErrorOr MainWidget::open(StringView path) +{ + auto source_file = TRY(try_make_ref_counted(path)); + size_t length = source_file->length(); + auto file_block = TRY(try_make_ref_counted( + source_file, (size_t)0, (size_t)length - 1)); + m_sample_widget->set(file_block); + m_sample_path = path; + m_sample_name = LexicalPath { path }.title(); + window()->set_title( + ByteString::formatted("Sample Editor - {}", m_sample_name)); + return {}; +} + +ErrorOr MainWidget::save(StringView path) +{ + TRY(m_sample_widget->save(path)); + window()->set_title( + ByteString::formatted("Sample Editor - {}", LexicalPath { path }.title())); + return {}; +} + +ErrorOr MainWidget::initialize_menu_and_toolbar( + NonnullRefPtr window) +{ + m_toolbar_container = add(); + m_toolbar = m_toolbar_container->add(); + + m_new_action = GUI::Action::create( + "&New", { Mod_Ctrl, Key_N }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"sv)), + [this, window](const GUI::Action&) { + m_sample_widget->clear(); + m_sample_path = {}; + m_sample_name = {}; + window->set_title("Sample Editor"); + }); + + m_open_action = GUI::CommonActions::make_open_action([this, window](auto&) { + FileSystemAccessClient::OpenFileOptions options { + .window_title = "Open sample file..."sv, + .allowed_file_types = { { GUI::FileTypeFilter { "Audio Files", { { "wav", "flac" } } }, + GUI::FileTypeFilter::all_files() } } + }; + auto response = FileSystemAccessClient::Client::the().open_file(window, options); + if (response.is_error()) + return; + auto filename = response.value().filename(); + if (auto result = open(filename); result.is_error()) { + auto message = MUST(String::formatted("Failed to open file: {}", result.error())); + GUI::MessageBox::show_error(window.ptr(), message); + } + }); + + m_save_action = GUI::CommonActions::make_save_action([this, window](auto&) { + if (m_sample_path.is_empty()) + return; + if (auto result = save(m_sample_path); result.is_error()) { + auto message = MUST(String::formatted("Failed to save file: {}", result.error())); + GUI::MessageBox::show_error(window.ptr(), message); + } + }); + + m_save_as_action = GUI::CommonActions::make_save_as_action([this, window](auto&) { + // Default extension based on current file, or wav if new + ByteString default_extension = "wav"; + if (!m_sample_path.is_empty()) { + auto current_extension = LexicalPath { m_sample_path }.extension(); + if (current_extension.equals_ignoring_ascii_case("flac"sv)) + default_extension = "flac"; + } + + auto response = FileSystemAccessClient::Client::the().save_file( + window, m_sample_name, default_extension, Core::File::OpenMode::ReadWrite); + if (response.is_error()) { + auto const& error = response.error(); + if (!error.is_errno() || error.code() != ECANCELED) { + auto message = MUST(String::formatted("Failed to prepare save target: {}", error)); + GUI::MessageBox::show_error(window.ptr(), message); + } + return; + } + auto filename = response.value().filename(); + if (auto result = save(filename); result.is_error()) { + auto message = MUST(String::formatted("Failed to save file: {}", result.error())); + GUI::MessageBox::show_error(window.ptr(), message); + return; + } + }); + + m_copy_action = GUI::CommonActions::make_copy_action([this, window](auto&) { + ErrorOr maybe_selection = m_sample_widget->selection(); + if (maybe_selection.is_error()) { + auto message = MUST(String::formatted("Copy failed: {}", maybe_selection.error())); + GUI::MessageBox::show_error(window.ptr(), message); + } else { + StringView selection = maybe_selection.value(); + GUI::Clipboard::the().set_plain_text(selection); + } + }); + + m_cut_action = GUI::CommonActions::make_cut_action([this, window](auto&) { + ErrorOr maybe_cut = m_sample_widget->cut(); + if (maybe_cut.is_error()) { + auto message = MUST(String::formatted("Cut failed: {}", maybe_cut.error())); + GUI::MessageBox::show_error(window.ptr(), message); + } else { + StringView cut_content = maybe_cut.value(); + GUI::Clipboard::the().set_plain_text(cut_content); + } + }); + + m_paste_action = GUI::CommonActions::make_paste_action([this, window](auto&) { + auto [data, mime_type, _] = GUI::Clipboard::the().fetch_data_and_type(); + if (!mime_type.starts_with("text/"sv) || data.is_empty()) { + GUI::MessageBox::show_error(window.ptr(), "Clipboard is empty or does not contain text data"_string); + return; + } + + auto clipboard_bytes = ByteString::from_utf8(data.bytes()); + if (clipboard_bytes.is_error()) { + auto message = MUST(String::formatted("Clipboard data is invalid UTF-8: {}", clipboard_bytes.error())); + GUI::MessageBox::show_error(window.ptr(), message); + return; + } + + auto clipboard_text = String::from_byte_string(clipboard_bytes.release_value()); + if (clipboard_text.is_error()) { + auto message = MUST(String::formatted("Clipboard text conversion failed: {}", clipboard_text.error())); + GUI::MessageBox::show_error(window.ptr(), message); + return; + } + + auto result = m_sample_widget->paste_from_text(clipboard_text.release_value()); + if (result.is_error()) { + auto message = MUST(String::formatted("Paste failed: {}", result.error())); + GUI::MessageBox::show_error(window.ptr(), message); + } + }); + + m_select_all_action = GUI::CommonActions::make_select_all_action([this](auto&) { + m_sample_widget->select_all(); + }); + + m_clear_selection_action = GUI::Action::create( + "Clear Selection", + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/clear-selection.png"sv)), + [this](auto&) { + m_sample_widget->clear_selection(); + }); + + m_zoom_in_action = GUI::CommonActions::make_zoom_in_action( + [this](auto&) { m_sample_widget->zoom_in(); }); + + m_zoom_out_action = GUI::CommonActions::make_zoom_out_action( + [this](auto&) { m_sample_widget->zoom_out(); }); + + m_play_action = GUI::Action::create( + "Play", { Mod_None, Key_Space }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/play.png"sv)), + [this](auto&) { + m_sample_widget->play(); + m_play_action->set_enabled(false); + m_stop_action->set_enabled(true); + }); + + m_stop_action = GUI::Action::create( + "Stop", + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/stop.png"sv)), + [this](auto&) { + m_sample_widget->stop(); + m_play_action->set_enabled(true); + m_stop_action->set_enabled(false); + }); + m_stop_action->set_enabled(false); + + m_toolbar->add_action(*m_new_action); + m_toolbar->add_action(*m_open_action); + m_toolbar->add_action(*m_save_action); + m_toolbar->add_action(*m_save_as_action); + m_toolbar->add_action(*m_copy_action); + m_toolbar->add_action(*m_cut_action); + m_toolbar->add_action(*m_paste_action); + m_toolbar->add_action(*m_select_all_action); + m_toolbar->add_action(*m_clear_selection_action); + m_toolbar->add_separator(); + m_toolbar->add_action(*m_play_action); + m_toolbar->add_action(*m_stop_action); + m_toolbar->add_separator(); + m_toolbar->add_action(*m_zoom_in_action); + m_toolbar->add_action(*m_zoom_out_action); + m_sample_widget = add(); + + m_sample_widget->on_playback_finished = [this]() { + m_play_action->set_enabled(true); + m_stop_action->set_enabled(false); + }; + + auto file_menu = window->add_menu("&File"_string); + file_menu->add_action(*m_new_action); + file_menu->add_action(*m_open_action); + file_menu->add_action(*m_save_action); + file_menu->add_action(*m_save_as_action); + file_menu->add_separator(); + file_menu->add_action(GUI::CommonActions::make_quit_action( + [](auto&) { GUI::Application::the()->quit(); })); + + auto edit_menu = window->add_menu("&Edit"_string); + edit_menu->add_action(*m_copy_action); + edit_menu->add_action(*m_cut_action); + edit_menu->add_action(*m_paste_action); + edit_menu->add_separator(); + edit_menu->add_action(*m_select_all_action); + edit_menu->add_action(*m_clear_selection_action); + + auto view_menu = window->add_menu("&View"_string); + view_menu->add_action(*m_zoom_in_action); + view_menu->add_action(*m_zoom_out_action); + + auto help_menu = window->add_menu("&Help"_string); + help_menu->add_action(GUI::CommonActions::make_help_action([](auto&) { + Desktop::Launcher::open(URL::create_with_file_scheme("/usr/share/man/man1/Applications/SampleEditor.md"), "/bin/Help"); + })); + + help_menu->add_action(GUI::CommonActions::make_about_action( + "Sample Editor"_string, GUI::Icon::default_icon("app-sample-editor"sv), + window)); + + m_sample_widget->on_selection_changed = [this]() { + update_action_states(); + }; + + update_action_states(); + + return {}; +} + +void MainWidget::update_action_states() +{ + bool has_selection = m_sample_widget->has_selection(); + m_copy_action->set_enabled(has_selection); + m_cut_action->set_enabled(has_selection); + + bool has_cursor = m_sample_widget->has_cursor_placed(); + bool is_initial_state = m_sample_widget->is_initial_null_block(); + auto clipboard_mime = GUI::Clipboard::the().fetch_mime_type(); + bool has_text_in_clipboard = clipboard_mime.starts_with("text/"sv); + m_paste_action->set_enabled((has_cursor || is_initial_state) && has_text_in_clipboard); +} + +void MainWidget::clipboard_content_did_change(ByteString const& mime_type) +{ + bool has_cursor = m_sample_widget->has_cursor_placed(); + bool is_initial_state = m_sample_widget->is_initial_null_block(); + bool has_text_in_clipboard = mime_type.starts_with("text/"sv); + m_paste_action->set_enabled((has_cursor || is_initial_state) && has_text_in_clipboard); +} diff --git a/Userland/Applications/SampleEditor/MainWidget.h b/Userland/Applications/SampleEditor/MainWidget.h new file mode 100755 index 00000000000000..6cf04a33065437 --- /dev/null +++ b/Userland/Applications/SampleEditor/MainWidget.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "SampleWidget.h" +#include +#include +#include +#include +#include +#include +#include +#include + +class MainWidget : public GUI::Frame + , public GUI::Clipboard::ClipboardClient { + C_OBJECT(MainWidget) + +public: + ErrorOr initialize_menu_and_toolbar(NonnullRefPtr window); + ErrorOr open(StringView path); + ErrorOr save(StringView path); + void update_action_states(); + + // ^GUI::Clipboard::ClipboardClient + virtual void clipboard_content_did_change(ByteString const& mime_type) override; + +private: + MainWidget(); + virtual ~MainWidget() override = default; + + ByteString m_sample_name; + ByteString m_sample_path; + RefPtr m_toolbar_container; + RefPtr m_toolbar; + RefPtr m_new_action; + RefPtr m_open_action; + RefPtr m_save_action; + RefPtr m_save_as_action; + RefPtr m_save_all_action; + RefPtr m_copy_action; + RefPtr m_cut_action; + RefPtr m_paste_action; + RefPtr m_zoom_in_action; + RefPtr m_zoom_out_action; + RefPtr m_clear_selection_action; + RefPtr m_select_all_action; + RefPtr m_play_action; + RefPtr m_stop_action; + RefPtr m_sample_widget; +}; diff --git a/Userland/Applications/SampleEditor/RenderStruct.h b/Userland/Applications/SampleEditor/RenderStruct.h new file mode 100755 index 00000000000000..ad01003224b1a4 --- /dev/null +++ b/Userland/Applications/SampleEditor/RenderStruct.h @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +typedef struct { + double RMS_plus = 0; + double peak_plus = 0; + double RMS_minus = 0; + double peak_minus = 0; +} RenderStruct; diff --git a/Userland/Applications/SampleEditor/SampleBlock.h b/Userland/Applications/SampleEditor/SampleBlock.h new file mode 100755 index 00000000000000..47fc640fb4f7b2 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleBlock.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" +#include "SampleSourceFile.h" +#include +#include +#include +#include +#include + +class SampleBlock : public RefCounted { + +public: + virtual size_t length() = 0; + virtual double duration() = 0; + virtual double sample_rate() = 0; + virtual String description() = 0; + + virtual ~SampleBlock() = default; + RenderStruct rendered_sample_at(size_t position) + { + return ((position < length()) ? rendered_sample_at_valid(position) : (RenderStruct) { 0, 0, 0, 0 }); + } + virtual void begin_loading_samples() = 0; + virtual FixedArray load_more_samples() = 0; + SampleFormat format() { return m_format; } + +protected: + virtual RenderStruct rendered_sample_at_valid(size_t position) = 0; + SampleFormat m_format; + +private: +}; diff --git a/Userland/Applications/SampleEditor/SampleBlockContainer.cpp b/Userland/Applications/SampleEditor/SampleBlockContainer.cpp new file mode 100644 index 00000000000000..4d2c872ea9485e --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleBlockContainer.cpp @@ -0,0 +1,736 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleBlockContainer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "RenderStruct.h" +#include "SampleBlock.h" +#include "SampleBuffer.h" +#include "SampleFileBlock.h" +#include "SampleFormatStruct.h" +#include "SampleNullBlock.h" +#include "SampleSourceFile.h" + +SampleBlockContainer::SampleBlockContainer() +{ + m_blocks = AK::Vector>(); +} + +ErrorOr SampleBlockContainer::parse_and_insert(String json, double position) +{ + auto root = JsonValue::from_string(json); + if (root.is_error()) { + return Error::from_string_literal("Failed to parse clipboard JSON"); + } + + if (!root.value().is_object()) { + return Error::from_string_literal("Clipboard JSON is not an object"); + } + + auto data = root.value().as_object(); + + auto start_opt = data.get_double_with_precision_loss("start"sv); + if (!start_opt.has_value()) { + return Error::from_string_literal("Missing 'start' field in clipboard JSON"); + } + + auto end_opt = data.get_double_with_precision_loss("end"sv); + if (!end_opt.has_value()) { + return Error::from_string_literal("Missing 'end' field in clipboard JSON"); + } + + auto start = start_opt.value(); + auto end = end_opt.value(); + + if (start < 0.0 || start > 1.0) { + return Error::from_string_literal("Start value must be between 0.0 and 1.0"); + } + + if (end < 0.0 || end > 1.0) { + return Error::from_string_literal("End value must be between 0.0 and 1.0"); + } + + if (start > end) { + return Error::from_string_literal("Start value cannot be greater than end value"); + } + + if (start == end) { + return Error::from_string_literal("Cannot paste zero-length selection"); + } + + struct source_info { + ByteString path; + long length; + long file_start; // Start position within the source file + long file_end; // End position within the source file + long rate; + long channels; + long bits; + double duration; + }; + + auto source_infos = AK::Vector(); + + auto sources_root = data.get_array("sources"sv); + if (!sources_root.has_value()) { + return Error::from_string_literal("Missing 'sources' array in clipboard JSON"); + } + + { + auto sources = sources_root.value(); + auto source_count = sources.size(); + + for (size_t i = 0; i < source_count; i++) { + auto source = sources.at(i).as_object(); + source_info info; + info.path = source.get_byte_string("path"sv).value(); + info.length = source.get_i64("length"sv).value(); + auto file_start_opt = source.get_i64("start"sv); + auto file_end_opt = source.get_i64("end"sv); + info.file_start = file_start_opt.value_or(0); + info.file_end = file_end_opt.value_or(info.length - 1); + info.rate = source.get_i64("rate"sv).value(); + info.channels = source.get_i64("channels"sv).value(); + info.bits = source.get_i64("bits"sv).value(); + info.duration = (double)info.length / (double)info.rate; + source_infos.append(info); + } + } + + double total_duration = 0.0; + for (auto& info : source_infos) { + total_duration += info.duration; + } + + double start_seconds = total_duration * start; + double end_seconds = total_duration * end; + + int start_block = -1, end_block = -1; + double start_point, end_point; + double input_accumulator = 0.0; + for (size_t block_index = 0; block_index < source_infos.size(); block_index++) { + auto& info = source_infos[block_index]; + double duration_mark = input_accumulator; + input_accumulator += info.duration; + if (-1 == start_block && input_accumulator > start_seconds) { + start_block = block_index; + start_point = (start_seconds - duration_mark) / info.duration; + } + if (-1 == end_block && input_accumulator > end_seconds) { + end_block = block_index; + end_point = (end_seconds - duration_mark) / info.duration; + break; + } + } + + auto new_blocks = AK::Vector>(); + + double position_seconds = position * duration(); + + int position_block = -1; + double position_point; + double destination_accumulator = 0.0; + for (size_t block_index = 0; block_index < m_blocks.size(); block_index++) { + auto& block = m_blocks[block_index]; + double mark = destination_accumulator; + auto block_duration = block->duration(); + destination_accumulator += block_duration; + if (-1 == position_block && destination_accumulator > position_seconds) { + position_block = block_index; + position_point = (position_seconds - mark) / block_duration; + break; + } + } + + AK::Vector failed_files; + for (int block_index = start_block; block_index <= end_block; block_index++) { + auto& info = source_infos[block_index]; + + auto source_file_or_error = try_make_ref_counted(info.path); + if (source_file_or_error.is_error()) { + failed_files.append(info.path); + continue; + } + auto source_file = source_file_or_error.release_value(); + + size_t block_start = info.file_start; + size_t block_end = info.file_end; + + if (block_index == start_block) { + size_t block_length = block_end - block_start + 1; + block_start = block_start + (size_t)(start_point * (double)block_length); + } + + if (block_index == end_block) { + size_t original_start = info.file_start; + size_t original_end = info.file_end; + size_t original_length = original_end - original_start + 1; + block_end = original_start + (size_t)(end_point * (double)original_length); + } + + auto file_block_or_error = try_make_ref_counted( + source_file, block_start, block_end); + if (file_block_or_error.is_error()) { + failed_files.append(info.path); + continue; + } + + new_blocks.append(file_block_or_error.release_value()); + } + + if (!failed_files.is_empty()) { + StringBuilder error_message; + error_message.append("Failed to open the following source files:\n"sv); + for (auto& file : failed_files) { + error_message.append(" - "sv); + error_message.append(file); + error_message.append('\n'); + } + return Error::from_string_view(error_message.string_view()); + } + + if (new_blocks.is_empty()) { + return Error::from_string_literal("No valid blocks could be created from clipboard data"); + } + + bool is_initial_null_block = false; + if (m_blocks.size() == 1) { + auto* null_block = dynamic_cast(m_blocks[0].ptr()); + if (null_block) { + is_initial_null_block = true; + } + } + + if (m_blocks.size() > 0 && !is_initial_null_block) { + auto existing_format = m_blocks[0]->format(); + for (auto& new_block : new_blocks) { + auto new_format = new_block->format(); + if (new_format.sample_rate != existing_format.sample_rate) { + return Error::from_string_literal("Cannot paste: sample rate mismatch. Paste content has different sample rate than existing content."); + } + if (new_format.num_channels != existing_format.num_channels) { + return Error::from_string_literal("Cannot paste: channel count mismatch. Paste content has different number of channels than existing content."); + } + if (new_format.bits_per_sample != existing_format.bits_per_sample) { + return Error::from_string_literal("Cannot paste: bit depth mismatch. Paste content has different bit depth than existing content."); + } + } + } + + auto updated_blocks = AK::Vector>(); + + if (position_block == -1) { + for (auto& block : new_blocks) { + updated_blocks.append(block); + } + for (auto& block : m_blocks) { + updated_blocks.append(block); + } + } else { + for (size_t i = 0; i < m_blocks.size(); i++) { + if ((int)i == position_block) { + auto& block = m_blocks[i]; + + if (position_point <= 0.0) { + for (auto& new_block : new_blocks) { + updated_blocks.append(new_block); + } + updated_blocks.append(block); + } else if (position_point >= 1.0) { + updated_blocks.append(block); + for (auto& new_block : new_blocks) { + updated_blocks.append(new_block); + } + } else { + auto* file_block = dynamic_cast(block.ptr()); + if (file_block) { + auto split_result = file_block->split_at(position_point); + if (split_result.is_error()) { + updated_blocks.append(block); + for (auto& new_block : new_blocks) { + updated_blocks.append(new_block); + } + } else { + auto blocks = split_result.release_value(); + updated_blocks.append(blocks[0]); + for (auto& new_block : new_blocks) { + updated_blocks.append(new_block); + } + updated_blocks.append(blocks[1]); + } + } else { + updated_blocks.append(block); + for (auto& new_block : new_blocks) { + updated_blocks.append(new_block); + } + } + } + } else { + updated_blocks.append(m_blocks[i]); + } + } + } + + m_blocks = updated_blocks; + + auto old_duration = m_total_duration; + m_total_length = calc_length(); + m_total_duration = calc_duration(); + m_used = false; + + double pasted_duration = 0.0; + for (auto& block : new_blocks) { + pasted_duration += block->duration(); + } + + double insert_position_seconds = position * old_duration; + double new_cursor_seconds = insert_position_seconds + pasted_duration; + double new_cursor_position = new_cursor_seconds / m_total_duration; + + return new_cursor_position; +} + +void SampleBlockContainer::set(NonnullRefPtr block) +{ + m_blocks = AK::Vector>(); + m_blocks.append(block); + m_total_length = calc_length(); + m_total_duration = calc_duration(); + m_used = false; +} + +void SampleBlockContainer::append(NonnullRefPtr block) +{ + + m_blocks.append(block); + m_total_length = calc_length(); + m_total_duration = calc_duration(); + m_used = false; +} + +String SampleBlockContainer::sources() +{ + String result; + bool first = true; + for (auto& block : m_blocks) { + String source = block->description(); + if (first) { + result = String::formatted("[ {}", source).value(); + } else { + result = String::formatted("{}, {}", result, source).value(); + } + first = false; + } + result = String::formatted("{} ]", result).value(); + return result; +} + +String SampleBlockContainer::sources_for_range(double start, double end) +{ + // Return descriptions for blocks overlapping the requested range. + + double start_seconds = start * m_total_duration; + double end_seconds = end * m_total_duration; + + String result; + bool first = true; + double accumulated_duration = 0.0; + + for (auto& block : m_blocks) { + double block_duration = block->duration(); + double block_start = accumulated_duration; + double block_end = accumulated_duration + block_duration; + + if (block_end > start_seconds && block_start < end_seconds) { + String source = block->description(); + if (first) { + result = String::formatted("[ {}", source).value(); + } else { + result = String::formatted("{}, {}", result, source).value(); + } + first = false; + } + + accumulated_duration += block_duration; + } + + result = String::formatted("{} ]", result).value(); + return result; +} + +SampleBlockContainer::SelectionInfo SampleBlockContainer::selection_info(double start, double end) +{ + double start_seconds = start * m_total_duration; + double end_seconds = end * m_total_duration; + + double first_block_start = 0.0; + double last_block_end = 0.0; + double selection_duration = 0.0; + + String result; + bool first = true; + double accumulated_duration = 0.0; + + for (auto& block : m_blocks) { + double block_duration = block->duration(); + double block_start = accumulated_duration; + double block_end = accumulated_duration + block_duration; + + if (block_end > start_seconds && block_start < end_seconds) { + if (first) { + first_block_start = block_start; + first = false; + } + last_block_end = block_end; + selection_duration += block_duration; + + String source = block->description(); + if (result.is_empty()) { + result = String::formatted("[ {}", source).value(); + } else { + result = String::formatted("{}, {}", result, source).value(); + } + } + + accumulated_duration += block_duration; + } + + result = String::formatted("{} ]", result).value(); + + double blocks_duration = last_block_end - first_block_start; + double adjusted_start = (start_seconds - first_block_start) / blocks_duration; + double adjusted_end = (end_seconds - first_block_start) / blocks_duration; + + adjusted_start = max(0.0, min(1.0, adjusted_start)); + adjusted_end = max(0.0, min(1.0, adjusted_end)); + + return SelectionInfo { + .sources = result, + .adjusted_start = adjusted_start, + .adjusted_end = adjusted_end + }; +} + +ErrorOr SampleBlockContainer::cut(double start, double end) +{ + if (start < 0.0 || end > 1.0 || start >= end) { + return Error::from_string_literal("Invalid cut range"); + } + + auto selection = selection_info(start, end); + + double start_seconds = start * m_total_duration; + double end_seconds = end * m_total_duration; + + auto new_blocks = AK::Vector>(); + double accumulated_duration = 0.0; + + for (auto& block : m_blocks) { + double block_duration = block->duration(); + double block_start = accumulated_duration; + double block_end = accumulated_duration + block_duration; + + if (block_end <= start_seconds) { + new_blocks.append(block); + } else if (block_start >= end_seconds) { + new_blocks.append(block); + } else { + auto* file_block = dynamic_cast(block.ptr()); + + if (!file_block) { + // Non-file blocks can't be split yet. + new_blocks.append(block); + } else { + double cut_start_in_block = max(0.0, start_seconds - block_start); + double cut_end_in_block = min(block_duration, end_seconds - block_start); + + bool cut_at_start = (cut_start_in_block <= 0.001); + bool cut_at_end = (cut_end_in_block >= block_duration - 0.001); + + if (cut_at_start && cut_at_end) { + } else if (cut_at_start) { + double fraction_to_remove = cut_end_in_block / block_duration; + size_t samples_to_remove = (size_t)(fraction_to_remove * (double)file_block->length()); + + size_t new_start = file_block->start() + samples_to_remove; + size_t new_end = file_block->end(); + + if (new_start <= new_end) { + auto trimmed_block = TRY(try_make_ref_counted( + file_block->file(), new_start, new_end)); + new_blocks.append(trimmed_block); + } + } else if (cut_at_end) { + double fraction_to_keep = cut_start_in_block / block_duration; + size_t samples_to_keep = (size_t)(fraction_to_keep * (double)file_block->length()); + + size_t new_start = file_block->start(); + size_t new_end = file_block->start() + samples_to_keep - 1; + + if (new_start <= new_end) { + auto trimmed_block = TRY(try_make_ref_counted( + file_block->file(), new_start, new_end)); + new_blocks.append(trimmed_block); + } + } else { + double start_fraction = cut_start_in_block / block_duration; + double end_fraction = cut_end_in_block / block_duration; + + size_t block_length = file_block->end() - file_block->start() + 1; + size_t cut_start_sample = (size_t)(start_fraction * (double)block_length); + size_t cut_end_sample = (size_t)(end_fraction * (double)block_length); + + size_t first_start = file_block->start(); + size_t first_end = file_block->start() + cut_start_sample - 1; + + if (first_start <= first_end) { + auto first_block = TRY(try_make_ref_counted( + file_block->file(), first_start, first_end)); + new_blocks.append(first_block); + } + + size_t second_start = file_block->start() + cut_end_sample; + size_t second_end = file_block->end(); + + if (second_start <= second_end) { + auto second_block = TRY(try_make_ref_counted( + file_block->file(), second_start, second_end)); + new_blocks.append(second_block); + } + } + } + } + + accumulated_duration += block_duration; + } + + m_blocks = new_blocks; + m_total_length = calc_length(); + m_total_duration = calc_duration(); + m_used = false; + + return selection; +} + +void SampleBlockContainer::set_used() { m_used = true; } + +bool SampleBlockContainer::used() { return m_used; } + +bool SampleBlockContainer::is_initial_null_block() const +{ + if (m_blocks.size() != 1) { + return false; + } + auto* null_block = dynamic_cast(m_blocks[0].ptr()); + return null_block != nullptr; +} + +size_t SampleBlockContainer::length() { return m_total_length; } + +double SampleBlockContainer::duration() { return m_total_duration; } + +double SampleBlockContainer::sample_rate() +{ + if (m_blocks.size() > 0) { + return m_blocks[0]->sample_rate(); + } + return 44100.0; // Fallback when no blocks exist +} + +ErrorOr SampleBlockContainer::get_format() +{ + if (m_blocks.size() == 0) { + return Error::from_string_literal("No blocks available"); + } + + auto first_format = m_blocks[0]->format(); + + for (size_t i = 1; i < m_blocks.size(); i++) { + auto block_format = m_blocks[i]->format(); + + if (block_format.sample_rate != first_format.sample_rate) { + return Error::from_string_literal("Blocks have inconsistent sample rates"); + } + if (block_format.num_channels != first_format.num_channels) { + return Error::from_string_literal("Blocks have inconsistent channel counts"); + } + if (block_format.bits_per_sample != first_format.bits_per_sample) { + return Error::from_string_literal("Blocks have inconsistent bit depths"); + } + } + + return first_format; +} + +size_t SampleBlockContainer::calc_length() +{ + size_t length = 0; + for (auto& block : m_blocks) { + length += block->length(); + } + return length; +} + +double SampleBlockContainer::calc_duration() +{ + double duration = 0.0; + for (auto& block : m_blocks) { + duration += block->duration(); + } + return duration; +} + +RenderStruct SampleBlockContainer::rendered_sample_at(double position) +{ + if (position < 0 || position > 1 || m_total_length == 0 || m_blocks.size() == 0) + return { 0, 0, 0, 0 }; + + size_t total = 0; + double start = 0; + double end = 0; + + for (auto& block : m_blocks) { + size_t length = block->length(); + total += length; + end = (double)total / (double)m_total_length; + assert(end > start); + if (position <= end) { + double within = (position - start) / (end - start); + size_t sample_position = (size_t)(((double)length) * within); + return block->rendered_sample_at(sample_position); + } + start = end; + } + + return { 0, 0, 0, 0 }; +} + +void SampleBlockContainer::begin_loading_samples() +{ + m_stream_block = 0; + m_stream_position = 0; + for (auto& block : m_blocks) { + block->begin_loading_samples(); + } +} + +void SampleBlockContainer::begin_loading_samples_at(double start_position) +{ + double start_seconds = start_position * m_total_duration; + double accumulated_duration = 0.0; + + for (size_t i = 0; i < m_blocks.size(); i++) { + double block_duration = m_blocks[i]->duration(); + if (accumulated_duration + block_duration > start_seconds) { + m_stream_block = i; + m_stream_position = 0; + + for (auto& block : m_blocks) { + block->begin_loading_samples(); + } + + return; + } + accumulated_duration += block_duration; + } + + m_stream_block = m_blocks.size() > 0 ? m_blocks.size() - 1 : 0; + m_stream_position = 0; + for (auto& block : m_blocks) { + block->begin_loading_samples(); + } +} + +FixedArray SampleBlockContainer::load_more_samples() +{ + size_t num_blocks = m_blocks.size(); + if (m_stream_block < num_blocks) { + SampleBlock& block = m_blocks[m_stream_block]; + FixedArray samples = block.load_more_samples(); + if (samples.size() == 0) { + m_stream_block++; + m_stream_position = 0; + return load_more_samples(); + } + return samples; + } else { + return SampleBuffer::null_samples(); + } +} + +FixedArray SampleBlockContainer::load_more_samples_in_range(double start, double end, size_t& samples_loaded) +{ + if (m_blocks.size() == 0 || m_total_length == 0) + return SampleBuffer::null_samples(); + + size_t start_sample = (size_t)(start * (double)m_total_length); + size_t end_sample = (size_t)(end * (double)m_total_length); + size_t total_samples_in_range = end_sample - start_sample; + + if (samples_loaded >= total_samples_in_range) + return SampleBuffer::null_samples(); + + if (m_stream_block >= m_blocks.size()) + return SampleBuffer::null_samples(); + + SampleBlock& block = m_blocks[m_stream_block]; + FixedArray samples = block.load_more_samples(); + + if (samples.size() == 0) { + m_stream_block++; + m_stream_position = 0; + return load_more_samples_in_range(start, end, samples_loaded); + } + + size_t current_absolute_position = 0; + for (size_t i = 0; i < m_stream_block; i++) { + current_absolute_position += m_blocks[i]->length(); + } + current_absolute_position += m_stream_position; + + size_t skip_samples = 0; + if (current_absolute_position < start_sample) { + size_t samples_until_start = start_sample - current_absolute_position; + skip_samples = min(samples_until_start, samples.size()); + } + + m_stream_position += samples.size(); + + size_t samples_available = samples.size() - skip_samples; + size_t samples_remaining = total_samples_in_range - samples_loaded; + size_t samples_to_use = min(samples_available, samples_remaining); + + if (samples_to_use == 0 && samples_remaining > 0) { + return load_more_samples_in_range(start, end, samples_loaded); + } + + if (skip_samples > 0 || samples_to_use < samples.size()) { + auto trimmed = FixedArray::create(samples_to_use); + if (trimmed.is_error()) + return SampleBuffer::null_samples(); + + auto result = trimmed.release_value(); + for (size_t i = 0; i < samples_to_use; i++) { + result[i] = samples[skip_samples + i]; + } + samples_loaded += samples_to_use; + return result; + } + + samples_loaded += samples.size(); + return samples; +} diff --git a/Userland/Applications/SampleEditor/SampleBlockContainer.h b/Userland/Applications/SampleEditor/SampleBlockContainer.h new file mode 100755 index 00000000000000..f5996bb2110839 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleBlockContainer.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBlock.h" +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" +#include +#include +#include +#include +#include +#include + +class SampleBlockContainer { + +public: + SampleBlockContainer(); + ErrorOr parse_and_insert(String json, double position); + void set(NonnullRefPtr block); + void append(NonnullRefPtr block); + size_t length(); + double duration(); + double sample_rate(); + ErrorOr get_format(); + RenderStruct rendered_sample_at(double position); + bool used(); + void set_used(); + void begin_loading_samples(); + void begin_loading_samples_at(double start_position); + FixedArray load_more_samples(); + FixedArray load_more_samples_in_range(double start, double end, size_t& samples_loaded); + String sources(); + String sources_for_range(double start, double end); + struct SelectionInfo { + String sources; + double adjusted_start; + double adjusted_end; + }; + SelectionInfo selection_info(double start, double end); + + // Cut a portion of the timeline, returning the cut data for clipboard + // and removing it from the block container + ErrorOr cut(double start, double end); + + // Check if we're in the initial state with only a null block + bool is_initial_null_block() const; + +private: + AK::Vector> m_blocks; + size_t calc_length(); + double calc_duration(); + size_t m_total_length { 0 }; + double m_total_duration { 0.0 }; + bool m_used { false }; + size_t m_stream_position { 0 }; + size_t m_stream_block { 0 }; +}; diff --git a/Userland/Applications/SampleEditor/SampleBuffer.h b/Userland/Applications/SampleEditor/SampleBuffer.h new file mode 100644 index 00000000000000..88fd7c2ceefeeb --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleBuffer.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +#pragma once + +class SampleBuffer { + +public: + static size_t const BUFF_SIZE = 64 * 1024; + + static FixedArray null_samples() + { + auto maybe_empty_samples = FixedArray::create(0); + return maybe_empty_samples.release_value(); + } +}; diff --git a/Userland/Applications/SampleEditor/SampleEditorPalette.cpp b/Userland/Applications/SampleEditor/SampleEditorPalette.cpp new file mode 100644 index 00000000000000..7745a7781867b5 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleEditorPalette.cpp @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleEditorPalette.h" + +SampleEditorPalette::SampleEditorPalette(Gfx::Palette theme) +{ + black = theme.black(); + light_blue = theme.blue().lightened(1.1); + light_gray = light_blue.to_grayscale(); + dark_blue = theme.blue().darkened(0.9); + dark_gray = dark_blue.to_grayscale(); + window_color = theme.window(); + selection_color = window_color.to_grayscale().lightened(); + cursor_color = theme.red(); + timeline_selection_color = theme.white().lightened(); + timeline_selection_color.set_alpha(128); + timeline_cursor_color = theme.red().lightened(); + timeline_cursor_color.set_alpha(128); + timeline_background_color = theme.white(); + timeline_main_mark_color = theme.black(); + timeline_sub_mark_color = timeline_main_mark_color; + timeline_sub_mark_color.set_alpha(96); +} diff --git a/Userland/Applications/SampleEditor/SampleEditorPalette.h b/Userland/Applications/SampleEditor/SampleEditorPalette.h new file mode 100644 index 00000000000000..5a1656abd78286 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleEditorPalette.h @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +#pragma once + +class SampleEditorPalette { + +public: + SampleEditorPalette(Gfx::Palette theme); + + Gfx::Color black; + Gfx::Color light_blue; + Gfx::Color dark_blue; + Gfx::Color light_gray; + Gfx::Color dark_gray; + Gfx::Color window_color; + Gfx::Color selection_color; + Gfx::Color cursor_color; + Gfx::Color timeline_selection_color; + Gfx::Color timeline_cursor_color; + Gfx::Color timeline_background_color; + Gfx::Color timeline_main_mark_color; + Gfx::Color timeline_sub_mark_color; +}; diff --git a/Userland/Applications/SampleEditor/SampleFile.h b/Userland/Applications/SampleEditor/SampleFile.h new file mode 100755 index 00000000000000..ca82603e5be4bd --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleFile.h @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" +#include +#include +#include +#include + +class SampleFile : public RefCounted { + +public: + virtual size_t length() = 0; + virtual double duration() = 0; + virtual double sample_rate() = 0; + virtual ~SampleFile() = default; + virtual RenderStruct rendered_sample_at(size_t position) = 0; + virtual void begin_loading_samples() = 0; + virtual FixedArray load_more_samples() = 0; + virtual String filename() = 0; + SampleFormat format() { return m_format; } + +protected: + SampleFormat m_format; +}; diff --git a/Userland/Applications/SampleEditor/SampleFileBlock.cpp b/Userland/Applications/SampleEditor/SampleFileBlock.cpp new file mode 100644 index 00000000000000..2058555a10883b --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleFileBlock.cpp @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleFileBlock.h" + +#include +#include +#include + +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" + +SampleFileBlock::SampleFileBlock(NonnullRefPtr file, size_t start, + size_t end) + : m_file(file) +{ + m_file = file; + auto file_length = m_file->length(); + m_start = start; + m_end = (file_length > end) ? end : file_length; + m_length = (m_end >= m_start) ? (m_end - m_start + 1) : 0; + m_format = m_file->format(); +} + +size_t SampleFileBlock::length() { return m_length; } + +double SampleFileBlock::duration() +{ + return (double)m_length / (double)m_format.sample_rate; +} + +String SampleFileBlock::description() +{ + int rate = m_format.sample_rate; + int channels = m_format.num_channels; + int bits = m_format.bits_per_sample; + + return String::formatted( + "{{ \"path\": \"{}\", \"length\":{}, \"start\":{}, \"end\":{}, \"rate\":{}, \"channels\":{}, \"bits\":{} }}", + m_file->filename(), m_length, m_start, m_end, rate, channels, bits) + .value(); +} + +double SampleFileBlock::sample_rate() { return m_format.sample_rate; } + +RenderStruct SampleFileBlock::rendered_sample_at_valid(size_t position) +{ + return m_file->rendered_sample_at(position + m_start); +} + +void SampleFileBlock::begin_loading_samples() +{ + m_stream_position = m_start; +} + +FixedArray SampleFileBlock::load_more_samples() +{ + if (m_stream_position > m_end) { + return SampleBuffer::null_samples(); + } + + size_t remaining_in_block = m_end - m_stream_position + 1; + size_t samples_to_load = min(remaining_in_block, SampleBuffer::BUFF_SIZE); + + auto maybe_loader = Audio::Loader::create(m_file->filename()); + if (maybe_loader.is_error()) + return SampleBuffer::null_samples(); + auto loader = maybe_loader.value(); + + auto maybe_error = loader->seek(m_stream_position); + if (maybe_error.is_error()) + return SampleBuffer::null_samples(); + + auto maybe_samples = loader->get_more_samples(samples_to_load); + if (maybe_samples.is_error()) + return SampleBuffer::null_samples(); + + auto samples = maybe_samples.release_value(); + m_stream_position += samples.size(); + return samples; +} + +ErrorOr, 2>> SampleFileBlock::split_at(double position) +{ + if (position <= 0.0 || position >= 1.0) { + return Error::from_string_literal("Split position must be between 0.0 and 1.0 (exclusive)"); + } + + size_t total_length = m_end - m_start + 1; + size_t split_offset = static_cast(position * total_length); + size_t split_point = m_start + split_offset; + + if (split_point <= m_start || split_point >= m_end) { + return Error::from_string_literal("Split point would create empty block"); + } + + auto first_block = TRY(try_make_ref_counted(m_file, m_start, split_point - 1)); + + auto second_block = TRY(try_make_ref_counted(m_file, split_point, m_end)); + + return Array, 2> { first_block, second_block }; +} diff --git a/Userland/Applications/SampleEditor/SampleFileBlock.h b/Userland/Applications/SampleEditor/SampleFileBlock.h new file mode 100755 index 00000000000000..6c6fd59cb36d3b --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleFileBlock.h @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBlock.h" +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" +#include "SampleSourceFile.h" +#include +#include +#include +#include + +class SampleFileBlock : public SampleBlock { + +public: + SampleFileBlock(NonnullRefPtr file, size_t start, size_t end); + size_t length() override; + double duration() override; + double sample_rate() override; + String description() override; + void begin_loading_samples() override; + FixedArray load_more_samples() override; + + // Split this block at a fractional position (0.0 to 1.0) + // Returns a pair: (first_part, second_part) + ErrorOr, 2>> split_at(double position); + + // Accessors for block range within file + size_t start() const { return m_start; } + size_t end() const { return m_end; } + NonnullRefPtr file() const { return m_file; } + +protected: + NonnullRefPtr m_file; + size_t m_start { 0 }; + size_t m_end { 0 }; + size_t m_length { 0 }; + double m_duration { 0.0 }; + size_t m_stream_position { 0 }; // Track position for streaming playback + + RenderStruct rendered_sample_at_valid(size_t position) override; + +private: +}; diff --git a/Userland/Applications/SampleEditor/SampleFormatStruct.h b/Userland/Applications/SampleEditor/SampleFormatStruct.h new file mode 100644 index 00000000000000..fcee99813bc325 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleFormatStruct.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +typedef struct SampleFormat { + ByteString format_name = ByteString { "RIFF WAVE (.wav)" }; + int sample_rate = 44100.0; + u16 num_channels = 1; + u16 bits_per_sample = 16; + + bool operator==(SampleFormat const& other) const + { + return sample_rate == other.sample_rate && num_channels == other.num_channels && bits_per_sample == other.bits_per_sample; + } + + bool operator!=(SampleFormat const& other) const + { + return !(*this == other); + } + +} SampleFormat; diff --git a/Userland/Applications/SampleEditor/SampleNullBlock.cpp b/Userland/Applications/SampleEditor/SampleNullBlock.cpp new file mode 100644 index 00000000000000..d35fb84559a7b5 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleNullBlock.cpp @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleNullBlock.h" + +#include +#include + +#include "SampleBlock.h" +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" + +SampleNullBlock::SampleNullBlock(size_t size, double duration) +{ + m_size = size; + m_duration = duration; + m_format.sample_rate = (double)size / duration; +} + +size_t SampleNullBlock::length() { return m_size; } + +double SampleNullBlock::duration() { return m_duration; } + +double SampleNullBlock::sample_rate() { return m_format.sample_rate; } + +String SampleNullBlock::description() +{ + return String::formatted("null").value(); +} + +RenderStruct SampleNullBlock::rendered_sample_at_valid( + [[maybe_unused]] size_t position) +{ + return { 0, 0, 0, 0 }; +} + +void SampleNullBlock::begin_loading_samples() { m_position = 0; } + +FixedArray SampleNullBlock::load_more_samples() +{ + size_t remaining = m_size - m_position; + size_t to_read = min(remaining, SampleBuffer::BUFF_SIZE); + m_position += to_read; + auto result = FixedArray::create(to_read); + if (result.is_error()) { + return FixedArray(); + } + return result.release_value(); +} diff --git a/Userland/Applications/SampleEditor/SampleNullBlock.h b/Userland/Applications/SampleEditor/SampleNullBlock.h new file mode 100644 index 00000000000000..cb66a0890d1023 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleNullBlock.h @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBlock.h" +#include "SampleBuffer.h" +#include "SampleFormatStruct.h" +#include "SampleSourceFile.h" +#include +#include +#include +#include + +class SampleNullBlock : public SampleBlock { + +public: + SampleNullBlock(size_t size, double duration); + size_t length() override; + double duration() override; + double sample_rate() override; + String description() override; + void begin_loading_samples() override; + FixedArray load_more_samples() override; + +protected: + size_t m_size { 0 }; + double m_duration { 0 }; + RenderStruct rendered_sample_at_valid(size_t position) override; + +private: + size_t m_position { 0 }; +}; diff --git a/Userland/Applications/SampleEditor/SampleRenderer.cpp b/Userland/Applications/SampleEditor/SampleRenderer.cpp new file mode 100755 index 00000000000000..57a6d585c1fd6e --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleRenderer.cpp @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleRenderer.h" + +#include + +#include "SampleFormatStruct.h" + +SampleRenderer::SampleRenderer(SampleBlockContainer& samples, size_t width, + double start, double scale, size_t start_offset, size_t end_offset) +{ + m_width = width; + m_buffer = Vector(); + + for (size_t i = 0; i < m_width; i++) { + RenderStruct value; + if (i >= start_offset && i < end_offset) { + double position = ((double)i / (double)m_width) / scale + start; + value = samples.rendered_sample_at(position); + } + m_buffer.append(value); + } +} + +RenderStruct SampleRenderer::rendered_sample_at(size_t index) +{ + auto render_value = m_buffer[index]; + return render_value; +} diff --git a/Userland/Applications/SampleEditor/SampleRenderer.h b/Userland/Applications/SampleEditor/SampleRenderer.h new file mode 100755 index 00000000000000..eee177c61baa5c --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleRenderer.h @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBlockContainer.h" +#include "SampleFormatStruct.h" +#include +#include +#include +#include + +class SampleRenderer : public RefCounted { + +public: + SampleRenderer(SampleBlockContainer& samples, size_t width, double start, double scale, size_t start_offset, size_t end_offset); + RenderStruct rendered_sample_at(size_t index); + +private: + size_t m_width { 0 }; + Vector m_buffer; +}; diff --git a/Userland/Applications/SampleEditor/SampleSourceFile.cpp b/Userland/Applications/SampleEditor/SampleSourceFile.cpp new file mode 100755 index 00000000000000..ce1df2dfacbe8d --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleSourceFile.cpp @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleSourceFile.h" + +#include +#include +#include + +#include "RenderStruct.h" +#include "SampleBuffer.h" +#include "SampleFile.h" +#include "SampleFormatStruct.h" + +SampleSourceFile::SampleSourceFile(StringView filename) +{ + m_filename = String::formatted("{}", filename).value(); + MUST(m_buffer.create(SampleBuffer::BUFF_SIZE)); + load_metadata(); +} + +void SampleSourceFile::load_metadata() +{ + auto maybe_loader = Audio::Loader::create(m_filename); + if (maybe_loader.is_error()) + return; + m_loading = true; + auto loader = maybe_loader.value(); + m_buffer_position = 0; + m_buffered = false; + m_format.format_name = loader->format_name(); + m_format.sample_rate = loader->sample_rate(); + m_format.num_channels = loader->num_channels(); + m_format.bits_per_sample = loader->bits_per_sample(); + m_samples = loader->total_samples(); +} + +String SampleSourceFile::filename() { return m_filename; } + +size_t SampleSourceFile::length() { return m_samples; } + +double SampleSourceFile::duration() +{ + return (double)m_samples / (double)m_format.sample_rate; +} + +double SampleSourceFile::sample_rate() { return m_format.sample_rate; } + +RenderStruct SampleSourceFile::rendered_sample_within_buffer( + FixedArray& buffer, size_t at) +{ + size_t size = buffer.size(); + size_t window = size / 4; + + size_t start = max(0, at - window); + size_t end = max(size, at + window); + + size_t samples = max(window, 25); + size_t increment = window / samples; + + size_t count_minus = 0; + double total_square_minus = 0; + double peak_minus = 0; + size_t count_plus = 0; + double total_square_plus = 0; + double peak_plus = 0; + + for (size_t pos = start; pos < end; pos += increment) { + double val = buffer[at].left; + double square = val * val; + double mod = AK::abs(val); + + if (val >= 0) { + count_plus++; + total_square_plus += square; + peak_plus = AK::max(mod, peak_plus); + } else { + count_minus++; + total_square_minus += square; + peak_minus = AK::max(mod, peak_minus); + } + } + + RenderStruct value = { 0, 0, 0, 0 }; + + if (count_plus) { + value.RMS_plus = AK::sqrt(total_square_plus / ((double)count_plus)); + value.peak_plus = peak_plus; + } + + if (count_minus) { + value.RMS_minus = AK::sqrt(total_square_minus / ((double)count_minus)); + value.peak_minus = peak_minus; + } + + return value; +} + +void SampleSourceFile::begin_loading_samples() { m_stream_position = 0; } + +FixedArray SampleSourceFile::load_more_samples() +{ + auto maybe_loader = Audio::Loader::create(m_filename); + if (maybe_loader.is_error()) + return SampleBuffer::null_samples(); + auto loader = maybe_loader.value(); + + size_t number_of_samples = loader->total_samples(); + + if (m_stream_position >= number_of_samples) { + return SampleBuffer::null_samples(); + } + + auto maybe_error = loader->seek(m_stream_position); + if (maybe_error.is_error()) { + m_stream_position = 0; + return SampleBuffer::null_samples(); + } + + auto maybe_samples = loader->get_more_samples(SampleBuffer::BUFF_SIZE); + if (maybe_samples.is_error()) { + m_stream_position = 0; + return SampleBuffer::null_samples(); + } + + auto samples = maybe_samples.release_value(); + m_stream_position += samples.size(); + return samples; +} + +RenderStruct SampleSourceFile::rendered_sample_at(size_t position) +{ + RenderStruct result = { 0, 0, 0, 0 }; + + auto maybe_loader = Audio::Loader::create(m_filename); + if (maybe_loader.is_error()) + return result; + m_loading = true; + auto loader = maybe_loader.value(); + + auto error = loader->seek(m_buffer_position); + if (error.is_error()) + return result; + + if (m_buffered && position >= m_buffer_position && position < m_buffer_position + m_buffer.size()) { + auto at = position - m_buffer_position; + result = rendered_sample_within_buffer(m_buffer, at); + } else { + auto error = loader->seek(position); + if (error.is_error()) + return result; + m_buffer_position = position; + auto samples = loader->get_more_samples(SampleBuffer::BUFF_SIZE); + m_buffered = !samples.is_error(); + if (m_buffered) { + auto buffer = samples.release_value(); + m_buffer.swap(buffer); + } + result = rendered_sample_within_buffer(m_buffer, position - m_buffer_position); + } + + return result; +} diff --git a/Userland/Applications/SampleEditor/SampleSourceFile.h b/Userland/Applications/SampleEditor/SampleSourceFile.h new file mode 100755 index 00000000000000..9b61091874beb7 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleSourceFile.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBuffer.h" +#include "SampleFile.h" +#include "SampleFormatStruct.h" +#include +#include +#include +#include +#include + +class SampleSourceFile : public SampleFile { + +public: + SampleSourceFile(StringView filename); + RenderStruct rendered_sample_at(size_t position) override; + size_t length() override; + double duration() override; + double sample_rate() override; + String filename() override; + ErrorOr> try_create(StringView filename); + void begin_loading_samples() override; + FixedArray load_more_samples() override; + +private: + void load_metadata(); + RenderStruct rendered_sample_within_buffer(FixedArray& buffer, size_t at); + String m_filename; + FixedArray m_buffer; + StringView m_format_name; + size_t m_samples; + size_t m_buffer_position { 0 }; + bool m_buffered { false }; + bool m_loading { false }; + size_t m_stream_position { 0 }; +}; diff --git a/Userland/Applications/SampleEditor/SampleWidget.cpp b/Userland/Applications/SampleEditor/SampleWidget.cpp new file mode 100644 index 00000000000000..4da4d537da3d71 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleWidget.cpp @@ -0,0 +1,802 @@ +/* + * Copyright (c) 2022-2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "SampleWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RenderStruct.h" +#include "SampleBlockContainer.h" +#include "SampleBuffer.h" +#include "SampleEditorPalette.h" +#include "SampleFileBlock.h" +#include "SampleFormatStruct.h" +#include "SampleNullBlock.h" +#include "SampleRenderer.h" + +static double emphasized_amplitude(double amplitude) +{ + double clamped = AK::clamp(amplitude, 0.0, 1.0); + return (4.0 / 3.0) * clamped * clamped * clamped - 3.0 * clamped * clamped + (8.0 / 3.0) * clamped; +} + +SampleWidget::SampleWidget() +{ + clear(); + + // Initialize audio playback + auto audio_connection_or_error = Audio::ConnectionToServer::try_create(); + if (!audio_connection_or_error.is_error()) { + m_audio_connection = audio_connection_or_error.release_value(); + m_playback_timer = Core::Timer::create_repeating(playback_update_rate_ms, [this]() { + next_audio_buffer(); + if (m_playing) + update(); + }); + MUST(m_current_audio_buffer.create(0)); + } + + m_playback_visual_timer = Core::Timer::create_repeating(playback_visual_update_rate_ms, [this]() { + if (!m_playing) + return; + update(); + }); +} + +void SampleWidget::clear() +{ + size_t null_length = 16 * 1024; + auto null_block = make_ref_counted( + null_length, null_length / 44100.0); + + set(null_block); +} + +void SampleWidget::set(NonnullRefPtr block) +{ + set_fill_with_background_color(false); + set_should_hide_unnecessary_scrollbars(false); + set_focus_policy(GUI::FocusPolicy::StrongFocus); + set_scrollbars_enabled(true); + horizontal_scrollbar().set_step(32); + horizontal_scrollbar().set_visible(true); + vertical_scrollbar().set_visible(false); + horizontal_scrollbar().set_value(0); + + m_start = 0.0; + m_scale = 1.0; + m_previous_width = -1; + m_previous_scale = -1; + m_previous_start = -1; + m_selected = false; + m_dragging = false; + m_selection_start = 0; + m_selection_end = 1; + m_cursor_placed = false; + + m_samples.set(block); + zoom(); +} + +ErrorOr SampleWidget::selection() +{ + if (!m_selected) { + return Error::from_string_literal("No selection to copy"); + } + + double start = min(m_selection_start, m_selection_end); + double end = max(m_selection_start, m_selection_end); + + if (start == end) { + return Error::from_string_literal("Cannot copy zero-length selection"); + } + + auto info = m_samples.selection_info(start, end); + + return String::formatted( + "{{ \"sources\": {}, \"start\":{}, \"end\":{} }}", + info.sources, info.adjusted_start, info.adjusted_end); +} + +ErrorOr SampleWidget::cut() +{ + if (!m_selected) { + return Error::from_string_literal("No selection to cut"); + } + + double start = min(m_selection_start, m_selection_end); + double end = max(m_selection_start, m_selection_end); + + if (start == end) { + return Error::from_string_literal("Cannot cut zero-length selection"); + } + + auto info = TRY(m_samples.cut(start, end)); + + m_selected = false; + + must_repaint(); + + if (on_selection_changed) + on_selection_changed(); + + return String::formatted( + "{{ \"sources\": {}, \"start\":{}, \"end\":{} }}", + info.sources, info.adjusted_start, info.adjusted_end); +} + +void SampleWidget::select_all() +{ + m_selection_start = 0; + m_selection_end = 1; + m_selected = true; + must_repaint(); + + if (on_selection_changed) + on_selection_changed(); +} + +ErrorOr SampleWidget::paste_from_text(String clipboard_text) +{ + bool is_initial_state = m_samples.is_initial_null_block(); + + if (!m_cursor_placed && !is_initial_state) { + return Error::from_string_literal("No cursor position set. Click to place cursor before pasting."); + } + + double paste_position = m_cursor_placed ? m_cursor : 0.0; + + if (paste_position < 0.0 || paste_position > 1.0) { + return Error::from_string_literal("Invalid cursor position (out of bounds)"); + } + + auto new_cursor_position = TRY(m_samples.parse_and_insert(clipboard_text, paste_position)); + + m_cursor = new_cursor_position; + m_cursor_placed = true; + + m_selected = false; + + must_repaint(); + + if (on_selection_changed) + on_selection_changed(); + + return {}; +} + +void SampleWidget::clear_selection() +{ + m_selection_start = 0; + m_selection_end = 1; + m_selected = false; + must_repaint(); + + if (on_selection_changed) + on_selection_changed(); +} + +void SampleWidget::zoom_in() +{ + if (m_scale < m_samples.length()) { + m_scale *= 2; + zoom(); + } +} + +void SampleWidget::zoom_out() +{ + m_scale /= 2; + m_scale = floor(m_scale); + if (m_scale < 1) { + m_scale = 1; + } + zoom(); +} + +void SampleWidget::zoom() +{ + int height = frame_inner_rect().height() - horizontal_scrollbar().height(); + int width = frame_inner_rect().width(); + auto scrollable_size = Gfx::IntSize(width * m_scale, height); + set_content_size(scrollable_size); + int h_pos = (m_start * m_scale * ((double)width)); + horizontal_scrollbar().set_value(h_pos); + must_repaint(); +} + +void SampleWidget::draw_timeline( + GUI::Painter painter, + Gfx::Rect frame, + SampleEditorPalette& colors, + int offset, + double duration, + double h_pos, + double width) +{ + + auto timeline_rect = frame; + timeline_rect.set_height(offset); + painter.fill_rect(timeline_rect, colors.timeline_background_color); + + double duration_on_view = duration / m_scale; + double start_seconds = h_pos / width * duration / m_scale; + double mag = floor(log10(duration_on_view)); + double tick = pow(10, mag); + double first_tick = start_seconds - fmod(start_seconds, tick); + + for (double t = first_tick; t < start_seconds + duration_on_view; t += tick) { + for (double t2 = t; t2 < t + tick; t2 += (tick / 10)) { + double x2 = (t2 - start_seconds) / duration_on_view * (double)width; + Gfx::IntPoint sub_mark_top = { (int)x2, offset - (offset / 8) }; + Gfx::IntPoint sub_mark_bottom = { (int)x2, offset }; + painter.draw_line(sub_mark_top, sub_mark_bottom, colors.timeline_sub_mark_color); + } + + double x = (t - start_seconds) / duration_on_view * (double)width; + + Gfx::IntPoint mark_top = { (int)x, offset / 2 }; + Gfx::IntPoint mark_bottom = { (int)x, offset }; + painter.draw_line(mark_top, mark_bottom, colors.timeline_main_mark_color); + + Gfx::IntRect text_rec = { (int)x + 3, (offset / 2) - (offset / 8), + (offset / 2), (offset / 2) }; + + StringBuilder time; + + if (mag < 0) { + StringBuilder format; + format.appendff("{{:.{}}}", mag * -1); + time.appendff(format.string_view(), t + (tick / 2)); + } else { + time.appendff("{}", t); + } + + painter.draw_text(text_rec, time.string_view(), Gfx::TextAlignment::TopLeft, + colors.black); + } +} + +void SampleWidget::paint_event([[maybe_unused]] GUI::PaintEvent& event) +{ + + GUI::Painter real_painter(*this); + + RefPtr old_bitmap; + if (m_has_bitmap) { + auto clone_or_error = m_bitmap->clone(); + if (!clone_or_error.is_error()) + old_bitmap = clone_or_error.release_value(); + } + + auto colors = SampleEditorPalette(palette()); + double duration = m_samples.duration(); + + int const offset = 16; + + double width = frame_inner_rect().width(); + double height = (double)content_rect().height() - offset - horizontal_scrollbar().height(); + int h_pos = horizontal_scrollbar().value(); + m_start = (double)h_pos / (double)width / m_scale; + + if (!m_samples.used()) + m_dragging = false; + + auto rect = frame_inner_rect(); + auto x = rect.x(); + auto y = rect.y(); + auto w = rect.width(); + auto h = rect.height(); + + int sample_diff = 0; + int start_sample = 0; + int end_sample = width; + bool changed = false; + bool full_redraw = false; + + if (!m_drag_redraw) { + + if (!(width == m_previous_width && height == m_previous_height && m_previous_scale == m_scale && m_previous_start == m_start && m_samples.used())) { + + full_redraw = true; + changed = true; + + if (width == m_previous_width && height == m_previous_height && m_scale == m_previous_scale) { + + full_redraw = false; + sample_diff = ((m_start - m_previous_start) * m_scale * width); + + if ((sample_diff > 0 && sample_diff > width) || (sample_diff < 0 && -sample_diff > width)) { + full_redraw = true; + + } else if (sample_diff > 0) { + start_sample = width - sample_diff; + end_sample = width; + + } else if (sample_diff < 0) { + start_sample = 0; + end_sample = -sample_diff; + + } else { + changed = false; + } + } + } + + m_renderer = make_ref_counted(m_samples, width, m_start, m_scale, start_sample, end_sample); + m_samples.set_used(); + + int selection_start = width * m_scale * (m_selection_start - m_start); + int selection_end = width * m_scale * (m_selection_end - m_start); + int cursor = width * m_scale * (m_cursor - m_start); + + if (selection_start > selection_end + 1) { + auto temp = selection_end; + selection_end = selection_start; + selection_start = temp; + } + + double half = height / 2; + + auto paint_bitmap_or_error = Gfx::Bitmap::create(Gfx::BitmapFormat::BGRA8888, frame_inner_rect().size()); + auto paint_bitmap = paint_bitmap_or_error.value(); + GUI::Painter painter(*paint_bitmap); + + auto bitmap_or_error = Gfx::Bitmap::create(Gfx::BitmapFormat::BGRA8888, frame_inner_rect().size()); + m_bitmap = bitmap_or_error.value(); + GUI::Painter composite_painter(*m_bitmap); + + painter.fill_rect(frame_inner_rect(), colors.window_color); + Gfx::IntPoint left = { 0, (int)half + offset }; + Gfx::IntPoint right = { (int)width, (int)half + offset }; + int top_y = (int)frame_inner_rect().y(); + int bottom_y = (int)frame_inner_rect().height() - frame_inner_rect().y() - horizontal_scrollbar().height(); + + draw_timeline(painter, frame_inner_rect(), colors, offset, duration, h_pos, width); + + if (changed || full_redraw || m_must_redraw) { + + for (int sample = start_sample; sample < end_sample; sample++) { + + Gfx::Color waveform_light = colors.light_blue; + Gfx::Color waveform_dark = colors.dark_blue; + + Gfx::IntPoint top = { sample, top_y + offset }; + Gfx::IntPoint bottom = { sample, bottom_y + offset }; + Gfx::IntPoint timeline_top = { sample, 0 }; + Gfx::IntPoint timeline_bottom = { sample, offset }; + + if (m_selected && (sample >= selection_start) && (sample <= selection_end)) { + painter.draw_line(top, bottom, colors.selection_color); + painter.draw_line(timeline_top, timeline_bottom, + colors.timeline_selection_color); + waveform_light = colors.light_gray.lightened(); + waveform_dark = colors.dark_gray.lightened(); + } + + if (m_cursor_placed && (sample == cursor)) { + painter.draw_line(top, bottom, colors.cursor_color); + painter.draw_line(timeline_top, timeline_bottom, + colors.timeline_cursor_color); + } + + RenderStruct value = m_renderer->rendered_sample_at(sample); + auto emphasized_peak_plus = emphasized_amplitude(value.peak_plus); + auto emphasized_peak_minus = emphasized_amplitude(value.peak_minus); + + double y_max = half * (1 + (emphasized_peak_plus * 0.9)); + double y_min = half * (1 - (emphasized_peak_minus * 0.9)); + + Gfx::IntPoint min_point = { sample, (int)y_max + offset }; + Gfx::IntPoint max_point = { sample, (int)y_min + offset }; + + painter.draw_line(min_point, max_point, waveform_dark); + + auto emphasized_rms_plus = emphasized_amplitude(value.RMS_plus); + auto emphasized_rms_minus = emphasized_amplitude(value.RMS_minus); + y_max = half * (1 + (emphasized_rms_plus * 0.45)); + y_min = half * (1 - (emphasized_rms_minus * 0.45)); + + min_point = { sample, (int)y_max + offset }; + max_point = { sample, (int)y_min + offset }; + + painter.draw_line(min_point, max_point, waveform_light); + } + + painter.draw_line(left, right, colors.dark_blue); + } + + if (full_redraw || !m_has_bitmap || m_must_redraw) { + + composite_painter.blit(frame_inner_rect().top_left(), *paint_bitmap, frame_inner_rect()); + real_painter.blit(frame_inner_rect().top_left(), *m_bitmap, frame_inner_rect()); + m_has_bitmap = true; + + } else if (changed) { + + Gfx::Point old_dest; + Gfx::Rect old_source; + Gfx::Point new_dest; + Gfx::Rect new_source; + + if (sample_diff < 0) { + old_dest = Gfx::Point(-sample_diff, y); + old_source = Gfx::Rect(x, y, w + sample_diff, h); + new_dest = Gfx::Point(x, y); + new_source = Gfx::Rect(x, y, -sample_diff, h); + + } else { + old_dest = Gfx::Point(x, y); + old_source = Gfx::Rect(sample_diff, y, w - sample_diff, h); + new_dest = Gfx::Point(x + w - sample_diff, y); + new_source = Gfx::Rect(x + w - sample_diff, y, sample_diff, h); + } + + composite_painter.blit(new_dest, *paint_bitmap, new_source); + + composite_painter.blit(old_dest, *old_bitmap, old_source); + + real_painter.blit(frame_inner_rect().top_left(), *m_bitmap, frame_inner_rect()); + + m_has_bitmap = true; + + } else if (m_has_bitmap) { + + composite_painter.blit(frame_inner_rect().top_left(), *old_bitmap, frame_inner_rect()); + real_painter.blit(frame_inner_rect().top_left(), *m_bitmap, frame_inner_rect()); + } + + } else { + + if (m_has_bitmap) { + + int start = min(m_selection_start_absolute, m_selection_end_absolute); + int end = max(m_selection_start_absolute, m_selection_end_absolute); + + if (start > 0) { + auto left_dest = Gfx::Point(0, y); + auto left_source = Gfx::Rect(0, y, start - 1, h); + real_painter.blit(left_dest, *m_bitmap, left_source); + } + + if (end < width - 1) { + auto right_dest = Gfx::Point(end, y); + auto right_source = Gfx::Rect(end, y, width - end - 1, h); + real_painter.blit(right_dest, *m_bitmap, right_source); + } + + auto dest = Gfx::Point(start, y); + auto source = Gfx::Rect(start, y, end - start, h); + + real_painter.blit_dimmed(dest, *m_bitmap, source); + } + } + + m_must_redraw = false; + m_drag_redraw = false; + + m_previous_width = width; + m_previous_height = height; + m_previous_scale = m_scale; + m_previous_start = m_start; + + if (!m_timer) { + m_timer = Core::Timer::create_single_shot(25, [&]() { + m_awaiting_repaint = false; + must_repaint(); + }); + } + + if (changed) { + m_awaiting_repaint = true; + m_timer->restart(); + } + + if (!m_drag_redraw) { + if (auto playback_ratio = current_playback_position_ratio(); playback_ratio.has_value() && m_scale != 0.0) { + double playback_position = playback_ratio.value(); + double visible_start = m_start; + double visible_end = m_start + (1.0 / m_scale); + if (playback_position >= visible_start && playback_position <= visible_end) { + int playback_cursor = AK::round_to(width * m_scale * (playback_position - m_start)); + if (playback_cursor >= 0 && playback_cursor < (int)width) { + int absolute_x = frame_inner_rect().x() + playback_cursor; + int inner_top = frame_inner_rect().y(); + int inner_bottom = frame_inner_rect().y() + frame_inner_rect().height(); + int scrollbar_height = horizontal_scrollbar().is_visible() ? horizontal_scrollbar().height() : 0; + int timeline_top_y = inner_top; + int timeline_bottom_y = min(inner_top + offset, inner_bottom); + int waveform_top_y = timeline_bottom_y; + int waveform_bottom_y = inner_bottom - scrollbar_height; + waveform_bottom_y = max(waveform_top_y, waveform_bottom_y); + + if (timeline_bottom_y > timeline_top_y) + real_painter.draw_line({ absolute_x, timeline_top_y }, { absolute_x, timeline_bottom_y }, colors.timeline_cursor_color); + if (waveform_bottom_y > waveform_top_y) + real_painter.draw_line({ absolute_x, waveform_top_y }, { absolute_x, waveform_bottom_y }, colors.cursor_color); + } + } + } + } +} + +void SampleWidget::mousedown_event(GUI::MouseEvent& event) +{ + double width = frame_inner_rect().width(); + int clamped_x = AK::clamp(event.position().x(), 0, (int)width); + m_dragging = true; + m_drag_elapsed_timer.start(); + m_drag_elapsed_timer_active = true; + m_cursor_placed = false; + m_selection_start = m_selection_end = ((double)clamped_x / width) / m_scale + m_start; + m_selection_start_absolute = m_selection_end_absolute = clamped_x; + m_drag_paint_position_valid = true; + m_last_drag_paint_absolute = m_selection_end_absolute; + must_repaint(); +} + +void SampleWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (!m_dragging) + return; + double width = frame_inner_rect().width(); + int clamped_x = AK::clamp(event.position().x(), 0, (int)width); + m_selection_end = ((double)clamped_x / width) / m_scale + m_start; + m_selection_end_absolute = clamped_x; + m_selected = true; + constexpr int drag_refresh_interval_ms = 16; + constexpr int minimum_unthrottled_pixels = 16; + int selection_width = abs(m_selection_end_absolute - m_selection_start_absolute); + auto record_paint_position = [&]() { + m_drag_paint_position_valid = true; + m_last_drag_paint_absolute = m_selection_end_absolute; + }; + + if (!m_drag_elapsed_timer_active) { + m_drag_elapsed_timer.start(); + m_drag_elapsed_timer_active = true; + drag_repaint(); + record_paint_position(); + return; + } + if (selection_width < minimum_unthrottled_pixels) { + drag_repaint(); + m_drag_elapsed_timer.start(); + record_paint_position(); + return; + } + constexpr int drag_distance_threshold_px = 24; + bool moved_far_since_last_paint = !m_drag_paint_position_valid || abs(m_selection_end_absolute - m_last_drag_paint_absolute) >= drag_distance_threshold_px; + if (m_drag_elapsed_timer.elapsed() >= drag_refresh_interval_ms || moved_far_since_last_paint) { + drag_repaint(); + m_drag_elapsed_timer.start(); + record_paint_position(); + } +} + +void SampleWidget::mouseup_event(GUI::MouseEvent& event) +{ + double width = frame_inner_rect().width(); + int clamped_x = AK::clamp(event.position().x(), 0, (int)width); + if (m_dragging) { + m_selection_end = ((double)clamped_x / width) / m_scale + m_start; + } + m_dragging = false; + if (m_selection_start == m_selection_end) { + m_cursor = m_selection_end; + m_selection_start = 0; + m_selection_end = 1; + m_selected = 0; + m_cursor_placed = true; + must_repaint(); + } + + m_drag_elapsed_timer_active = false; + m_drag_paint_position_valid = false; + + if (on_selection_changed) + on_selection_changed(); +} + +ErrorOr SampleWidget::save(StringView path) +{ + auto format_or_error = m_samples.get_format(); + if (format_or_error.is_error()) { + return Error::from_string_literal("Cannot save: no valid audio format found"); + } + auto format = format_or_error.value(); + + int const rate = format.sample_rate; + int const channels = format.num_channels; + int const bits = format.bits_per_sample; + + auto temp_file = TRY(FileSystem::TempFile::create_temp_file()); + auto temp_path = temp_file->path(); + + auto extension = LexicalPath { path }.extension(); + + auto create_writer = [&]() -> ErrorOr> { + if (extension.equals_ignoring_ascii_case("flac"sv)) { + auto stream = TRY(Core::File::open(temp_path, Core::File::OpenMode::Write)); + return TRY(Audio::FlacWriter::create(move(stream), rate, channels, bits)); + } else { + // Default to WAV for .wav or unknown extensions + return TRY(Audio::WavWriter::create_from_file( + temp_path, rate, channels, + Audio::integer_sample_format_for(bits).value())); + } + }; + + auto writer = TRY(create_writer()); + + m_samples.begin_loading_samples(); + while (true) { + FixedArray samples = m_samples.load_more_samples(); + if (samples.size()) { + TRY(writer->write_samples(samples.span())); + } else { + TRY(writer->finalize()); + break; + } + }; + + TRY(FileSystem::move_file(path, temp_path)); + + auto source_file = TRY(try_make_ref_counted(path)); + size_t length = source_file->length(); + auto file_block = TRY(try_make_ref_counted( + source_file, (size_t)0, (size_t)length - 1)); + set(file_block); + + return {}; +} + +void SampleWidget::play() +{ + if (!m_audio_connection) + return; + + if (m_selected) { + m_playback_start = min(m_selection_start, m_selection_end); + m_playback_end = max(m_selection_start, m_selection_end); + } else if (m_cursor_placed) { + m_playback_start = m_cursor; + m_playback_end = 1.0; + } else { + m_playback_start = 0.0; + m_playback_end = 1.0; + } + + if (m_samples.length() == 0) + return; + + must_repaint(); + + double normalized_start = min(m_playback_start, m_playback_end); + double normalized_end = max(m_playback_start, m_playback_end); + + size_t total_samples = m_samples.length(); + size_t range_start_sample = min(total_samples, static_cast(normalized_start * static_cast(total_samples))); + size_t range_end_sample = min(total_samples, static_cast(normalized_end * static_cast(total_samples))); + if (total_samples > 0) { + range_start_sample = min(range_start_sample, total_samples - 1); + if (range_end_sample <= range_start_sample) + range_end_sample = min(total_samples, range_start_sample + 1); + } + m_playback_total_sample_count = range_end_sample > range_start_sample ? range_end_sample - range_start_sample : total_samples; + if (m_playback_total_sample_count == 0 && total_samples > 0) + m_playback_total_sample_count = total_samples; + if (m_audio_connection) + m_playback_start_device_sample_index = m_audio_connection->total_played_samples(); + else + m_playback_start_device_sample_index = 0; + + double sample_rate = m_samples.sample_rate(); + m_audio_connection->set_self_sample_rate(sample_rate); + m_samples_to_load_per_buffer = buffer_size_ms / 1000.0 * sample_rate; + + m_samples.begin_loading_samples_at(m_playback_start); + m_samples_played = 0; + m_finished_loading = false; + + m_audio_connection->clear_client_buffer(); + m_audio_connection->async_clear_buffer(); + + m_playing = true; + m_audio_connection->async_start_playback(); + m_playback_timer->start(); + if (m_playback_visual_timer) + m_playback_visual_timer->start(); + update(); +} + +void SampleWidget::stop() +{ + if (!m_audio_connection) + return; + + m_playing = false; + m_playback_timer->stop(); + if (m_playback_visual_timer) + m_playback_visual_timer->stop(); + m_playback_total_sample_count = 0; + m_audio_connection->async_pause_playback(); + m_audio_connection->clear_client_buffer(); + m_audio_connection->async_clear_buffer(); + update(); +} + +AK::Optional SampleWidget::current_playback_position_ratio() +{ + if (!m_playing) + return {}; + + double start_ratio = AK::clamp(min(m_playback_start, m_playback_end), 0.0, 1.0); + double end_ratio = AK::clamp(max(m_playback_start, m_playback_end), 0.0, 1.0); + double range_ratio = end_ratio - start_ratio; + + if (range_ratio <= 0.0 || m_playback_total_sample_count == 0 || !m_audio_connection) + return start_ratio; + + auto total_played_samples = static_cast(m_audio_connection->total_played_samples()); + u64 samples_since_start = 0; + if (total_played_samples >= m_playback_start_device_sample_index) + samples_since_start = total_played_samples - m_playback_start_device_sample_index; + + double progress = static_cast(samples_since_start) / static_cast(m_playback_total_sample_count); + progress = AK::clamp(progress, 0.0, 1.0); + + return start_ratio + (range_ratio * progress); +} + +void SampleWidget::next_audio_buffer() +{ + if (!m_playing || !m_audio_connection) + return; + + if (m_finished_loading) { + if (m_audio_connection->remaining_samples() == 0) { + stop(); + if (on_playback_finished) + on_playback_finished(); + } + return; + } + + while (m_audio_connection->remaining_samples() < m_samples_to_load_per_buffer * always_enqueued_buffer_count) { + auto buffer = m_samples.load_more_samples_in_range(m_playback_start, m_playback_end, m_samples_played); + + if (buffer.size() == 0) { + m_finished_loading = true; + return; + } + + m_current_audio_buffer.swap(buffer); + MUST(m_audio_connection->async_enqueue(m_current_audio_buffer)); + } +} diff --git a/Userland/Applications/SampleEditor/SampleWidget.h b/Userland/Applications/SampleEditor/SampleWidget.h new file mode 100644 index 00000000000000..3bb4e983e4dfc6 --- /dev/null +++ b/Userland/Applications/SampleEditor/SampleWidget.h @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "RenderStruct.h" +#include "SampleBlockContainer.h" +#include "SampleEditorPalette.h" +#include "SampleFormatStruct.h" +#include "SampleRenderer.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class SampleWidget : public GUI::AbstractScrollableWidget { + C_OBJECT(SampleWidget) +public: + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent& event) override; + virtual void mousemove_event(GUI::MouseEvent& event) override; + virtual void mouseup_event(GUI::MouseEvent& event) override; + + void zoom_in(); + void zoom_out(); + void select_all(); + ErrorOr paste_from_text(String clipboard_text); + void clear_selection(); + void set(NonnullRefPtr block); + void clear(); + ErrorOr save(StringView path); + ErrorOr selection(); + ErrorOr cut(); + void play(); + void stop(); + bool is_playing() const { return m_playing; } + bool has_selection() const { return m_selected; } + bool has_cursor_placed() const { return m_cursor_placed; } + bool is_initial_null_block() const { return m_samples.is_initial_null_block(); } + + Function on_playback_finished; + Function on_selection_changed; + + void must_repaint() + { + m_must_redraw = true; + repaint(); + } + void drag_repaint() + { + m_drag_redraw = true; + repaint(); + } + +protected: + SampleWidget(); + void draw_timeline( + GUI::Painter painter, + Gfx::Rect frame, + SampleEditorPalette& palette, + int offset, + double duration, + double h_pos, + double width); + void zoom(); + void next_audio_buffer(); + AK::Optional current_playback_position_ratio(); + SampleBlockContainer m_samples; + RefPtr m_renderer { nullptr }; + RefPtr m_bitmap; + RefPtr m_timer; + double m_cursor { 0.0 }; + double m_start { 0.0 }; + double m_scale { 1.0 }; + double m_previous_width { -1 }; + double m_previous_height { -1 }; + double m_previous_scale { -1 }; + double m_previous_start { -1 }; + bool m_selected { false }; + bool m_dragging { false }; + double m_selection_start { 0 }; + double m_selection_end { 1 }; + int m_selection_start_absolute { 0 }; + int m_selection_end_absolute { 1 }; + bool m_painting { false }; + bool m_waiting { false }; + bool m_cursor_placed { false }; + bool m_has_bitmap { false }; + bool m_must_redraw { false }; + bool m_drag_redraw { false }; + bool m_awaiting_repaint { false }; + bool m_drag_elapsed_timer_active { false }; + Core::ElapsedTimer m_drag_elapsed_timer; + bool m_drag_paint_position_valid { false }; + int m_last_drag_paint_absolute { 0 }; + + // Audio playback + RefPtr m_audio_connection; + RefPtr m_playback_timer; + RefPtr m_playback_visual_timer; + u64 m_playback_start_device_sample_index { 0 }; + size_t m_playback_total_sample_count { 0 }; + bool m_playing { false }; + bool m_finished_loading { false }; + size_t m_samples_to_load_per_buffer { 0 }; + FixedArray m_current_audio_buffer; + double m_playback_start { 0.0 }; + double m_playback_end { 1.0 }; + size_t m_samples_played { 0 }; + + static constexpr size_t always_enqueued_buffer_count = 5; + static constexpr u32 playback_update_rate_ms = 50; + static constexpr u32 playback_visual_update_rate_ms = 16; + static constexpr u32 buffer_size_ms = 100; +}; diff --git a/Userland/Applications/SampleEditor/main.cpp b/Userland/Applications/SampleEditor/main.cpp new file mode 100755 index 00000000000000..8edb6ff375e568 --- /dev/null +++ b/Userland/Applications/SampleEditor/main.cpp @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025, Lee Hanken + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "MainWidget.h" + +ErrorOr serenity_main(Main::Arguments arguments) +{ + TRY(Core::System::pledge( + "stdio recvfd sendfd rpath unix cpath wpath thread fattr proc")); + + auto app = TRY(GUI::Application::create(arguments)); + + auto const man_file = "/usr/share/man/man1/Applications/SampleEditor.md"; + + TRY(Desktop::Launcher::add_allowed_handler_with_only_specific_urls("/bin/Help", { URL::create_with_file_scheme(man_file) })); + TRY(Desktop::Launcher::seal_allowlist()); + + Config::pledge_domain("SampleEditor"); + + auto app_icon = GUI::Icon::default_icon("app-sample-editor"sv); + auto window = GUI::Window::construct(); + window->set_title("Sample Editor"); + window->resize(720, 360); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto main_widget = TRY(MainWidget::try_create()); + window->set_main_widget(main_widget); + TRY(main_widget->initialize_menu_and_toolbar(window)); + + TRY(Core::System::unveil("/", "r")); + TRY(Core::System::unveil("/etc", "r")); + TRY(Core::System::unveil("/res", "r")); + TRY(Core::System::unveil("/home", "rwc")); + TRY(Core::System::unveil("/home/anon", "rwc")); + TRY(Core::System::unveil("/tmp", "rwc")); + TRY(Core::System::unveil(nullptr, nullptr)); + + if (arguments.argc > 1) { + TRY(main_widget->open(arguments.strings[1])); + } + + window->show(); + return app->exec(); +}