-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Applications: Add SampleEditor audio editing application #26509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| [App] | ||
| Name=Sample Editor | ||
| Executable=/bin/SampleEditor | ||
| Category=&Media | ||
|
|
||
| [Launcher] | ||
| FileTypes=wav,flac |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| ## Name | ||
|
|
||
|  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 and waveform visualization. | ||
|
|
||
| ## 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) | ||
| - **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 | ||
| - **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. | ||
|
|
||
| ### 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 | ||
|
|
||
| - **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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| serenity_component( | ||
| SampleEditor | ||
| REQUIRED | ||
| TARGETS SampleEditor | ||
| ) | ||
|
|
||
| set(SOURCES | ||
| SampleSourceFile.cpp | ||
| SampleEditorPalette.cpp | ||
| SampleBlock.cpp | ||
| SampleFileBlock.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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| /* | ||
| * Copyright (c) 2025, Lee Hanken | ||
| * | ||
| * SPDX-License-Identifier: BSD-2-Clause | ||
| */ | ||
|
|
||
| #include "MainWidget.h" | ||
|
|
||
| #include <AK/LexicalPath.h> | ||
| #include <AK/RefPtr.h> | ||
| #include <AK/Types.h> | ||
| #include <LibCore/File.h> | ||
| #include <LibDesktop/Launcher.h> | ||
| #include <LibFileSystemAccessClient/Client.h> | ||
| #include <LibGUI/Application.h> | ||
| #include <LibGUI/BoxLayout.h> | ||
| #include <LibGUI/Icon.h> | ||
| #include <LibGUI/Menu.h> | ||
| #include <LibGUI/MessageBox.h> | ||
| #include <LibGUI/Toolbar.h> | ||
| #include <LibGUI/ToolbarContainer.h> | ||
| #include <LibGUI/Window.h> | ||
| #include <LibGfx/Bitmap.h> | ||
| #include <LibURL/URL.h> | ||
| #include <errno.h> | ||
|
|
||
| #include "SampleFileBlock.h" | ||
|
|
||
| MainWidget::MainWidget() | ||
| { | ||
| set_layout<GUI::VerticalBoxLayout>(); | ||
| set_fill_with_background_color(true); | ||
| } | ||
|
|
||
| ErrorOr<void> MainWidget::open(StringView path) | ||
| { | ||
| auto source_file = TRY(try_make_ref_counted<SampleSourceFile>(path)); | ||
| size_t length = source_file->length(); | ||
| auto file_block = TRY(try_make_ref_counted<SampleFileBlock>( | ||
| source_file, (size_t)0, (size_t)length - 1)); | ||
|
Comment on lines
+37
to
+40
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems that you're always creating a The code will be a bit neater but will also make it easier to understand the class hierarchy. |
||
| 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<void> MainWidget::save(StringView path) | ||
| { | ||
| TRY(m_sample_widget->save(path)); | ||
| window()->set_title( | ||
| ByteString::formatted("Sample Editor - {}", LexicalPath { path }.title())); | ||
| return {}; | ||
| } | ||
|
|
||
| ErrorOr<void> MainWidget::initialize_menu_and_toolbar( | ||
| NonnullRefPtr<GUI::Window> window) | ||
| { | ||
| m_toolbar_container = add<GUI::ToolbarContainer>(); | ||
| m_toolbar = m_toolbar_container->add<GUI::Toolbar>(); | ||
|
|
||
| m_new_action = GUI::Action::create( | ||
| "&New", { Mod_Ctrl, Key_N }, | ||
| TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"sv)), | ||
|
Comment on lines
+63
to
+65
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No required action: Seems like we could use a |
||
| [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_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_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<SampleWidget>(); | ||
|
|
||
| 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_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() | ||
| { | ||
| // Currently no actions need dynamic state updates | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| /* | ||
| * Copyright (c) 2025, Lee Hanken | ||
| * | ||
| * SPDX-License-Identifier: BSD-2-Clause | ||
| */ | ||
|
|
||
| #pragma once | ||
|
|
||
| #include "SampleWidget.h" | ||
| #include <AK/ByteString.h> | ||
| #include <AK/RefPtr.h> | ||
| #include <LibGUI/Action.h> | ||
| #include <LibGUI/Frame.h> | ||
| #include <LibGUI/Toolbar.h> | ||
| #include <LibGUI/ToolbarContainer.h> | ||
| #include <LibGUI/Window.h> | ||
|
|
||
| class MainWidget : public GUI::Frame { | ||
| C_OBJECT(MainWidget) | ||
|
|
||
| public: | ||
| ErrorOr<void> initialize_menu_and_toolbar(NonnullRefPtr<GUI::Window> window); | ||
| ErrorOr<void> open(StringView path); | ||
| ErrorOr<void> save(StringView path); | ||
| void update_action_states(); | ||
|
|
||
| private: | ||
| MainWidget(); | ||
| virtual ~MainWidget() override = default; | ||
|
|
||
| ByteString m_sample_name; | ||
| ByteString m_sample_path; | ||
| RefPtr<GUI::ToolbarContainer> m_toolbar_container; | ||
| RefPtr<GUI::Toolbar> m_toolbar; | ||
| RefPtr<GUI::Action> m_new_action; | ||
| RefPtr<GUI::Action> m_open_action; | ||
| RefPtr<GUI::Action> m_save_action; | ||
| RefPtr<GUI::Action> m_save_as_action; | ||
| RefPtr<GUI::Action> m_save_all_action; | ||
| RefPtr<GUI::Action> m_zoom_in_action; | ||
| RefPtr<GUI::Action> m_zoom_out_action; | ||
| RefPtr<GUI::Action> m_clear_selection_action; | ||
| RefPtr<GUI::Action> m_select_all_action; | ||
| RefPtr<GUI::Action> m_play_action; | ||
| RefPtr<GUI::Action> m_stop_action; | ||
| RefPtr<SampleWidget> m_sample_widget; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know the app is great and all, but let's use
RECOMMENDEDinstead 😄