/**
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.sox.impl;
import static org.opencastproject.util.data.Option.some;
import org.opencastproject.job.api.AbstractJobProducer;
import org.opencastproject.job.api.Job;
import org.opencastproject.mediapackage.AudioStream;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.mediapackage.identifier.IdBuilder;
import org.opencastproject.mediapackage.identifier.IdBuilderFactory;
import org.opencastproject.mediapackage.track.AudioStreamImpl;
import org.opencastproject.mediapackage.track.TrackImpl;
import org.opencastproject.security.api.OrganizationDirectoryService;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UserDirectoryService;
import org.opencastproject.serviceregistry.api.ServiceRegistry;
import org.opencastproject.serviceregistry.api.ServiceRegistryException;
import org.opencastproject.sox.api.SoxException;
import org.opencastproject.sox.api.SoxService;
import org.opencastproject.util.FileSupport;
import org.opencastproject.util.IoSupport;
import org.opencastproject.util.LoadUtil;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.data.Option;
import org.opencastproject.workspace.api.Workspace;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.List;
import java.util.UUID;
public class SoxServiceImpl extends AbstractJobProducer implements SoxService, ManagedService {
/** The logging instance */
private static final Logger logger = LoggerFactory.getLogger(SoxServiceImpl.class);
/** Default location of the SoX binary (resembling the installer) */
public static final String SOX_BINARY_DEFAULT = "sox";
public static final String CONFIG_SOX_PATH = "org.opencastproject.sox.path";
/** The load introduced on the system by creating a analyze job */
public static final float DEFAULT_ANALYZE_JOB_LOAD = 1.0f;
/** The key to look for in the service configuration file to override the {@link DEFAULT_ANALYZE_JOB_LOAD} */
public static final String ANALYZE_JOB_LOAD_KEY = "job.load.analyze";
/** The load introduced on the system by creating a analyze job */
private float analyzeJobLoad = DEFAULT_ANALYZE_JOB_LOAD;
/** The load introduced on the system by creating a normalize job */
public static final float DEFAULT_NORMALIZE_JOB_LOAD = 2.0f;
/** The key to look for in the service configuration file to override the {@link DEFAULT_NORMALIZE_JOB_LOAD} */
public static final String NORMALIZE_JOB_LOAD_KEY = "job.load.normalize";
/** The load introduced on the system by creating a normalize job */
private float normalizeJobLoad = DEFAULT_NORMALIZE_JOB_LOAD;
/** List of available operations on jobs */
private enum Operation {
Analyze, Normalize
}
/** The collection name */
public static final String COLLECTION = "sox";
/** Reference to the workspace service */
private Workspace workspace = null;
/** Reference to the receipt service */
private ServiceRegistry serviceRegistry;
/** Id builder used to create ids for encoded tracks */
private final IdBuilder idBuilder = IdBuilderFactory.newInstance().newIdBuilder();
/** The security service */
protected SecurityService securityService = null;
/** The user directory service */
protected UserDirectoryService userDirectoryService = null;
/** The organization directory service */
protected OrganizationDirectoryService organizationDirectoryService = null;
private String binary = SOX_BINARY_DEFAULT;
/** Creates a new composer service instance. */
public SoxServiceImpl() {
super(JOB_TYPE);
}
/**
* OSGi callback on component activation.
*
* @param cc
* the component context
*/
@Override
public void activate(ComponentContext cc) {
logger.info("Activating sox service");
super.activate(cc);
// Configure sox
String path = (String) cc.getBundleContext().getProperty(CONFIG_SOX_PATH);
if (path == null) {
logger.debug("DEFAULT " + CONFIG_SOX_PATH + ": " + SOX_BINARY_DEFAULT);
} else {
binary = path;
logger.debug("SoX config binary: {}", path);
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.sox.api.SoxService#analyze(Track)
*/
@Override
public Job analyze(Track sourceAudioTrack) throws MediaPackageException, SoxException {
try {
return serviceRegistry.createJob(JOB_TYPE, Operation.Analyze.toString(),
Arrays.asList(MediaPackageElementParser.getAsXml(sourceAudioTrack)), analyzeJobLoad);
} catch (ServiceRegistryException e) {
throw new SoxException("Unable to create a job", e);
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.sox.api.SoxService#normalize(Track, Float)
*/
@Override
public Job normalize(Track sourceAudioTrack, Float targetRmsLevDb) throws MediaPackageException, SoxException {
try {
return serviceRegistry.createJob(JOB_TYPE, Operation.Normalize.toString(),
Arrays.asList(MediaPackageElementParser.getAsXml(sourceAudioTrack), targetRmsLevDb.toString()),
normalizeJobLoad);
} catch (ServiceRegistryException e) {
throw new SoxException("Unable to create a job", e);
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
*/
@Override
protected String process(Job job) throws Exception {
Operation op = null;
String operation = job.getOperation();
List<String> arguments = job.getArguments();
try {
op = Operation.valueOf(operation);
TrackImpl audioTrack = null;
final String serialized;
switch (op) {
case Analyze:
audioTrack = (TrackImpl) MediaPackageElementParser.getFromXml(arguments.get(0));
serialized = analyze(job, audioTrack).map(MediaPackageElementParser.<Track> getAsXml()).getOrElse("");
break;
case Normalize:
audioTrack = (TrackImpl) MediaPackageElementParser.getFromXml(arguments.get(0));
Float targetRmsLevDb = new Float(arguments.get(1));
serialized = normalize(job, audioTrack, targetRmsLevDb).map(MediaPackageElementParser.<Track> getAsXml())
.getOrElse("");
break;
default:
throw new IllegalStateException("Don't know how to handle operation '" + operation + "'");
}
return serialized;
} catch (IllegalArgumentException e) {
throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e);
} catch (Exception e) {
throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
}
}
protected Option<Track> analyze(Job job, Track audioTrack) throws SoxException {
if (!audioTrack.hasAudio())
throw new SoxException("No audio stream available");
if (audioTrack.hasVideo())
throw new SoxException("It must not have a video stream");
try {
// Get the tracks and make sure they exist
final File audioFile;
try {
audioFile = workspace.get(audioTrack.getURI());
} catch (NotFoundException e) {
throw new SoxException("Requested audio track " + audioTrack + " is not found");
} catch (IOException e) {
throw new SoxException("Unable to access audio track " + audioTrack);
}
logger.info("Analyzing audio track {}", audioTrack.getIdentifier());
// Do the work
ArrayList<String> command = new ArrayList<String>();
command.add(binary);
command.add(audioFile.getAbsolutePath());
command.add("-n");
command.add("remix");
command.add("-");
command.add("stats");
List<String> analyzeResult = launchSoxProcess(command);
// Add audio metadata and return audio track
return some(addAudioMetadata(audioTrack, analyzeResult));
} catch (Exception e) {
logger.warn("Error analyzing {}: {}", audioTrack, e.getMessage());
if (e instanceof SoxException) {
throw (SoxException) e;
} else {
throw new SoxException(e);
}
}
}
private Track addAudioMetadata(Track audioTrack, List<String> metadata) {
TrackImpl track = (TrackImpl) audioTrack;
List<AudioStream> audio = track.getAudio();
if (audio.size() == 0) {
audio.add(new AudioStreamImpl());
logger.info("No audio streams found created new audio stream");
}
AudioStreamImpl audioStream = (AudioStreamImpl) audio.get(0);
if (audio.size() > 1)
logger.info("Multiple audio streams found, take first audio stream {}", audioStream);
for (String value : metadata) {
if (value.startsWith("Pk lev dB")) {
Float pkLevDb = new Float(StringUtils.substringAfter(value, "Pk lev dB").trim());
audioStream.setPkLevDb(pkLevDb);
} else if (value.startsWith("RMS lev dB")) {
Float rmsLevDb = new Float(StringUtils.substringAfter(value, "RMS lev dB").trim());
audioStream.setRmsLevDb(rmsLevDb);
} else if (value.startsWith("RMS Pk dB")) {
Float rmsPkDb = new Float(StringUtils.substringAfter(value, "RMS Pk dB").trim());
audioStream.setRmsPkDb(rmsPkDb);
}
}
return track;
}
private List<String> launchSoxProcess(List<String> command) throws SoxException {
Process process = null;
BufferedReader in = null;
try {
logger.info("Start sox process {}", command);
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true); // Unfortunately merges but necessary for deadlock prevention
process = pb.start();
in = new BufferedReader(new InputStreamReader(process.getInputStream()));
process.waitFor();
String line = null;
List<String> stats = new ArrayList<String>();
while ((line = in.readLine()) != null) {
logger.info(line);
stats.add(line);
}
if (process.exitValue() != 0)
throw new SoxException("Sox process failed with error code: " + process.exitValue());
logger.info("Sox process finished");
return stats;
} catch (IOException e) {
throw new SoxException("Could not start sox process: " + command + "\n" + e.getMessage());
} catch (InterruptedException e) {
throw new SoxException("Could not start sox process: " + command + "\n" + e.getMessage());
} finally {
IoSupport.closeQuietly(in);
}
}
private Option<Track> normalize(Job job, TrackImpl audioTrack, Float targetRmsLevDb) throws SoxException {
if (!audioTrack.hasAudio())
throw new SoxException("No audio stream available");
if (audioTrack.hasVideo())
throw new SoxException("It must not have a video stream");
if (audioTrack.getAudio().size() < 1)
throw new SoxException("No audio stream metadata available");
if (audioTrack.getAudio().get(0).getRmsLevDb() == null)
throw new SoxException("No RMS Lev dB metadata available");
final String targetTrackId = idBuilder.createNew().toString();
Float rmsLevDb = audioTrack.getAudio().get(0).getRmsLevDb();
// Get the tracks and make sure they exist
final File audioFile;
try {
audioFile = workspace.get(audioTrack.getURI());
} catch (NotFoundException e) {
throw new SoxException("Requested audio track " + audioTrack + " is not found");
} catch (IOException e) {
throw new SoxException("Unable to access audio track " + audioTrack);
}
String outDir = audioFile.getAbsoluteFile().getParent();
String outFileName = FilenameUtils.getBaseName(audioFile.getName()) + "_" + UUID.randomUUID().toString();
String suffix = "-norm." + FilenameUtils.getExtension(audioFile.getName());
File normalizedFile = new File(outDir, outFileName + suffix);
logger.info("Normalizing audio track {} to {}", audioTrack.getIdentifier(), targetTrackId);
// Do the work
ArrayList<String> command = new ArrayList<String>();
command.add(binary);
command.add(audioFile.getAbsolutePath());
command.add(normalizedFile.getAbsolutePath());
command.add("remix");
command.add("-");
command.add("gain");
if (targetRmsLevDb > rmsLevDb)
command.add("-l");
command.add(new Float(targetRmsLevDb - rmsLevDb).toString());
command.add("stats");
List<String> normalizeResult = launchSoxProcess(command);
if (normalizedFile.length() == 0)
throw new SoxException("Normalization failed: Output file is empty!");
// Put the file in the workspace
URI returnURL = null;
InputStream in = null;
try {
in = new FileInputStream(normalizedFile);
returnURL = workspace.putInCollection(COLLECTION,
job.getId() + "." + FilenameUtils.getExtension(normalizedFile.getAbsolutePath()), in);
logger.info("Copied the normalized file to the workspace at {}", returnURL);
if (normalizedFile.delete()) {
logger.info("Deleted the local copy of the normalized file at {}", normalizedFile.getAbsolutePath());
} else {
logger.warn("Unable to delete the normalized output at {}", normalizedFile);
}
} catch (Exception e) {
throw new SoxException("Unable to put the normalized file into the workspace", e);
} finally {
IOUtils.closeQuietly(in);
FileSupport.deleteQuietly(normalizedFile);
}
Track normalizedTrack = (Track) audioTrack.clone();
normalizedTrack.setURI(returnURL);
normalizedTrack.setIdentifier(targetTrackId);
// Add audio metadata and return audio track
normalizedTrack = addAudioMetadata(normalizedTrack, normalizeResult);
return some(normalizedTrack);
}
/**
* Sets the workspace
*
* @param workspace
* an instance of the workspace
*/
protected void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
/**
* Sets the service registry
*
* @param serviceRegistry
* the service registry
*/
protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
this.serviceRegistry = serviceRegistry;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
*/
@Override
protected ServiceRegistry getServiceRegistry() {
return serviceRegistry;
}
/**
* Callback for setting the security service.
*
* @param securityService
* the securityService to set
*/
public void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
/**
* Callback for setting the user directory service.
*
* @param userDirectoryService
* the userDirectoryService to set
*/
public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
this.userDirectoryService = userDirectoryService;
}
/**
* Sets a reference to the organization directory service.
*
* @param organizationDirectory
* the organization directory
*/
public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
this.organizationDirectoryService = organizationDirectory;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
*/
@Override
protected SecurityService getSecurityService() {
return securityService;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
*/
@Override
protected UserDirectoryService getUserDirectoryService() {
return userDirectoryService;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
*/
@Override
protected OrganizationDirectoryService getOrganizationDirectoryService() {
return organizationDirectoryService;
}
@Override
public void updated(Dictionary properties) throws ConfigurationException {
analyzeJobLoad = LoadUtil.getConfiguredLoadValue(properties, ANALYZE_JOB_LOAD_KEY, DEFAULT_ANALYZE_JOB_LOAD,
serviceRegistry);
normalizeJobLoad = LoadUtil.getConfiguredLoadValue(properties, NORMALIZE_JOB_LOAD_KEY, DEFAULT_NORMALIZE_JOB_LOAD,
serviceRegistry);
}
}