audacia/src/export/ExportCL.cpp

800 lines
22 KiB
C++

/**********************************************************************
Audacity: A Digital Audio Editor
ExportCL.cpp
Joshua Haberman
This code allows Audacity to export data by piping it to an external
program.
**********************************************************************/
#include "../ProjectSettings.h"
#include <wx/app.h>
#include <wx/button.h>
#include <wx/cmdline.h>
#include <wx/combobox.h>
#include <wx/log.h>
#include <wx/process.h>
#include <wx/sizer.h>
#include <wx/textctrl.h>
#if defined(__WXMSW__)
#include <wx/msw/registry.h> // for wxRegKey
#endif
#include "../FileNames.h"
#include "Export.h"
#include "../Mix.h"
#include "../Prefs.h"
#include "../ShuttleGui.h"
#include "../Tags.h"
#include "../Track.h"
#include "../float_cast.h"
#include "../widgets/FileHistory.h"
#include "../widgets/AudacityMessageBox.h"
#include "../widgets/ProgressDialog.h"
#include "../widgets/Warning.h"
#include "../wxFileNameWrapper.h"
#ifdef USE_LIBID3TAG
#include <id3tag.h>
extern "C" {
struct id3_frame *id3_frame_new(char const *);
}
#endif
//----------------------------------------------------------------------------
// ExportCLOptions
//----------------------------------------------------------------------------
class ExportCLOptions final : public wxPanelWrapper
{
public:
ExportCLOptions(wxWindow *parent, int format);
virtual ~ExportCLOptions();
void PopulateOrExchange(ShuttleGui & S);
bool TransferDataToWindow() override;
bool TransferDataFromWindow() override;
void OnBrowse(wxCommandEvent & event);
private:
wxComboBox *mCmd;
FileHistory mHistory;
DECLARE_EVENT_TABLE()
};
#define ID_BROWSE 5000
BEGIN_EVENT_TABLE(ExportCLOptions, wxPanelWrapper)
EVT_BUTTON(ID_BROWSE, ExportCLOptions::OnBrowse)
END_EVENT_TABLE()
///
///
ExportCLOptions::ExportCLOptions(wxWindow *parent, int WXUNUSED(format))
: wxPanelWrapper(parent, wxID_ANY)
{
mHistory.Load(*gPrefs, wxT("/FileFormats/ExternalProgramHistory"));
if (mHistory.empty()) {
mHistory.Append(wxT("ffmpeg -i - \"%f.opus\""));
mHistory.Append(wxT("ffmpeg -i - \"%f.wav\""));
mHistory.Append(wxT("ffmpeg -i - \"%f\""));
mHistory.Append(wxT("lame - \"%f\""));
}
mHistory.Append(gPrefs->Read(wxT("/FileFormats/ExternalProgramExportCommand"),
mHistory[ 0 ]));
ShuttleGui S(this, eIsCreatingFromPrefs);
PopulateOrExchange(S);
TransferDataToWindow();
parent->Layout();
}
ExportCLOptions::~ExportCLOptions()
{
TransferDataFromWindow();
}
///
///
void ExportCLOptions::PopulateOrExchange(ShuttleGui & S)
{
wxArrayStringEx cmds( mHistory.begin(), mHistory.end() );
auto cmd = cmds[0];
S.StartVerticalLay();
{
S.StartHorizontalLay(wxEXPAND);
{
S.SetSizerProportion(1);
S.StartMultiColumn(3, wxEXPAND);
{
S.SetStretchyCol(1);
mCmd = S.AddCombo(XXO("Command:"),
cmd,
cmds);
S.Id(ID_BROWSE).AddButton(XXO("Browse..."),
wxALIGN_CENTER_VERTICAL);
S.AddFixedText( {} );
S.TieCheckBox(XXO("Show output"),
{wxT("/FileFormats/ExternalProgramShowOutput"),
false});
}
S.EndMultiColumn();
}
S.EndHorizontalLay();
S.AddTitle(XO(
/* i18n-hint: Some programmer-oriented terminology here:
"Data" refers to the sound to be exported, "piped" means sent,
and "standard in" means the default input stream that the external program,
named by %f, will read. And yes, it's %f, not %s -- this isn't actually used
in the program as a format string. Keep %f unchanged. */
"Data will be piped to standard in. \"%f\" uses the file name in the export window."), 250);
}
S.EndVerticalLay();
}
///
///
bool ExportCLOptions::TransferDataToWindow()
{
return true;
}
///
///
bool ExportCLOptions::TransferDataFromWindow()
{
ShuttleGui S(this, eIsSavingToPrefs);
PopulateOrExchange(S);
wxString cmd = mCmd->GetValue();
mHistory.Append(cmd);
mHistory.Save(*gPrefs);
gPrefs->Write(wxT("/FileFormats/ExternalProgramExportCommand"), cmd);
gPrefs->Flush();
return true;
}
///
///
void ExportCLOptions::OnBrowse(wxCommandEvent& WXUNUSED(event))
{
wxString path;
FileExtension ext;
FileNames::FileType type = FileNames::AllFiles;
#if defined(__WXMSW__)
ext = wxT("exe");
/* i18n-hint files that can be run as programs */
type = { XO("Executables"), { ext } };
#endif
path = FileNames::SelectFile(FileNames::Operation::Open,
XO("Find path to command"),
wxEmptyString,
wxEmptyString,
ext,
{ type },
wxFD_OPEN | wxRESIZE_BORDER,
this);
if (path.empty()) {
return;
}
if (path.Find(wxT(' ')) == wxNOT_FOUND) {
mCmd->SetValue(path);
}
else {
mCmd->SetValue(wxT('"') + path + wxT('"'));
}
mCmd->SetInsertionPointEnd();
return;
}
//----------------------------------------------------------------------------
// ExportCLProcess
//----------------------------------------------------------------------------
static void Drain(wxInputStream *s, wxString *o)
{
while (s->CanRead()) {
char buffer[4096];
s->Read(buffer, WXSIZEOF(buffer) - 1);
buffer[s->LastRead()] = wxT('\0');
*o += LAT1CTOWX(buffer);
}
}
class ExportCLProcess final : public wxProcess
{
public:
ExportCLProcess(wxString *output)
{
#if defined(__WXMAC__)
// Don't want to crash on broken pipe
signal(SIGPIPE, SIG_IGN);
#endif
mOutput = output;
mActive = true;
mStatus = -555;
Redirect();
}
bool IsActive()
{
return mActive;
}
void OnTerminate(int WXUNUSED( pid ), int status)
{
Drain(GetInputStream(), mOutput);
Drain(GetErrorStream(), mOutput);
mStatus = status;
mActive = false;
}
int GetStatus()
{
return mStatus;
}
private:
wxString *mOutput;
bool mActive;
int mStatus;
};
//----------------------------------------------------------------------------
// ExportCL
//----------------------------------------------------------------------------
class ExportCL final : public ExportPlugin
{
public:
ExportCL();
// Required
void OptionsCreate(ShuttleGui &S, int format) override;
ProgressResult Export(AudacityProject *project,
std::unique_ptr<ProgressDialog> &pDialog,
unsigned channels,
const wxFileNameWrapper &fName,
bool selectedOnly,
double t0,
double t1,
MixerSpec *mixerSpec = NULL,
const Tags *metadata = NULL,
int subformat = 0) override;
// Optional
bool CheckFileName(wxFileName &filename, int format = 0) override;
private:
void GetSettings();
std::vector<char> GetMetaChunk(const Tags *metadata);
wxString mCmd;
bool mShow;
struct ExtendPath
{
#if defined(__WXMSW__)
wxString opath;
ExtendPath()
{
// Give Windows a chance at finding lame command in the default location.
wxString paths[] = {wxT("HKEY_LOCAL_MACHINE\\Software\\Lame for Audacity"),
wxT("HKEY_LOCAL_MACHINE\\Software\\FFmpeg for Audacity")};
wxString npath;
wxRegKey reg;
wxGetEnv(wxT("PATH"), &opath);
npath = opath;
for (int i = 0; i < WXSIZEOF(paths); i++) {
reg.SetName(paths[i]);
if (reg.Exists()) {
wxString ipath;
reg.QueryValue(wxT("InstallPath"), ipath);
if (!ipath.empty()) {
npath += wxPATH_SEP + ipath;
}
}
}
wxSetEnv(wxT("PATH"),npath);
};
~ExtendPath()
{
if (!opath.empty())
{
wxSetEnv(wxT("PATH"),opath);
}
}
#endif
};
};
ExportCL::ExportCL()
: ExportPlugin()
{
AddFormat();
SetFormat(wxT("CL"),0);
AddExtension(wxT(""),0);
SetMaxChannels(255,0);
SetCanMetaData(false,0);
SetDescription(XO("(external program)"),0);
}
ProgressResult ExportCL::Export(AudacityProject *project,
std::unique_ptr<ProgressDialog> &pDialog,
unsigned channels,
const wxFileNameWrapper &fName,
bool selectionOnly,
double t0,
double t1,
MixerSpec *mixerSpec,
const Tags *metadata,
int WXUNUSED(subformat))
{
ExtendPath ep;
wxString output;
long rc;
const auto path = fName.GetFullPath();
GetSettings();
// Bug 2178 - users who don't know what they are doing will
// now get a file extension of .wav appended to their ffmpeg filename
// and therefore ffmpeg will be able to choose a file type.
if( mCmd == wxT("ffmpeg -i - \"%f\"") && !fName.HasExt())
mCmd.Replace( "%f", "%f.wav" );
mCmd.Replace(wxT("%f"), path);
// Kick off the command
ExportCLProcess process(&output);
rc = wxExecute(mCmd, wxEXEC_ASYNC, &process);
if (!rc) {
AudacityMessageBox( XO("Cannot export audio to %s").Format( path ) );
process.Detach();
process.CloseOutput();
return ProgressResult::Cancelled;
}
// Turn off logging to prevent broken pipe messages
wxLogNull nolog;
// establish parameters
int rate = lrint( ProjectSettings::Get( *project ).GetRate());
const size_t maxBlockLen = 44100 * 5;
unsigned long totalSamples = lrint((t1 - t0) * rate);
unsigned long sampleBytes = totalSamples * channels * SAMPLE_SIZE(floatSample);
wxOutputStream *os = process.GetOutputStream();
// RIFF header
struct {
char riffID[4]; // "RIFF"
wxUint32 riffLen; // basically the file len - 8
char riffType[4]; // "WAVE"
} riff;
// format chunk */
struct {
char fmtID[4]; // "fmt " */
wxUint32 formatChunkLen; // (format chunk len - first two fields) 16 in our case
wxUint16 formatTag; // 1 for PCM
wxUint16 channels;
wxUint32 sampleRate;
wxUint32 avgBytesPerSec; // sampleRate * blockAlign
wxUint16 blockAlign; // bitsPerSample * channels (assume bps % 8 = 0)
wxUint16 bitsPerSample;
} fmt;
// id3 chunk header
struct {
char id3ID[4]; // "id3 "
wxUint32 id3Len; // length of metadata in bytes
} id3;
// data chunk header
struct {
char dataID[4]; // "data"
wxUint32 dataLen; // length of all samples in bytes
} data;
riff.riffID[0] = 'R';
riff.riffID[1] = 'I';
riff.riffID[2] = 'F';
riff.riffID[3] = 'F';
riff.riffLen = wxUINT32_SWAP_ON_BE(sizeof(riff) +
sizeof(fmt) +
sizeof(data) +
sampleBytes -
8);
riff.riffType[0] = 'W';
riff.riffType[1] = 'A';
riff.riffType[2] = 'V';
riff.riffType[3] = 'E';
fmt.fmtID[0] = 'f';
fmt.fmtID[1] = 'm';
fmt.fmtID[2] = 't';
fmt.fmtID[3] = ' ';
fmt.formatChunkLen = wxUINT32_SWAP_ON_BE(16);
fmt.formatTag = wxUINT16_SWAP_ON_BE(3);
fmt.channels = wxUINT16_SWAP_ON_BE(channels);
fmt.sampleRate = wxUINT32_SWAP_ON_BE(rate);
fmt.bitsPerSample = wxUINT16_SWAP_ON_BE(SAMPLE_SIZE(floatSample) * 8);
fmt.blockAlign = wxUINT16_SWAP_ON_BE(fmt.bitsPerSample * fmt.channels / 8);
fmt.avgBytesPerSec = wxUINT32_SWAP_ON_BE(fmt.sampleRate * fmt.blockAlign);
// Retrieve tags if not given a set
if (metadata == NULL) {
metadata = &Tags::Get(*project);
}
auto metachunk = GetMetaChunk(metadata);
if (metachunk.size()) {
id3.id3ID[0] = 'i';
id3.id3ID[1] = 'd';
id3.id3ID[2] = '3';
id3.id3ID[3] = ' ';
id3.id3Len = wxUINT32_SWAP_ON_BE(metachunk.size());
riff.riffLen += sizeof(id3) + metachunk.size();
}
data.dataID[0] = 'd';
data.dataID[1] = 'a';
data.dataID[2] = 't';
data.dataID[3] = 'a';
data.dataLen = wxUINT32_SWAP_ON_BE(sampleBytes);
// write the headers and metadata
os->Write(&riff, sizeof(riff));
os->Write(&fmt, sizeof(fmt));
if (metachunk.size()) {
os->Write(&id3, sizeof(id3));
os->Write(metachunk.data(), metachunk.size());
}
os->Write(&data, sizeof(data));
// Mix 'em up
const auto &tracks = TrackList::Get( *project );
auto mixer = CreateMixer(
tracks,
selectionOnly,
t0,
t1,
channels,
maxBlockLen,
true,
rate,
floatSample,
mixerSpec);
size_t numBytes = 0;
samplePtr mixed = NULL;
auto updateResult = ProgressResult::Success;
{
auto closeIt = finally ( [&] {
// Should make the process die, before propagating any exception
process.CloseOutput();
} );
// Prepare the progress display
InitProgress( pDialog, XO("Export"),
selectionOnly
? XO("Exporting the selected audio using command-line encoder")
: XO("Exporting the audio using command-line encoder") );
auto &progress = *pDialog;
// Start piping the mixed data to the command
while (updateResult == ProgressResult::Success && process.IsActive() && os->IsOk()) {
// Capture any stdout and stderr from the command
Drain(process.GetInputStream(), &output);
Drain(process.GetErrorStream(), &output);
// Need to mix another block
if (numBytes == 0) {
auto numSamples = mixer->Process(maxBlockLen);
if (numSamples == 0) {
break;
}
mixed = mixer->GetBuffer();
numBytes = numSamples * channels;
// Byte-swapping is necessary on big-endian machines, since
// WAV files are little-endian
#if wxBYTE_ORDER == wxBIG_ENDIAN
float *buffer = (float *) mixed;
for (int i = 0; i < numBytes; i++) {
buffer[i] = wxUINT32_SWAP_ON_BE(buffer[i]);
}
#endif
numBytes *= SAMPLE_SIZE(floatSample);
}
// Don't write too much at once...pipes may not be able to handle it
size_t bytes = wxMin(numBytes, 4096);
numBytes -= bytes;
while (bytes > 0) {
os->Write(mixed, bytes);
if (!os->IsOk()) {
updateResult = ProgressResult::Cancelled;
break;
}
bytes -= os->LastWrite();
mixed += os->LastWrite();
}
// Update the progress display
updateResult = progress.Update(mixer->MixGetCurrentTime() - t0, t1 - t0);
}
// Done with the progress display
}
// Wait for process to terminate
while (process.IsActive()) {
wxMilliSleep(10);
wxTheApp->Yield();
}
// Display output on error or if the user wants to see it
if (process.GetStatus() != 0 || mShow) {
// TODO use ShowInfoDialog() instead.
wxDialogWrapper dlg(nullptr,
wxID_ANY,
XO("Command Output"),
wxDefaultPosition,
wxSize(600, 400),
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
dlg.SetName();
ShuttleGui S(&dlg, eIsCreating);
S
.Style( wxTE_MULTILINE | wxTE_READONLY | wxTE_RICH )
.AddTextWindow(mCmd + wxT("\n\n") + output);
S.StartHorizontalLay(wxALIGN_CENTER, false);
{
S.Id(wxID_OK).AddButton(XXO("&OK"), wxALIGN_CENTER, true);
}
dlg.GetSizer()->AddSpacer(5);
dlg.Layout();
dlg.SetMinSize(dlg.GetSize());
dlg.Center();
dlg.ShowModal();
if (process.GetStatus() != 0)
updateResult = ProgressResult::Failed;
}
return updateResult;
}
std::vector<char> ExportCL::GetMetaChunk(const Tags *tags)
{
std::vector<char> buffer;
#ifdef USE_LIBID3TAG
struct id3_tag_deleter {
void operator () (id3_tag *p) const { if (p) id3_tag_delete(p); }
};
std::unique_ptr<id3_tag, id3_tag_deleter> tp { id3_tag_new() };
for (const auto &pair : tags->GetRange()) {
const auto &n = pair.first;
const auto &v = pair.second;
const char *name = "TXXX";
if (n.CmpNoCase(TAG_TITLE) == 0) {
name = ID3_FRAME_TITLE;
}
else if (n.CmpNoCase(TAG_ARTIST) == 0) {
name = ID3_FRAME_ARTIST;
}
else if (n.CmpNoCase(TAG_ALBUM) == 0) {
name = ID3_FRAME_ALBUM;
}
else if (n.CmpNoCase(TAG_YEAR) == 0) {
name = ID3_FRAME_YEAR;
}
else if (n.CmpNoCase(TAG_GENRE) == 0) {
name = ID3_FRAME_GENRE;
}
else if (n.CmpNoCase(TAG_COMMENTS) == 0) {
name = ID3_FRAME_COMMENT;
}
else if (n.CmpNoCase(TAG_TRACK) == 0) {
name = ID3_FRAME_TRACK;
}
else if (n.CmpNoCase(wxT("composer")) == 0) {
name = "TCOM";
}
struct id3_frame *frame = id3_frame_new(name);
if (!n.IsAscii() || !v.IsAscii()) {
id3_field_settextencoding(id3_frame_field(frame, 0), ID3_FIELD_TEXTENCODING_UTF_16);
}
else {
id3_field_settextencoding(id3_frame_field(frame, 0), ID3_FIELD_TEXTENCODING_ISO_8859_1);
}
MallocString<id3_ucs4_t> ucs4{
id3_utf8_ucs4duplicate((id3_utf8_t *) (const char *) v.mb_str(wxConvUTF8)) };
if (strcmp(name, ID3_FRAME_COMMENT) == 0) {
// A hack to get around iTunes not recognizing the comment. The
// language defaults to XXX and, since it's not a valid language,
// iTunes just ignores the tag. So, either set it to a valid language
// (which one???) or just clear it. Unfortunately, there's no supported
// way of clearing the field, so do it directly.
id3_field *f = id3_frame_field(frame, 1);
memset(f->immediate.value, 0, sizeof(f->immediate.value));
id3_field_setfullstring(id3_frame_field(frame, 3), ucs4.get());
}
else if (strcmp(name, "TXXX") == 0) {
id3_field_setstring(id3_frame_field(frame, 2), ucs4.get());
ucs4.reset(id3_utf8_ucs4duplicate((id3_utf8_t *) (const char *) n.mb_str(wxConvUTF8)));
id3_field_setstring(id3_frame_field(frame, 1), ucs4.get());
}
else {
auto addr = ucs4.get();
id3_field_setstrings(id3_frame_field(frame, 1), 1, &addr);
}
id3_tag_attachframe(tp.get(), frame);
}
tp->options &= (~ID3_TAG_OPTION_COMPRESSION); // No compression
// If this version of libid3tag supports it, use v2.3 ID3
// tags instead of the newer, but less well supported, v2.4
// that libid3tag uses by default.
#ifdef ID3_TAG_HAS_TAG_OPTION_ID3V2_3
tp->options |= ID3_TAG_OPTION_ID3V2_3;
#endif
id3_length_t len;
len = id3_tag_render(tp.get(), 0);
if ((len % 2) != 0) {
len++; // Length must be even.
}
if (len > 0) {
buffer.resize(len);
id3_tag_render(tp.get(), (id3_byte_t *) buffer.data());
}
#endif
return buffer;
}
void ExportCL::OptionsCreate(ShuttleGui &S, int format)
{
S.AddWindow( safenew ExportCLOptions{ S.GetParent(), format } );
}
bool ExportCL::CheckFileName(wxFileName &filename, int WXUNUSED(format))
{
ExtendPath ep;
if (filename.GetExt().empty()) {
if (ShowWarningDialog(NULL,
wxT("MissingExtension"),
XO("You've specified a file name without an extension. Are you sure?"),
true) == wxID_CANCEL) {
return false;
}
}
GetSettings();
wxArrayString argv = wxCmdLineParser::ConvertStringToArgs(mCmd,
#if defined(__WXMSW__)
wxCMD_LINE_SPLIT_DOS
#else
wxCMD_LINE_SPLIT_UNIX
#endif
);
if (argv.size() == 0) {
ShowExportErrorDialog(
":745",
XO("Program name appears to be missing."));
return false;
}
// Normalize the path (makes absolute and resolves variables)
wxFileName cmd(argv[0]);
cmd.Normalize(wxPATH_NORM_ALL & ~wxPATH_NORM_ABSOLUTE);
// Just verify the given path exists if it is absolute.
if (cmd.IsAbsolute()) {
if (!cmd.Exists()) {
AudacityMessageBox(
XO("\"%s\" couldn't be found.").Format(cmd.GetFullPath()),
XO("Warning"),
wxOK | wxICON_EXCLAMATION);
return false;
}
return true;
}
// Search for the command in the PATH list
wxPathList pathlist;
pathlist.AddEnvList(wxT("PATH"));
wxString path = pathlist.FindAbsoluteValidPath(argv[0]);
#if defined(__WXMSW__)
if (path.empty()) {
path = pathlist.FindAbsoluteValidPath(argv[0] + wxT(".exe"));
}
#endif
if (path.empty()) {
int action = AudacityMessageBox(
XO("Unable to locate \"%s\" in your path.").Format(cmd.GetFullPath()),
XO("Warning"),
wxOK | wxICON_EXCLAMATION);
return false;
}
return true;
}
void ExportCL::GetSettings()
{
// Retrieve settings
gPrefs->Read(wxT("/FileFormats/ExternalProgramShowOutput"), &mShow, false);
mCmd = gPrefs->Read(wxT("/FileFormats/ExternalProgramExportCommand"), wxT("lame - \"%f.mp3\""));
}
static Exporter::RegisteredExportPlugin sRegisteredPlugin{ "CommandLine",
[]{ return std::make_unique< ExportCL >(); }
};