mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-19 05:37:43 +00:00
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import React from "react";
|
|
|
|
import WaveSurfer from "wavesurfer.js";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Icon, P } from "@jambonz/ui-kit";
|
|
import { Icons, ModalClose } from "src/components";
|
|
import { getBlob, getJaegerTrace } from "src/api";
|
|
import { DownloadedBlob, RecentCall } from "src/api/types";
|
|
import RegionsPlugin, { Region } from "wavesurfer.js/src/plugin/regions";
|
|
import TimelinePlugin from "wavesurfer.js/src/plugin/timeline";
|
|
import { API_BASE_URL } from "src/api/constants";
|
|
import {
|
|
JaegerRoot,
|
|
JaegerSpan,
|
|
WaveSufferDtmfResult,
|
|
WaveSufferSttResult,
|
|
} from "src/api/jaeger-types";
|
|
import {
|
|
getSpanAttributeByName,
|
|
getSpansByName,
|
|
getSpansByNameRegex,
|
|
getSpansFromJaegerRoot,
|
|
} from "./utils";
|
|
|
|
type PlayerProps = {
|
|
call: RecentCall;
|
|
};
|
|
|
|
export const Player = ({ call }: PlayerProps) => {
|
|
const { recording_url, call_sid } = call;
|
|
const url =
|
|
recording_url && recording_url.startsWith("http://")
|
|
? recording_url
|
|
: `${API_BASE_URL}${recording_url}`;
|
|
const JUMP_DURATION = 15; //seconds
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isReady, setIsReady] = useState(false);
|
|
const [playBackTime, setPlayBackTime] = useState("");
|
|
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
|
|
const [waveSufferRegionData, setWaveSufferRegionData] =
|
|
useState<WaveSufferSttResult | null>();
|
|
const [waveSufferDtmfData, setWaveSufferDtmfData] =
|
|
useState<WaveSufferDtmfResult | null>();
|
|
const [regionChecked, setRegionChecked] = useState(false);
|
|
|
|
const wavesurferId = `wavesurfer--${call_sid}`;
|
|
const wavesurferTimelineId = `timeline-${wavesurferId}`;
|
|
const waveSufferRef = useRef<WaveSurfer | null>(null);
|
|
|
|
const [record, setRecord] = useState<DownloadedBlob | null>(null);
|
|
|
|
const drawDtmfRegionForSpan = (s: JaegerSpan, startPoint: JaegerSpan) => {
|
|
if (waveSufferRef.current) {
|
|
const r = waveSufferRef.current.regions.list[s.spanId];
|
|
if (!r) {
|
|
const [dtmfValue] = getSpanAttributeByName(s.attributes, "dtmf");
|
|
const [durationValue] = getSpanAttributeByName(
|
|
s.attributes,
|
|
"duration"
|
|
);
|
|
if (dtmfValue && durationValue) {
|
|
const start =
|
|
(s.startTimeUnixNano - startPoint.startTimeUnixNano) /
|
|
1_000_000_000;
|
|
const duration =
|
|
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
|
|
// as duration of DTMF is short, cannot be shown in wavesuffer,
|
|
// adjust region width here.
|
|
const delta = duration <= 0.1 ? 0.1 : duration;
|
|
const end = start + delta;
|
|
|
|
const region = waveSufferRef.current.addRegion({
|
|
id: s.spanId,
|
|
start,
|
|
end,
|
|
color: "rgba(138, 43, 226, 0.15)",
|
|
drag: false,
|
|
loop: false,
|
|
resize: false,
|
|
});
|
|
changeRegionMouseStyle(region);
|
|
|
|
const att: WaveSufferDtmfResult = {
|
|
dtmf: dtmfValue.value.stringValue,
|
|
duration: durationValue.value.stringValue,
|
|
};
|
|
|
|
region.on("click", () => {
|
|
setWaveSufferDtmfData(att);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const changeRegionMouseStyle = (region: Region, channel = 0) => {
|
|
region.element.style.display = regionChecked ? "" : "none";
|
|
region.element.style.height = "49%";
|
|
region.element.style.top = channel === 0 ? "0" : "51%";
|
|
|
|
region.element.addEventListener("mouseenter", () => {
|
|
region.element.style.cursor = "pointer"; // Change to your desired cursor style
|
|
});
|
|
|
|
region.element.addEventListener("mouseleave", () => {
|
|
region.element.style.cursor = "default";
|
|
});
|
|
};
|
|
|
|
const drawSttRegionForSpan = (
|
|
s: JaegerSpan,
|
|
startPoint: JaegerSpan,
|
|
channel = 0
|
|
) => {
|
|
if (waveSufferRef.current) {
|
|
const r = waveSufferRef.current.regions.list[s.spanId];
|
|
if (!r) {
|
|
const start =
|
|
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000 +
|
|
0.05; // add magic 0.01 second in each region start time to isolate 2 near regions
|
|
const end =
|
|
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
|
|
|
|
const region = waveSufferRef.current.addRegion({
|
|
id: s.spanId,
|
|
start,
|
|
end,
|
|
color: "rgba(255, 0, 0, 0.15)",
|
|
drag: false,
|
|
loop: false,
|
|
resize: false,
|
|
});
|
|
changeRegionMouseStyle(region, channel);
|
|
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
|
|
let att: WaveSufferSttResult;
|
|
if (sttResult) {
|
|
const data = JSON.parse(sttResult.value.stringValue);
|
|
att = {
|
|
vendor: data.vendor.name,
|
|
transcript: data.alternatives[0].transcript,
|
|
confidence: data.alternatives[0].confidence,
|
|
language_code: data.language_code,
|
|
};
|
|
} else {
|
|
const [sttResolve] = getSpanAttributeByName(
|
|
s.attributes,
|
|
"stt.resolve"
|
|
);
|
|
if (sttResolve && sttResolve.value.stringValue === "timeout") {
|
|
att = {
|
|
vendor: "",
|
|
transcript: "None (speech session timeout)",
|
|
confidence: 0,
|
|
language_code: "",
|
|
};
|
|
} else {
|
|
att = {
|
|
vendor: "",
|
|
transcript:
|
|
"None (call disconnected or speech session terminated)",
|
|
confidence: 0,
|
|
language_code: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
region.on("click", () => {
|
|
setWaveSufferRegionData(att);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const buildWavesufferRegion = () => {
|
|
if (jaegerRoot) {
|
|
const spans = getSpansFromJaegerRoot(jaegerRoot);
|
|
const [startPoint] = getSpansByName(spans, "background-listen:listen");
|
|
// there should be only one startPoint for background listen
|
|
if (startPoint) {
|
|
const gatherSpans = getSpansByNameRegex(spans, /:gather{/);
|
|
gatherSpans.forEach((s) => {
|
|
drawSttRegionForSpan(s, startPoint);
|
|
});
|
|
|
|
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
|
|
transcribeSpans.forEach((cs) => {
|
|
// Channel start from 0
|
|
const channel = Number(cs.name.split(":")[1]);
|
|
drawSttRegionForSpan(
|
|
cs,
|
|
startPoint,
|
|
channel > 0 ? channel - 1 : channel
|
|
);
|
|
});
|
|
|
|
const dtmfSpans = getSpansByNameRegex(spans, /dtmf:/);
|
|
dtmfSpans.forEach((ds) => {
|
|
drawDtmfRegionForSpan(ds, startPoint);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
buildWavesufferRegion();
|
|
}, [jaegerRoot, isReady]);
|
|
|
|
useEffect(() => {
|
|
getBlob(url).then(({ blob, headers }) => {
|
|
if (blob) {
|
|
const ext = headers.get("Content-Type") === "audio/wav" ? "wav" : "mp3";
|
|
setRecord({
|
|
data_url: URL.createObjectURL(blob),
|
|
file_name: `callid-${call_sid}.${ext}`,
|
|
});
|
|
}
|
|
});
|
|
|
|
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
|
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
|
|
if (json) {
|
|
setJeagerRoot(json);
|
|
}
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
function formatTime(seconds: number) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = Math.floor(seconds % 60);
|
|
|
|
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
|
|
.toString()
|
|
.padStart(2, "0")}`;
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (waveSufferRef.current !== null || !record) return;
|
|
waveSufferRef.current = WaveSurfer.create({
|
|
container: `#${wavesurferId}`,
|
|
waveColor: "#da1c5c",
|
|
progressColor: "grey",
|
|
height: 50,
|
|
cursorWidth: 1,
|
|
cursorColor: "lightgray",
|
|
normalize: true,
|
|
responsive: true,
|
|
fillParent: true,
|
|
splitChannels: true,
|
|
scrollParent: true,
|
|
plugins: [
|
|
RegionsPlugin.create({}),
|
|
TimelinePlugin.create({
|
|
container: `#${wavesurferTimelineId}`,
|
|
}),
|
|
],
|
|
});
|
|
|
|
waveSufferRef.current.load(record?.data_url);
|
|
// All event should be after load
|
|
waveSufferRef.current.on("finish", () => {
|
|
setIsPlaying(false);
|
|
});
|
|
|
|
waveSufferRef.current.on("play", () => {
|
|
setIsPlaying(true);
|
|
});
|
|
|
|
waveSufferRef.current.on("pause", () => {
|
|
setIsPlaying(false);
|
|
});
|
|
|
|
waveSufferRef.current.on("ready", () => {
|
|
setIsReady(true);
|
|
setPlayBackTime(formatTime(waveSufferRef.current?.getDuration() || 0));
|
|
});
|
|
|
|
waveSufferRef.current.on("audioprocess", () => {
|
|
setPlayBackTime(formatTime(waveSufferRef.current?.getCurrentTime() || 0));
|
|
});
|
|
}, [record]);
|
|
|
|
const togglePlayback = () => {
|
|
if (waveSufferRef.current) {
|
|
if (!isPlaying) {
|
|
waveSufferRef.current.play();
|
|
} else {
|
|
waveSufferRef.current.pause();
|
|
}
|
|
}
|
|
};
|
|
|
|
const setPlaybackJump = (delta: number) => {
|
|
if (waveSufferRef.current) {
|
|
const idx = waveSufferRef.current.getCurrentTime() + delta;
|
|
const value =
|
|
idx <= 0
|
|
? 0
|
|
: idx >= waveSufferRef.current.getDuration()
|
|
? waveSufferRef.current.getDuration() - 1
|
|
: idx;
|
|
waveSufferRef.current.setCurrentTime(value);
|
|
setPlayBackTime(formatTime(value));
|
|
}
|
|
};
|
|
|
|
if (!record) return null;
|
|
|
|
return (
|
|
<>
|
|
<div className="media-container">
|
|
<div id={wavesurferId} />
|
|
<div id={wavesurferTimelineId} />
|
|
<div className="media-container__center">
|
|
<strong>{playBackTime}</strong>
|
|
</div>
|
|
<div className="media-container__center">
|
|
<button
|
|
className="btnty"
|
|
type="button"
|
|
onClick={() => {
|
|
setPlaybackJump(-JUMP_DURATION);
|
|
}}
|
|
title="Jump left"
|
|
disabled={!isReady}
|
|
>
|
|
<Icon>
|
|
<Icons.ChevronsLeft />
|
|
</Icon>
|
|
</button>
|
|
<button
|
|
className="btnty"
|
|
type="button"
|
|
onClick={togglePlayback}
|
|
title="play/pause"
|
|
disabled={!isReady}
|
|
>
|
|
<Icon>{isPlaying ? <Icons.Pause /> : <Icons.Play />}</Icon>
|
|
</button>
|
|
|
|
<button
|
|
className="btnty"
|
|
type="button"
|
|
onClick={() => {
|
|
setPlaybackJump(JUMP_DURATION);
|
|
}}
|
|
title="Jump right"
|
|
disabled={!isReady}
|
|
>
|
|
<Icon>
|
|
<Icons.ChevronsRight />
|
|
</Icon>
|
|
</button>
|
|
<a
|
|
href={record.data_url}
|
|
download={record.file_name}
|
|
className="btnty"
|
|
title="Download record file"
|
|
>
|
|
<Icon>
|
|
<Icons.Download />
|
|
</Icon>
|
|
</a>
|
|
</div>
|
|
<label htmlFor="is_active" className="chk">
|
|
<input
|
|
id={`is_active${call.call_sid}`}
|
|
name="is_active"
|
|
type="checkbox"
|
|
checked={regionChecked}
|
|
onChange={(e) => {
|
|
setRegionChecked(e.target.checked);
|
|
if (waveSufferRef.current) {
|
|
const regionsList = waveSufferRef.current.regions.list;
|
|
for (const [, region] of Object.entries(regionsList)) {
|
|
region.element.style.display = e.target.checked ? "" : "none";
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
<div>Overlay STT and DTMF events</div>
|
|
</label>
|
|
</div>
|
|
{waveSufferRegionData && (
|
|
<ModalClose handleClose={() => setWaveSufferRegionData(null)}>
|
|
<div className="spanDetailsWrapper__header">
|
|
<P>
|
|
<strong>Speech to text result</strong>
|
|
</P>
|
|
</div>
|
|
<div className="spanDetailsWrapper">
|
|
<div className="spanDetailsWrapper__detailsWrapper">
|
|
{waveSufferRegionData.vendor && (
|
|
<div className="spanDetailsWrapper__details">
|
|
<div className="spanDetailsWrapper__details_header">
|
|
<strong>Vendor:</strong>
|
|
</div>
|
|
<div className="spanDetailsWrapper__details_body">
|
|
{waveSufferRegionData.vendor}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{waveSufferRegionData.confidence !== 0 && (
|
|
<div className="spanDetailsWrapper__details">
|
|
<div className="spanDetailsWrapper__details_header">
|
|
<strong>Confidence:</strong>
|
|
</div>
|
|
<div className="spanDetailsWrapper__details_body">
|
|
{waveSufferRegionData.confidence}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{waveSufferRegionData.language_code && (
|
|
<div className="spanDetailsWrapper__details">
|
|
<div className="spanDetailsWrapper__details_header">
|
|
<strong>Language code:</strong>
|
|
</div>
|
|
<div className="spanDetailsWrapper__details_body">
|
|
{waveSufferRegionData.language_code}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{waveSufferRegionData.transcript && (
|
|
<div className="spanDetailsWrapper__details">
|
|
<div className="spanDetailsWrapper__details_header">
|
|
<strong>Transcript:</strong>
|
|
</div>
|
|
<div className="spanDetailsWrapper__details_body">
|
|
{waveSufferRegionData.transcript}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ModalClose>
|
|
)}
|
|
{waveSufferDtmfData && (
|
|
<ModalClose handleClose={() => setWaveSufferDtmfData(null)}>
|
|
<div className="spanDetailsWrapper__header">
|
|
<P>
|
|
<strong>Dtmf result</strong>
|
|
</P>
|
|
</div>
|
|
<div className="spanDetailsWrapper">
|
|
<div className="spanDetailsWrapper__detailsWrapper">
|
|
<div className="spanDetailsWrapper__details">
|
|
<div className="spanDetailsWrapper__details_header">
|
|
<strong>Dtmf:</strong>
|
|
</div>
|
|
<div className="spanDetailsWrapper__details_body">
|
|
{waveSufferDtmfData.dtmf}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="spanDetailsWrapper__details">
|
|
<div className="spanDetailsWrapper__details_header">
|
|
<strong>Duration:</strong>
|
|
</div>
|
|
<div className="spanDetailsWrapper__details_body">
|
|
{waveSufferDtmfData.duration}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ModalClose>
|
|
)}
|
|
</>
|
|
);
|
|
};
|