Skip to content
Snippets Groups Projects
audio-clipper.ts 22.9 KiB
Newer Older
  • Learn to ignore specific revisions
  • import { css, html, LitElement, TemplateResult } from "lit";
    import {
      customElement,
      property,
      query,
    
      queryAssignedNodes,
      state,
    } from "lit/decorators.js";
    import WaveSurfer from "wavesurfer.js";
    
    
      StretchLeft,
      StretchRight,
      Seek,
    }
    
    
    interface EventElement {
      events: string[];
      onEvent: EventListener;
    }
    
    
    @customElement("audio-clipper")
    export class AudioClipper extends LitElement {
      @queryAssignedNodes("audio", true)
      _audio!: NodeListOf<HTMLAudioElement>;
    
      @queryAssignedNodes("start_time", true)
      _startTimeInput!: NodeListOf<HTMLInputElement>;
    
      @queryAssignedNodes("duration", true)
      _durationInput!: NodeListOf<HTMLInputElement>;
    
      @query(".slider")
      _sliderNode!: HTMLDivElement;
    
      @query(".slider__segment--wrapper")
      _segmentNode!: HTMLDivElement;
    
      @query(".slider__segment-content")
      _segmentContentNode!: HTMLDivElement;
    
      @query(".slider__segment-progress-handle")
      _progressNode!: HTMLDivElement;
    
    
      @query(".slider__seeking-placeholder")
      _seekingNode!: HTMLDivElement;
    
    
      @query("#waveform")
      _waveformNode!: HTMLDivElement;
    
    
      @query(".buffering-bar")
      _bufferingBarNode!: HTMLCanvasElement;
    
    
      @queryAll(".slider__segment-handle")
      _segmentHandleNodes!: NodeListOf<HTMLButtonElement>;
    
    
      @property({ type: Number, attribute: "start-time" })
    
      @property({ type: Number, attribute: "duration" })
      initDuration = 10;
    
    
      @property({ type: Number, attribute: "min-duration" })
      minDuration = 5;
    
      @property({ type: Number, attribute: "volume" })
      initVolume = 0.5;
    
    
      @property({ type: Number, attribute: "height" })
      height = 100;
    
    
      @state()
      _isPlaying = false;
    
      @state()
      _clip = {
        startTime: 0,
        endTime: 0,
      };
    
      @state()
    
    
      @state()
      _audioDuration = 0;
    
      @state()
      _sliderWidth = 0;
    
      @state()
      _currentTime = 0;
    
      @state()
      _volume = 0.5;
    
    
      @state()
      _wavesurfer!: WaveSurfer;
    
    
      _windowEvents: EventElement[] = [
        {
          events: ["load", "resize"],
          onEvent: () => {
            this._sliderWidth = this._sliderNode.clientWidth;
            this.setSegmentPosition();
          },
        },
      ];
    
      _documentEvents: EventElement[] = [
        {
          events: ["mouseup"],
          onEvent: () => {
            if (this._action !== null) {
              document.body.style.cursor = "";
    
              if (this._action.type === ActionType.Seek && this._seekingTime) {
    
                this._audio[0].currentTime = this._seekingTime;
                this._seekingTime = 0;
              }
              this._action = null;
            }
          },
        },
        {
          events: ["mousemove"],
          onEvent: (event: Event) => {
            if (this._action !== null) {
              this.updatePosition(event as MouseEvent);
            }
          },
        },
      ];
    
      _audioEvents: EventElement[] = [
        {
          events: ["play"],
          onEvent: () => {
            this._isPlaying = true;
          },
        },
        {
          events: ["pause"],
          onEvent: () => {
            this._isPlaying = false;
          },
        },
        {
    
            const context = this._bufferingBarNode.getContext("2d");
    
            if (context) {
              context.fillStyle = "lightgray";
              context.fillRect(
                0,
                0,
                this._bufferingBarNode.width,
                this._bufferingBarNode.height
              );
              context.fillStyle = "#04AC64";
    
              const inc = this._bufferingBarNode.width / this._audio[0].duration;
    
              for (let i = 0; i < this._audio[0].buffered.length; i++) {
                const startX = this._audio[0].buffered.start(i) * inc;
                const endX = this._audio[0].buffered.end(i) * inc;
                const width = endX - startX;
    
                context.fillRect(startX, 0, width, this._bufferingBarNode.height);
                context.rect(startX, 0, width, this._bufferingBarNode.height);
              }
            }
    
            // TODO: change this?
            this._currentTime = parseFloat(this._audio[0].currentTime.toFixed(3));
    
            if (this._currentTime > this._clip.endTime) {
              this.pause();
    
              this._audio[0].currentTime = this._clip.endTime;
    
            } else if (this._currentTime < this._clip.startTime) {
    
              this._audio[0].currentTime = this._clip.startTime;
            } else {
    
      _segmentHandleEvents: EventElement[] = [
        {
          events: ["mouseenter", "focus"],
          onEvent: (event: Event) => {
            const timeInfoElement = (
              event.target as HTMLButtonElement
            ).querySelector("span");
            if (timeInfoElement) {
              timeInfoElement.style.opacity = "1";
            }
          },
        },
        {
          events: ["mouseleave", "blur"],
          onEvent: (event: Event) => {
            const timeInfoElement = (
              event.target as HTMLButtonElement
            ).querySelector("span");
            if (timeInfoElement) {
              timeInfoElement.style.opacity = "0";
            }
          },
        },
      ];
    
    
      connectedCallback(): void {
        super.connectedCallback();
    
        this._clip = {
    
          startTime: this.initStartTime,
          endTime: this.initStartTime + this.initDuration,
    
        };
        this._volume = this.initVolume;
      }
    
      protected firstUpdated(): void {
        this._audioDuration = this._audio[0].duration;
        this._audio[0].volume = this._volume;
    
        this._startTimeInput[0].hidden = true;
        this._durationInput[0].hidden = true;
    
    
        this._wavesurfer = WaveSurfer.create({
          container: this._waveformNode,
    
          interact: false,
    
          responsive: true,
    
          waveColor: "hsl(0 5% 85%)",
    
        });
        this._wavesurfer.load(this._audio[0].src);
    
    
      disconnectedCallback(): void {
        super.disconnectedCallback();
    
      addEventListeners(): void {
        for (const event of this._windowEvents) {
          event.events.forEach((name) => {
            window.addEventListener(name, event.onEvent);
          });
        }
    
        for (const event of this._documentEvents) {
          event.events.forEach((name) => {
            document.addEventListener(name, event.onEvent);
          });
        }
    
        for (const event of this._audioEvents) {
          event.events.forEach((name) => {
            this._audio[0].addEventListener(name, event.onEvent);
          });
        }
    
    
        for (const event of this._segmentHandleEvents) {
          event.events.forEach((name) => {
            for (let i = 0; i < this._segmentHandleNodes.length; i++) {
              this._segmentHandleNodes[i].addEventListener(name, event.onEvent);
            }
          });
        }
    
      removeEventListeners(): void {
        for (const event of this._windowEvents) {
          event.events.forEach((name) => {
            window.removeEventListener(name, event.onEvent);
          });
        }
    
        for (const event of this._documentEvents) {
          event.events.forEach((name) => {
            document.removeEventListener(name, event.onEvent);
          });
        }
    
        for (const event of this._audioEvents) {
          event.events.forEach((name) => {
            this._audio[0].removeEventListener(name, event.onEvent);
          });
        }
    
    
        for (const event of this._segmentHandleEvents) {
          event.events.forEach((name) => {
            for (let i = 0; i < this._segmentHandleNodes.length; i++) {
              this._segmentHandleNodes[i].addEventListener(name, event.onEvent);
            }
          });
        }
    
      }
    
      setSegmentPosition(): void {
        const startTimePosition = this.getPositionFromSeconds(this._clip.startTime);
        const endTimePosition = this.getPositionFromSeconds(this._clip.endTime);
    
        this._segmentNode.style.transform = `translateX(${startTimePosition}px)`;
        this._segmentContentNode.style.width = `${
          endTimePosition - startTimePosition
        }px`;
      }
    
    
      private getPositionFromSeconds(seconds: number) {
    
        return (seconds * this._sliderWidth) / this._audioDuration;
      }
    
    
      private getSecondsFromPosition(position: number) {
    
        return (this._audioDuration * position) / this._sliderWidth;
      }
    
      protected updated(
        _changedProperties: Map<string | number | symbol, unknown>
      ): void {
        if (_changedProperties.has("_clip")) {
          this.pause();
          this.setSegmentPosition();
    
    
          this._startTimeInput[0].value = this._clip.startTime.toString();
          this._durationInput[0].value = (
            this._clip.endTime - this._clip.startTime
          ).toFixed(3);
    
          this._durationInput[0].dispatchEvent(new Event("change"));
    
          this._audio[0].currentTime = this._clip.startTime;
        }
        if (_changedProperties.has("_seekingTime")) {
          if (this._seekingTime) {
            this._audio[0].currentTime = this._seekingTime;
          }
    
        }
      }
    
      play(): void {
        this._audio[0].play();
      }
    
      pause(): void {
        this._audio[0].pause();
      }
    
    
      private updatePosition(event: MouseEvent): void {
    
        const cursorPosition =
    
          (this._sliderNode.getBoundingClientRect().left +
            document.documentElement.scrollLeft);
    
        const seconds = this.getSecondsFromPosition(cursorPosition);
    
    
        switch (this._action.type) {
          case ActionType.StretchLeft: {
    
            if (seconds > 0) {
              if (seconds > this._clip.endTime - this.minDuration) {
                startTime = this._clip.endTime - this.minDuration;
              } else {
                startTime = seconds;
              }
            }
            this._clip = {
    
              startTime: parseFloat(startTime.toFixed(3)),
    
              endTime: this._clip.endTime,
            };
            break;
          }
    
            let endTime;
            if (seconds < this._audioDuration) {
              if (seconds < this._clip.startTime + this.minDuration) {
                endTime = this._clip.startTime + this.minDuration;
              } else {
                endTime = seconds;
              }
            } else {
              endTime = this._audioDuration;
            }
    
            this._clip = {
              startTime: this._clip.startTime,
    
              endTime: parseFloat(endTime.toFixed(3)),
    
            if (seconds < this._clip.startTime) {
              this._seekingTime = this._clip.startTime;
            } else if (seconds > this._clip.endTime) {
              this._seekingTime = this._clip.endTime;
            } else {
    
              this._seekingTime = parseFloat(seconds.toFixed(3));
    
        const cursorPosition =
          event.clientX -
          (this._sliderNode.getBoundingClientRect().left +
            document.documentElement.scrollLeft);
    
        const seconds = this.getSecondsFromPosition(cursorPosition);
    
        this._audio[0].currentTime = seconds;
      }
    
    
      setVolume(event: InputEvent): void {
        this._volume = parseFloat((event.target as HTMLInputElement).value);
        this._audio[0].volume = this._volume;
      }
    
      setCurrentTime(currentTime: number): void {
        const seekingTimePosition = this.getPositionFromSeconds(currentTime);
        const startTimePosition = this.getPositionFromSeconds(this._clip.startTime);
        const seekingTimeSegmentPosition = seekingTimePosition - startTimePosition;
        const seekingTimePercentage =
          (seekingTimeSegmentPosition / this._segmentContentNode.clientWidth) *
          this._segmentContentNode.clientWidth;
        this._progressNode.style.transform = `translateX(${seekingTimeSegmentPosition}px)`;
        this._seekingNode.style.transform = `scaleX(${seekingTimePercentage})`;
      }
    
    
      setAction(event: MouseEvent, action: Action): void {
        switch (action.type) {
          case ActionType.StretchLeft:
            action.payload = {
              offset:
                this._segmentHandleNodes[0].getBoundingClientRect().right -
                event.clientX,
            };
            break;
          case ActionType.StretchRight:
            action.payload = {
              offset:
                this._segmentHandleNodes[1].getBoundingClientRect().left -
                event.clientX,
            };
    
      private secondsToHHMMSS(seconds: number): string {
    
        return new Date(seconds * 1000).toISOString().substr(11, 8);
      }
    
    
      trim(side: "start" | "end") {
        if (side === "start") {
          this._clip = {
    
            startTime: parseFloat(this._audio[0].currentTime.toFixed(3)),
    
            endTime: this._clip.endTime,
          };
        } else {
          this._clip = {
            startTime: this._clip.startTime,
            endTime: this._currentTime,
          };
        }
      }
    
    
      static styles = css`
    
          position: relative;
    
        .buffering-bar {
          position: absolute;
          width: 100%;
          height: 4px;
          background-color: gray;
          bottom: -4px;
          left: 0;
        }
    
    
          display: flex;
          align-items: center;
    
          width: 100%;
        }
    
        .slider__segment--wrapper {
          position: absolute;
    
        }
    
        .slider__segment {
          position: relative;
          display: flex;
    
        }
    
        .slider__segment-content {
    
          background-color: rgba(255, 255, 255, 0.5);
          height: 100%;
    
          border-top: 2px dashed #b91c1c;
          border-bottom: 2px dashed #b91c1c;
    
        .slider__seeking-placeholder {
          position: absolute;
          pointer-events: none;
          background-color: rgba(255, 255, 255, 0.5);
          height: 100%;
          width: 1px;
          transform-origin: left;
        }
    
    
        .slider__segment-progress-handle {
          position: absolute;
    
          background-color: #3b82f6;
    
        }
    
        .slider__segment-progress-handle::after {
          position: absolute;
          content: "";
          width: 0px;
          height: 0px;
          bottom: -12px;
    
          border: 10px solid transparent;
          border-top-color: transparent;
          border-top-style: solid;
          border-top-width: 10px;
          border-top: 10px solid #3b82f6;
    
        }
    
        .slider__segment .slider__segment-handle {
          position: absolute;
          width: 1rem;
    
          background-color: #b91c1c;
          border: none;
    
        }
    
        .slider__segment .slider__segment-handle::before {
          content: "";
          position: absolute;
    
          width: 2px;
          background-color: #ffffff;
          margin: auto;
          top: 0;
          bottom: 0;
          left: 0;
          right: 0;
        }
    
        .slider__segment .clipper__handle-left {
          left: -1rem;
    
          border-radius: 0.2rem 0 0 0.2rem;
    
        .slider__segment .slider__segment-handle span {
          opacity: 0;
          pointer-events: none;
          position: absolute;
          left: -100%;
          top: -30%;
          background-color: #0f172a;
          color: #ffffff;
          padding: 0 0.25rem;
        }
    
    
        .slider__segment .clipper__handle-right {
          right: -1rem;
    
          border-radius: 0 0.2rem 0.2rem 0;
        }
    
        .toolbar {
          display: flex;
          align-items: center;
          padding: 0.5rem 0.5rem 0.25rem 0.5rem;
          justify-content: space-between;
          background-color: hsl(var(--color-background-elevated));
          box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
    
          flex-wrap: wrap;
          gap: 0.5rem;
        }
    
        .toolbar__audio-controls {
          display: flex;
          align-items: center;
          gap: 0.5rem;
        }
    
        .toolbar .toolbar__play-button {
          padding: 0.5rem;
          height: 32px;
          width: 32px;
          font-size: 1em;
        }
    
        .toolbar__trim-controls {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          flex-shrink: 0;
        }
    
        .toolbar button {
          cursor: pointer;
          background-color: hsl(var(--color-accent-base));
          color: hsl(var(--color-accent-contrast));
          border-radius: 9999px;
          border: none;
          padding: 0.25rem 0.5rem;
    
          box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
        }
    
        .toolbar button:hover {
          background-color: hsl(var(--color-accent-hover));
        }
    
        .toolbar button:focus {
          outline: 2px solid transparent;
          outline-offset: 2px;
          --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0
            var(--tw-ring-offset-width) var(--tw-ring-offset-color);
          --tw-ring-shadow: var(--tw-ring-inset) 0 0 0
            calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
          box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
            0 0 rgba(0, 0, 0, 0);
          box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
            0 0 rgba(0, 0, 0, 0);
          box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
            var(--tw-shadow, 0 0 rgba(0, 0, 0, 0));
          --tw-ring-offset-width: 2px;
          --tw-ring-opacity: 1;
          --tw-ring-color: hsl(var(--color-accent-base) / var(--tw-ring-opacity));
          --tw-ring-offset-color: hsl(var(--color-background-base));
        }
    
        .toolbar__trim-controls button {
          font-weight: 600;
          font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI,
            Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont,
            "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
            "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
            "Noto Color Emoji";
    
        }
    
        .animate-spin {
          animation: spin 1s linear infinite;
        }
    
        @keyframes spin {
          from {
            transform: rotate(0deg);
          }
          to {
            transform: rotate(360deg);
          }
        }
    
        .volume {
          display: flex;
          font-size: 1.2rem;
          color: hsl(var(--color-accent-base));
          align-items: center;
          gap: 0.25rem;
        }
    
        .range-slider {
          accent-color: hsl(var(--color-accent-base));
          width: 100px;
    
      `;
    
      render(): TemplateResult<1> {
        return html`
          <slot name="audio"></slot>
          <slot name="start_time"></slot>
          <slot name="duration"></slot>
    
          <div class="slider-wrapper" style="height:${this.height}">
            <div id="waveform"></div>
            <div class="slider" role="slider">
              <div class="slider__segment--wrapper">
    
                  @mousedown="${(event: MouseEvent) =>
                    this.setAction(event, { type: ActionType.Seek })}"
    
                <div class="slider__segment">
                  <button
                    class="slider__segment-handle clipper__handle-left"
    
                    @mousedown="${(event: MouseEvent) =>
                      this.setAction(event, {
                        type: ActionType.StretchLeft,
                      })}"
                  >
                    <span>${this.secondsToHHMMSS(this._clip.startTime)}</span>
                  </button>
    
                  <div class="slider__seeking-placeholder"></div>
                  <div
                    class="slider__segment-content"
    
                    @mousedown="${(event: MouseEvent) =>
                      this.setAction(event, { type: ActionType.Seek })}"
    
                    @click="${(event: MouseEvent) => this.goTo(event)}"
                  ></div>
                  <button
                    class="slider__segment-handle clipper__handle-right"
    
                    @mousedown="${(event: MouseEvent) =>
                      this.setAction(event, { type: ActionType.StretchRight })}"
                  >
                    <span>${this.secondsToHHMMSS(this._clip.endTime)}</span>
                  </button>
    
            <canvas class="buffering-bar"></canvas>
    
          <div class="toolbar">
            <div class="toolbar__audio-controls">
              <button
                class="toolbar__play-button"
                @click="${this._isPlaying ? this.pause : this.play}"
              >
                ${this._isBuffering
                  ? html`<svg
                      class="animate-spin"
                      xmlns="http://www.w3.org/2000/svg"
                      fill="none"
                      viewBox="0 0 24 24"
                    >
                      <circle
                        opacity="0.25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        stroke-width="4"
                      ></circle>
                      <path
                        opacity="0.75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                      ></path>
                    </svg>`
                  : this._isPlaying
                  ? html`<svg
                      viewBox="0 0 24 24"
                      fill="currentColor"
                      width="1em"
                      height="1em"
                    >
                      <g>
                        <path fill="none" d="M0 0h24v24H0z" />
                        <path d="M6 5h2v14H6V5zm10 0h2v14h-2V5z" />
                      </g>
                    </svg>`
                  : html` <svg
                      viewBox="0 0 24 24"
                      fill="currentColor"
                      width="1em"
                      height="1em"
                    >
                      <path fill="none" d="M0 0h24v24H0z" />
                      <path
                        d="M7.752 5.439l10.508 6.13a.5.5 0 0 1 0 .863l-10.508 6.13A.5.5 0 0 1 7 18.128V5.871a.5.5 0 0 1 .752-.432z"
                      />
                    </svg>`}
              </button>
              <div class="volume">
                <svg
    
                  viewBox="0 0 24 24"
                  fill="currentColor"
                  width="1em"
                  height="1em"
                >
                  <g>
                    <path fill="none" d="M0 0h24v24H0z" />
    
                    <path
                      d="M8.889 16H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3.889l5.294-4.332a.5.5 0 0 1 .817.387v15.89a.5.5 0 0 1-.817.387L8.89 16zm9.974.591l-1.422-1.422A3.993 3.993 0 0 0 19 12c0-1.43-.75-2.685-1.88-3.392l1.439-1.439A5.991 5.991 0 0 1 21 12c0 1.842-.83 3.49-2.137 4.591z"
                    />
    
                </svg>
                <input
                  class="range-slider"
                  type="range"
                  id="volume"
                  min="0"
                  max="1"
                  step="0.1"
                  value="${this._volume}"
                  @change="${this.setVolume}"
                />
              </div>
    
              <time>${this.secondsToHHMMSS(this._currentTime)}</time>
    
            </div>
            <div class="toolbar__trim-controls">
              <button @click="${() => this.trim("start")}">Trim start</button>
              <button @click="${() => this.trim("end")}">Trim end</button>
            </div>
          </div>