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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Base/res/apps/SampleEditor.af
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
Binary file added Base/res/icons/16x16/app-sample-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Base/res/icons/32x32/app-sample-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions Base/usr/share/man/man1/Applications/SampleEditor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
## 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 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
```
1 change: 1 addition & 0 deletions Userland/Applications/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions Userland/Applications/SampleEditor/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
serenity_component(
SampleEditor
REQUIRED
Copy link
Copy Markdown
Member

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 RECOMMENDED instead 😄

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)
218 changes: 218 additions & 0 deletions Userland/Applications/SampleEditor/MainWidget.cpp
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that you're always creating a SampleSourceFile to pass it to a SampleFileBlock.
Please make SampleFileBlock's constructor take a path and create the SourceFile itself.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No required action: Seems like we could use a make_new_action factory in the project, multiple place would benefit.

[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
}
47 changes: 47 additions & 0 deletions Userland/Applications/SampleEditor/MainWidget.h
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;
};
Loading
Loading