mirror of
https://github.com/danieldemus/openhab-core.git
synced 2026-07-29 12:34:22 +02:00
[audio|voice] Add actions/commands to synthesize melodies and add configurable melody to the dialog processor (#3139)
* [voice] Add notification sounds to dialog processor * [audio] add actions and commands to play melodies Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
This commit is contained in:
@@ -112,6 +112,45 @@ public interface AudioManager {
|
||||
*/
|
||||
void stream(@Nullable String url, @Nullable String sinkId) throws AudioException;
|
||||
|
||||
/**
|
||||
* Parse and synthesize a melody and play it into the default sink.
|
||||
*
|
||||
* The melody should be a spaced separated list of note names or silences (character 0 or O).
|
||||
* You can optionally add the character "'" to increase the note one octave.
|
||||
* You can optionally add ":ms" where ms is an int value to customize the note/silence milliseconds duration
|
||||
* (defaults to 200ms).
|
||||
*
|
||||
* @param melody The url to stream from or null if streaming should be stopped.
|
||||
*/
|
||||
void playMelody(String melody);
|
||||
|
||||
/**
|
||||
* Parse and synthesize a melody and play it into the given sink.
|
||||
*
|
||||
* The melody should be a spaced separated list of note names or silences (character 0 or O).
|
||||
* You can optionally add the character "'" to increase the note one octave.
|
||||
* You can optionally add ":ms" where ms is an int value to customize the note/silence milliseconds duration
|
||||
* (defaults to 200ms).
|
||||
*
|
||||
* @param melody The url to stream from or null if streaming should be stopped.
|
||||
* @param sinkId The id of the audio sink to use or null for the default.
|
||||
*/
|
||||
void playMelody(String melody, @Nullable String sinkId);
|
||||
|
||||
/**
|
||||
* Parse and synthesize a melody and play it into the given sink at the desired volume.
|
||||
*
|
||||
* The melody should be a spaced separated list of note names or silences (character 0 or O).
|
||||
* You can optionally add the character "'" to increase the note one octave.
|
||||
* You can optionally add ":ms" where ms is an int value to customize the note/silence milliseconds duration
|
||||
* (defaults to 200ms).
|
||||
*
|
||||
* @param melody The url to stream from or null if streaming should be stopped.
|
||||
* @param sinkId The id of the audio sink to use or null for the default.
|
||||
* @param volume The volume to be used or null if the default notification volume should be used
|
||||
*/
|
||||
void playMelody(String melody, @Nullable String sinkId, @Nullable PercentType volume);
|
||||
|
||||
/**
|
||||
* Retrieves the current volume of a sink
|
||||
*
|
||||
|
||||
+48
@@ -49,6 +49,7 @@ public class AudioConsoleCommandExtension extends AbstractConsoleCommandExtensio
|
||||
|
||||
static final String SUBCMD_PLAY = "play";
|
||||
static final String SUBCMD_STREAM = "stream";
|
||||
static final String SUBCMD_SYNTHESIZE = "synthesize";
|
||||
static final String SUBCMD_SOURCES = "sources";
|
||||
static final String SUBCMD_SINKS = "sinks";
|
||||
|
||||
@@ -72,6 +73,11 @@ public class AudioConsoleCommandExtension extends AbstractConsoleCommandExtensio
|
||||
"plays a sound file from the sounds folder through the specified audio sink(s) with the specified volume"),
|
||||
buildCommandUsage(SUBCMD_STREAM + " [<sink>] <url>",
|
||||
"streams the sound from the url through the optionally specified audio sink(s)"),
|
||||
buildCommandUsage(SUBCMD_SYNTHESIZE + " [<sink>] \"<melody>\"",
|
||||
"synthesize a tone melody and play it through the optionally specified audio sink(s) ("
|
||||
+ SUBCMD_SYNTHESIZE + " \"A O A O A\")"),
|
||||
buildCommandUsage(SUBCMD_SYNTHESIZE + " <sink> \"<melody>\" <volume>",
|
||||
"synthesize a tone melody and play it through the optionally specified audio sink(s) with the specified volume"),
|
||||
buildCommandUsage(SUBCMD_SOURCES, "lists the audio sources"),
|
||||
buildCommandUsage(SUBCMD_SINKS, "lists the audio sinks") });
|
||||
}
|
||||
@@ -96,6 +102,14 @@ public class AudioConsoleCommandExtension extends AbstractConsoleCommandExtensio
|
||||
console.println("Specify url to stream from, and optionally the sink(s) to use");
|
||||
}
|
||||
return;
|
||||
case SUBCMD_SYNTHESIZE:
|
||||
if (args.length > 1) {
|
||||
synthesizeMelody(Arrays.copyOfRange(args, 1, args.length), console);
|
||||
} else {
|
||||
console.println(
|
||||
"Specify file to play, and optionally the sink(s) to use (e.g. 'play javasound A B C:1000')");
|
||||
}
|
||||
return;
|
||||
case SUBCMD_SOURCES:
|
||||
listSources(console);
|
||||
return;
|
||||
@@ -161,6 +175,40 @@ public class AudioConsoleCommandExtension extends AbstractConsoleCommandExtensio
|
||||
}
|
||||
}
|
||||
|
||||
private void synthesizeMelody(String[] args, Console console) {
|
||||
switch (args.length) {
|
||||
case 1:
|
||||
playMelodyOnSink(null, args[0], null, console);
|
||||
break;
|
||||
case 2:
|
||||
playMelodyOnSinks(args[0], args[1], null, console);
|
||||
break;
|
||||
case 3:
|
||||
PercentType volume = null;
|
||||
try {
|
||||
volume = PercentType.valueOf(args[2]);
|
||||
} catch (Exception e) {
|
||||
console.println("Specify volume as percentage between 0 and 100");
|
||||
break;
|
||||
}
|
||||
playMelodyOnSink(args[0], args[1], volume, console);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void playMelodyOnSinks(String pattern, String melody, @Nullable PercentType volume, Console console) {
|
||||
for (String sinkId : audioManager.getSinkIds(pattern)) {
|
||||
playMelodyOnSink(sinkId, melody, volume, console);
|
||||
}
|
||||
}
|
||||
|
||||
private void playMelodyOnSink(@Nullable String sinkId, String melody, @Nullable PercentType volume,
|
||||
Console console) {
|
||||
audioManager.playMelody(melody, sinkId, volume);
|
||||
}
|
||||
|
||||
private void playOnSinks(String pattern, String fileName, @Nullable PercentType volume, Console console) {
|
||||
for (String sinkId : audioManager.getSinkIds(pattern)) {
|
||||
playOnSink(sinkId, fileName, volume, console);
|
||||
|
||||
+34
@@ -18,6 +18,7 @@ import static java.util.stream.Collectors.toList;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.text.ParseException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
@@ -30,6 +31,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.OpenHAB;
|
||||
import org.openhab.core.audio.AudioException;
|
||||
import org.openhab.core.audio.AudioFormat;
|
||||
import org.openhab.core.audio.AudioManager;
|
||||
import org.openhab.core.audio.AudioSink;
|
||||
import org.openhab.core.audio.AudioSource;
|
||||
@@ -38,6 +40,7 @@ import org.openhab.core.audio.FileAudioStream;
|
||||
import org.openhab.core.audio.URLAudioStream;
|
||||
import org.openhab.core.audio.UnsupportedAudioFormatException;
|
||||
import org.openhab.core.audio.UnsupportedAudioStreamException;
|
||||
import org.openhab.core.audio.utils.ToneSynthesizer;
|
||||
import org.openhab.core.config.core.ConfigOptionProvider;
|
||||
import org.openhab.core.config.core.ConfigurableService;
|
||||
import org.openhab.core.config.core.ParameterOption;
|
||||
@@ -191,6 +194,37 @@ public class AudioManagerImpl implements AudioManager, ConfigOptionProvider {
|
||||
play(audioStream, sinkId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playMelody(String melody) {
|
||||
playMelody(melody, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playMelody(String melody, @Nullable String sinkId) {
|
||||
playMelody(melody, sinkId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playMelody(String melody, @Nullable String sinkId, @Nullable PercentType volume) {
|
||||
AudioSink sink = getSink(sinkId);
|
||||
if (sink == null) {
|
||||
logger.warn("Failed playing melody as no audio sink {} was found.", sinkId);
|
||||
return;
|
||||
}
|
||||
var synthesizerFormat = AudioFormat.getBestMatch(ToneSynthesizer.getSupportedFormats(),
|
||||
sink.getSupportedFormats());
|
||||
if (synthesizerFormat == null) {
|
||||
logger.warn("Failed playing melody as sink {} does not support wav.", sinkId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var audioStream = new ToneSynthesizer(synthesizerFormat).getStream(ToneSynthesizer.parseMelody(melody));
|
||||
play(audioStream, sinkId, volume);
|
||||
} catch (IOException | ParseException e) {
|
||||
logger.warn("Failed playing melody: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PercentType getVolume(@Nullable String sinkId) throws IOException {
|
||||
AudioSink sink = getSink(sinkId);
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.core.audio.utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.text.ParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.sound.sampled.AudioFileFormat;
|
||||
import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.audio.AudioFormat;
|
||||
import org.openhab.core.audio.AudioStream;
|
||||
import org.openhab.core.audio.ByteArrayAudioStream;
|
||||
|
||||
/**
|
||||
* An audio tone synthesizer. A utility to sent tone melodies to audio sinks.
|
||||
* Limited to wav little endian streams.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ToneSynthesizer {
|
||||
private final long sampleRate;
|
||||
private final int bitDepth;
|
||||
private final int bitRate;
|
||||
private final int channels;
|
||||
private final boolean bigEndian;
|
||||
|
||||
public static Set<AudioFormat> getSupportedFormats() {
|
||||
return Set.of(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, null, null, null,
|
||||
null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a tone melody into a list of {@link Tone} instances.
|
||||
* The melody should be a spaced separated list of note names or silences (character 0 or O).
|
||||
* You can optionally add the character "'" to increase the note one octave.
|
||||
* You can optionally add ":ms" where ms is an int value to customize the note/silence milliseconds duration
|
||||
* (defaults to 200ms).
|
||||
*
|
||||
* @param melody to be parsed.
|
||||
* @return list of {@link Tone} instances.
|
||||
* @throws ParseException if melody can not be played.
|
||||
*/
|
||||
public static List<Tone> parseMelody(String melody) throws ParseException {
|
||||
var melodySounds = new ArrayList<Tone>();
|
||||
var noteTextList = melody.split("\\s");
|
||||
var melodyTextIndex = 0;
|
||||
for (var i = 0; i < noteTextList.length; i++) {
|
||||
var noteText = noteTextList[i];
|
||||
var noteTextParts = noteText.split(":");
|
||||
var soundMillis = 200;
|
||||
switch (noteTextParts.length) {
|
||||
case 2:
|
||||
try {
|
||||
soundMillis = Integer.parseInt(noteTextParts[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new ParseException("Unable to parse note duration " + noteText, melodyTextIndex);
|
||||
}
|
||||
case 1:
|
||||
var note = noteTextParts[0];
|
||||
int octaves = (int) note.chars().filter(ch -> ch == '\'').count();
|
||||
note = note.replaceAll("'", "");
|
||||
var noteObj = Note.fromString(note);
|
||||
if (noteObj.isPresent()) {
|
||||
melodySounds.add(noteTone(noteObj.get(), soundMillis, octaves));
|
||||
break;
|
||||
} else if (note.equals("O") || note.equals("0")) {
|
||||
melodySounds.add(silenceTone(soundMillis));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseException("Unable to parse note " + noteText, melodyTextIndex);
|
||||
}
|
||||
melodyTextIndex += noteText.length() + 1;
|
||||
}
|
||||
return melodySounds;
|
||||
}
|
||||
|
||||
public static Tone noteTone(Note note, long millis) {
|
||||
return noteTone(note, millis, 0);
|
||||
}
|
||||
|
||||
public static Tone noteTone(Note note, long millis, int octaves) {
|
||||
return new Tone(note.getFrequency() * (octaves + 1), millis);
|
||||
}
|
||||
|
||||
public static Tone silenceTone(long millis) {
|
||||
return new Tone(0.0, millis);
|
||||
}
|
||||
|
||||
public ToneSynthesizer(AudioFormat audioFormat) {
|
||||
assert audioFormat.getFrequency() != null;
|
||||
this.sampleRate = audioFormat.getFrequency();
|
||||
assert audioFormat.getBitDepth() != null;
|
||||
this.bitDepth = audioFormat.getBitDepth();
|
||||
assert audioFormat.getBitRate() != null;
|
||||
this.bitRate = audioFormat.getBitRate();
|
||||
assert audioFormat.getChannels() != null;
|
||||
this.channels = audioFormat.getChannels();
|
||||
var bigEndian = audioFormat.isBigEndian();
|
||||
assert bigEndian != null;
|
||||
this.bigEndian = bigEndian;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a list of {@link Tone} into a wav audio stream
|
||||
*
|
||||
* @param tones the list of {@link Tone}
|
||||
* @return an audio stream with the synthesized tones
|
||||
* @throws IOException in case of problems writing the audio stream
|
||||
*/
|
||||
public AudioStream getStream(List<Tone> tones) throws IOException {
|
||||
int byteRate = (int) (sampleRate * bitDepth * channels / 8);
|
||||
byte[] audioBuffer = new byte[0];
|
||||
var fixedTones = new ArrayList<>(tones);
|
||||
fixedTones.add(silenceTone(100));
|
||||
for (var sound : fixedTones) {
|
||||
var frequency = sound.frequency;
|
||||
var millis = sound.millis;
|
||||
int samplesPerChannel = (int) Math.ceil(sampleRate * (((double) millis) / 1000));
|
||||
int byteSize = (int) Math.ceil(byteRate * ((double) millis / 1000));
|
||||
byte[] audioPart = getAudioBytes(frequency, samplesPerChannel);
|
||||
audioBuffer = ByteBuffer.allocate(audioBuffer.length + audioPart.length).put(audioBuffer).put(audioPart)
|
||||
.array();
|
||||
}
|
||||
// ensure min audio size
|
||||
int minByteSize = (int) Math.ceil(byteRate * 0.5);
|
||||
if (audioBuffer.length < minByteSize) {
|
||||
// ensure min duration of half second, prevents issue with pulseaudio sink
|
||||
byte[] padBytes = new byte[minByteSize - audioBuffer.length];
|
||||
audioBuffer = ByteBuffer.allocate(minByteSize).put(padBytes).put(audioBuffer).array();
|
||||
}
|
||||
if (!bigEndian) {
|
||||
// for little endian add the RIFF header to the stream to increase compatibility
|
||||
return getAudioStreamWithRIFFHeader(audioBuffer);
|
||||
} else {
|
||||
return getAudioStream(audioBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
private double getSample(double frequency, int sampleNum) {
|
||||
return 0xfff * Math.sin(frequency * (2 * Math.PI) * sampleNum / sampleRate);
|
||||
}
|
||||
|
||||
private ByteArrayAudioStream getAudioStreamWithRIFFHeader(byte[] audioBytes) throws IOException {
|
||||
var jAudioFormat = new javax.sound.sampled.AudioFormat(sampleRate, bitDepth, channels, true, bigEndian);
|
||||
AudioInputStream audioInputStreamTemp = new AudioInputStream(new ByteArrayInputStream(audioBytes), jAudioFormat,
|
||||
(long) Math.ceil(((double) audioBytes.length) / jAudioFormat.getFrameSize()));
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
AudioSystem.write(audioInputStreamTemp, AudioFileFormat.Type.WAVE, outputStream);
|
||||
return getAudioStream(outputStream.toByteArray());
|
||||
}
|
||||
|
||||
private ByteArrayAudioStream getAudioStream(byte[] audioBytes) {
|
||||
return new ByteArrayAudioStream(audioBytes, new AudioFormat(AudioFormat.CONTAINER_WAVE,
|
||||
AudioFormat.CODEC_PCM_SIGNED, bigEndian, bitDepth, bitRate, sampleRate, channels));
|
||||
}
|
||||
|
||||
private byte[] getAudioBytes(double frequency, int samplesPerChannel) {
|
||||
var audioBuffer = ByteBuffer.allocate(samplesPerChannel * (bitDepth / 8) * channels);
|
||||
audioBuffer.order(bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);
|
||||
for (int i = 0; i < samplesPerChannel; i++) {
|
||||
short sample = (short) getSample(frequency, i);
|
||||
for (int c = 0; c < channels; c++) {
|
||||
switch (bitDepth) {
|
||||
case 8:
|
||||
audioBuffer.put((byte) (sample & 0xff));
|
||||
break;
|
||||
case 16:
|
||||
audioBuffer.putShort(sample);
|
||||
break;
|
||||
case 24:
|
||||
putInt24Bits(audioBuffer, ((int) sample) << 8);
|
||||
break;
|
||||
case 32:
|
||||
audioBuffer.putInt(((int) sample) << 16);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return audioBuffer.array();
|
||||
}
|
||||
|
||||
private void putInt24Bits(ByteBuffer buffer, int value) {
|
||||
if (bigEndian) {
|
||||
buffer.put((byte) ((value >> 16) & 0xff));
|
||||
buffer.put((byte) ((value >> 8) & 0xff));
|
||||
buffer.put((byte) (value & 0xff));
|
||||
} else {
|
||||
buffer.put((byte) (value & 0xff));
|
||||
buffer.put((byte) ((value >> 8) & 0xff));
|
||||
buffer.put((byte) ((value >> 16) & 0xff));
|
||||
}
|
||||
}
|
||||
|
||||
public static class Tone {
|
||||
private final double frequency;
|
||||
private final long millis;
|
||||
|
||||
private Tone(double frequency, long millis) {
|
||||
this.frequency = frequency;
|
||||
this.millis = millis;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Note {
|
||||
B(List.of("B", "Si"), 493.88),
|
||||
Bb(List.of("A#", "Bb", "LA#", "SIb"), 466.16),
|
||||
A(List.of("A", "LA"), 440.0),
|
||||
Ab(List.of("G#", "Ab", "SOL#", "LAb"), 415.30),
|
||||
G(List.of("G", "SOL"), 392.0),
|
||||
Gb(List.of("F#", "Gb", "FA#", "SOLb"), 369.99),
|
||||
F(List.of("F", "FA"), 349.23),
|
||||
E(List.of("E", "MI"), 329.63),
|
||||
Eb(List.of("D#", "Eb", "RE#", "MIb"), 311.13),
|
||||
D(List.of("D", "RE"), 293.66),
|
||||
Cb(List.of("C#", "Db", "DO#", "REb"), 277.18),
|
||||
C(List.of("C", "DO"), 261.63);
|
||||
|
||||
private final List<String> names;
|
||||
private final double frequency;
|
||||
|
||||
Note(List<String> names, double frequency) {
|
||||
this.names = names;
|
||||
this.frequency = frequency;
|
||||
}
|
||||
|
||||
public double getFrequency() {
|
||||
return frequency;
|
||||
}
|
||||
|
||||
public static Optional<Note> fromString(String note) {
|
||||
return Arrays.stream(Note.values())
|
||||
.filter(note1 -> note1.names.stream().filter(note::equalsIgnoreCase).count() == 1).findAny();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -41,7 +41,7 @@ import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* This class dynamically provides the Play and Say action types.
|
||||
* This class dynamically provides the Play, Say and Synthesize action types.
|
||||
* This is necessary since there is no other way to provide dynamic config param options for module types.
|
||||
*
|
||||
* @author Kai Kreuzer - Initial contribution
|
||||
@@ -67,6 +67,8 @@ public class MediaActionTypeProvider implements ModuleTypeProvider {
|
||||
return getPlayActionType(locale);
|
||||
case SayActionHandler.TYPE_ID:
|
||||
return getSayActionType(locale);
|
||||
case SynthesizeActionHandler.TYPE_ID:
|
||||
return getSynthesizeActionType(locale);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -88,6 +90,12 @@ public class MediaActionTypeProvider implements ModuleTypeProvider {
|
||||
null, null);
|
||||
}
|
||||
|
||||
private ModuleType getSynthesizeActionType(@Nullable Locale locale) {
|
||||
return new ActionType(SynthesizeActionHandler.TYPE_ID, getConfigSynthesizeDesc(locale),
|
||||
"synthesize a tone melody", "Synthesize the given melody text and play it. Optionally sets the volume.",
|
||||
null, Visibility.VISIBLE, null, null);
|
||||
}
|
||||
|
||||
private List<ConfigDescriptionParameter> getConfigPlayDesc(@Nullable Locale locale) {
|
||||
return List.of(
|
||||
ConfigDescriptionParameterBuilder.create(PlayActionHandler.PARAM_SOUND, Type.TEXT).withRequired(true)
|
||||
@@ -103,6 +111,14 @@ public class MediaActionTypeProvider implements ModuleTypeProvider {
|
||||
getAudioSinkConfigDescParam(locale), getVolumeConfigDescParam(locale));
|
||||
}
|
||||
|
||||
private List<ConfigDescriptionParameter> getConfigSynthesizeDesc(@Nullable Locale locale) {
|
||||
return List.of(
|
||||
ConfigDescriptionParameterBuilder.create(SynthesizeActionHandler.PARAM_MELODY, Type.TEXT)
|
||||
.withRequired(true).withLabel("Melody")
|
||||
.withDescription("the melody as spaced separated note names").build(),
|
||||
getAudioSinkConfigDescParam(locale), getVolumeConfigDescParam(locale));
|
||||
}
|
||||
|
||||
private ConfigDescriptionParameter getAudioSinkConfigDescParam(@Nullable Locale locale) {
|
||||
ConfigDescriptionParameter param2 = ConfigDescriptionParameterBuilder
|
||||
.create(SayActionHandler.PARAM_SINK, Type.TEXT).withRequired(false).withLabel("Sink")
|
||||
|
||||
+2
@@ -68,6 +68,8 @@ public class MediaModuleHandlerFactory extends BaseModuleHandlerFactory {
|
||||
return new SayActionHandler((Action) module, voiceManager);
|
||||
case PlayActionHandler.TYPE_ID:
|
||||
return new PlayActionHandler((Action) module, audioManager);
|
||||
case SynthesizeActionHandler.TYPE_ID:
|
||||
return new SynthesizeActionHandler((Action) module, audioManager);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.core.automation.module.media.internal;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.audio.AudioManager;
|
||||
import org.openhab.core.automation.Action;
|
||||
import org.openhab.core.automation.handler.BaseActionModuleHandler;
|
||||
import org.openhab.core.library.types.PercentType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This is an ModuleHandler implementation for Actions that synthesize a tone melody.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SynthesizeActionHandler extends BaseActionModuleHandler {
|
||||
|
||||
public static final String TYPE_ID = "media.SynthesizeAction";
|
||||
public static final String PARAM_MELODY = "melody";
|
||||
public static final String PARAM_SINK = "sink";
|
||||
public static final String PARAM_VOLUME = "volume";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(SynthesizeActionHandler.class);
|
||||
|
||||
private final AudioManager audioManager;
|
||||
private final String melody;
|
||||
private final @Nullable String sink;
|
||||
private final @Nullable PercentType volume;
|
||||
|
||||
public SynthesizeActionHandler(Action module, AudioManager audioManager) {
|
||||
super(module);
|
||||
this.audioManager = audioManager;
|
||||
|
||||
this.melody = module.getConfiguration().get(PARAM_MELODY).toString();
|
||||
|
||||
Object sinkParam = module.getConfiguration().get(PARAM_SINK);
|
||||
this.sink = sinkParam != null ? sinkParam.toString() : null;
|
||||
|
||||
Object volumeParam = module.getConfiguration().get(PARAM_VOLUME);
|
||||
this.volume = volumeParam instanceof BigDecimal ? new PercentType((BigDecimal) volumeParam) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Map<String, Object> execute(Map<String, Object> context) {
|
||||
audioManager.playMelody(melody, sink, volume);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -40,10 +40,11 @@ public class DialogContext {
|
||||
private final AudioSink sink;
|
||||
private final Locale locale;
|
||||
private final @Nullable String listeningItem;
|
||||
private final @Nullable String listeningMelody;
|
||||
|
||||
public DialogContext(@Nullable KSService ks, @Nullable String keyword, STTService stt, TTSService tts,
|
||||
@Nullable Voice voice, List<HumanLanguageInterpreter> hlis, AudioSource source, AudioSink sink,
|
||||
Locale locale, @Nullable String listeningItem) {
|
||||
Locale locale, @Nullable String listeningItem, @Nullable String listeningMelody) {
|
||||
this.ks = ks;
|
||||
this.keyword = keyword;
|
||||
this.stt = stt;
|
||||
@@ -54,6 +55,7 @@ public class DialogContext {
|
||||
this.sink = sink;
|
||||
this.locale = locale;
|
||||
this.listeningItem = listeningItem;
|
||||
this.listeningMelody = listeningMelody;
|
||||
}
|
||||
|
||||
public @Nullable KSService ks() {
|
||||
@@ -96,6 +98,10 @@ public class DialogContext {
|
||||
return listeningItem;
|
||||
}
|
||||
|
||||
public @Nullable String listeningMelody() {
|
||||
return listeningMelody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link DialogContext}
|
||||
* Allows to describe a dialog context without requiring the involved services to be loaded
|
||||
@@ -111,6 +117,7 @@ public class DialogContext {
|
||||
private List<HumanLanguageInterpreter> hlis = List.of();
|
||||
// options
|
||||
private @Nullable String listeningItem;
|
||||
private @Nullable String listeningMelody;
|
||||
private String keyword;
|
||||
private Locale locale;
|
||||
|
||||
@@ -168,6 +175,11 @@ public class DialogContext {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withMelody(@Nullable String listeningMelody) {
|
||||
this.listeningMelody = listeningMelody;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withLocale(Locale locale) {
|
||||
this.locale = locale;
|
||||
return this;
|
||||
@@ -206,7 +218,7 @@ public class DialogContext {
|
||||
throw new IllegalStateException("Cannot build dialog context: " + String.join(", ", errors) + ".");
|
||||
}
|
||||
return new DialogContext(ksService, keyword, sttService, ttsService, voice, hliServices, audioSource,
|
||||
audioSink, locale, listeningItem);
|
||||
audioSink, locale, listeningItem, listeningMelody);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -13,8 +13,12 @@
|
||||
package org.openhab.core.voice.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.ParseException;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
@@ -23,6 +27,7 @@ import org.openhab.core.audio.AudioFormat;
|
||||
import org.openhab.core.audio.AudioStream;
|
||||
import org.openhab.core.audio.UnsupportedAudioFormatException;
|
||||
import org.openhab.core.audio.UnsupportedAudioStreamException;
|
||||
import org.openhab.core.audio.utils.ToneSynthesizer;
|
||||
import org.openhab.core.events.EventPublisher;
|
||||
import org.openhab.core.i18n.TranslationProvider;
|
||||
import org.openhab.core.items.ItemUtil;
|
||||
@@ -63,6 +68,7 @@ import org.slf4j.LoggerFactory;
|
||||
* @author Laurent Garnier - Added stop() + null annotations + resources releasing
|
||||
* @author Miguel Álvarez - Close audio streams + use RecognitionStartEvent
|
||||
* @author Miguel Álvarez - Use dialog context
|
||||
* @author Miguel Álvarez - Add sounds
|
||||
*
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@@ -71,6 +77,7 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
private final Logger logger = LoggerFactory.getLogger(DialogProcessor.class);
|
||||
|
||||
private final DialogContext dialogContext;
|
||||
private @Nullable List<ToneSynthesizer.Tone> listeningMelody;
|
||||
private final EventPublisher eventPublisher;
|
||||
private final TranslationProvider i18nProvider;
|
||||
private final Bundle bundle;
|
||||
@@ -95,6 +102,7 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
|
||||
private @Nullable AudioStream streamKS;
|
||||
private @Nullable AudioStream streamSTT;
|
||||
private @Nullable ToneSynthesizer toneSynthesizer;
|
||||
|
||||
public DialogProcessor(DialogContext context, DialogEventListener eventListener, EventPublisher eventPublisher,
|
||||
TranslationProvider i18nProvider, Bundle bundle) {
|
||||
@@ -111,6 +119,30 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
context.stt().getSupportedFormats());
|
||||
this.ttsFormat = VoiceManagerImpl.getBestMatch(context.tts().getSupportedFormats(),
|
||||
context.sink().getSupportedFormats());
|
||||
initToneSynthesizer(context.listeningMelody());
|
||||
}
|
||||
|
||||
private void initToneSynthesizer(@Nullable String listeningMelodyText) {
|
||||
@Nullable
|
||||
List<ToneSynthesizer.Tone> listeningMelody = null;
|
||||
ToneSynthesizer toneSynthesizer = null;
|
||||
if (listeningMelodyText != null && !listeningMelodyText.isBlank()) {
|
||||
try {
|
||||
listeningMelody = ToneSynthesizer.parseMelody(listeningMelodyText);
|
||||
var synthesizerFormat = VoiceManagerImpl.getBestMatch(ToneSynthesizer.getSupportedFormats(),
|
||||
dialogContext.sink().getSupportedFormats());
|
||||
if (synthesizerFormat != null) {
|
||||
toneSynthesizer = new ToneSynthesizer(synthesizerFormat);
|
||||
logger.debug("Sounds enabled");
|
||||
} else {
|
||||
logger.warn("Sounds disabled, synthesizer is not compatible with this sink");
|
||||
}
|
||||
} catch (ParseException e) {
|
||||
logger.warn("Sounds disabled, unable to parse 'listening' melody: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
this.toneSynthesizer = toneSynthesizer;
|
||||
this.listeningMelody = listeningMelody;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +166,7 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
AudioStream stream = dialogContext.source().getInputStream(fmt);
|
||||
streamKS = stream;
|
||||
ksServiceHandle = ksService.spot(this, stream, dialogContext.locale(), keyword);
|
||||
playStartSound();
|
||||
} catch (AudioException e) {
|
||||
logger.warn("Encountered audio error: {}", e.getMessage());
|
||||
} catch (KSException e) {
|
||||
@@ -158,6 +191,7 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
dialogContext.source().getId());
|
||||
return;
|
||||
}
|
||||
playOnListeningSound();
|
||||
try {
|
||||
AudioStream stream = dialogContext.source().getInputStream(fmt);
|
||||
streamSTT = stream;
|
||||
@@ -185,6 +219,7 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
abortKS();
|
||||
closeStreamKS();
|
||||
toggleProcessing(false);
|
||||
playStopSound();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,6 +401,39 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
}
|
||||
}
|
||||
|
||||
private void playStartSound() {
|
||||
playNotes(Stream.of(ToneSynthesizer.Note.G, ToneSynthesizer.Note.A, ToneSynthesizer.Note.B)
|
||||
.map(note -> ToneSynthesizer.noteTone(note, 100L)).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private void playStopSound() {
|
||||
playNotes(Stream.of(ToneSynthesizer.Note.B, ToneSynthesizer.Note.A, ToneSynthesizer.Note.G)
|
||||
.map(note -> ToneSynthesizer.noteTone(note, 100L)).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private void playOnListeningSound() {
|
||||
var listeningMelody = this.listeningMelody;
|
||||
if (listeningMelody != null) {
|
||||
playNotes(listeningMelody);
|
||||
}
|
||||
}
|
||||
|
||||
private void playNotes(List<ToneSynthesizer.Tone> notes) {
|
||||
var toneSynthesizer = this.toneSynthesizer;
|
||||
if (toneSynthesizer != null) {
|
||||
try (AudioStream stream = toneSynthesizer.getStream(notes)) {
|
||||
var sink = dialogContext.sink();
|
||||
if (sink.getSupportedStreams().stream().anyMatch(clazz -> clazz.isInstance(stream))) {
|
||||
sink.process(stream);
|
||||
} else {
|
||||
logger.warn("Failed playing synthesizer sound as audio sink doesn't support it.");
|
||||
}
|
||||
} catch (UnsupportedAudioFormatException | UnsupportedAudioStreamException | IOException e) {
|
||||
logger.warn("{} playing synthesizer sound: {}", e.getClass().getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if other DialogProcessor instance have same configuration ignoring the keyword spotting configuration
|
||||
*
|
||||
|
||||
+6
@@ -94,6 +94,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
protected static final String CONFIG_URI = "system:voice";
|
||||
private static final String CONFIG_KEYWORD = "keyword";
|
||||
private static final String CONFIG_LISTENING_ITEM = "listeningItem";
|
||||
private static final String CONFIG_LISTENING_MELODY = "listeningMelody";
|
||||
private static final String CONFIG_DEFAULT_HLI = "defaultHLI";
|
||||
private static final String CONFIG_DEFAULT_KS = "defaultKS";
|
||||
private static final String CONFIG_DEFAULT_STT = "defaultSTT";
|
||||
@@ -121,6 +122,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
*/
|
||||
private String keyword = DEFAULT_KEYWORD;
|
||||
private @Nullable String listeningItem;
|
||||
private @Nullable String listeningMelody;
|
||||
private @Nullable String defaultTTS;
|
||||
private @Nullable String defaultSTT;
|
||||
private @Nullable String defaultKS;
|
||||
@@ -160,6 +162,9 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
this.listeningItem = config.containsKey(CONFIG_LISTENING_ITEM)
|
||||
? config.get(CONFIG_LISTENING_ITEM).toString()
|
||||
: null;
|
||||
this.listeningMelody = config.containsKey(CONFIG_LISTENING_MELODY)
|
||||
? config.get(CONFIG_LISTENING_MELODY).toString()
|
||||
: null;
|
||||
this.defaultTTS = config.containsKey(CONFIG_DEFAULT_TTS) ? config.get(CONFIG_DEFAULT_TTS).toString() : null;
|
||||
this.defaultSTT = config.containsKey(CONFIG_DEFAULT_STT) ? config.get(CONFIG_DEFAULT_STT).toString() : null;
|
||||
this.defaultKS = config.containsKey(CONFIG_DEFAULT_KS) ? config.get(CONFIG_DEFAULT_KS).toString() : null;
|
||||
@@ -494,6 +499,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
.withTTS(this.getTTS()) //
|
||||
.withHLIs(this.getHLIs()) //
|
||||
.withVoice(this.getDefaultVoice()) //
|
||||
.withMelody(listeningMelody) //
|
||||
.withListeningItem(listeningItem);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,17 @@
|
||||
<description>If provided, the item will be switched on during the period when the dialog processor has spotted the
|
||||
keyword and is listening for commands.</description>
|
||||
</parameter>
|
||||
<parameter name="listeningMelody" type="text">
|
||||
<options>
|
||||
<option value="Bb">Bb</option>
|
||||
<option value="F#">F#</option>
|
||||
<option value="E">E</option>
|
||||
</options>
|
||||
<limitToOptions>false</limitToOptions>
|
||||
<label>Listening Melody</label>
|
||||
<description>A melody to be played to advertise the user the dialog processing is going to start, leave empty to
|
||||
disable it. (Spaced separated list of notes. Example: "A O:100 A':50")</description>
|
||||
</parameter>
|
||||
</config-description>
|
||||
|
||||
</config-description:config-descriptions>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
system.config.voice.defaultTTS.label = Default Text-to-Speech
|
||||
system.config.voice.defaultTTS.description = The default text-to-speech service to use if no other is specified.
|
||||
system.config.voice.defaultSTT.label = Default Speech-to-Text
|
||||
system.config.voice.defaultSTT.description = The default speech-to-text service to use if no other is specified.
|
||||
system.config.voice.defaultVoice.label = Default Voice
|
||||
system.config.voice.defaultVoice.description = The default voice to use if no specific TTS service or voice is specified.
|
||||
system.config.voice.defaultHLI.label = Default Human Language Interpreter
|
||||
system.config.voice.defaultHLI.description = The default human language interpreter to use if no other is specified.
|
||||
system.config.voice.defaultKS.label = Default Keyword Spotter
|
||||
system.config.voice.defaultKS.description = The default keyword spotting service to use if no other is specified.
|
||||
system.config.voice.defaultSTT.label = Default Speech-to-Text
|
||||
system.config.voice.defaultSTT.description = The default speech-to-text service to use if no other is specified.
|
||||
system.config.voice.defaultTTS.label = Default Text-to-Speech
|
||||
system.config.voice.defaultTTS.description = The default text-to-speech service to use if no other is specified.
|
||||
system.config.voice.defaultVoice.label = Default Voice
|
||||
system.config.voice.defaultVoice.description = The default voice to use if no specific TTS service or voice is specified.
|
||||
system.config.voice.keyword.label = Magic Word
|
||||
system.config.voice.keyword.description = The magic word to spot before initiating a dialog.
|
||||
system.config.voice.listeningItem.label = Listening Switch
|
||||
system.config.voice.listeningItem.description = If provided, the item will be switched on during the period when the dialog processor has spotted the keyword and is listening for commands.
|
||||
|
||||
service.system.voice.label = Voice
|
||||
system.config.voice.listeningMelody.label = Listening Melody
|
||||
system.config.voice.listeningMelody.description = A melody to be played to advertise the user the dialog processing is going to start, leave empty to disable it. (Spaced separated list of notes. Example: "A O:100 A':50")
|
||||
system.config.voice.listeningMelody.option.Bb = Bb
|
||||
system.config.voice.listeningMelody.option.F# = F#
|
||||
system.config.voice.listeningMelody.option.E = E
|
||||
|
||||
error.ks-error = Encountered error while spotting keywords, {0}
|
||||
error.stt-error = Encountered error while recognizing text, {0}
|
||||
error.stt-exception = Error during recognition, {0}
|
||||
service.system.voice.label = Voice
|
||||
|
||||
Reference in New Issue
Block a user